diff --git a/CHANGELOG.md b/CHANGELOG.md index d80de51..d259e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,48 @@ # Changelog +## 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. + +- **Tre modelli, scelti in alto.** In barra ci sono tre pillole: **Qwen base** (40 passi), **Klein veloce** (8 passi) e **Klein Base** (50 passi, CFG 3,5). Il selettore «Qualità» (bozza, alta, massima, Turbo) è sparito da Crea, Modifica e dalle ricette: + i passi li decide il modello. Il **Turbo di Qwen non c'è più** (né il LoRA Turbo8 né i GGUF Turbo nel Setup); resta per chi li ha già e per aprire i PNG vecchi. La scelta di prima si migra da sola + (un Turbo GGUF diventa il denoiser normale della stessa quantizzazione, una qualità «turbo» salvata vale «alta»). La mappa dei modelli sta in `src/shared/modelli.ts`, provata da `test/modelli.mjs`. +- **Klein veloce passa da 16 a 8 passi.** Misurato in Modifica e in zona (rosso lucido su una teiera, 8, 12 e 16 passi): il risultato è lo stesso a occhio, e 8 passi costano circa un terzo in meno (zona 26 s contro 41 s, Modifica 14 s contro 21 s). +- **Rifai in qualità piena.** Dal risultato di Crea o dalla Galleria, una foto fatta con Klein veloce si rifà con **Klein Base** (stesso prompt, stesso seme). C'è anche **Rifai con Qwen** (non promette la stessa immagine). Sostituiscono il vecchio Turbo e i suoi avvisi. +- **Crea essenziale.** Via «Qualità» e le opzioni avanzate: gli stili stanno in un menu, «Quante» è 1, 2 o 4, e **Altro** (chiuso) raccoglie seme, megapixel, misura e sfondo. +- **Modifica: una scheda «Scrivi» al posto di «Tutta la foto» e «Solo una zona».** Se sulla tela hai disegnato una zona, il cambiamento resta lì dentro; se no, vale tutta la foto. **Espandi** ha la sua scheda, **Estrai** resta com'era. +- **Il Laboratorio è dentro Modifica come scheda «Ricette»**: stesso catalogo (e le tue ricette, i preset, il lotto su più foto), ma nella stessa pagina della tela. **Unisci foto** (prima «Fusion») e **Come l'esempio** sono due ricette + che usano la tela e la colonna di destra di Modifica. Le impostazioni salvate dei vecchi modi si portano da sole alla scheda giusta. Nella rotaia a sinistra ci sono quattro voci: Crea, Modifica, Galleria, Album. I **LoRA** si gestiscono da Opzioni. +- **Il testo si scrive col Qwen3-VL 4B anche con Qwen** (impostazione `testoCon4B`, accesa di serie, interruttore in Opzioni): Migliora prompt, Foto → prompt, Idee, Estrai e il resto sono più rapidi e occupano meno VRAM. Se il file del 4B non c'è, + si usa l'encoder di Qwen come prima, senza errori. Misurato su una RTX 4060 da 8 GB: **Migliora prompt + Crea** con Qwen passa da 186 a **134 secondi** (con l'encoder GGUF di questa macchina da 446 a 150), 46-47 token al secondo contro 3,3 dell'8B int8 e 0,55 dell'8B GGUF. +- **Una cosa alla volta in VRAM, più precisa.** Il motore scarica i modelli (`/free`) anche passando da Qwen a Klein, da Klein a Klein Base e dal 4B all'8B, non solo fra disegno e testo (`src/main/motore/spazio.ts`, `test/spazio.mjs`). + Se dopo lo scarico la VRAM non scende compare un avviso **«VRAM non liberata»** con il pulsante **Libera**. +- **ComfyUI 0.42.3 (motore 8).** Al primo avvio dopo l'aggiornamento il motore si aggiorna da solo. Provato con un disegno vero: Qwen GGUF (con la nostra correzione di `ops.py`), Klein a 8 e 16 passi, zona, Modifica e Klein Base a 50 passi danno + immagini **identiche al pixel** a quelle della 0.41.2, alla stessa velocità, senza errori nel log. +- **Misure su RTX 4060 8 GB, 1 MP** (`docs/misure-1.1.0.md`): Klein veloce 8 passi in 16 s a freddo e 7 s a caldo (picco 6,9 GB), in zona 26 s; Klein Base 50 passi 60 s in Crea (picco 6,9 GB) e 3,2 minuti in zona (picco 7,3 GB: entra negli 8 GB); + Qwen base 40 passi 166 s. Il nodo `ReferenceMultiLatent` dà la stessa immagine e lo stesso tempo della catena di `ReferenceLatent`: il grafo di Klein non cambia. +- **I grafi di Qwen non sono cambiati.** `test/confronta_grafi.mjs` costruisce Crea, Modifica in zona ed Espandi (bozza, alta, massima) con il codice della 1.0.3 e con questo: 9 casi su 9 identici. +- **Testi.** «Fusion» diventa «Unisci foto»; le descrizioni di Klein dicono 8 passi; l'avviso sulla VRAM non nomina più il Turbo. +- **Prove.** `test/foto.mjs` (app vera a motore spento: Crea, Modifica, Galleria, Album, Opzioni) segue la nuova rotaia; `test/ricette.mjs` prova le ricette in Chromium con il finto `window.daprod`; `scripts/misure_gpu.mjs` ripete le misure. + +## 1.0.3 — 2026-10-21 + +Il potenziamento del prompt di Klein non ricopia più le frasi di cortesia e le regole del 4B, e ogni volta scrive una didascalia diversa. + +- **Pulizia della didascalia.** Il Qwen3-VL 4B apre spesso con «Here’s the expanded, detailed caption for your image request:», con le regole del sistema ricopiate («You're a prompt upsampler…») o con un esempio «Input / Output»: + quella frase finiva nel prompt del modello e gli faceva male. `pulisciDidascalia` (ora in `src/main/motore/didascalia.ts`, file puro senza Electron) toglie preambolo, regole, etichette e titolo in grassetto e tiene la didascalia vera. + Lo stesso file ha `senzaTitolo`, che usano anche le altre funzioni di testo con Klein. +- **Seme casuale nel potenziamento.** Prima era sempre 0, quindi lo stesso prompt dava sempre la stessa didascalia anche cambiando seme; ora, se il seme è casuale, ne esce una nuova (la cache per prompt resta: finché l'app è aperta, lo stesso prompt dà la stessa didascalia). +- **Prove.** `test/pulisci_didascalia.mjs` importa il codice vero (non ne ricopia più la logica) e prova dieci casi più due risposte vere del 4B in `test/campioni-pup/`. + `scripts/bench_upsampler.mjs` misura tok/s, VRAM e RAM del potenziamento caricando il 4B come `krea2` e con `thinking: true`, come fa l'app. + ## 1.0.2 — 2026-10-21 -Con Klein anche tutto il resto del testo (Migliora prompt, Foto → prompt, Idee, Estrai, Fusion, Esempio, Sorpresa) scrive col Qwen3-VL 4B: da circa 0,4 token al secondo a **15-40**. +Con Klein anche tutto il resto del testo (Migliora prompt, Foto → prompt, Idee, Estrai, Fusion, Esempio, Sorpresa) lo scrive il Qwen3-VL 4B: da circa 0,4 token al secondo a **15-40**. - **Tutte le funzioni di testo usano il 4B di Klein.** Nella 1.0.1 ci arrivava solo il potenziamento del prompt; Migliora prompt, Foto → prompt, «Sorprendimi», Idee, Estrai, Fusion/Esempio, Sorpresa del Laboratorio, il controllo della guida di posa e la traduzione con l'LLM continuavano a usare l'encoder di Qwen (l'8B, quasi tutto sul processore). Ora, se la famiglia scelta è Klein o Klein Base e il file del 4B c'è, scrive lui - (lo decide il motore dell'app, `conEncoderDiTesto` in `lavori.ts`: le pagine non cambiano). Con Qwen-Image tutto resta com'era; se il 4B non si trova si ripiega sull'encoder di Qwen. + (lo decide il motore dell'app, `conEncoderDiTesto` in `lavori.ts`: le pagine restano uguali). Con Qwen-Image tutto resta com'era; se il 4B non si trova si ripiega sull'encoder di Qwen. - **Il 4B non risponde più «user», «assistant» o niente.** Il nodo `TextGenerate` con `thinking: false` aggiunge in fondo al prompt un blocco `` vuoto e il 4B si confondeva (foto + richiesta breve → risposta vuota, traduzione → «user», Sorpresa → «assistant …»). Col 4B ora si manda `thinking: true`: il blocco non c'è, risponde subito e non ragiona (nessun token in più). Questo vale anche per il potenziamento della 1.0.1. - Se il 4B mette un titolo in grassetto prima del testo («**Image Idea:** …») l'app lo toglie. diff --git a/CLAUDE.md b/CLAUDE.md index 4ee9547..21129bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ interfaccia**, installato e guidato dall'app. Per preparare una versione c'è la skill `.claude/skills/rilascio`. ## Il motore (src/main/installa, src/main/motore) -- **Versioni fissate** in `engine/versioni.json` (ora **ComfyUI 0.41.2**, motore 7): ComfyUI a un commit preciso (zip da codeload, estratto col `tar.exe` +- **Versioni fissate** in `engine/versioni.json` (ora **ComfyUI 0.42.3**, motore 8): ComfyUI a un commit preciso (zip da codeload, estratto col `tar.exe` di Windows: il tar di Git non apre gli zip), Python 3.13 e PyTorch cu130 via **uv** (`resources/bin/uv.exe`, `npm run prendi-uv`). Tutto in `%LOCALAPPDATA%\DaProdImage\engine`; `stato.json` lì dentro dice quali passi sono fatti. - **ComfyUI-GGUF è copiato in `engine/custom_nodes/ComfyUI-GGUF`** (fork di leejet al commit `373048b`, l'unico che @@ -50,6 +50,7 @@ interfaccia**, installato e guidato dall'app. ## FLUX 3 Klein (famiglie di modelli) - `Famiglia = 'qwen' | 'klein' | 'klein-base'` (`shared/tipi.ts`, `FAMIGLIE` = passi per qualità), `Richiesta.famiglia`, `impostazioni.famiglia`. +- **Tre modelli, niente Turbo di Qwen** (1.1, `src/shared/modelli.ts`, la mappa unica, provata da `node test/modelli.mjs`): `qwen-base` (40 passi), `klein` (veloce, 8 passi), `klein-base` (50, CFG 3,5). `impostazioni.modelloScelto` va sempre d'accordo con `famiglia` (`allineaScelta` in `salvaImpostazioni`); le impostazioni vecchie si migrano una volta (`migra`: Turbo GGUF → UC dello stesso quant). La qualità «turbo» salvata vale «alta» (`senzaTurbo`): l'interfaccia non manda più `turbo: true`, i grafi del Turbo restano per i PNG vecchi. Il selettore è `componenti/ScegliModello.tsx` (3 schede, "da scaricare", download di modello + `klein-te` + `klein-vae`), la qualità `ScegliQualita.tsx`. - Catalogo: `klein-int8/bf16`, `klein-base-int8/bf16`, `klein-te` (Qwen3-VL 4B), `klein-vae` dal repo `black-forest-labs/flux-3-image-klein-comfy` (`repo` + `cerca` regex, risolti a runtime da `installa/risolvi.ts`: **i nomi vengono dai template ufficiali, vanno confermati con i file veri**). @@ -92,8 +93,8 @@ interfaccia**, installato e guidato dall'app. - **Descrivi** (Migliora prompt, Foto → prompt): `TextGenerate` con lo stesso Qwen3-VL già caricato, niente modelli in più. ## Interfaccia (src/renderer) -- **Modifica** ha 6 modi: tutta la foto, zona, espandi, **Fusion**, **Esempio** e **Estrai** (vedi sotto) (`componenti/Fusion.tsx`, richieste in `fusion.ts`: nessuna base, `m.riferimenti` sono le foto; - Fusion = `modalita: 'crea'` con `images.image_N`, Esempio = `modalita: 'modifica'` con l'esempio come `image_1`; i risultati stanno nel +- **Modifica** ha 4 modi (`ModoModifica`): **Scrivi** (tutta la foto, o solo la zona se ne hai disegnata una: `zona = modo==='scrivi' && haMaschera`, la `modalita` si decide in `manda()` da `tela.esporta()`; schede in `pagine/modifica/SchedaScrivi.tsx`), **Espandi** (`SchedaEspandi.tsx`, `bordiPreset`), **Ricette** (`pagine/modifica/SchedaRicette.tsx`: il catalogo che era il Laboratorio, con il lotto su più foto; la pagina «Laboratorio» non c'è più) ed **Estrai** (vedi sotto). **Unisci foto** e **Come l'esempio** sono due ricette `modo: 'speciale'` (`ricetteBase.ts`) che non usano SchedaRicette ma la tela e la colonna di destra di Modifica (`usaSpeciale()` / `specialeOra()` in `workflow.ts` dicono quale è scelta; `griglia` in `Modifica.tsx`); le impostazioni vecchie `tutta`/`zona` si migrano a `scrivi` e `fusion`/`esempio` a `ricette` in `daVecchioMod` (`componenti/Fusion.tsx`, richieste in `fusion.ts`: nessuna base, `m.riferimenti` sono le foto; + Unisci foto = `modalita: 'crea'` con `images.image_N`, Esempio = `modalita: 'modifica'` con l'esempio come `image_1`; i risultati stanno nel componente, non diventano versioni; il confronto prima/dopo usa sempre `versioni[0]`); `★ base` scambia base e riferimento. **Dimensioni** (`componenti/Misura.tsx`, `Richiesta.uscita`): auto / come l'originale / misura tua; `allaMisura()` in `grafi.ts` porta il risultato alla misura esatta (ingranditore se serve > 1,5×, taglio al centro). **Laboratorio** (`workflow.ts` = esecuzione, `ricetteBase.ts` = le ricette di serie, `ricette.ts` = le tue e i preset in localStorage, diff --git a/README.md b/README.md index bd248af..4005265 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ # DaProd Image -**Qwen-Image 2.1 sul tuo PC. Scrivi, segna, fatto.** +**Qwen-Image 2.1 e FLUX 3 Klein sul tuo PC. Scrivi, segna, fatto.** Crei immagini dal testo, anche **poster, infografiche, loghi e fumetti** con i testi scritti giusti. Modifichi le foto con una frase, o **solo la zona che segni col pennello**: il resto resta identico al pixel. @@ -33,7 +33,7 @@ Gira su una scheda NVIDIA **da 6-8 GB**, senza cloud e senza abbonamenti. ---
-Crea: il preset Foto a sinistra, risoluzione e Turbo, la Vespa a Positano generata in 8 passi +Crea: il preset Foto a sinistra, i tre modelli in alto, la Vespa a Positano
## Installare @@ -43,8 +43,7 @@ Gira su una scheda NVIDIA **da 6-8 GB**, senza cloud e senza abbonamenti. *Ulteriori informazioni → Esegui comunque*. 2. Al primo avvio l'app guarda il PC (scheda, VRAM, RAM, disco) e **trova da sola il modello** se l'hai già scaricato (in Download, Desktop o Documenti): lo usa senza copiarlo. -3. **Installa e inizia**: scarica il motore (ComfyUI + PyTorch CUDA, ~4 GB) e i file che mancano (text encoder - Qwen3-VL, VAE Texture-Fix, 4x-UltraSharp, il decoder delle anteprime), controllando ognuno con lo sha256. +3. **Installa e inizia**: scarica il motore (ComfyUI + PyTorch CUDA, ~4 GB) e i file che mancano (per Qwen: text encoder Qwen3-VL, VAE Texture-Fix, 4x-UltraSharp; il decoder delle anteprime), controllando ognuno con lo sha256. I file di Klein, se li scegli, si scaricano con un clic. Se si interrompe, riparte da lì. Gli **aggiornamenti** arrivano da soli: l'app li scarica in sottofondo (solo i pezzi cambiati) e propone @@ -60,9 +59,10 @@ Qwen-Image capisce meglio. I testi fra virgolette, quelli da scrivere nell'immag formato e risoluzione giusti, chiede due o tre cose in italiano, ha i suoi stili e scrive il prompt da solo. - **Risoluzioni da 256p al massimo**: 256p per un'idea in pochi secondi, poi 480p, 720p, 1K, 1080p, 1440p (per i testi piccoli) e MAX, i 4,2 MP del 2K nativo. Formati da 1:1 a 21:9, compresi 4:5 e 9:16. -- **Turbo**: 8 passi invece di 40 col LoRA di distillazione Turbo8, circa 5 volte più veloce. Si scarica con un clic. -- **Migliora** riscrive il prompt in un paragrafo ricco; **Da foto** trasforma una foto in prompt. -- **Bozza → Rifinisci**: esplori tante idee a metà pixel, poi porti quella giusta in alta qualità. +- **Il modello lo scegli in alto**: **Klein veloce** (8 passi) per esplorare tante idee, **Klein Base** o **Qwen-Image 2.1** quando conta la qualità. + Un risultato di Klein veloce lo rifai con **Rifai in qualità piena** (Klein Base, stesso prompt e stesso seme) o **Rifai con Qwen**. +- **Migliora** riscrive il prompt in un paragrafo ricco; **Da foto** trasforma una foto in prompt; **Sorprendimi** ne inventa uno. +- **Altro** (chiuso di serie) tiene seme, megapixel, misura e sfondo, per non riempire la pagina. | I preset: infografica, poster, menù… | La galleria, a righe con le proporzioni vere | | --- | --- | @@ -70,17 +70,18 @@ Qwen-Image capisce meglio. I testi fra virgolette, quelli da scrivere nell'immag ## Modifica -- **Tutta la foto** con una frase ("fallo di notte con la neve"), azioni rapide (rimuovi sfondo, restaura, colora - il bianco e nero, luce da studio, cambia o traduci i testi, stili) e **fino a 9 foto di riferimento** (``…). -- **Più foto**: trascini quante ne vuoi, con **★ base** scegli quale modificare e le altre sono riferimenti; oppure - **Fusion**: tutte le foto sono riferimenti e ne esce una foto nuova (persone, vestiti, oggetti, luoghi, stili) in base al prompt; - **Esempio**: dai le tue foto e una foto di esempio, il modello rifà l'esempio con le tue foto. +Quattro schede in alto: **Scrivi**, **Espandi**, **Ricette**, **Estrai**. + +- **Scrivi**: dici cosa cambiare con una frase («fallo di notte con la neve»), o scegli una o più **idee** (persone, vestiti, luce, stili, testi…) + che il modello mescola. Se sulla foto hai disegnato una zona (pennello, rettangolo o lazo) cambia **solo lì**: il modello guarda **tutta la foto** + con la zona evidenziata e fuori dalla zona la foto resta identica al pixel. Se non c'è nessuna zona, vale tutta la foto. + Niente cursori da regolare: bordi e sfumatura si adattano alla zona, e se scrivi «rimuovi…» la zona si ripulisce prima di ridisegnarla. +- **Fino a 9 foto di riferimento** (``…), e con **★ base** scegli quale foto modificare. - **Dimensioni**: come la foto originale, secondo i megapixel, o una misura tua. -- **Solo una zona**: la segni con pennello, rettangolo o lazo e scrivi cosa farci. Il modello guarda **tutta la foto** - con la zona evidenziata e cambia **solo lì**: fuori, la foto resta identica al pixel. Niente cursori da regolare: - bordi e sfumatura si adattano alla zona, e se scrivi "rimuovi…" la zona si ripulisce prima di ridisegnarla. -- **La vedi nascere al suo posto**: l'anteprima dal vivo si posa sulla foto, nitida a ogni passo. - **Espandi**: allarga la foto oltre i bordi (16:9, 9:16, quadrata, +25%). +- **Ricette**: il catalogo di prima, ora dentro Modifica (vedi sotto). **Unisci foto** (tutte le foto sono riferimenti e ne esce una foto nuova) + e **Come l'esempio** (dai le tue foto e una foto di esempio: il modello rifà l'esempio con le tue) sono due ricette. +- **La vedi nascere al suo posto**: l'anteprima dal vivo si posa sulla foto, nitida a ogni passo. - Ogni risultato è una **versione**: continui a modificare da lì, passi da una all'altra, tieni premuto per vedere com'era prima, confronti con la linea da trascinare, torni indietro. @@ -98,12 +99,15 @@ a 300 dpi con l'abbondanza. ## L'omino Un manichino con le ossa che muovi come vuoi (anche **mani e dita**, una per una, e la **testa**) per dare una posa al personaggio: in -Modifica → Estrai e nelle ricette del Laboratorio. Diventa un manichino grigio che il modello usa solo come guida di posa. +Modifica → Estrai e nelle ricette. Diventa un manichino grigio che il modello usa solo come guida di posa. + +## Tre modelli: Qwen e FLUX 3 Klein -## Due modelli: Qwen e FLUX 3 Klein +Nella barra in alto scegli **chi disegna**: **Qwen base** (Qwen-Image 2.1, 40 passi, con i LoRA), **Klein veloce** (8 passi) o **Klein Base** (la versione normale, 50 passi). I file di Klein si scaricano con un clic dal repository ufficiale di Black Forest Labs. +Il Turbo di Qwen non c'è più: Klein veloce è il modo rapido. -In cima a Crea, Modifica ed Estrai scegli **chi disegna**: **Qwen-Image 2.1** (con i LoRA e il Turbo), **Klein** (veloce, 16 passi) o **Klein Base** -(la versione normale, 50 passi). I file di Klein si scaricano con un clic dal repository ufficiale di Black Forest Labs. +Anche la scrittura dei testi (Migliora prompt, Foto → prompt, Idee, Estrai, Unisci foto, Come l'esempio) usa il **Qwen3-VL 4B** di Klein, con Klein e con Qwen: scrive a 46 token al secondo sulla RTX 4060, +contro 3,3 dell'encoder 8B int8 e 0,55 dell'8B GGUF. «Migliora prompt + Crea» con Qwen passa da 186 a 134 secondi. In Opzioni puoi tornare all'encoder di Qwen. Klein non usa i LoRA: l'app li nasconde. ## Estrai @@ -111,16 +115,16 @@ In Modifica: dai una foto e il modello trova **persone e oggetti**; clicchi quel pulito: una persona a figura intera (T-pose, A-pose, disegnata con l'omino), fino alla vita o il volto; un oggetto intero, da davanti, di lato o dall'alto. Vedi sempre a che risoluzione esce. -## Laboratorio +## Ricette -Oltre 50 ricette pronte (e **quelle che fai tu**: nuove, copie modificate, preset), in serie o a catena, anche su più foto insieme: il personaggio in 9 pose, espressioni, età, armadio, **camera da trascinare**, +La scheda **Ricette** di Modifica ha oltre 50 ricette pronte (e **quelle che fai tu**: nuove, copie modificate, preset), in serie o a catena, anche su più foto insieme: il personaggio in 9 pose, espressioni, età, armadio, **camera da trascinare**, luce, what-if, stili, prodotto, testi, restauro… Una foto dentro, un foglio di risultati fuori, con prima/dopo. Segni solo i passi che ti servono, e ogni ricetta ha il passo **🎲 Sorpresa**. ## I modelli -Per tutti solo **Q4, Q6, Q8**. Denoiser: Qwen-Image 2.1 Uncensored (40 passi) o il **Turbo di AtomicChat** (8 passi, -senza LoRA). LLM (legge il prompt e guarda le foto; i prompt li traduce Opus-MT): Qwen3-VL 8B int8 di sempre, oppure GGUF uncensored di -AtomicChat o di mradermacher. Si cambiano e si scaricano da Opzioni. +Per Qwen-Image solo **Q4, Q6, Q8**. Denoiser: Qwen-Image 2.1 Uncensored (40 passi). LLM (legge il prompt e guarda le foto; i prompt li traduce Opus-MT): Qwen3-VL 8B int8 di sempre, oppure GGUF uncensored di +AtomicChat o di mradermacher. Per scrivere i testi, di serie, si usa il Qwen3-VL 4B di Klein se c'è. Si cambiano e si scaricano da Opzioni, dove stanno anche i **LoRA**. +Klein ha i suoi file (versioni int8 o bf16, più l'encoder Qwen3-VL 4B e il VAE FLUX.2): si scaricano con un clic dal selettore «Modello che disegna». ## E poi @@ -128,7 +132,7 @@ AtomicChat o di mradermacher. Si cambiano e si scaricano da Opzioni. **Riusa**, **Varia**, **Rifinisci 2K**, **Ingrandisci 2×/4×**. Le impostazioni stanno dentro ogni PNG: trascini un'immagine fatta con DaProd Image e torna con prompt, seed e LoRA. - **Ogni foto entra come PNG**: JPG, WEBP, AVIF, HEIC, TIFF, i RAW delle fotocamere, girata per il verso giusto. -- **LoRA con un clic**: trascini i `.safetensors` o incolli un link Hugging Face / Civitai, l'app controlla che +- **LoRA con un clic** (in Opzioni): trascini i `.safetensors` o incolli un link Hugging Face / Civitai, l'app controlla che siano davvero per Qwen-Image 2.1, aggiunge le parole chiave al prompt. - **La barra in alto** dice sempre quanta RAM e VRAM stai usando e quanto lavora la scheda video. @@ -139,21 +143,22 @@ AtomicChat o di mradermacher. Si cambiano e si scaricano da Opzioni. **Ci mette minuti per una foto?** Quasi sempre è la VRAM finita: Windows continua nella RAM del PC e tutto va 5-10 volte più piano senza dare errori. Nel *Pannello di controllo NVIDIA → Gestisci impostazioni 3D* metti -*Criterio di fallback della memoria di sistema CUDA* su **Preferisci nessun fallback**, poi usa il **Turbo** e, +*Criterio di fallback della memoria di sistema CUDA* su **Preferisci nessun fallback**, poi scegli **Klein veloce** e, se la scheda è da 6 GB, il profilo **6 GB** in Opzioni. L'app ti avvisa da sola quando va troppo piano. ## Come funziona dentro -- Il motore è **ComfyUI** a una versione fissata, senza la sua interfaccia, installato con **uv** in +- Il motore è **ComfyUI 0.42.3**, a una versione fissata, senza la sua interfaccia, installato con **uv** in `%LOCALAPPDATA%\DaProdImage`. Il modello è [Qwen-Image 2.1 Uncensored GGUF](https://huggingface.co/abenzerps/Qwen-Image-2.1-Uncensored-GGUF) (Q4_K_M per 6-8 GB) con i passi della pipeline ufficiale (40, euler, CFG 1). In alternativa **FLUX 3 Klein** (`black-forest-labs/flux-3-image-klein-comfy`), con il suo encoder Qwen3-VL 4B e il VAE FLUX.2. - **ComfyUI-GGUF** viaggia dentro l'app: il fork di leejet, l'unico che conosce `qwen_image21`, con una nostra - correzione per ComfyUI 0.39 (`engine/custom_nodes/ComfyUI-GGUF/DAPROD.md`). I **DaProd-Nodi** riempiono la zona + correzione per ComfyUI 0.39 e successive (`engine/custom_nodes/ComfyUI-GGUF/DAPROD.md`). I **DaProd-Nodi** riempiono la zona prima di "rimuovi" e accordano i colori dopo il VAE, così l'incollaggio non si vede. - Anteprime dal vivo con **TAEQI 2.1** di madebyollin; traduzione dei prompt con **Opus-MT** (un modello di sola traduzione, sul processore, ~300 MB, scaricato da solo); foto convertite con WIC di Windows, come in [DaP Convertitore](https://github.com/cammo22/DaP-Convertitore). -- Su una RTX 4060: circa 3 secondi per passo a 1 MP, un'immagine da 40 passi in circa 2 minuti, col Turbo in circa 30 secondi. +- Su una RTX 4060 da 8 GB, a 1 MP: Qwen base circa 3 secondi per passo (40 passi in poco meno di 3 minuti a freddo), **Klein veloce 8 passi in circa 7-16 secondi**, + Klein Base 50 passi in circa un minuto. Le misure complete sono in [docs/misure-1.1.0.md](docs/misure-1.1.0.md). ## Sviluppare @@ -169,5 +174,5 @@ quando su `main` arriva un `version` nuovo in `package.json`. Le regole del prog ## Licenze App: MIT. Qwen-Image 2.1 e Texture-Fix VAE: Qwen Research License. ComfyUI: GPL-3.0. ComfyUI-GGUF: Apache-2.0. -4x-UltraSharp: CC-BY-NC-SA 4.0. TAESD/TAEQI: MIT. Turbo8: vedi la sua [pagina](https://huggingface.co/chriswritescode/Turbo8-LoRA-Qwen-Image-2.1). +4x-UltraSharp: CC-BY-NC-SA 4.0. TAESD/TAEQI: MIT. Il modello "Uncensored" non ha filtri: quello che generi è responsabilità tua. diff --git a/docs/confronto-grafi-qwen-708b74d-vs-head.md b/docs/confronto-grafi-qwen-708b74d-vs-head.md new file mode 100644 index 0000000..789fd58 --- /dev/null +++ b/docs/confronto-grafi-qwen-708b74d-vs-head.md @@ -0,0 +1,17 @@ +# Confronto grafi di Qwen - commit 708b74d vs HEAD + +Generato da `test/confronta_grafi.mjs`. Casi verificati: 9, identici: 9, diversi: 0. + +| Qualita | Scenario | Esito | Passi nel grafo | +|---|---|---|---:| +| bozza | Crea | identici | 20 | +| bozza | Modifica zona | identici | 20 | +| bozza | Espandi | identici | 20 | +| alta | Crea | identici | 40 | +| alta | Modifica zona | identici | 40 | +| alta | Espandi | identici | 40 | +| massima | Crea | identici | 50 | +| massima | Modifica zona | identici | 50 | +| massima | Espandi | identici | 50 | + +Tutti i grafi di Qwen sono identici tra 708b74d e HEAD. diff --git a/docs/misure-1.1.0.md b/docs/misure-1.1.0.md new file mode 100644 index 0000000..2718ac8 --- /dev/null +++ b/docs/misure-1.1.0.md @@ -0,0 +1,116 @@ +# Misure GPU per la 1.1.0 (T12) + +Fatte il 21/10/2026 con `scripts/misure_gpu.mjs`: lo script costruisce i grafi veri dell'app (`src/main/motore/grafi.ts`, `costruisci()`), +li manda a un ComfyUI 0.41.2 avviato a parte (porta 8819, stesso `main.py`, stessi argomenti dell'app) e cronometra. +Prove **una alla volta**, `/free` fra l'una e l'altra quando si misura «a freddo». Nessun subagent, nessuna automazione dello schermo: solo script e API. + +- Scheda: **RTX 4060 8 GB** (8188 MiB), 64 GB di RAM, driver e PyTorch 2.14 cu130 dell'engine installato (motore 7). +- Stesso prompt e stesso seed (424242) per ogni prova; 1024×1024 (1 MP). Impostazioni dell'app com'erano sulla macchina + (VAE Texture-Fix, denoiser UC Q4_K_M, Klein int8 «convrot», 4B int8, VAE di Klein `flux2-vae`). +- Prompt di Crea: *a ceramic teapot with blue floral patterns on a rustic wooden table by a window, soft morning light, steam rising, shallow depth of field*. + Modifica e zona: *make the teapot bright glossy red* (zona = rettangolo 420×380 sul corpo della teiera, contesto 0,8, ritaglio ingrandito come in app). +- **Una nota onesta sul «niente browser aperti»**: Chrome di Cammo era aperto durante le misure (non l'ho chiuso: non potevo sapere se c'era lavoro non salvato). + `nvidia-smi` mostrava 0-177 MiB usati e nessun processo GPU prima di ogni giro, quindi la scheda era libera; la CPU però non era dedicata. + Chi ripete le misure a macchina completamente ferma può scostarsi di qualche punto percentuale. +- «A freddo» = modelli scaricati dalla VRAM con `/free`, ma i file restano nella cache del disco di Windows (un vero primo avvio dopo il riavvio costa di più). + «Caldo» = il modello è già in VRAM (la prova precedente era dello stesso modello). + +## Tabella + +| Prova | Totale | Campionatore | s/passo | VRAM picco | +|---|---:|---:|---:|---:| +| **Klein veloce · Crea · 8 passi · a freddo** | 16,2 s | 3,3 s | 0,47 | 6,8 GB | +| Klein veloce · Crea · 8 passi · caldo | 7,2 s | 3,2 s | 0,46 | 6,9 GB | +| Klein veloce · Crea · 16 passi · caldo | 10,7 s | 7,4 s | 0,50 | 5,8 GB | +| Klein veloce · **zona** · 8 passi · caldo | 26,4 s | 13,0 s | 1,86 | 7,3 GB | +| Klein veloce · zona · 12 passi | 31,5 s | 21,5 s | 1,96 | 7,3 GB | +| Klein veloce · zona · 16 passi | 40,6 s | 30,1 s | 2,01 | 7,3 GB | +| Klein veloce · Modifica tutta la foto · 8 passi | 13,9 s | 7,3 s | 1,04 | 7,1 GB | +| Klein veloce · Modifica tutta la foto · 16 passi | 21,4 s | 16,9 s | 1,12 | 6,9 GB | +| **Klein Base · Crea · 50 passi (CFG 3,5) · a freddo** | 59,9 s | 49,5 s | 1,01 | 6,9 GB | +| Klein Base · Crea · 50 passi · caldo (seed 77) | 55,8 s | 49,5 s | 1,01 | 6,9 GB | +| Klein Base · zona · 50 passi | 193,9 s | 174,8 s | 3,57 | 7,3 GB | +| Qwen base · Crea · 40 passi · a freddo | 165,9 s | 112,3 s | 2,88 | 6,2 GB | +| Modifica con riferimento · `ReferenceLatent` in catena · 8 passi | 20,2 s | 12,9 s | 1,85 | 7,3 GB | +| Modifica con riferimento · **`ReferenceMultiLatent`** · 8 passi | 20,3 s | 12,9 s | 1,85 | 7,3 GB | + +Nota: la prima prova «a freddo» di Klein carica 5,6 GB di denoiser e il 4B: costa circa 9 s in più della stessa prova a caldo. + +### Testo col 4B (T5, `testoCon4B`) e «Migliora prompt + Crea» con Qwen + +«Migliora prompt» = il lavoro di testo dell'app (`SISTEMA_MIGLIORA`, fino a 380 token), poi `/free` (cambio di modello, come `fattiSpazio`), poi un Crea Qwen a 40 passi col prompt migliorato. +Si parte con Qwen già caricato (riscaldamento), come quando l'utente ha appena disegnato. + +| Chi scrive il prompt | Velocità di scrittura | Testo | Scarichi `/free` | Crea Qwen 40 passi | **Totale** | +|---|---:|---:|---:|---:|---:| +| Encoder 8B **int8** (predefinito dell'app), senza il 4B | 3,3 token/s | 56,3 s | 4,0 + 1,5 s | 124,1 s | **185,9 s** | +| Qwen3-VL **4B**, con `testoCon4B` | 46 token/s* | 5,4 s | 3,3 + 1,5 s | 123,3 s | **133,5 s** | +| Encoder GGUF Q4 «uncensored» (le impostazioni di questa macchina), senza il 4B | 0,55 token/s | 299,0 s | 4,5 + 2,3 s | 139,6 s | **445,5 s** | +| Qwen3-VL **4B**, con `testoCon4B` (stessa macchina) | 47 token/s* | 4,7 s | 4,5 + 1,5 s | 139,3 s | **150,0 s** | + +\* Velocità misurata dai messaggi di avanzamento sul solo tratto di generazione: 46-47 token/s sul 4B (128 token in 2,7 s), 0,55 token/s sull'8B GGUF (157 token in 286 s), +3,3 token/s sull'8B int8 (120 token in 36 s). +Il tempo del testo col 4B (5 s) comprende anche il caricamento dell'encoder. + +Il costo temuto dal piano (il denoiser di Qwen che si ricarica dopo il testo) c'è, ma **si paga uguale con l'8B**: anche l'8B fa uscire il denoiser +(`/free` a ogni cambio di modello), quindi il ricaricamento è lo stesso nei due casi (circa 124-140 s di Crea contro 112 s di campionatore: la differenza è il caricamento). +Il 4B toglie solo il tempo di scrittura, e la qualità dei testi è quella attesa (paragrafo ricco da 100-130 parole, stesse regole del sistema). + +## Decisioni (applicate nel codice) + +1. **Klein in Modifica e in zona resta a 8 passi.** Rosso lucido sulla teiera: 8, 12 e 16 passi danno lo stesso risultato per l'occhio (stessa tinta, stessi bordi, + nessuna cucitura; Modifica a tutta foto idem). Il costo cresce quasi linearmente (zona: 26 s a 8 passi, 31 a 12, 41 a 16; Modifica: 14 s contro 21 s a 16). Niente 12 passi: nessun numero cambia nella mappa + (`src/shared/modelli.ts`: `klein.passi` resta 8). + Prova fatta anche con un'istruzione più difficile («turn the teapot into a polished brass teapot with engraved patterns»): a tutti i passi il modello cambia poco + (ceramica giallina invece di ottone) e 8 non è peggio di 16. È un limite del modello su quell'istruzione, non dei passi. +2. **`testoCon4B` acceso di serie** (`src/main/impostazioni.ts`): «Migliora prompt + Crea» con Qwen scende da 186 s a 134 s con l'encoder 8B int8 predefinito + (da 446 s a 150 s con l'8B GGUF). La regola `scriveCol4B` lo usa solo se il file del 4B c'è sul disco: chi non ha scaricato il 4B (Klein) resta con l'8B, niente errori. + L'interruttore in Opzioni resta per chi vuole l'8B; testo dell'interruttore aggiornato. +3. **`ReferenceMultiLatent` non sostituisce la catena di `ReferenceLatent`**: stesso tempo (20,3 s contro 20,2-21,5 s) e immagine **identica al pixel** (differenza massima 0 fra le due uscite). + Nessun vantaggio, nessun cambio nel grafo. Il nodo si chiama `ReferenceMultiLatent` («Set Reference Multi Latent», `images.image1…`), non `SetReferenceMultiLatent`. +4. **Klein Base a 50 passi entra negli 8 GB**: picco 6,9 GB in Crea e 7,3 GB in zona, nessun rallentamento da memoria condivisa (1,01 s/passo in Crea; 3,6 s/passo in zona con tre foto di riferimento + nel condizionamento). Crea in 56-60 s; zona 3,2 minuti: lenta ma regolare. Il valore 50 resta. + +## Altri numeri utili + +- Klein veloce zona a 8 passi: 26 s totali (di cui 13 s di campionatore); Klein Base zona: 7 volte tanto. Ha senso dire in interfaccia che «Rifai in qualità piena» da zona costa ~3 minuti. +- Nel confronto Crea a 50 passi, Klein Base disegna una composizione diversa da Klein veloce con lo stesso seed (come atteso: modelli diversi): più dettaglio fine sul decoro, teiera più grande nell'inquadratura. +- L'encoder 8B int8 è tre volte più veloce nel testo del GGUF Q4 (3,3 contro 0,55 token/s): chi ha il GGUF come encoder predefinito ha un motivo in più per accendere il 4B. + +Come ripeterle: avviare un ComfyUI sulla porta 8819 e lanciare `node scripts/misure_gpu.mjs --out --prova ` (`--prova elenco` stampa i nomi); +`MOTORE_OUT` dice dove il motore salva le immagini, `ENCODER_QWEN` cambia l'encoder di Qwen, `PROMPT_ZONA` il prompt di zona e Modifica. + +--- + +# ComfyUI 0.42.3 (T13) + +Prova su un ramo a parte (`prova/comfyui-0.42`), senza toccare l'engine installato (0.41.2, motore 7): zip del commit `edb3db0` (tag `v0.42.3`) estratto in una cartella di prova, +stessi `custom_nodes` dell'app (`ComfyUI-GGUF` con la nostra correzione in `ops.py`, `DaProd-Nodi`), stesso Python/PyTorch (2.14 cu130), sulla porta 8820. +Le tre dipendenze che la 0.42.3 alza nei `requirements.txt` (`comfy-kitchen` 0.2.37 → 0.2.39, `comfyui-frontend-package` 1.55.17 → 1.55.18, `comfyui-workflow-templates` 0.11.86 → 0.11.93) +sono state messe in una cartella a parte, vicino alla prova (`PYTHONPATH`); l'installatore dell'app le aggiorna da solo (`requisiti` = commit). +Stessa serie di grafi, stesso seed (424242), stessi file, scheda libera prima di ogni prova. + +| Prova | 0.41.2 | 0.42.3 | Immagine | +|---|---:|---:|---| +| Qwen base · Crea · 40 passi · a freddo (**GGUF Q4 + correzione `ops.py`**) | 165,9 s · 2,88 s/passo | 139,6 s · 2,87 s/passo | **identica al pixel** (differenza massima 0) | +| Klein veloce · Crea · 8 passi · a freddo | 16,2 s · 0,47 s/passo | 14,3 s · 0,47 s/passo (9,7 s alla ripetizione) | identica | +| Klein veloce · Crea · 8 / 16 passi · caldo (ripetuto) | 0,46 / 0,50 s/passo | 0,46 / 0,49 s/passo | 16 passi identica | +| Klein veloce · zona · 8 passi | 26,4 s | 23,2-29,7 s (1,86-2,02 s/passo; la prova più lenta era dentro la serie lunga, le due ripetizioni a 1,86-1,88) | identica | +| Klein veloce · Modifica · 8 passi | 13,9 s | 13,9 s | identica | +| Klein veloce · riferimento con `ReferenceMultiLatent` · 8 passi | 20,3 s | 22,4 s | — | +| Klein Base · Crea · 50 passi · a freddo | 59,9 s · 1,01 s/passo | 57,5 s · 1,01 s/passo | identica | +| Testo col 4B (380 token al massimo) | 46 token/s | 49 token/s (129 token in 2,6 s) | — | +| Testo con l'8B int8 (120 token) | 36,4 s · 3,3 token/s | 36,0 s · 3,3 token/s | — | +| **Migliora prompt + Crea** con il 4B (8B int8 per il Crea) | 133,5 s | 134,7 s | — | +| **Migliora prompt + Crea** senza il 4B (8B int8) | 185,9 s | 183,2 s | — | + +(Il totale a freddo dipende dalla cache dei file di Windows: il campionatore, che è la parte che conta per il confronto fra le due versioni, è uguale. Le piccole differenze sul caldo di Klein, in una prima serie 0.46 contro 0.56 s/passo, sono rumore: ripetute dopo, 0,46 / 0,49 come nella 0.41.2.) + +Esito: +- **Tutte le immagini delle prove sono identiche al pixel** fra 0.41.2 e 0.42.3 (Qwen GGUF, Klein 8 e 16 passi, Klein zona, Klein Modifica, Klein Base 50 passi). Nessun cambio di qualità. +- **La correzione GGUF in `ops.py` regge**: il KSampler di Qwen parte e termina, nessuna eccezione `unexpected keyword argument` né nel log del motore (0 `Traceback`). I cambi della 0.42 in `comfy/ops.py` + (`linear_input_act` per i pesi quantizzati) non toccano `GGMLOps.Linear`, che ha `comfy_force_forward = True`. +- **Velocità**: uguale. Il «Qwen 3.x: generazione di testo più veloce» della 0.42.0 non si vede sul nostro `TextGenerate` (4B: 46 → 49 token/s, 8B: invariato, nella rumorosità delle misure). + Nessun vantaggio di velocità, ma nemmeno uno svantaggio; la prova di memoria («release memory reliably», 0.42.1) ha fatto tutti i `/free` fra le prove senza residui (VRAM a 0 MiB a fine serie). +- Decisione: **`motore: 8`**, ComfyUI `edb3db00fd88f059e748c8a400391b5eab76266b` (0.42.3) in `engine/versioni.json`; al primo avvio dopo l'aggiornamento l'app aggiorna il motore da sola (modo «aggiorna»). +- Non provato qui: l'aggiornamento vero dell'engine installato dall'app (passo di installazione in Electron). La 0.42.3 gira con lo stesso venv e le stesse dipendenze alzate, ma il percorso dell'installatore con `motore: 8` va visto una volta con l'app vera (`node test/setup.mjs --installa`, che richiede Electron e la scheda libera). diff --git a/docs/piano-release.md b/docs/piano-release.md new file mode 100644 index 0000000..6a0eb8a --- /dev/null +++ b/docs/piano-release.md @@ -0,0 +1,226 @@ +# Piano della grande release (1.1.0): Modifica e Lab insieme, tre modelli, meno tasti + +Scritto da claude-opus il 21/10/2026 (task t_e7c42490). È **solo un piano**: niente codice, niente GPU, niente push. +Si parte dal ramo `feat/klein-upsampler-veloce` (1.0.2 + le modifiche non committate di `lavori.ts`, vedi §7). + +Cosa ha chiesto Cammo: +1. guardare le novità di Hugging Face e ComfyUI e prendere quello che conviene; +2. unire Modifica e Lab; +3. selettore del modello **sempre visibile**, con tre modelli soli (D1 rivista: Qwen base, Klein veloce a 8 passi, Klein Base); +4. molti meno tasti, solo il meglio; +5. pensare alla GPU da ~8 GB: niente carichi in parallelo, tenere d'occhio la memoria, niente Chrome nelle prove. + +--- + +## 0. Decisioni (prese qui, da confermare con Cammo prima di T3) + +- **D1 — I tre modelli (rivista da Cammo il 21/10: niente Turbo di Qwen, a Cammo non piace).** Due famiglie, tre voci: + - **Qwen base** (`qwen-base`) = Qwen-Image 2.1 pieno, **40 passi**, CFG 1, con tutti i LoRA. È il modello di partenza. + - **Klein veloce** (`klein`) = FLUX 3 Klein distillato a **8 passi** (oggi 16), senza CFG: è lui il "Flux turbo". BFL dice 4–16 passi per il distillato, quindi 8 rientra. + - **Klein Base** (`klein-base`) = FLUX 3 Klein pieno, 50 passi con CFG 3,5: **resta nell'interfaccia** (la qualità piena di Klein). + - **Il Turbo di Qwen sparisce**: né il LoRA Turbo8 né il GGUF Turbo di AtomicChat si propongono più. Il LoRA non si scarica più da solo + (`lora-turbo` non è più `consigliato`); chi ha il GGUF Turbo come denoiser torna, una volta sola, a quello pieno dello stesso quant se c'è. + I file e il codice del Turbo nei grafi restano (i PNG vecchi con `turbo: true` si riaprono), ma l'interfaccia non manda più `turbo: true`. +- **D2 — Il backend non cambia forma.** `Famiglia` (`qwen | klein | klein-base`) resta com'è nel main e nei PNG. La mappa unica è + `src/shared/modelli.ts` (fatta in T2): `IdModello = 'qwen-base' | 'klein' | 'klein-base'`, + `qwen-base → {famiglia:'qwen', turbo:false, passi:40}`, `klein → {famiglia:'klein', passi:8}`, `klein-base → {famiglia:'klein-base', passi:50}`; + `FAMIGLIE` in `tipi.ts` prende nomi e passi da lì. Nelle impostazioni il campo nuovo si chiama **`modelloScelto`** (non `modello`, che è già il file + del denoiser di Qwen) e resta sempre d'accordo con `famiglia` (`allineaScelta`). Così **i grafi di Qwen non cambiano di un nodo** (regola del CLAUDE.md) + e i PNG vecchi si riaprono (`modelloDaOpera`: Klein Base, Klein, tutto il resto Qwen base). +- **D3 — La Qualità sparisce.** Ogni modello ha i suoi passi. Il flusso "bozza → rifinisci" diventa: esplori con **Klein veloce**, + sulla foto che ti piace premi **Rifai in qualità piena** (stesso seed, stesso prompt) con **Klein Base** (il più vicino) o **Qwen base** + (`inQualitaPiena('klein')`). Qwen base e Klein Base sono già pieni: lì il pulsante non c'è. `qualita` resta nella `Richiesta` + per i PNG e le ricette vecchie (`turbo` vale `alta`: `senzaTurbo`), ma l'interfaccia non la mostra più. +- **D4 — Il nome.** La pagina unica si chiama **Modifica**. Le ricette del Lab diventano la scheda **Ricette** dentro Modifica. +- **D5 — Versione.** 1.1.0 (interfaccia e modelli). Se in T13 si alza ComfyUI, anche `motore: 8` in `engine/versioni.json`. + +--- + +## 1. Novità di ComfyUI e Hugging Face (controllate oggi, 21/10/2026) + +Noi siamo su **ComfyUI 0.41.2**. L'ultima stabile è la **0.42.3** (21/10). Cosa c'è di utile per noi: + +| Novità | Dove | Per noi | Decisione | +|---|---|---|---| +| "Qwen 3.x performance: faster text generation" | ComfyUI 0.42.0 | Il nostro `TextGenerate` gira su Qwen3-VL (4B e 8B): più token al secondo per Migliora prompt, Idee, Estrai, potenziamento | **Sì** (T13), con prova vera | +| "Synchronize model-prefetch graph cleanups so repeated runs release memory reliably" | 0.42.1 | È proprio il nostro caso: tanti lavori di fila e `/free` fra testo e immagine | **Sì** (T13) | +| `SetReferenceMultiLatent` (più latenti di riferimento da un nodo) | 0.41.0, c'è già | Alternativa ai `ReferenceLatent` in catena di Klein, che il CLAUDE.md dà per "non provati" | Prova in T12, cambio solo se meglio | +| FLUX.3 Layout Prompt (riquadri nel prompt) | 0.41.0 | Bello per Estrai/zona con Klein | **Dopo** questa release | +| Klein distillato "4–16 passi" | docs.comfy.org, tutorial Klein | Conferma D1 (Klein a 8 passi) | Sì | +| Klein in nvfp4 | HF di BFL | Serve una RTX 50 (Blackwell); la 4060 è Ada | No, resta int8 | +| Upsampler ufficiale Qwen3.5 9B | template Klein | 9B non sta in 8 GB con Klein | No, resta il 4B | +| Qwen-Image 2.1: fino a 10 riferimenti, modifiche locali "con cerchi e annotazioni dipinte" | HF Qwen/Qwen-Image-2.1 | Conferma il nostro modo della zona (velo rosso sull'ultima immagine) | Niente da cambiare | +| MOSS-Image, Krea 3, Bria FIBO 2 | 0.41.1–0.42.3 | Modelli nuovi o a pagamento (partner node) | No: Cammo vuole meno modelli, non di più | +| Dynamic VRAM (dalla 0.18, attiva di serie) | blog Comfy | Già in uso; `--lowvram` viene ignorato | Niente flag nuovi | + +Rischio di T13: il nostro `ComfyUI-GGUF` ha una correzione in `ops.py` per la 0.39+/0.41. Va riprovata una generazione vera +prima di pubblicare (regola del CLAUDE.md). Se si rompe, la 0.42 si rimanda e la release esce con la 0.41.2: **T13 non blocca il resto**. + +--- + +## 2. Il flusso dell'interfaccia dopo + +### La cornice (sempre) +- **Barra in alto, accanto a RAM/VRAM/GPU: il selettore del modello**, tre pillole: `Qwen base · 40 passi` / `Klein veloce · 8 passi` / `Klein Base · 50 passi` (ordine di `ORDINE_MODELLI`). + Si vede su tutte le pagine (oggi `ScegliModello` sta in cima a Crea e Modifica e occupa una sezione intera). + La pillola di un modello da scaricare ha un puntino; un clic apre il pannellino di download che c'è già. + Durante un lavoro il selettore resta cliccabile, ma vale **dal lavoro dopo** (si scrive "dal prossimo lavoro"). +- **Rotaia a sinistra: 4 voci** — Crea, Modifica, Galleria, Album. **LoRA va dentro Opzioni** (scheda LoRA, stessa pagina di oggi). + Lab sparisce dalla rotaia. + +### Crea +Restano: prompt (`Casella`) con **Migliora prompt**, **Stili** (i preset in un menu solo), **Formato** (proporzioni a chip + lato), **Quante** (1/2/4), pulsante **Crea**. +LoRA: una riga "LoRA · 2 accesi" che si apre (solo con i modelli Qwen). +Sotto **Altro** (chiuso): seed, megapixel del modello, misura precisa. +Vanno via: Qualità/Bozza (D3), sampler, scheduler, CFG, negativo, "Rimetti quelli ufficiali" (si usano sempre quelli ufficiali del modello). + +### Modifica (Modifica + Lab) +A sinistra la foto (o le foto), in mezzo la tela, a destra **quattro schede**: + +1. **Scrivi** — prompt + Idee (le azioni rapide a interruttore, `regista.ts`). Niente più "tutta la foto" o "zona" da scegliere: + **se hai disegnato una zona vale la zona, se no tutta la foto** (`modalita` si decide al momento dell'invio). Pennello e Pulisci stanno sulla tela. +2. **Espandi** — i bordi come oggi (Quadrata, Azzera). +3. **Ricette** — il catalogo del Lab (le carte di `LabVista`), con dentro anche **Unisci foto** (Fusion) e **Come l'esempio** (Esempio), che oggi sono modi a parte. + Scelta una ricetta: i suoi ingressi, i passi da segnare, Avvia. Qui, e solo qui, si possono mettere **più foto** (il lotto di `avviaLotto`). +4. **Estrai** — come oggi. + +Sotto le schede, uguale per tutte: **Riferimenti** (chiuso se vuoto), **Quante versioni**, **Altro** (misura, LoRA), e il pulsante grande con la stima. +Il prima/dopo e "Continua da qui" restano. + +### Cosa va via (o si nasconde) +- Il selettore Qualità ovunque (Crea, Modifica, Laboratorio, `ScegliQualita.tsx`): sostituito da "Rifai in qualità piena" in galleria e sul risultato. +- `AvvisoTurbo` e `Turbo.tsx`: il Turbo di Qwen non c'è più (D1). Dalla T2 il selettore Qualità non mostra più la voce Turbo. +- In Modifica: la sezione Dimensioni sempre aperta (va in Altro), "Mostra la zona al modello" (sperimentale), il doppio selettore modello. +- Nel Lab: la sezione Preset delle ricette (si tengono le ricette tue, non i preset dei valori), "Vedi i prompt" (va dentro l'editor della ricetta). +- Il LoRA Turbo8 da "consigliato" (fatto in T2) e i `turbo-q*` dai suggerimenti del Setup. Klein Base resta. + +**Obiettivo misurabile**: oggi `Crea.tsx` + `Modifica.tsx` + `Laboratorio.tsx` hanno **72** righe con `onClick` (22 + 31 + 19). +Dopo, Crea + Modifica (con le ricette) devono stare **sotto 40**. Si conta con `grep -c onClick` in T14. + +--- + +## 3. Modelli e VRAM (RTX 4060, 8 GB) + +| Modello | Denoiser | Encoder per il disegno | Passi | VRAM attesa | +|---|---|---|---|---| +| Qwen base | UC Q4 GGUF 4,6 GB | Qwen3-VL 8B int8 (9,35 GB: non entra, va a pezzi) | 40 | piena, con scambi | +| Klein veloce | int8 ~5,6 GB | Qwen3-VL 4B (entra tutto) | 8 | ~7 GB | +| Klein Base | int8 | Qwen3-VL 4B | 50 (CFG 3,5) | ~7 GB, il doppio del calcolo per il CFG | + +Regole (alcune ci sono già, altre sono nuove): +- **Un lavoro alla volta** (`lavori.ts`, la coda): resta. Nessun precaricamento "per fare prima", nessun download pesante del modello mentre la GPU disegna + (il download va bene, è rete e disco, ma l'**import** di un modello nuovo aspetta la coda ferma). +- **Una cosa alla volta in VRAM** (`fattiSpazio`): oggi distingue solo `immagine / testo / cpu`. Passando da Qwen a Klein restano entrambi `immagine` + e **non parte `/free`**: due modelli da 5-6 GB si contendono VRAM e RAM. Nuovo: la chiave diventa `immagine:qwen`, `immagine:klein`, `testo:4b`, `testo:8b` + (T4). È l'unico cambiamento del main sulla memoria, e non tocca i grafi. +- **Testo sempre col 4B, se c'è.** Dalla 1.0.2 il 4B scrive 15-40 token/s contro 0,35 dell'8B. Oggi lo usa solo chi disegna con Klein. + Proposta: se il file del 4B c'è, `conEncoderDiTesto` lo usa **con qualsiasi modello** (Migliora prompt, Idee, Estrai, Fusion/Esempio, Sorpresa). + Costo: dopo un testo, il denoiser di Qwen si ricarica (si paga già oggi, perché l'8B fa uscire il denoiser). Da misurare in T12 prima di accenderlo di serie. + Il disegno di Qwen continua a usare l'8B (lo vuole il modello). +- **Monitorare**: la barra in alto ha già RAM/VRAM/GPU (`risorse.ts`, nvidia-smi ogni 2 s). Nuovo, piccolo: se la VRAM resta sopra il 95% per più di 10 s + a coda ferma, un avviso "la memoria della scheda non si è liberata, premi Libera" (pulsante che manda `/free`). Niente di automatico in più. +- **Profilo memoria** (`profiloMemoria()`): non cambia. + +--- + +## 4. File da toccare + +Renderer: +- `src/shared/modelli.ts` (fatto in T2, puro): `IdModello`, la mappa di D2, `modelloDa`, `modelloDaImpostazioni`, `modelloDaOpera`, `parametriModello`, `inQualitaPiena`, `senzaTurbo`, `denoiserSenzaTurbo`. +- `src/shared/tipi.ts`: `FAMIGLIE` dalla mappa (Klein a 8 passi); `Impostazioni.modelloScelto` (fatto in T2, migrazione in `src/main/impostazioni.ts`). `Famiglia` resta. +- `src/renderer/src/componenti/ScegliModello.tsx`: diventa la riga di pillole della barra in alto (la logica di download resta). +- `src/renderer/src/App.tsx`: selettore nella barra, rotaia a 4 voci, `Pagina` senza `laboratorio` (con redirect se arriva da localStorage). +- `src/renderer/src/stato.ts`: `ModoModifica` → `'scrivi' | 'espandi' | 'ricette' | 'estrai'`, migrazione di `tutta/zona → scrivi`, `fusion/esempio → ricette`. +- `src/renderer/src/pagine/Modifica.tsx` (839 righe): si spezza. `Modifica.tsx` tiene la tela e le schede; nuovi `componenti/SchedaScrivi.tsx`, + `SchedaEspandi.tsx`, `SchedaRicette.tsx` (prende il corpo di `Laboratorio.tsx`). **Hotspot**: un solo task alla volta su questo file. +- `src/renderer/src/pagine/Laboratorio.tsx`: svuotato in `SchedaRicette`, poi tolto. `workflow.ts`, `ricetteBase.ts`, `ricette.ts`, `LabVista.tsx`, `EditorRicetta.tsx` restano. +- `src/renderer/src/ricetteBase.ts`: due voci nuove, `unisci` (Fusion) ed `esempio`, di tipo "speciale" che mostrano `Fusion.tsx`. +- `src/renderer/src/pagine/Crea.tsx`: via Qualità, avanzate; Formato e Altro. +- `src/renderer/src/azioni.ts` (`perQualita`), `workflow.ts`: i passi vengono dal modello, non più dalla qualità. +- `src/renderer/src/componenti/ScegliQualita.tsx`, `Turbo.tsx`: tolti a fine lavoro. +- `src/renderer/src/pagine/Galleria.tsx` + `MenuContestuale.tsx`: "Rifai in qualità piena". +- `src/renderer/src/pagine/Impostazioni.tsx`: scheda LoRA (monta `pagine/Lora.tsx`), via Klein Base. +- `src/renderer/src/stile.css`: barra, pillole, schede. +- `test/finto.js`: stessi campi del main (`modello`, niente `laboratorio`). + +Main: +- `src/main/motore/lavori.ts`: chiave di `fattiSpazio` (T4), testo col 4B (T5), più le modifiche già in corso (§7). +- `src/main/installa/catalogo.ts`: il LoRA Turbo8 fuori dai consigliati (fatto in T2); i `turbo-q*` non si propongono nel Setup (T7). +- `src/main/impostazioni.ts`: migrazione `famiglia` (+ Turbo GGUF) → `modelloScelto` (fatto in T2). +- `engine/versioni.json`: solo se T13 va bene. +- **Non si toccano**: `grafi.ts` per Qwen (si controlla col confronto del grafo, T3), `engine/custom_nodes/*`, l'Album. + +Testi: `CHANGELOG.md` (1.1.0), `README.md`, la guida del primo avvio. + +--- + +## 5. Prove (senza Chrome, una cosa alla volta) + +- Ogni task: `npm run typecheck` e `npm run build`. +- Test puri con Node 26 (che legge i `.ts` da solo, togliendo i tipi): `node test/pulisci_didascalia.mjs`, e un nuovo `test/modelli.mjs` per la mappa di D2 e le migrazioni. +- **Confronto dei grafi di Qwen** (T3): uno script che costruisce i grafi di Crea/Modifica/Zona/Espandi con `costruisci()` prima e dopo e li confronta in JSON. Deve uscire "uguali". +- Interfaccia: `node test/foto.mjs` (Electron vero, a motore spento). **`test/interfaccia.mjs` (Chromium) in questa release non si usa**, come chiesto. +- GPU: le fa solo **@ollamo**, **una prova alla volta**, con l'app sola aperta (niente browser), log di `nvidia-smi` ogni 2 s salvato accanto al report. + +--- + +## 6. Rischi + +1. **Grafi di Qwen cambiati per sbaglio** passando da "qualità" a "modello" → confronto dei grafi (T3) prima di tutto, e di nuovo in T14. +2. **localStorage e impostazioni vecchie** (`modo: 'fusion'`, `pagina: 'laboratorio'`, `famiglia: 'klein-base'`, ricette tue con `qualita`) → + migrazioni in `stato.ts`/`impostazioni.ts` con test; mai buttare via i dati, solo tradurli. +3. **Klein a 8 passi peggio del previsto sulle modifiche** (zona, riferimenti) → T12 lo misura; se serve, Klein in Modifica usa 12 passi (solo un numero nella mappa). +4. ~~Turbo8 + altri LoRA~~: il Turbo di Qwen non c'è più (D1 rivista). Resta da vedere che i LoRA vadano su Qwen base a 40 passi, come oggi. +5. **Più foto in Modifica**: la tela è per una foto sola → il lotto è permesso solo nella scheda Ricette; nelle altre schede si usa la prima foto e si dice. +6. **`Modifica.tsx` troppo grande e toccato da tutti** → spezzarlo è il primo lavoro sull'interfaccia (T6), poi un task alla volta. +7. **ComfyUI 0.42 rompe la correzione GGUF** → T13 è separato e facoltativo. +8. **Testo col 4B con Qwen** fa ricaricare il denoiser più spesso → si accende di serie solo se T12 dice che conviene (tempo totale di un "Migliora prompt + Crea"). + +--- + +## 7. Le modifiche non committate (da integrare, non da scartare) + +Oggi nel ramo, fuori dalla 1.0.2: +- `src/main/motore/lavori.ts` (+60 righe): `pulisciDidascalia()` (toglie le frasi di cortesia e le regole che il 4B ricopia prima della didascalia) e il **seed casuale** + del potenziamento (prima era sempre 0). +- `test/pulisci_didascalia.mjs` (144 righe): prova `pulisciDidascalia` sui campioni veri, ma **ne ricopia la logica** ("stessa logica di…"). +- `test/campioni/out_*.txt`: due risposte vere del 4B. +- `scripts/bench_upsampler.mjs` (431 righe): misura tok/s, VRAM, RAM del potenziamento. + +Come si integrano (T1, prima di tutto il resto, uscita come **1.0.3**): +1. `pulisciDidascalia`, `PREAMBOLI`, `REGOLA`, `INIZIO_DIDASCALIA` e il vecchio `senzaTitolo` (fa la stessa cosa, il titolo in grassetto) vanno in un file puro + `src/main/motore/didascalia.ts`, senza import di Electron; `lavori.ts` lo importa. `senzaTitolo` usa la stessa funzione. +2. Il test importa `../src/main/motore/didascalia.ts` (Node 26) invece di ricopiare le regex: se cambia il codice, il test lo vede. +3. `test/campioni/` diventa `test/campioni-pup/` (nome chiaro), si committa (sono testi piccoli). +4. `bench_upsampler.mjs`: dice che il 4B si carica come `flux3`; dalla 1.0.1 per scrivere si usa `krea2` → si allinea, poi si committa. I `bench*.json` sono già nel `.gitignore`. +5. Il seed casuale: si tiene, ma la cache `potenziati` deve restare per prompt (lo è già: lo stesso prompt dà la stessa didascalia finché l'app è aperta). +6. Commit come `cammo22 <78992276+cammo22@users.noreply.github.com>`, senza push finché Cammo non dice ok. + +--- + +## 8. I task (30-60 minuti l'uno) + +| # | Task | Chi | Dipende da | Min | +|---|---|---|---|---| +| T1 | Integra le modifiche non committate (§7), 1.0.3 | sonnet | — | 45 | +| T2 | `src/shared/modelli.ts` + migrazione impostazioni + `test/modelli.mjs` (**fatto**, D1 rivista) | opus | T1 | 45 | +| T3 | Script di confronto dei grafi di Qwen (prima/dopo), eseguito su T2 | opus | T2 | 45 | +| T4 | `fattiSpazio` per famiglia (`immagine:qwen` / `immagine:klein` / `testo:4b` / `testo:8b`) | opus | T2 | 30 | +| T5 | Testo col 4B con qualsiasi modello, dietro un'impostazione spenta | sonnet | T4 | 30 | +| T6 | Spezza `Modifica.tsx` in tela + schede, senza cambiare niente di visibile | sonnet | T1 | 60 | +| T7 | Selettore modello nella barra in alto (tre pillole Qwen base / Klein veloce / Klein Base da `ORDINE_MODELLI`, salva `modelloScelto`), via i `turbo-q*` dal Setup, rotaia a 4 voci, LoRA in Opzioni | sonnet | T2 | 60 | +| T8 | Scheda Scrivi (zona automatica se c'è la maschera) + Espandi | sonnet | T6 | 60 | +| T9 | Scheda Ricette (dal Laboratorio) + Unisci/Esempio come ricette; via `Laboratorio.tsx` | sonnet | T8 | 60 | +| T10 | Crea essenziale: via Qualità e avanzate, Formato, Altro | sonnet | T7 | 45 | +| T11 | "Rifai in qualità piena" in galleria e sul risultato (solo sulle foto di Klein veloce: `modelloDaOpera` + `inQualitaPiena` → Klein Base o Qwen base, stesso seed); via `ScegliQualita`, `Turbo.tsx`, `AvvisoTurbo`, `turboPresente` | sonnet | T10 | 45 | +| T12 | Misure GPU (una alla volta, niente browser): Klein 8 vs 16 passi in Crea e zona, Klein Base 50 passi su 8 GB, testo 4B con Qwen, `SetReferenceMultiLatent` | ollamo | T5, T9 | 60 | +| T13 | ComfyUI 0.42.3 su un ramo a parte: generazione vera Qwen + Klein, `motore: 8` solo se va | beam | T1 | 60 | +| T14 | Controllo finale: conteggio `onClick` < 40, confronto grafi, typecheck, build, `test/foto.mjs` | ollamo | T9, T10, T11 | 30 | +| T15 | CHANGELOG 1.1.0, README, testi dell'interfaccia | haiku | T14 | 45 | +| T16 | Avviso "VRAM non liberata" + pulsante Libera | beam | T4 | 30 | + +Totale circa 12 ore di lavoro, in serie sui file "hotspot" (`Modifica.tsx`, `lavori.ts`), in parallelo solo dove i file non si toccano +(es. T7 e T6 sì, T8 e T9 no). + +Ordine consigliato: **T1 → T2 → (T3, T4, T6, T7) → T5, T8, T10 → T9, T11, T16 → T12, T13 → T14 → T15**. +D1 confermata e cambiata da Cammo il 21/10: Qwen base (40) + Klein veloce (8) + Klein Base (50), niente Turbo di Qwen. diff --git a/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md b/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md index 594ebd8..2119823 100644 --- a/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md +++ b/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md @@ -10,4 +10,4 @@ KSampler si ferma con `unexpected keyword argument 'input_act'`. Da ComfyUI 0.41 quei parametri non arrivano più alla `Linear` (c'è `comfy.ops.linear_input_act()`, che per i pesi GGUF ricade su `forward()`): la correzione resta compatibile con tutte e due le versioni. In più `GGMLOps.Linear` ha `comfy_force_forward = True`, così `--fast fp16_accumulation` non porta mai i pesi quantizzati nel percorso fp16. -Provato (CPU, Q8_0 reali) con ComfyUI 0.39 e 0.41.2; il motore ora è alla 0.41.2. +Provato (CPU, Q8_0 reali) con ComfyUI 0.39 e 0.41.2, e con una generazione vera (Qwen UC Q4 GGUF, 40 passi, RTX 4060) con la 0.42.3: immagine identica al pixel a quella della 0.41.2, stessa velocità; il motore ora è alla 0.42.3 (T13, docs/misure-1.1.0.md). diff --git a/engine/versioni.json b/engine/versioni.json index 2721388..2370f2e 100644 --- a/engine/versioni.json +++ b/engine/versioni.json @@ -1,9 +1,9 @@ { - "motore": 7, + "motore": 8, "python": "3.13", "comfyui": { - "commit": "b53f77a74e21091cbdebfdf7b85fc80326932646", - "versione": "0.41.2" + "commit": "edb3db00fd88f059e748c8a400391b5eab76266b", + "versione": "0.42.3" }, "torch": { "indice": "https://download.pytorch.org/whl/cu130", diff --git a/package-lock.json b/package-lock.json index 7061304..dd4f536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-image", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-image", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/package.json b/package.json index 3921eff..efe4f87 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-image", "productName": "DaProd Image", - "version": "1.0.2", + "version": "1.1.0", "description": "DaProd Image: crea e modifica foto con Qwen-Image-2.1 sul tuo PC (CUDA, da 6 GB di VRAM). Turbo a 8 passi, preset per poster e infografiche, zone con anteprima dal vivo, LoRA con un clic, galleria.", "author": "DaProd", "license": "MIT", diff --git a/scripts/bench_upsampler.mjs b/scripts/bench_upsampler.mjs new file mode 100644 index 0000000..254ffa7 --- /dev/null +++ b/scripts/bench_upsampler.mjs @@ -0,0 +1,431 @@ +#!/usr/bin/env node +// Benchmark del PROMPT UPSAMPLER di DaProdImage. +// +// Misura, sul motore ComfyUI gia' avviato (quello che avvia l'app), i tempi veri +// dell'upsampler: il nodo "TextGenerate" che scrive la didascalia. confrontando +// l'encoder di FLUX 3 Klein (Qwen3-VL 4B, type 'krea2', come in produzione) con quello di Qwen-Image +// (Qwen3-VL 8B, type 'qwen_image'). +// +// Per ogni caso misura: +// - i token al secondo (dai messaggi 'progress' del websocket: la stessa fonte +// del tok/s che l'app mostra in Coda) +// - il tempo di caricamento del text encoder e quello di generazione (separati) +// - la VRAM (torch_vram_total) e la RAM del processo del motore +// - il carico della GPU (nvidia-smi) e i core CPU usati dal processo +// - quante parole ha scritto davvero (l'upsampler che risponde "#" non serve a nulla) +// +// I prompt di sistema sono presi direttamente da src/main/motore/pup.ts: le misure +// riguardano cio' il codice di produzione, non una copia. +// +// Uso: +// node scripts/bench_upsampler.mjs +// node scripts/bench_upsampler.mjs --porta 8818 --out report.json --etichetta prima-fix +// node scripts/bench_upsampler.mjs --casi klein-4b-libera,qwen-8b +// node scripts/bench_upsampler.mjs --ripeti 3 +// +// Il file JSON e' pensato per essere confrontato fra un run prima e uno dopo il fix: +// i campioni hanno sempre gli stessi nomi. +import { spawn, spawnSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +// i prompt di sistema della produzione (type stripping di Node >= 22.18) +const { PUP_T2I, PUP_I2I, PUP_T2I_BREVE, PUP_I2I_BREVE } = await import( + pathToFileURL(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'main', 'motore', 'pup.ts')).href +) + +const args = process.argv.slice(2) +const opt = (nome, def) => { + const i = args.indexOf('--' + nome) + if (i < 0) return def + const v = args[i + 1] + return v && !v.startsWith('--') ? v : true +} +const PORTA = Number(opt('porta', 8818)) +const BASE = `http://127.0.0.1:${PORTA}` +const ETICHETTA = String(opt('etichetta', 'run')) +const RIPETI = Number(opt('ripeti', 1)) +const MAX_TOKEN = Number(opt('max-token', 300)) +const SOLO = String(opt('casi', '')) +const SOLO_LISTA = SOLO ? SOLO.split(',').map((s) => s.trim()) : [] +const TIMEOUT_CASO = Number(opt('timeout', 1200)) * 1000 +const CARICA_MODELLO = opt('carica-modello', '') // file del modello di diffusione da tenere in VRAM +const LIBERA = opt('libera', true) !== false // /free prima di ogni caso + +const KLEIN = 'qwen3vl_4b_int8_convrot.safetensors' +const QWEN = 'qwen3vl_8b_int8_convrot.safetensors' +const IMG = 'bench_test.png' // foto nel cartellone input del motore (per i casi i2i) + +const T2I = 'TARGET CANVAS: 1:1\na cat sitting on a windowsill in the rain' +const I2I = 'Reference images follow in order (the first image, the second image, ...).\nUser request: Make the scene look like it was shot in the rain at night.\nOutput the caption.' + +// i casi: encoder, tipo del loader, prompt di sistema, testo utente, con foto +const CASI = { + 'klein-4b-libera': { clip: KLEIN, tipo: 'krea2', sistema: PUP_T2I_BREVE, testo: T2I, foto: false }, + 'klein-4b-lungo': { clip: KLEIN, tipo: 'krea2', sistema: PUP_T2I, testo: T2I, foto: false }, + 'klein-4b-i2i': { clip: KLEIN, tipo: 'krea2', sistema: PUP_I2I_BREVE, testo: I2I, foto: true }, + 'klein-4b-cpu': { clip: KLEIN, tipo: 'krea2', sistema: PUP_T2I_BREVE, testo: T2I, foto: false, device: 'cpu' }, + 'qwen-8b-libera': { clip: QWEN, tipo: 'qwen_image', sistema: PUP_T2I, testo: T2I, foto: false }, + 'qwen-8b-breve': { clip: QWEN, tipo: 'qwen_image', sistema: PUP_T2I_BREVE, testo: T2I, foto: false }, + 'qwen-8b-i2i': { clip: QWEN, tipo: 'qwen_image', sistema: PUP_I2I, testo: I2I, foto: true } +} +const ordine = ['klein-4b-libera', 'klein-4b-lungo', 'klein-4b-i2i', 'qwen-8b-libera', 'qwen-8b-breve', 'qwen-8b-i2i', 'klein-4b-cpu'] + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function httpJson(path, opts) { + const r = await fetch(BASE + path, { signal: AbortSignal.timeout(30_000), ...opts }) + if (!r.ok) throw new Error(`${path} -> ${r.status} ${(await r.text()).slice(0, 200)}`) + const testo = await r.text() + if (!testo.trim()) return {} // /free risponde vuoto + return JSON.parse(testo) +} + +// --- il campionatore di sistema: GPU, RAM e CPU del processo del motore --- +function nvidiaSmi() { + const r = spawnSync('nvidia-smi', ['--query-gpu=utilization.gpu,memory.used,memory.total', '--format=csv,noheader,nounits'], { + encoding: 'utf8', + windowsHide: true + }) + if (r.status !== 0 || !r.stdout) return null + const p = r.stdout.trim().split(',').map((x) => Number(x.trim())) + return { gpuUtil: p[0], vramUsata: p[1], vramTotale: p[2] } +} + +// il PID del processo python che gira main.py (il motore) +function pidMotore() { + if (process.platform === 'win32') { + const r = spawnSync( + 'powershell', + [ + '-NoProfile', + '-Command', + "Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | Where-Object { $_.CommandLine -like '*main.py*' } | Select-Object -First 1 -ExpandProperty ProcessId" + ], + { encoding: 'utf8', windowsHide: true } + ) + const v = Number(String(r.stdout || '').trim()) + return v > 0 ? v : null + } + return null +} + +// campiona i tempi CPU del processo del motore: in Windows con PowerShell, altrove con /proc +function avviaCpu(pid) { + if (!pid) return null + if (process.platform === 'win32') { + const ps = `$ErrorActionPreference='SilentlyContinue' +$id=${pid} +while ($true) { + $pr = Get-Process -Id $id -ErrorAction SilentlyContinue + if (-not $pr) { break } + "{0},{1},{2}" -f [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(), $pr.TotalProcessorTime.TotalSeconds, [math]::Round($pr.WorkingSet64/1MB) + Start-Sleep -Milliseconds 400 +}` + const p = spawn('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true }) + return p + } + return null +} + +function leggiCpu(proc) { + return new Promise((ok) => { + if (!proc) return ok([]) + let buf = '' + const righe = [] + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (b) => { + buf += b + const rr = buf.split(/\r?\n/) + buf = rr.pop() || '' + for (const r of rr) if (r.trim()) righe.push(r.trim()) + }) + proc.on('close', () => ok(righe)) + proc.on('error', () => ok(righe)) + }) +} + +// --- il grafo del benchmark (uguale a quello che costruisce l'app) --- +function grafo(c) { + const nodi = { + '1': { class_type: 'CLIPLoader', inputs: { clip_name: c.clip, type: c.tipo, device: c.device || 'default' } }, + '2': { + class_type: 'TextGenerate', + inputs: { + clip: ['1', 0], + prompt: c.testo, + max_length: c.maxToken, + sampling_mode: 'on', + 'sampling_mode.temperature': 0.15, + 'sampling_mode.top_k': 20, + 'sampling_mode.top_p': 0.9, + 'sampling_mode.min_p': 0, + 'sampling_mode.repetition_penalty': 1.05, + 'sampling_mode.seed': 0, + 'sampling_mode.presence_penalty': 1.5, + thinking: c.tipo === 'krea2', // come grafi.ts: col 4B (krea2) `false` aggiunge un vuoto e risponde vuoto/«user» + use_default_template: true, + system_prompt: c.sistema + } + }, + '3': { class_type: 'PreviewAny', inputs: { source: ['2', 0] } } + } + if (c.foto) nodi['4'] = { class_type: 'LoadImage', inputs: { image: IMG } } + if (c.foto) nodi['2'].inputs.image = ['4', 0] + return nodi +} + +// --- un caso: accoda, ascolta i progress, aspetta la fine --- +async function eseguiCaso(nome, c) { + const clientId = 'bench-ollamo-' + Math.random().toString(36).slice(2) + const ws = new WebSocket(`ws://127.0.0.1:${PORTA}/ws?clientId=${clientId}`) + await new Promise((res) => { + ws.addEventListener('open', res) + ws.addEventListener('error', res) + }) + const campioni = [] + const eventi = [] + let pid = null + let esito = 'timeout' + let errore = '' + ws.addEventListener('message', (ev) => { + if (typeof ev.data !== 'string') return + let m + try { + m = JSON.parse(ev.data) + } catch { + return + } + const d = m.data || {} + if (d.prompt_id && d.prompt_id !== pid) return + if (m.type === 'progress' && d.node === '2') campioni.push({ n: d.value, max: d.max, t: Date.now() }) + else if (m.type === 'executing') eventi.push({ t: Date.now(), tipo: 'executing', nodo: d.node }) + else if (m.type === 'execution_error') { + esito = 'errore' + errore = `${d.node_type}: ${String(d.exception_message || '').slice(0, 300)}` + } else if (m.type === 'execution_success') esito = 'ok' + else if (m.type === 'execution_interrupted') esito = 'annullato' + }) + + const grafoDaMandare = grafo({ ...c, maxToken: c.maxToken || MAX_TOKEN }) + const accodato = await httpJson('/prompt', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: grafoDaMandare, client_id: clientId }) + }) + pid = accodato.prompt_id + + // il campionatore di sistema gira durante il caso + const pidMot = pidMotore() + const procCpu = avviaCpu(pidMot) + const t0 = Date.now() + const campioniSys = [] + const timer = setInterval(async () => { + try { + const s = await httpJson('/system_stats') + const d = (s.devices || []).find((x) => x.type === 'cuda') || (s.devices || [])[0] + const g = nvidiaSmi() + campioniSys.push({ + t: Date.now() - t0, + torchVram: d?.torch_vram_total ?? null, + vramLibera: d?.vram_free ?? null, + gpuUtil: g?.gpuUtil ?? null, + vramNvidia: g?.vramUsata ?? null + }) + } catch { + /* il motore potrebbe essere occupato: si prosegue */ + } + }, 500) + + while (Date.now() - t0 < TIMEOUT_CASO && esito === 'timeout') await sleep(300) + clearInterval(timer) + const durata = Date.now() - t0 + if (procCpu) procCpu.kill() + const righeCpu = await leggiCpu(procCpu) + ws.close() + + // il testo prodotto + let testo = '' + try { + const h = await httpJson('/history/' + pid) + for (const out of Object.values(h[pid]?.outputs || {})) { + if (out && Array.isArray(out.text)) testo += out.text.join('\n') + } + } catch { + /* niente */ + } + + // il tok/s: finestra fra il 25% e il 75% dei token (la parte "a regime") + const max = campioni.length ? campioni[campioni.length - 1].max : 0 + const finestra = campioni.filter((c2) => c2.n >= max * 0.25 && c2.n <= max * 0.75) + const usata = finestra.length >= 3 ? finestra : campioni + let tokSec = null + if (usata.length >= 2) { + const a = usata[0] + const b = usata[usata.length - 1] + const dt = (b.t - a.t) / 1000 + if (dt > 0 && b.n > a.n) tokSec = (b.n - a.n) / dt + } + // il tempo di caricamento: il primo messaggio 'progress' arriva quando il modello e' gia' in memoria + const primo = campioni.length ? campioni[0].t : null + const caricamento = primo ? primo - t0 : null + const cpu = calcolaCpu(righeCpu) + const parole = testo.trim() ? testo.trim().split(/\s+/).length : 0 + + return { + caso: nome, + esito, + errore, + encoder: c.clip, + tipo: c.tipo, + device: c.device || 'default', + promptSistema: c.sistema === PUP_T2I_BREVE ? 'breve' : c.sistema === PUP_T2I ? 'lungo' : c.sistema === PUP_I2I_BREVE ? 'breve-i2i' : 'lungo-i2i', + conFoto: !!c.foto, + maxToken: c.maxToken || MAX_TOKEN, + durataTotaleS: ar(1, durata / 1000), + caricamentoModelloS: caricamento === null ? null : ar(1, caricamento / 1000), + generazioneS: primo === null ? null : ar(1, (durata - caricamento) / 1000), + tokenGenerati: max, + tokenCampioni: campioni.length, + tokAlSecondo: tokSec === null ? null : ar(2, tokSec), + secondiPerToken: tokSec ? ar(3, 1 / tokSec) : null, + paroleScritte: parole, + testo: testo.trim().slice(0, 400), + torchVramMaxMB: maxVram(campioniSys), + gpuUtilMedio: media(campioniSys.map((x) => x.gpuUtil)), + gpuUtilMax: maxVram2(campioniSys.map((x) => x.gpuUtil)), + cpuCoreMedio: cpu ? ar(2, cpu.coreMedio) : null, + ramProcessoMB: cpu ? ar(0, cpu.ramMB) : null + } +} + +function ar(n, v) { + if (v === null || v === undefined || Number.isNaN(v)) return null + const p = Math.pow(10, n) + return Math.round(v * p) / p +} +function media(a) { + const b = a.filter((x) => typeof x === 'number' && !Number.isNaN(x)) + if (!b.length) return null + return b.reduce((x, y) => x + y, 0) / b.length +} +function maxVram(c) { + const b = c.map((x) => x.torchVram).filter((x) => typeof x === 'number') + return b.length ? ar(0, Math.max(...b) / 1048576) : null +} +function maxVram2(a) { + const b = a.filter((x) => typeof x === 'number' && !Number.isNaN(x)) + return b.length ? ar(0, Math.max(...b)) : null +} +function calcolaCpu(righe) { + if (!righe || righe.length < 2) return null + const p = righe + .map((r) => r.split(',')) + .map((a) => ({ t: Number(a[0]), cpu: Number(a[1]), ram: Number(a[2]) })) + .filter((x) => Number.isFinite(x.t) && Number.isFinite(x.cpu)) + if (p.length < 2) return null + const dt = (p[p.length - 1].t - p[0].t) / 1000 + const dc = p[p.length - 1].cpu - p[0].cpu + return { coreMedio: dt > 0 ? dc / dt : null, ramMB: p[p.length - 1].ram } +} + +// --- il modello di diffusione da tenere in VRAM (per misurare l'effetto "GPU occupata") --- +async function caricaModello(file) { + const g = { + '1': { class_type: 'UNETLoader', inputs: { unet_name: file, weight_dtype: 'default' } }, + '2': { class_type: 'EmptyLatentImage', inputs: { width: 256, height: 256, batch_size: 1 } }, + '3': { class_type: 'KSampler', inputs: { model: ['1', 0], seed: 0, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1, latent_image: ['2', 0] } }, + '4': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['5', 0] } }, + '5': { class_type: 'VAELoader', inputs: { vae_name: 'flux2-vae.safetensors' } }, + '6': { class_type: 'SaveImage', inputs: { filename_prefix: 'bench_carica', images: ['4', 0] } } + } + await httpJson('/prompt', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: g, client_id: 'bench-ollamo-carica' }) + }) + console.log(` (modello di diffusione caricato: ${file})`) +} + +async function liberaMemoria() { + await httpJson('/free', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ unload_models: true, free_memory: true }) + }) +} + +// --- main --- +console.log('='.repeat(78)) +console.log(`BENCHMARK PROMPT UPSAMPLER DaProdImage — etichetta: ${ETICHETTA}`) +console.log('='.repeat(78)) +try { + const s = await httpJson('/system_stats') + const d = (s.devices || [])[0] + console.log(` motore: ComfyUI ${s.system?.comfyui_version} | ${(d?.name || '').replace(/^cuda:\d+\s*/, '')} | VRAM ${ar(0, (d?.vram_total || 0) / 1048576)} MB`) +} catch (e) { + console.error(` il motore non risponde sulla porta ${PORTA}: ${e.message}`) + console.error(' avvialo con l-app (Opzioni -> Riavvia il motore) e rilancia') + process.exit(2) +} +console.log(` casi: ${ordine.filter((n) => !SOLO_LISTA.length || SOLO_LISTA.includes(n)).join(', ')} | ripeti: ${RIPETI} | max token: ${MAX_TOKEN}\n`) + +if (CARICA_MODELLO) await caricaModello(String(CARICA_MODELLO)) + +const risultati = [] +for (let giro = 1; giro <= RIPETI; giro++) { + for (const nome of ordine) { + if (SOLO_LISTA.length && !SOLO_LISTA.includes(nome)) continue + const c = CASI[nome] + if (LIBERA) await liberaMemoria() + await sleep(500) + process.stdout.write(` [giro ${giro}] ${nome.padEnd(18)} `) + const r = await eseguiCaso(nome, c) + risultati.push(r) + if (r.esito !== 'ok') { + console.log(`ERRORE (${r.esito}) ${r.errore || ''}`) + continue + } + console.log( + `${String(r.tokAlSecondo ?? 'N/D').padStart(6)} tok/s | ${r.tokenGenerati} tok | ` + + `caric. ${r.caricamentoModelloS}s | gen ${r.generazioneS}s | ${r.paroleScritte} parole | ` + + `VRAM ${r.torchVramMaxMB} MB | GPU ${r.gpuUtilMedio}% | CPU ${r.cpuCoreMedio} core` + ) + } +} + +// confronto +const klein = risultati.filter((r) => r.caso.startsWith('klein') && r.tokAlSecondo) +const qwen = risultati.filter((r) => r.caso.startsWith('qwen') && r.tokAlSecondo) +const confronto = {} +if (klein.length && qwen.length) { + const km = Math.max(...klein.map((r) => r.tokAlSecondo)) + const qm = Math.max(...qwen.map((r) => r.tokAlSecondo)) + confronto.kleinMiglioreTokSec = km + confronto.qwenMiglioreTokSec = qm + confronto.rapporto = ar(1, km / qm) +} + +const report = { + etichetta: ETICHETTA, + quando: new Date().toISOString(), + porta: PORTA, + gpu: (() => { + const g = nvidiaSmi() + return g ? { gpuUtil: g.gpuUtil, vramUsataMB: g.vramUsata, vramTotaleMB: g.vramTotale } : null + })(), + modelloTenutoInVram: CARICA_MODELLO || null, + casi: risultati, + confronto +} + +const out = String(opt('out', '')) +if (out) { + mkdirSync(dirname(resolve(out)), { recursive: true }) + writeFileSync(resolve(out), JSON.stringify(report, null, 2), 'utf8') + console.log(`\n report scritto in ${resolve(out)}`) +} else { + console.log('\n' + JSON.stringify(report, null, 2)) +} +if (confronto.rapporto) console.log(`\n Klein 4B ${confronto.kleinMiglioreTokSec} tok/s vs Qwen 8B ${confronto.qwenMiglioreTokSec} tok/s -> ${confronto.rapporto}x`) diff --git a/scripts/misure_gpu.mjs b/scripts/misure_gpu.mjs new file mode 100644 index 0000000..536112b --- /dev/null +++ b/scripts/misure_gpu.mjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node +// Misure GPU di DaProdImage (T12 del piano 1.1.0): tempi veri dei grafi che costruisce l'app (src/main/motore/grafi.ts), +// su un motore ComfyUI già avviato (non quello dell'app: una porta a parte, per non toccare il suo). +// +// Le prove sono SERIALI: una alla volta, con /free fra l'una e l'altra quando si misura «a freddo». +// Per ogni prova registra: tempo totale, tempo del KSampler (primo e ultimo passo), secondi per passo, +// picco di VRAM (nvidia-smi ogni secondo), e salva l'immagine in . +// +// Uso: +// node scripts/misure_gpu.mjs --porta 8819 --out cartella --prova klein-crea-8 +// node scripts/misure_gpu.mjs --porta 8819 --out cartella --prova elenco (stampa i nomi delle prove) +// +// Il file grafi.ts si importa com'è (type stripping di Node >= 22.18), con le stesse due toppe di test/confronta_grafi.mjs. +import { spawn, spawnSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { tmpdir } from 'node:os' + +const QUI = dirname(fileURLToPath(import.meta.url)) +const REPO = resolve(QUI, '..') +const args = process.argv.slice(2) +const opt = (n, d) => { + const i = args.indexOf('--' + n) + if (i < 0) return d + const v = args[i + 1] + return v && !v.startsWith('--') ? v : true +} +const PORTA = Number(opt('porta', 8819)) +const BASE = `http://127.0.0.1:${PORTA}` +const OUT = resolve(String(opt('out', join(tmpdir(), 'misure-gpu')))) +const PROVA = String(opt('prova', 'elenco')) +const SEME = Number(opt('seed', 424242)) +const ETICHETTA = String(opt('etichetta', '')) +mkdirSync(OUT, { recursive: true }) + +// ---- grafi.ts com'è ---- +function caricaGrafi() { + let s = readFileSync(join(REPO, 'src', 'main', 'motore', 'grafi.ts'), 'utf8') + s = s.replace(/^import type .*? from '@shared\/tipi'\r?\n/gm, '') + s = s.replace(/^import \{ modelloTurbo \} from '@shared\/tipi'\r?\n/m, 'const modelloTurbo = (file) => /turbo/i.test(file)\n') + s = s.replace("constructor(readonly memoria: Memoria = 'normale') {}", "memoria: Memoria = 'normale'\n constructor(memoria: Memoria = 'normale') { this.memoria = memoria }") + const p = join(tmpdir(), 'misure-gpu-grafi.ts') + writeFileSync(p, s) + return import(pathToFileURL(p).href) +} +const { costruisci } = await caricaGrafi() +const { SISTEMA_MIGLIORA } = await (async () => { + // la stringa di SISTEMA_MIGLIORA sta nel renderer (azioni.ts, ha import di React): la si legge dal sorgente + const t = readFileSync(join(REPO, 'src', 'renderer', 'src', 'azioni.ts'), 'utf8') + const m = /export const SISTEMA_MIGLIORA =\s*\n?\s*("(?:[^"\\]|\\.)*")/.exec(t) + return { SISTEMA_MIGLIORA: JSON.parse(m[1]) } +})() + +// ---- impostazioni: quelle vere dell'app, con i file di Klein al loro posto ---- +const IMPOSTAZIONI_APP = join(process.env.APPDATA || '', 'DaProd Image', 'impostazioni.json') +const imp = JSON.parse(readFileSync(IMPOSTAZIONI_APP, 'utf8')) +Object.assign(imp, { + kleinModello: 'flux3_image_klein_sd_int8_convrot.safetensors', + kleinBase: 'flux3_image_klein_base_int8_convrot.safetensors', + kleinEncoder: 'qwen3vl_4b_int8_convrot.safetensors', + kleinVae: 'flux2-vae.safetensors' +}) +const QWEN8_INT8 = 'qwen3vl_8b_int8_convrot.safetensors' +// ENCODER_QWEN=: prova con un altro encoder di Qwen (di serie l'app usa l'8B int8 safetensors; qui si parte da quello nelle impostazioni) +if (process.env.ENCODER_QWEN) imp.encoder = process.env.ENCODER_QWEN + +const PROMPT = 'a ceramic teapot with blue floral patterns on a rustic wooden table by a window, soft morning light, steam rising, shallow depth of field' +const PROMPT_ZONA = process.env.PROMPT_ZONA || 'make the teapot bright glossy red' +const PROMPT_ITA = 'una teiera di ceramica blu su un tavolo di legno vicino a una finestra, luce del mattino' + +// ---- motore ---- +const dorme = (ms) => new Promise((r) => setTimeout(r, ms)) +async function http(path, opts) { + const r = await fetch(BASE + path, { signal: AbortSignal.timeout(60_000), ...opts }) + if (!r.ok) throw new Error(`${path} -> ${r.status} ${(await r.text()).slice(0, 300)}`) + const t = await r.text() + return t.trim() ? JSON.parse(t) : {} +} +function smi() { + const r = spawnSync('nvidia-smi', ['--query-gpu=memory.used,utilization.gpu', '--format=csv,noheader,nounits'], { encoding: 'utf8', windowsHide: true }) + const p = (r.stdout || '').trim().split(',').map((x) => Number(x.trim())) + return { vram: p[0], gpu: p[1] } +} +async function libera() { + await http('/free', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ unload_models: true, free_memory: true }) }) + // come fa l'app: si aspetta che torch_vram_total scenda + const t0 = Date.now() + while (Date.now() - t0 < 60_000) { + const s = await http('/system_stats') + const d = s.devices[0] + if (d.vram_total - d.vram_free < 600 * 1048576 || (d.torch_vram_total ?? 0) < 600 * 1048576) break + await dorme(500) + } + await dorme(1500) +} + +async function carica(file, nome) { + const fd = new FormData() + fd.append('image', new Blob([readFileSync(file)]), nome) + fd.append('overwrite', 'true') + await fetch(BASE + '/upload/image', { method: 'POST', body: fd }) + return nome +} + +/** accoda un grafo e aspetta; ritorna i tempi e dove sta il risultato */ +async function esegui(grafo, etichetta) { + const clientId = 'misure-' + Math.random().toString(36).slice(2) + const ws = new WebSocket(`ws://127.0.0.1:${PORTA}/ws?clientId=${clientId}`) + await new Promise((ok) => { + ws.addEventListener('open', ok) + ws.addEventListener('error', ok) + }) + const promptId = crypto.randomUUID() + const prog = [] // [t, valore, max, nodo] + const nodi = [] // [t, nodo] + let testoOut = '' + let fine = false + let errore = '' + ws.addEventListener('message', (ev) => { + if (typeof ev.data !== 'string') return + let m + try { m = JSON.parse(ev.data) } catch { return } + const d = m.data || {} + if (d.prompt_id && d.prompt_id !== promptId) return + const t = Date.now() + if (m.type === 'progress') prog.push([t, d.value, d.max, d.node]) + else if (m.type === 'executing') nodi.push([t, d.node]) + else if (m.type === 'execution_error') { errore = d.exception_message || 'errore'; fine = true } + else if (m.type === 'execution_success') fine = true + else if (m.type === 'executing' && d.node === null) fine = true + }) + const campioni = [] + let ferma = false + const camp = (async () => { + while (!ferma) { + campioni.push({ t: Date.now(), ...smi() }) + await dorme(1000) + } + })() + const t0 = Date.now() + const r = await http('/prompt', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prompt: grafo.prompt, client_id: clientId, prompt_id: promptId }) }) + if (r.node_errors && Object.keys(r.node_errors).length) throw new Error('node_errors ' + JSON.stringify(r.node_errors).slice(0, 600)) + let h = null + while (Date.now() - t0 < 3_600_000) { + await dorme(700) + const hh = await http('/history/' + promptId).catch(() => ({})) + if (hh[promptId]?.status?.completed || hh[promptId]?.status?.status_str === 'error' || errore) { h = hh[promptId]; break } + } + const t1 = Date.now() + ferma = true + await camp + ws.close() + if (errore || !h || h.status?.status_str === 'error') throw new Error('lavoro fallito: ' + (errore || JSON.stringify(h?.status?.messages?.slice(-2)).slice(0, 600))) + const out = h.outputs?.[grafo.uscita] || {} + if (out.text) testoOut = Array.isArray(out.text) ? out.text.join('') : String(out.text) + // il KSampler (o TextGenerate): primo e ultimo messaggio di avanzamento + const pk = prog.filter((p) => p[2] > 1) + let sampler = null + if (pk.length >= 2) { + const a = pk[0] + const b = pk[pk.length - 1] + sampler = { passi: b[2], valore_ultimo: b[1], primo_ms: a[0] - t0, ultimo_ms: b[0] - t0, secondi: (b[0] - a[0]) / 1000, s_per_passo: (b[0] - a[0]) / 1000 / Math.max(1, b[1] - a[1]) } + } + const picco = Math.max(...campioni.map((c) => c.vram)) + const gpuMedia = campioni.filter((c) => c.t > t0 + 3000).reduce((s, c, _, v) => s + c.gpu / v.length, 0) + const img = (out.images || [])[0] + return { + etichetta, + totale_s: (t1 - t0) / 1000, + sampler, + picco_vram_mb: picco, + gpu_media_pct: Math.round(gpuMedia), + token: pk.length && grafo.prompt && Object.values(grafo.prompt).some((n) => n.class_type === 'TextGenerate') ? prog.length : undefined, + testo: testoOut || undefined, + immagine: img ? join(img.subfolder || '', img.filename) : undefined, + nodi: nodi.length + } +} + +// ---- le prove ---- +const FOTO = 'misure_foto.png' +const MASCHERA = 'misure_maschera.png' +const RIF2 = 'misure_rif2.png' + +function reqBase(modalita, fam, passi, extra = {}) { + return { + modalita, famiglia: fam, prompt: PROMPT, negativo: '', seed: SEME, passi, cfg: fam === 'klein-base' ? 3.5 : 1, sampler: 'euler', scheduler: 'simple', + larghezza: 1024, altezza: 1024, megapixel: 1, immagini: [], lora: [], quante: 1, trasparente: false, ...extra + } +} +const MEM = 'normale' +const grafoCrea = (fam, passi) => costruisci(reqBase('crea', fam, passi), imp, { immagini: [] }, 'misura', MEM, null) +function grafoZona(fam, passi) { + const riquadro = { x: 300, y: 380, w: 420, h: 380 } + return costruisci( + reqBase('zona', fam, passi, { prompt: PROMPT_ZONA, riquadro, contesto: 0.8, ritaglia: true, immagini: [FOTO] }), + imp, + { immagini: [{ nome: FOTO, larghezza: 1024, altezza: 1024 }], maschera: MASCHERA }, + 'misura', MEM, null + ) +} +const grafoModifica = (fam, passi, conRif) => + costruisci( + reqBase('modifica', fam, passi, { prompt: conRif ? 'replace the teapot with the object shown in the second image' : PROMPT_ZONA, immagini: conRif ? [FOTO, RIF2] : [FOTO] }), + imp, + { immagini: conRif ? [{ nome: FOTO, larghezza: 1024, altezza: 1024 }, { nome: RIF2, larghezza: 1024, altezza: 1024 }] : [{ nome: FOTO, larghezza: 1024, altezza: 1024 }] }, + 'misura', MEM, null + ) +/** lo stesso grafo di Modifica con riferimenti, ma con un solo nodo ReferenceMultiLatent al posto della catena VAEEncode → ReferenceLatent */ +function conMultiLatent(g) { + const n = g.prompt + const refs = Object.entries(n).filter(([, v]) => v.class_type === 'ReferenceLatent') + if (!refs.length) throw new Error('nessun ReferenceLatent') + // le immagini già pronte (prima dei VAEEncode), nell'ordine della catena + const encs = Object.entries(n).filter(([, v]) => v.class_type === 'VAEEncode') + const pronte = encs.map(([, v]) => v.inputs.pixels) + const vae = encs[0][1].inputs.vae + const primoRef = refs[0][1] + const ultimoPos = refs.filter(([, v]) => true).map(([id]) => id) + // positivo/negativo di partenza: i CLIPTextEncode + const clipEnc = Object.entries(n).filter(([, v]) => v.class_type === 'CLIPTextEncode') + const pos0 = [clipEnc[0][0], 0] + const neg0 = [clipEnc[1][0], 0] + const ks = Object.entries(n).find(([, v]) => v.class_type === 'KSampler')[1] + for (const [id] of refs) delete n[id] + for (const [id] of encs) delete n[id] + const idMulti = String(900) + const inputs = { positive: pos0, negative: neg0, vae, 'resolution': 'keep resolution' } + pronte.forEach((p, i) => { inputs[`images.image${i + 1}`] = p }) + n[idMulti] = { class_type: 'ReferenceMultiLatent', inputs } + ks.inputs.positive = [idMulti, 0] + ks.inputs.negative = [idMulti, 1] + return g +} + +// 4B/8B per il testo +function grafoTesto(quale, maxToken = 380, prompt = PROMPT_ITA) { + const q = reqBase('descrivi', quale === '4b' ? 'klein' : 'qwen', 1, { + prompt, sistema: SISTEMA_MIGLIORA, maxToken, temperatura: 0.7, seed: 7, encoderKlein: quale === '4b' + }) + const i2 = { ...imp } + if (quale === '8b-int8') { i2.encoder = QWEN8_INT8 } + return costruisci(q, i2, { immagini: [] }, 'misura', MEM, null) +} + +const PROVE = {} +const prova = (nome, f) => { PROVE[nome] = f } +// Klein veloce: Crea 8 e 16 passi (a freddo il primo, poi caldo), zona 8 e 16 +prova('klein-crea-8-freddo', async () => { await libera(); return esegui(grafoCrea('klein', 8), 'Klein 8 passi · Crea · a freddo') }) +// la seconda foto (riferimento) per le prove con riferimenti: un oggetto diverso, fatto da Klein 8 passi +prova('rif-crea', async () => esegui(costruisci(reqBase('crea', 'klein', 8, { prompt: 'a vintage red bicycle leaning on a brick wall, golden hour' }), imp, { immagini: [] }, 'misura', MEM, null), 'riferimento (bicicletta)')) +prova('klein-crea-8', async () => esegui(grafoCrea('klein', 8), 'Klein 8 passi · Crea · caldo')) +prova('klein-crea-16', async () => esegui(grafoCrea('klein', 16), 'Klein 16 passi · Crea · caldo')) +prova('klein-zona-8', async () => esegui(grafoZona('klein', 8), 'Klein 8 passi · zona · caldo')) +prova('klein-zona-12', async () => esegui(grafoZona('klein', 12), 'Klein 12 passi · zona · caldo')) +prova('klein-zona-16', async () => esegui(grafoZona('klein', 16), 'Klein 16 passi · zona · caldo')) +prova('klein-zona-8-freddo', async () => { await libera(); return esegui(grafoZona('klein', 8), 'Klein 8 passi · zona · a freddo') }) +prova('klein-mod-8', async () => esegui(grafoModifica('klein', 8, false), 'Klein 8 passi · Modifica tutta la foto')) +prova('klein-mod-16', async () => esegui(grafoModifica('klein', 16, false), 'Klein 16 passi · Modifica tutta la foto')) +// riferimenti: catena vs multi +prova('klein-rif-catena-8', async () => esegui(grafoModifica('klein', 8, true), 'Klein 8 passi · Modifica con riferimento · ReferenceLatent in catena')) +prova('klein-rif-multi-8', async () => esegui(conMultiLatent(grafoModifica('klein', 8, true)), 'Klein 8 passi · Modifica con riferimento · ReferenceMultiLatent')) +// Klein Base +prova('base-crea-50-freddo', async () => { await libera(); return esegui(grafoCrea('klein-base', 50), 'Klein Base 50 passi · Crea · a freddo') }) +prova('base-crea-50', async () => esegui(grafoCrea('klein-base', 50), 'Klein Base 50 passi · Crea · caldo')) +prova('base-crea-30', async () => esegui(grafoCrea('klein-base', 30), 'Klein Base 30 passi · Crea · caldo')) +prova('base-zona-50', async () => esegui(grafoZona('klein-base', 50), 'Klein Base 50 passi · zona · caldo')) +// Qwen +prova('qwen-crea-40-freddo', async () => { await libera(); return esegui(grafoCrea('qwen', 40), 'Qwen 40 passi · Crea · a freddo') }) +prova('qwen-crea-40', async () => esegui(grafoCrea('qwen', 40), 'Qwen 40 passi · Crea · caldo')) +// testo +prova('testo-4b', async () => { await libera(); return esegui(grafoTesto('4b'), 'Migliora prompt · 4B · a freddo') }) +prova('testo-8b', async () => { await libera(); return esegui(grafoTesto('8b'), 'Migliora prompt · encoder di Qwen (come nelle impostazioni) · a freddo') }) +prova('testo-8b-int8', async () => { await libera(); return esegui(grafoTesto('8b-int8', 120), 'Migliora prompt · 8B int8 (120 token) · a freddo') }) +prova('libera', async () => { await libera(); return { etichetta: 'libera' } }) +// migliora + crea: come fa l'app (lavoro di testo, /free, lavoro di immagine). Si parte con un Crea caldo (il denoiser è già in VRAM). +async function migliora(con4B) { + const prima = await esegui(grafoCrea('qwen', 4), 'riscaldamento Qwen (4 passi)') // il denoiser di Qwen e il suo encoder in VRAM, stato «immagine:qwen» + const t0 = Date.now() + await libera() // cambio di modello, come fattiSpazio + const tl = (Date.now() - t0) / 1000 + const t = await esegui(grafoTesto(con4B ? '4b' : '8b'), 'testo ' + (con4B ? '4B' : '8B')) + const t1 = Date.now() + await libera() + const tl2 = (Date.now() - t1) / 1000 + const g = costruisci(reqBase('crea', 'qwen', 40, { prompt: (t.testo || PROMPT).trim() }), imp, { immagini: [] }, 'misura', MEM, null) + const c = await esegui(g, 'Crea Qwen 40 passi con il prompt migliorato') + const totale = t.totale_s + tl2 + c.totale_s + tl + return { etichetta: 'Migliora prompt + Crea · ' + (con4B ? 'con il 4B' : 'senza il 4B (encoder di Qwen)'), totale_s: totale, scarico_prima_s: tl, scarico_dopo_s: tl2, testo: t, crea: c, riscaldamento_s: prima.totale_s } +} +prova('migliora-crea-8b', () => migliora(false)) +prova('migliora-crea-4b', () => migliora(true)) + +if (PROVA === 'elenco') { + console.log(Object.keys(PROVE).join('\n')) + process.exit(0) +} + +// foto e maschera di prova (una volta) +async function preparaIngressi() { + const foto = join(OUT, FOTO) + const rif = join(OUT, RIF2) + const maschera = join(OUT, MASCHERA) + if (!existsSync(maschera)) { + // maschera 1024x1024: rettangolo bianco nel riquadro della prova di zona + const py = join(REPO, '..', '..').replace(/\\/g, '/') + const r = spawnSync( + process.env.PYTHON_ENGINE || 'C:/Users/cammo/AppData/Local/DaProdImage/engine/venv/Scripts/python.exe', + ['-c', `from PIL import Image, ImageDraw\nm=Image.new('RGB',(1024,1024),(0,0,0))\nImageDraw.Draw(m).rectangle([300,380,720,760],fill=(255,255,255))\nm.save(r'${maschera.replace(/\\/g, '/')}')`], + { encoding: 'utf8' } + ) + if (r.status !== 0) throw new Error('maschera: ' + r.stderr) + } + await carica(maschera, MASCHERA) + if (existsSync(foto)) await carica(foto, FOTO) + if (existsSync(rif)) await carica(rif, RIF2) +} +await preparaIngressi() + +const f = PROVE[PROVA] +if (!f) { + console.error('prova sconosciuta: ' + PROVA) + process.exit(2) +} +console.error(`# ${PROVA}`) +const t0 = Date.now() +let esito +try { + esito = await f() +} catch (e) { + esito = { errore: String(e.message || e) } +} +esito.prova = PROVA +esito.seed = SEME +esito.quando = new Date().toISOString() +if (ETICHETTA) esito.nota = ETICHETTA +// copia l'immagine in OUT (il motore salva nella sua cartella) +const copia = (r) => { + if (r && r.immagine) { + const sorg = join(process.env.MOTORE_OUT || OUT, r.immagine) + if (existsSync(sorg)) { + const dest = join(OUT, PROVA + '.png') + if (sorg !== dest) copyFileSync(sorg, dest) + r.file = dest + } + } +} +copia(esito) +copia(esito.crea) +appendFileSync(join(OUT, 'risultati.jsonl'), JSON.stringify(esito) + '\n') +console.log(JSON.stringify(esito, (k, v) => (k === 'testo' && typeof v === 'string' ? v.slice(0, 400) : v), 2)) +process.exit(esito.errore ? 1 : 0) diff --git a/src/main/impostazioni.ts b/src/main/impostazioni.ts index 6308aa6..259882e 100644 --- a/src/main/impostazioni.ts +++ b/src/main/impostazioni.ts @@ -1,7 +1,8 @@ // Le impostazioni: un JSON nei dati dell'app, scritto subito a ogni modifica. -import { readFileSync, writeFileSync, renameSync } from 'node:fs' +import { readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs' import { join } from 'node:path' import type { Impostazioni } from '@shared/tipi' +import { allineaScelta, denoiserSenzaTurbo, famigliaDi, modelloDaImpostazioni } from '@shared/modelli' import { DATI, GALLERIA_PREDEFINITA, MODELLI_PREDEFINITI, assicura } from './percorsi' const FILE = join(DATI, 'impostazioni.json') @@ -14,6 +15,7 @@ const PREDEFINITE: Impostazioni = { vae: 'texture_fix_vae_for_qwen_image_2.1_bf16.safetensors', upscaler: '4x-UltraSharp.pth', famiglia: 'qwen', + modelloScelto: 'qwen-base', kleinModello: '', kleinBase: '', kleinEncoder: '', @@ -41,16 +43,50 @@ const PREDEFINITE: Impostazioni = { traduttore: 'dedicato', unaCosaAllaVolta: true, potenziaKlein: true, + testoCon4B: true, // misurato in T12 (docs/misure-1.1.0.md): Migliora prompt + Crea con Qwen passa da 186 s a 134 s (encoder 8B int8) o da 446 s a 150 s (8B GGUF) scaricaDopo: 120 } let attuali: Impostazioni | null = null +function scrivi(imp: Impostazioni): void { + assicura(DATI) + writeFileSync(FILE + '.tmp', JSON.stringify(imp, null, 2)) + renameSync(FILE + '.tmp', FILE) +} + +/** dalla 1.1 i modelli sono tre (`modelli.ts`) e il Turbo di Qwen non c'è più: le impostazioni senza `modelloScelto` + * lo prendono dalla famiglia (Qwen, anche col Turbo, → qwen-base) e, se il denoiser era il Turbo GGUF, tornano a quello pieno + * (stesso quant) se è nella cartella. Succede una volta sola: dopo, `modelloScelto` è nel file. */ +function migra(letto: Partial, imp: Impostazioni): boolean { + if (letto.modelloScelto) return false + let presenti: string[] = [] + try { + presenti = readdirSync(join(imp.cartellaModelli, 'diffusion_models')) + } catch { + /* cartella che non c'è: il denoiser resta com'è */ + } + const pieno = denoiserSenzaTurbo(imp.modello, presenti) + if (pieno) imp.modello = pieno + return true +} + export function impostazioni(): Impostazioni { if (attuali) return attuali try { const letto = JSON.parse(readFileSync(FILE, 'utf8')) as Partial - attuali = { ...PREDEFINITE, ...letto, predefiniti: { ...PREDEFINITE.predefiniti, ...(letto.predefiniti || {}) } } + const imp: Impostazioni = { ...PREDEFINITE, ...letto, predefiniti: { ...PREDEFINITE.predefiniti, ...(letto.predefiniti || {}) } } + // la scelta del modello e la famiglia vanno sempre d'accordo (anche con un file vecchio o scritto a mano) + imp.modelloScelto = modelloDaImpostazioni(letto) + imp.famiglia = famigliaDi(imp.modelloScelto) + attuali = imp + if (migra(letto, imp)) { + try { + scrivi(imp) + } catch { + /* si riscrive al primo salvataggio */ + } + } } catch { attuali = structuredClone(PREDEFINITE) } @@ -58,12 +94,10 @@ export function impostazioni(): Impostazioni { } export function salvaImpostazioni(modifiche: Partial): Impostazioni { - const nuove = { ...impostazioni(), ...modifiche } + const nuove = { ...impostazioni(), ...modifiche, ...allineaScelta(modifiche) } if (modifiche.predefiniti) nuove.predefiniti = { ...impostazioni().predefiniti, ...modifiche.predefiniti } attuali = nuove - assicura(DATI) - writeFileSync(FILE + '.tmp', JSON.stringify(nuove, null, 2)) - renameSync(FILE + '.tmp', FILE) + scrivi(nuove) return nuove } diff --git a/src/main/installa/catalogo.ts b/src/main/installa/catalogo.ts index 838302a..40fe9b0 100644 --- a/src/main/installa/catalogo.ts +++ b/src/main/installa/catalogo.ts @@ -77,11 +77,11 @@ export const CATALOGO: VoceCatalogo[] = [ repo: 'Helsinki-NLP/opus-mt-it-en', cerca: ['^model\\.safetensors$', '^pytorch_model\\.bin$'], accanto: ['^config\\.json$', '^generation_config\\.json$', '^tokenizer_config\\.json$', '^source\\.spm$', '^target\\.spm$', '^vocab\\.json$'] }, - // --- FLUX 3 Image Klein (Black Forest Labs): il repo «comfy» ha bf16, int8 («convrot») e NVFP4 per Klein (veloce, 16 passi) e Klein Base (50 passi, CFG). + // --- FLUX 3 Image Klein (Black Forest Labs): il repo «comfy» ha bf16, int8 («convrot») e NVFP4 per Klein (veloce, 8 passi) e Klein Base (50 passi, CFG). // I nomi dei file vengono dai workflow ufficiali di ComfyUI; il nome esatto lo chiede installa/risolvi.ts a Hugging Face. Per una scheda da 6-8 GB va bene l'INT8. // L'encoder è un Qwen3-VL 4B (diverso dal nostro 8B); il VAE è quello di FLUX.2. --- ...([ - ['klein-int8', 'FLUX 3 Klein · veloce · INT8', 'Il più veloce: 16 passi senza CFG. L\'INT8 sta nelle schede da 8 GB.', 5_700_000_000, ['^flux3_image_klein_sd_int8', '^(?=.*klein)(?!.*base)(?!.*(vae|qwen|text_enc|bf16|nvfp4|fp8))(?=.*int8)']], + ['klein-int8', 'FLUX 3 Klein · veloce · INT8', 'Il più veloce: 8 passi senza CFG. L\'INT8 sta nelle schede da 8 GB.', 5_700_000_000, ['^flux3_image_klein_sd_int8', '^(?=.*klein)(?!.*base)(?!.*(vae|qwen|text_enc|bf16|nvfp4|fp8))(?=.*int8)']], ['klein-bf16', 'FLUX 3 Klein · veloce · BF16', 'Stessa qualità dell\'originale, ma pesa il doppio: serve molta RAM.', 11_300_000_000, ['^flux3_image_klein_sd(_bf16)?\\.safetensors$', '^(?=.*klein)(?!.*base)(?!.*(vae|qwen|text_enc|int8|nvfp4|fp8))(?=.*bf16)']], ['klein-base-int8', 'FLUX 3 Klein Base · INT8', 'La versione «normale»: 50 passi con CFG 3,5, la qualità piena (3 volte più lenta di Klein).', 5_700_000_000, ['^flux3_image_klein_base_int8', '^(?=.*klein)(?=.*base)(?!.*(vae|qwen|text_enc|bf16|nvfp4|fp8))(?=.*int8)']], ['klein-base-bf16', 'FLUX 3 Klein Base · BF16', 'Klein Base non quantizzato.', 11_300_000_000, ['^flux3_image_klein_base(_bf16)?\\.safetensors$', '^(?=.*klein)(?=.*base)(?!.*(vae|qwen|text_enc|int8|nvfp4|fp8))(?=.*bf16)']] @@ -134,10 +134,11 @@ export const CATALOGO: VoceCatalogo[] = [ { // Turbo8 (chriswritescode): distillazione a 8 passi, CFG 1, euler/simple. Niente sha256 pubblicato: basta che ci sia id: 'lora-turbo', tipo: 'lora', nome: 'Turbo 8 passi (Turbo8)', - descrizione: 'Fa le immagini in 8 passi invece di 40: circa 5 volte più veloce, con poca perdita. Il modo Turbo di Crea e Modifica.', + descrizione: 'Fa le immagini in 8 passi invece di 40: circa 5 volte più veloce, con poca perdita. Dalla 1.1 non serve più: il Turbo di Qwen non esiste più.', file: 'turbo8_lora_step2500.safetensors', cartella: 'loras', url: 'https://huggingface.co/chriswritescode/Turbo8-LoRA-Qwen-Image-2.1/resolve/main/turbo8_lora_step2500.safetensors', - byte: 1_360_000_000, circa: true, consigliato: true + // dalla 1.1 il Turbo di Qwen non si usa più (D1 del piano): resta per chi ce l'ha, ma non si scarica da solo + byte: 1_360_000_000, circa: true, consigliato: false }, { // ausboss: tiene le modifiche "ferme" (luce, colori, stile, testi) senza spostare niente; non fa muovere i soggetti diff --git a/src/main/motore/didascalia.ts b/src/main/motore/didascalia.ts new file mode 100644 index 0000000..bca8ac6 --- /dev/null +++ b/src/main/motore/didascalia.ts @@ -0,0 +1,67 @@ +// Pulizia del testo che scrive il Qwen3-VL 4B (prompt upsampler di Klein e altre funzioni di testo). +// File PURO: niente Electron, niente import, così lo importa anche `test/pulisci_didascalia.mjs` (Node con type stripping). + +/** titolo in grassetto prima del testo («**Image Idea:** …»): il 4B lo scrive a volte, si toglie */ +const TITOLO_GRASSETTO = /^\s*\*\*[^*\n]{1,40}\*\*:?\s*/ + +/** toglie il titolo in grassetto in testa; se non c'è, il testo resta com'è (solo ripulito agli estremi) */ +export const senzaTitolo = (t: string): string => t.replace(TITOLO_GRASSETTO, '').trim() + +/** il 4B, quando scrive, apre spesso con una frase di cortesia o con le regole del sistema, invece che con la didascalia: + * «Here’s the expanded, detailed caption for your image request:», «You're a prompt upsampler for text-to-image models…», + * «Output only the caption.» e così via. Se non si toglie, quella frase finisce nel prompt del modello e gli fa male. + * Si taglia tutto ciò che precede la didascalia: la parte utile comincia dalla prima riga non vuota dopo il preambolo. */ +export const PREAMBOLI: RegExp[] = [ + /^\s*here(?:'s|’s| is|’ is)\s+the\s+expanded\b[^\n]*/i, + /^\s*here(?:'s|’s| is|’ is)\s+(?:your|the)\s+(?:expanded\s+)?(?:detailed\s+)?(?:caption|description|instruction|prompt)\b[^\n]*/i, + /^\s*you(?:'re| are)\s+a\s+prompt\s+upsampler\b[^\n]*/i, + /^\s*you(?:'re| are)\s+(?:a|the)\s+(?:helpful\s+)?(?:ai\s+)?(?:assistant|image\s+prompt)\b[^\n]*/i, + /^\s*output\s+only\s+the\s+(?:caption|instruction|description)\b[^\n]*/i, + /^\s*(?:sure|certainly|of\s+course|here)\b[^\n]*/i, + /^\s*\**\s*(?:expanded\s+)?(?:caption|instruction|description)\s*\**\s*:\s*/i, + /^\s*\d+\s*[.)]\s*(?:expanded\s+)?(?:caption|instruction)\s*:\s*/i +] + +/** frasi tipiche delle regole: se il 4B le ricopia prima della didascalia, quel paragrafo non serve */ +export const REGOLA = + /\b(?:your output|you should|you must|keep it under|avoid generic|don't mention|do not mention|if you(?:'re| are) unsure|if no style|output only|one continuous block|no greetings|include sensory|add sensory|example\s*:|input\s*:|one detailed, self-contained|the exact text the image model receives|the caption the editing model receives|to expand the user|your job is|always stay faithful|make an educated assumption|the caption must include|write one continuous)/i + +/** comincia una didascalia: quasi sempre «Full-color … photograph», ma anche «A », «Close-up », «Studio », «An » ecc. */ +export const INIZIO_DIDASCALIA = + /^(?:full[- ]colour|full[- ]color|a\s|an\s|the\s|close[- ]up|studio\s|outdoor\s|indoor\s|portrait\s|macro\s|wide[- ]angle|overhead\s|flat\s|lay\s|product\s|fashion\s|editorial\s|documentary\s|conceptual\s|minimalist\s|oil\s|watercolou?r\s|oil[- ]painting|photorealistic\s|realistic\s|3d\s|concept\s|illustration\s|film\s|cinematic\s|digital\s|render\s|photograph\s|photo\s|scene\s|image\s|view\s|close[- ]shot|aerial\s|isometric\s|watercolou?ristic\s|charcoal\s|pencil\s|sketch\s)/i + +/** toglie il preambolo e la parte di regole: la didascalia comincia dalla prima riga che sembra contenere la didascalia */ +export function pulisciDidascalia(t: string): string { + let s = t.trim() + // via i blocchi di regole: il 4B a volte ricopia le istruzioni prima della didascalia + s = s.replace(/^\s*(?:you(?:'re| are)\s+a\s+prompt\s+upsampler[\s\S]*?)(?=\n\n[A-Z“"])/i, '') + for (let i = 0; i < PREAMBOLI.length; i++) { + const m = s.match(PREAMBOLI[i]) + if (m) { + const resto = s.slice(m[0].length).replace(/^\s*[:\-–—]\s*/, '').trim() + if (resto.length > 40) s = resto + } + } + // resta un titolo in grassetto o un'etichetta + s = senzaTitolo(s) + s = s.replace(/^\s*(?:caption|instruction|description)\s*:\s*/i, '').trim() + // se ci sono paragrafi, si cerca quello che apre la didascalia: i paragrafi di regole (che parlano del compito) + // vengono scartati, quelli che descrivono la scena restano + const paragrafi = s.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean) + if (paragrafi.length > 1) { + const iD = paragrafi.findIndex((p) => INIZIO_DIDASCALIA.test(p) && !REGOLA.test(p)) + if (iD >= 0) { + // dalla didascalia in poi: i paragrafi che sono regole (parlano del compito) si scartano + const buoni = paragrafi.slice(iD).filter((p) => !REGOLA.test(p)) + if (buoni.length && buoni.join('\n\n').length > 40) s = buoni.join('\n\n') + } else { + // nessun paragrafo sembra una didascalia: si tengono solo quelli che non sono regole + const buoni = paragrafi.filter((p) => !REGOLA.test(p)) + if (buoni.length && buoni.join('\n\n').length > 40) s = buoni.join('\n\n') + } + } + // il 4B a volte scrive solo un ESEMPIO («Example: | Input: “…” | Output: “…”»): dentro c'è la didascalia giusta + const ex = s.match(/example\s*:\s*\n?input\s*:\s*[“"']?([\s\S]*?)[”"']?\s*\n?output\s*:\s*[“"']?([\s\S]*?)[”"']?\s*$/i) + if (ex && ex[2] && ex[2].trim().length > 40) s = ex[2].trim() + return s +} diff --git a/src/main/motore/grafi.ts b/src/main/motore/grafi.ts index 63a9985..ae0591d 100644 --- a/src/main/motore/grafi.ts +++ b/src/main/motore/grafi.ts @@ -172,7 +172,7 @@ function latenteVuota(g: Grafo, fam: Famiglia, w: number, h: number): Rif { } function campiona(g: Grafo, q: Richiesta, fam: Famiglia, model: Rif, c: Condizioni, latente: Rif, passi: number, denoise = 1): Rif { - // Klein veloce è distillato (16 passi, senza CFG); Klein Base vuole CFG 3,5; sampler e scheduler sono quelli del riferimento (euler, e la scaletta del modello) + // Klein veloce è distillato (8 passi, senza CFG); Klein Base vuole CFG 3,5; sampler e scheduler sono quelli del riferimento (euler, e la scaletta del modello) const cfg = fam === 'qwen' ? q.cfg : fam === 'klein' ? 1 : q.cfg > 1 ? q.cfg : 3.5 return r( g.add( diff --git a/src/main/motore/lavori.ts b/src/main/motore/lavori.ts index 73dde14..4f6a405 100644 --- a/src/main/motore/lavori.ts +++ b/src/main/motore/lavori.ts @@ -3,7 +3,7 @@ // e alla fine porta l'immagine in galleria con dentro le impostazioni. import { nativeImage } from 'electron' import { join } from 'node:path' -import { randomUUID } from 'node:crypto' +import { randomInt, randomUUID } from 'node:crypto' import type { Famiglia, Impostazioni, Lavoro, Richiesta } from '@shared/tipi' import { USCITA } from '../percorsi' import { impostazioni, profiloMemoria, registraTempo, stimaPasso } from '../impostazioni' @@ -12,8 +12,10 @@ import { SISTEMA_TRADUCI, daTradurre, pulisciTraduzione, tokenPer } from './trad import { accoda, caricaImmagine, chiaviRtx, collegato, connetti, interrompi, nodiDisponibili, scaricaModelli, suAnteprima, suMessaggio, usciteDa, type MessaggioComfy } from './cliente' import { avviaMotore, infoMotore, aggiornaVram } from './processo' import { aggiungi } from '../galleria' +import { pulisciDidascalia, senzaTitolo } from './didascalia' import { PUP_I2I, PUP_I2I_BREVE, PUP_T2I, PUP_T2I_BREVE } from './pup' import { encoderKleinFile, fileKlein } from './klein' +import { cambioDiModello, faseScarico, occupaImmagine, occupaTesto, scriveCol4B, type Occupa } from './spazio' import { elencoLora } from '../lora' import { scaricaVoce, statoModelli } from '../installa/installatore' @@ -211,19 +213,19 @@ async function prepara(q: Richiesta): Promise { type Uscita = { nodo: string; immagini: { filename: string; subfolder: string; type: string }[]; testo?: string } -/** che cosa occupa la scheda: il modello immagine, l'LLM, o niente di importante (il traduttore sta sul processore) */ -type Occupa = 'immagine' | 'testo' | 'cpu' +/** che cosa occupa la scheda (vedi spazio.ts): il denoiser di una famiglia, l'LLM 4B o 8B, o niente di importante */ let ultimoOccupa: Occupa | null = null -/** una cosa alla volta in VRAM: passando dal modello immagine all'LLM (o viceversa) si scarica quello che non serve più */ +/** una cosa alla volta in VRAM: quando cambia il modello grosso (Qwen ↔ Klein ↔ Klein Base, disegno ↔ LLM, 4B ↔ 8B) + * si scarica quello che non serve più, così su 8 GB due modelli non si contendono VRAM e RAM */ async function fattiSpazio(occupa: Occupa, l: Lavoro | null): Promise { - if (occupa === 'cpu') return - const cambia = ultimoOccupa !== null && ultimoOccupa !== occupa - ultimoOccupa = occupa - if (!cambia || !impostazioni().unaCosaAllaVolta) return + const prima = ultimoOccupa + const { libera, ricorda } = cambioDiModello(prima, occupa) + ultimoOccupa = ricorda + if (!libera || !impostazioni().unaCosaAllaVolta) return const fase = l?.fase if (l) { - l.fase = occupa === 'testo' ? 'Scarico il modello immagine' : 'Scarico l\'LLM' + l.fase = faseScarico(prima) avvisa(l) } await scaricaModelli() @@ -231,7 +233,7 @@ async function fattiSpazio(occupa: Occupa, l: Lavoro | null): Promise { } /** manda un grafo al motore e aspetta che finisca (le fasi e l'avanzamento arrivano dai messaggi) */ -async function lancia(grafo: GrafoPronto, occupa: Occupa = 'immagine', l: Lavoro | null = corrente, token = false): Promise { +async function lancia(grafo: GrafoPronto, occupa: Occupa, l: Lavoro | null = corrente, token = false): Promise { await fattiSpazio(occupa, l) misuraToken = token tokT0 = 0 @@ -305,12 +307,12 @@ async function potenzia(l: Lavoro, ing: Ingressi, impLavoro: Impostazioni, fam: : `${q.larghezza > 0 && q.modalita === 'crea' ? `TARGET CANVAS: ${Math.round(q.larghezza / g0)}:${Math.round(q.altezza / g0)}\n` : ''}${p}` const sistema = conFoto ? (encoderKlein ? PUP_I2I_BREVE : PUP_I2I) : encoderKlein ? PUP_T2I_BREVE : PUP_T2I const g = costruisci( - { ...q, modalita: 'descrivi', encoderKlein, prompt: utente, sistema, maxToken: conFoto ? 320 : 600, temperatura: 0.15, presenza: conFoto ? 0 : 1.5, seed: 0, immagini: [] }, + { ...q, modalita: 'descrivi', encoderKlein, prompt: utente, sistema, maxToken: conFoto ? 320 : 600, temperatura: 0.15, presenza: conFoto ? 0 : 1.5, seed: q.seed >= 0 ? q.seed : randomInt(1, 2 ** 31), immagini: [] }, imp, { immagini: ing.immagini.slice(0, 1) }, 'pup' ) g.fasi = Object.fromEntries(Object.keys(g.fasi).map((k) => [k, 'Potenzio il prompt'])) - const u = await lancia(g, 'testo', l, true) - const t = (u.testo || '').trim().replace(/^["“]+|["”]+$/g, '').replace(/^(caption|instruction)\s*:\s*/i, '') + const u = await lancia(g, occupaTesto(encoderKlein), l, true) + const t = pulisciDidascalia(u.testo || '').replace(/^["“]+|["”]+$/g, '').replace(/^(caption|instruction)\s*:\s*/i, '') if (t.length < 20) throw new Error('didascalia vuota') potenziati.set(chiave, t) if (potenziati.size > 100) potenziati.delete(potenziati.keys().next().value as string) @@ -324,15 +326,13 @@ async function potenzia(l: Lavoro, ing: Ingressi, impLavoro: Impostazioni, fam: * Se il 4B non c'è, o la richiesta dice `encoderKlein: false`, tutto resta com'era. */ function conEncoderDiTesto(q: Richiesta, imp: Impostazioni): { q: Richiesta; imp: Impostazioni } { const fam = q.famiglia ?? imp.famiglia - if (q.modalita !== 'descrivi' || fam === 'qwen' || q.encoderKlein === false) return { q, imp } + // con Qwen il 4B scrive solo se `testoCon4B` è acceso in Opzioni (di serie acceso: più veloce, vedi docs/misure-1.1.0.md); la regola è in spazio.ts + if (q.modalita !== 'descrivi' || q.encoderKlein === false || (fam === 'qwen' && !imp.testoCon4B)) return { q, imp } const enc = encoderKleinFile(imp) - if (!enc) return { q, imp } + if (!scriveCol4B(q.modalita, fam, q.encoderKlein, !!imp.testoCon4B, !!enc)) return { q, imp } return { q: { ...q, encoderKlein: true }, imp: { ...imp, kleinEncoder: enc } } } -/** il 4B scrive a volte un titolo in grassetto prima del testo («**Image Idea:** …»): si toglie */ -const senzaTitolo = (t: string): string => t.replace(/^\s*\*\*[^*\n]{1,40}\*\*:?\s*/, '').trim() - /** il prompt in inglese per il modello: se è in italiano lo traduce il traduttore dedicato (o, se manca, Qwen3-VL già caricato); se no resta com'è */ async function inInglese(l: Lavoro): Promise { const q = l.richiesta @@ -353,7 +353,7 @@ async function inInglese(l: Lavoro): Promise { const t4 = conEncoderDiTesto({ ...q, modalita: 'descrivi', prompt: p, sistema: SISTEMA_TRADUCI, maxToken: tokenPer(p), temperatura: 0.2, seed: 0, immagini: [] }, imp) const g = costruisci(t4.q, t4.imp, { immagini: [] }, 'traduci') g.fasi = Object.fromEntries(Object.keys(g.fasi).map((k) => [k, 'Traduco il prompt in inglese'])) - const u = await lancia(g, 'testo', l, true) + const u = await lancia(g, occupaTesto(t4.q.encoderKlein), l, true) t = pulisciTraduzione(u.testo || '', p) } tradotti.set(p, t) @@ -449,7 +449,9 @@ async function esegui(l: Lavoro): Promise { l.passiTotali = grafo.passi l.stima = grafo.megapixel ? Math.round(stimaPasso(grafo.megapixel) * grafo.passi + 6) : undefined const testo = l.richiesta.modalita === 'descrivi' - const uscita = await lancia(grafo, testo ? 'testo' : 'immagine', l, testo) + // la chiave della VRAM: chi scrive (4B o 8B), chi disegna (la famiglia), o l'ingranditore che non sposta niente + const occupa = testo ? occupaTesto(lavoroTesto.q.encoderKlein) : l.richiesta.modalita === 'ingrandisci' ? 'leggero' : occupaImmagine(fam) + const uscita = await lancia(grafo, occupa, l, testo) if (testo) { l.testo = lavoroTesto.q.encoderKlein ? senzaTitolo(uscita?.testo || '') : (uscita?.testo || '').trim() } else { diff --git a/src/main/motore/spazio.ts b/src/main/motore/spazio.ts new file mode 100644 index 0000000..dc76b8d --- /dev/null +++ b/src/main/motore/spazio.ts @@ -0,0 +1,49 @@ +// Che cosa occupa la scheda video, per la regola "una cosa alla volta in VRAM" (`fattiSpazio` in lavori.ts). +// File puro (niente Electron): lo prova `node test/spazio.mjs`. +import type { Famiglia } from '../../shared/tipi' + +/** il modello che un lavoro tiene in VRAM: + * - `immagine:`: il denoiser di Qwen, di Klein veloce o di Klein Base (sono tre file diversi da 5-6 GB l'uno); + * - `testo:4b` / `testo:8b`: l'LLM che scrive (il Qwen3-VL 4B di Klein o l'8B di Qwen); + * - `cpu`: il traduttore dedicato, che sta sul processore; + * - `leggero`: l'ingranditore (pochi MB): non chiede spazio e non cambia quello che c'è. */ +export type Occupa = 'immagine:qwen' | 'immagine:klein' | 'immagine:klein-base' | 'testo:4b' | 'testo:8b' | 'cpu' | 'leggero' + +/** la chiave di un lavoro che disegna */ +export const occupaImmagine = (fam: Famiglia): Occupa => (fam === 'klein' ? 'immagine:klein' : fam === 'klein-base' ? 'immagine:klein-base' : 'immagine:qwen') + +/** la chiave di un lavoro che scrive: col 4B di Klein (`encoderKlein`) o con l'8B di Qwen */ +export const occupaTesto = (encoderKlein?: boolean): Occupa => (encoderKlein ? 'testo:4b' : 'testo:8b') + +/** chi scrive nei lavori di testo (`descrivi`): il 4B di Klein o l'encoder di Qwen. + * Con Klein scrive il 4B (salvo `encoderKlein: false` nella richiesta); con Qwen solo se l'impostazione `testoCon4B` è accesa (di serie è accesa dalla 1.1.0, dopo le misure di T12). + * `c'è4B` = il file del 4B è sul disco: se no si resta con l'encoder della famiglia. */ +export function scriveCol4B(modalita: string, fam: Famiglia, encoderKlein: boolean | undefined, testoCon4B: boolean, ce4B: boolean): boolean { + if (modalita !== 'descrivi' || encoderKlein === false || !ce4B) return false + return fam !== 'qwen' || testoCon4B +} + +/** se fra il lavoro di prima e questo bisogna liberare la scheda (`/free`), e la chiave da ricordare dopo. + * Si libera ogni volta che cambia il modello grosso: da Qwen a Klein, da Klein a Klein Base, dal disegno all'LLM, dal 4B all'8B. + * Il traduttore e l'ingranditore non contano: non liberano niente e lasciano ricordato il modello di prima. */ +export function cambioDiModello(prima: Occupa | null, ora: Occupa): { libera: boolean; ricorda: Occupa | null } { + if (ora === 'cpu' || ora === 'leggero') return { libera: false, ricorda: prima } + return { libera: prima !== null && prima !== ora, ricorda: ora } +} + +/** che cosa dice la fase mentre si libera la scheda (il modello che esce) */ +export function faseScarico(prima: Occupa | null): string { + switch (prima) { + case 'immagine:qwen': + return 'Scarico Qwen-Image' + case 'immagine:klein': + return 'Scarico FLUX 3 Klein' + case 'immagine:klein-base': + return 'Scarico FLUX 3 Klein Base' + case 'testo:4b': + case 'testo:8b': + return 'Scarico l\'LLM' + default: + return 'Libero la scheda video' + } +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fe4aa3a..c718675 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,5 @@ // La cornice dell'app: barra del titolo, rotaia a sinistra, pagine, coda, avvisi. -import { useEffect, useState, type JSX } from 'react' +import { useEffect, useRef, useState, type JSX } from 'react' import { usaStato, collegaEventi, ricaricaLore, type Pagina } from './stato' import { api } from './api' import { I } from './componenti/Icone' @@ -7,11 +7,10 @@ import { Coda } from './componenti/Coda' import { Crea } from './pagine/Crea' import { Modifica } from './pagine/Modifica' import { Album } from './pagine/Album' -import { Laboratorio } from './pagine/Laboratorio' import { BarraStato } from './componenti/BarraStato' +import { SelettoreModello } from './componenti/ScegliModello' import { SelettoreFoto } from './componenti/SelettoreFoto' import { Galleria } from './pagine/Galleria' -import { Lora } from './pagine/Lora' import { Impostazioni } from './pagine/Impostazioni' import { Setup } from './pagine/Setup' import { normalizza } from './util' @@ -20,10 +19,8 @@ import { apriInModifica } from './azioni' const VOCI: { id: Pagina; nome: string; icona: () => JSX.Element }[] = [ { id: 'crea', nome: 'Crea', icona: I.crea }, { id: 'modifica', nome: 'Modifica', icona: I.modifica }, - { id: 'laboratorio', nome: 'Lab', icona: I.laboratorio }, - { id: 'album', nome: 'Album', icona: I.album }, { id: 'galleria', nome: 'Galleria', icona: I.galleria }, - { id: 'lora', nome: 'LoRA', icona: I.lora } + { id: 'album', nome: 'Album', icona: I.album } ] const gb = (b: number): string => (b / 1073741824).toFixed(b >= 10 * 1073741824 ? 0 : 1) @@ -59,12 +56,17 @@ function Indicatori(): JSX.Element | null { } export function App(): JSX.Element { - const { pagina, vai, motore, lavori, codaAperta, avvisi, avvisa, agg, imp } = usaStato() + const { pagina, schedaOpzioni, vai, motore, lavori, codaAperta, avvisi, avvisa, agg, imp } = usaStato() const [pronto, setPronto] = useState(false) const [installato, setInstallato] = useState(true) // il motore era installato ma la sua versione è cambiata (aggiornamento dell'app): si aggiorna da solo const [aggiornaMotore, setAggiornaMotore] = useState(false) const [rilascio, setRilascio] = useState(false) + // VRAM che resta alta a coda ferma: avviso con pulsante Libera (T16) + const [vramNonLiberata, setVramNonLiberata] = useState(false) + const vramAltoDa = useRef(null) + const risorse = usaStato((s) => s.risorse) + const scarico = usaStato((s) => s.scarico) useEffect(() => { void (async () => { @@ -137,6 +139,21 @@ export function App(): JSX.Element { } }, []) + // Monitoraggio VRAM: se resta sopra il 95% per più di 10 s a coda ferma, avvisa. + useEffect(() => { + const attiviNow = lavori.some((l) => l.stato === 'in coda' || l.stato === 'in corso') + const piena = !!risorse && (risorse.vramTotale || 0) > 0 && ((risorse.vramUsata || 0) / (risorse.vramTotale || 1)) > 0.95 + const idle = !attiviNow && !scarico + const motorePronto = motore.stato === 'pronto' + if (piena && idle && motorePronto) { + if (vramAltoDa.current === null) vramAltoDa.current = Date.now() + if (Date.now() - (vramAltoDa.current || 0) >= 10000) setVramNonLiberata(true) + } else { + vramAltoDa.current = null + setVramNonLiberata(false) + } + }, [risorse, lavori, motore, scarico]) + const attivi = lavori.filter((l) => l.stato === 'in coda' || l.stato === 'in corso').length const statoMotore = motore.stato === 'pronto' ? 'pronto' : motore.stato === 'avvio' ? 'avvio' : motore.stato === 'errore' ? 'errore' : '' const testoMotore = { pronto: 'Motore pronto', avvio: 'Avvio del motore…', errore: 'Motore fermo', spento: 'Motore spento', 'non installato': 'Da installare' }[motore.stato] @@ -148,6 +165,7 @@ export function App(): JSX.Element { DAPROD IMAGE + {pronto && installato && } {agg.stato === 'scarico' && ( vai('impostazioni')}> @@ -200,10 +218,8 @@ export function App(): JSX.Element { {/* le pagine restano montate: la maschera disegnata o il prompt non si perdono cambiando pagina */}
-
{pagina === 'album' && }
{pagina === 'galleria' && }
-
{pagina === 'lora' && }
{pagina === 'impostazioni' && }
@@ -213,13 +229,19 @@ export function App(): JSX.Element { {pronto && installato && }
+ {vramNonLiberata && ( +
+ La memoria della scheda non si è liberata, premi Libera + +
+ )} {avvisi.map((a) => (
{a.testo}
))}
{rilascio && (
-
{pagina === 'galleria' ? 'Rilascia: le foto entrano in Galleria (diventano PNG)' : pagina === 'lora' ? 'Rilascia: i .safetensors vanno fra i LoRA' : 'Rilascia: la foto si apre in Modifica (diventa PNG)'}
+
{pagina === 'galleria' ? 'Rilascia: le foto entrano in Galleria (diventano PNG)' : pagina === 'impostazioni' && schedaOpzioni === 'lora' ? 'Rilascia: i .safetensors vanno fra i LoRA' : 'Rilascia: la foto si apre in Modifica (diventa PNG)'}
)} diff --git a/src/renderer/src/azioni.ts b/src/renderer/src/azioni.ts index 130dc5c..971dec6 100644 --- a/src/renderer/src/azioni.ts +++ b/src/renderer/src/azioni.ts @@ -1,8 +1,8 @@ // Le azioni che servono a più pagine: costruire le richieste, aprire una foto in Modifica, rifinire, variare. -import { modelloTurbo } from '@shared/tipi' +import { inQualitaPiena, modelloDaOpera, parametriModello, senzaTurbo, type IdModello } from '@shared/modelli' import type { Opera, Richiesta } from '@shared/tipi' import { api } from './api' -import { famigliaCorrente, passiDi, usaStato, type FotoBase, type Qualita } from './stato' +import { passiDi, usaStato, type FotoBase, type Qualita } from './stato' import { dimensioniRis } from './util' import type { ChiaveLora } from './rapide' @@ -47,25 +47,13 @@ export function richiestaBase(): Richiesta { } } -/** il LoRA Turbo è fra i LoRA? */ -export function turboPresente(): boolean { - const { imp, lore } = usaStato.getState() - // Klein non usa il LoRA Turbo (è già distillato): per lui non manca niente - if (imp && imp.famiglia !== 'qwen') return true - if (imp && modelloTurbo(imp.modello)) return true - return !!imp?.loraTurbo && lore.some((l) => l.file === imp.loraTurbo) -} - -/** passi (e Turbo) per una qualità; il Turbo vuole CFG 1, euler, simple */ +/** passi per una qualità. Il Turbo di Qwen non c'è più (D1 del piano): una qualità «turbo» salvata vale «alta» e non accende il LoRA */ export function perQualita(q: Qualita): Pick & Partial> { - // Klein: niente Turbo (è già distillato), il CFG e il sampler li decide il motore - if (famigliaCorrente() !== 'qwen') return { passi: passiDi(q), turbo: false } - if (q === 'turbo') return { passi: passiDi('turbo'), turbo: true, cfg: 1, sampler: 'euler', scheduler: 'simple' } - return { passi: passiDi(q), turbo: false } + return { passi: passiDi(senzaTurbo(q)), turbo: false } } -/** la qualità per i lavori "di servizio" (rifinisci, varia): Turbo se è acceso in Crea */ -const qualitaServizio = (): Qualita => (usaStato.getState().crea.qualita === 'turbo' && turboPresente() ? 'turbo' : 'alta') +/** la qualità per i lavori "di servizio" (rifinisci, varia) */ +const qualitaServizio = (): Qualita => 'alta' export const fotoDaOpera = (o: Opera): FotoBase => ({ percorso: o.file, larghezza: o.larghezza, altezza: o.altezza, operaId: o.id, prompt: o.prompt }) @@ -140,10 +128,51 @@ export function ingrandisci(o: Opera, fattore = 2): Promise { ) } +/** i modelli con cui si può rifare una foto di Klein veloce (lista vuota per tutte le altre): `inQualitaPiena` di src/shared/modelli.ts. + * Solo le foto fatte da zero (Crea): le modifiche partono da una foto che l'opera non ricorda. */ +export function rifacibileIn(o: Opera): IdModello[] { + return o.modalita === 'crea' ? inQualitaPiena(modelloDaOpera(o)) : [] +} + +/** «Rifai in qualità piena» (Klein veloce → Klein Base) e «Rifai con Qwen»: stesso prompt e stesso seed. + * Da Klein veloce a Klein Base la famiglia è la stessa e l'immagine si somiglia; con Qwen è un altro modello, non promette la stessa immagine. */ +export function rifaiCon(o: Opera, id: IdModello): Promise { + const w = Math.max(256, Math.round(o.larghezza / 16) * 16) + const h = Math.max(256, Math.round(o.altezza / 16) * 16) + const stessaFamiglia = parametriModello(id).famiglia !== 'qwen' + return accoda( + { + ...richiestaBase(), + modalita: 'crea', + prompt: o.prompt, + // se la foto originale era una didascalia espansa da Klein (non un preset), si espande di nuovo: dalla cache esce lo stesso testo + potenzia: !o.etichetta, + negativo: '', + seed: o.seed, + cfg: 1, + sampler: 'euler', + scheduler: 'simple', + larghezza: w, + altezza: h, + megapixel: (w * h) / 1048576, + ...(w !== o.larghezza || h !== o.altezza ? { uscita: { w: o.larghezza, h: o.altezza } } : {}), + quante: 1, + immagini: [], + trasparente: o.trasparente, + ...parametriModello(id), + // i LoRA sono fatti per Qwen: con Klein Base non valgono + lora: stessaFamiglia ? [] : richiestaBase().lora, + origine: o.id, + etichetta: stessaFamiglia ? 'Qualità piena' : 'Con Qwen' + }, + stessaFamiglia ? 'Rifatta in qualità piena (Klein Base, stesso seed)' : 'Rifatta con Qwen in coda' + ) +} + /** Riusa: rimette prompt, seed e LoRA dell'opera nella pagina Crea */ export function riusa(o: Opera): void { const s = usaStato.getState() - s.setCrea({ prompt: o.prompt, negativo: o.negativo, seed: o.seed, casuale: false, cfg: o.cfg, sampler: o.sampler, scheduler: o.scheduler, trasparente: !!o.trasparente }) + s.setCrea({ prompt: o.prompt, seed: o.seed, casuale: false, trasparente: !!o.trasparente }) if (o.lora?.length) s.setLoraAttive(o.lora.filter((l) => s.lore.some((x) => x.file === l.file))) s.apriVisore(null) s.vai('crea') diff --git a/src/renderer/src/componenti/Estrai.tsx b/src/renderer/src/componenti/Estrai.tsx index 6b4fbf3..184fdfd 100644 --- a/src/renderer/src/componenti/Estrai.tsx +++ b/src/renderer/src/componenti/Estrai.tsx @@ -9,9 +9,6 @@ import { VistaZoom } from './VistaZoom' import { AdattaFoto } from './AdattaFoto' import { percentuale } from './Coda' import { Contatore, Interruttore, Segmenti } from './Controlli' -import { useTurbo } from './Turbo' -import { ScegliQualita } from './ScegliQualita' -import { ScegliModello } from './ScegliModello' import { ScegliMp, Uscita } from './Misura' import { Omino } from './Omino' import { controllaScheletro } from '../pulisciOmino' @@ -164,7 +161,6 @@ export function PalcoEstrai(p: { base: FotoBase | null; daGalleria: () => void; export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { const s = usaEstrai() const { modifica: m, setModifica, avvisa } = usaStato() - const turbo = useTurbo() const el = s.elementi.find((x) => x.id === s.scelto) const stima = stimaSecondi(usaStato.getState().imp?.tempi, m.mp, passiDi(m.qualita)) * m.quante const r = proporzioniEstrai(s, el, p.base) @@ -174,10 +170,6 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { const estrai = async (): Promise => { if (!p.base || !el) return - if (m.qualita === 'turbo' && !turbo.presente) { - avvisa('Per il Turbo scarica prima il suo LoRA (il pulsante sotto Qualità), o scegli Alta', 'errore') - return - } try { const q = await richiestaEstrai(p.base, el, { qualita: m.qualita, mp: m.mp, quante: m.quante }) const conOmino = persona && s.inquadratura === 'intera' && s.posa === 'omino' @@ -196,7 +188,6 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { return (
-

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

{el ? ( @@ -283,8 +274,7 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element {
- Qualità · {passiDi(m.qualita)} passi · ~{durata(stima)} - setModifica({ qualita })} /> + {passiDi(m.qualita)} passi · ~{durata(stima)}
Quante versioni diff --git a/src/renderer/src/componenti/Fusion.tsx b/src/renderer/src/componenti/Fusion.tsx index 7b610cc..710740f 100644 --- a/src/renderer/src/componenti/Fusion.tsx +++ b/src/renderer/src/componenti/Fusion.tsx @@ -71,7 +71,7 @@ export function Fusion(p: {
{p.foto.length === 0 ? (
-
{p.esempio ? 'COME L\'ESEMPIO' : 'FUSION'}
+
{p.esempio ? 'COME L\'ESEMPIO' : 'UNISCI FOTO'}
{p.esempio ? (

Metti qui le foto da usare (qualsiasi cosa) e sopra una foto di esempio: il modello guarda tutto insieme, decide da solo cosa sostituire con cosa e rifà l'esempio.

) : ( diff --git a/src/renderer/src/componenti/LabVista.tsx b/src/renderer/src/componenti/LabVista.tsx index 3a0caac..06a31c2 100644 --- a/src/renderer/src/componenti/LabVista.tsx +++ b/src/renderer/src/componenti/LabVista.tsx @@ -31,11 +31,11 @@ export function Catalogo(p: { cerca: string; scelto: string | null; scegli: (id: ))}
diff --git a/src/renderer/src/componenti/Preset.tsx b/src/renderer/src/componenti/Preset.tsx index 5bf95f6..b9e976d 100644 --- a/src/renderer/src/componenti/Preset.tsx +++ b/src/renderer/src/componenti/Preset.tsx @@ -1,20 +1,22 @@ -// La barra dei preset (a sinistra in Crea) e la scheda coi campi del preset scelto. +// 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, type Preset } from '../preset' +import { PRESET, campiCon, componiPreset, presetDa } from '../preset' const icona = (nome: string): JSX.Element => { const Ic = (I as Record JSX.Element>)[nome] || I.crea return } -export function BarraPreset({ prima }: { prima: (prompt: string) => void }): JSX.Element { +/** 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 = (p: Preset | null): void => { + const scegli = (id: string): void => { + const p = presetDa(id) if (!p) { setCrea({ preset: '' }) return @@ -23,20 +25,17 @@ export function BarraPreset({ prima }: { prima: (prompt: string) => void }): JSX 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 ( - + ) } diff --git a/src/renderer/src/componenti/ScegliModello.tsx b/src/renderer/src/componenti/ScegliModello.tsx index 0a07889..d20abb3 100644 --- a/src/renderer/src/componenti/ScegliModello.tsx +++ b/src/renderer/src/componenti/ScegliModello.tsx @@ -1,32 +1,34 @@ -// Il selettore del modello che disegna: Qwen-Image 2.1, FLUX 3 Klein (veloce, 16 passi) o FLUX 3 Klein Base (50 passi con CFG). -// Se i file di Klein non ci sono, la scheda dice quanto pesano e li scarica con un clic. -import { useEffect, useState, type JSX } from 'react' -import { FAMIGLIE, type Download, type Famiglia } from '@shared/tipi' +// Il selettore del modello che disegna, nella barra in alto: tre pillole (Qwen base, Klein veloce, Klein Base) da ORDINE_MODELLI. +// Salva `modelloScelto` (la famiglia si allinea da sola nel main). Se i file di Klein non ci sono, la pillola ha un puntino +// e un clic apre il pannellino che dice quanto pesano e li scarica. +import { useEffect, useRef, useState, type JSX } from 'react' +import type { Download } from '@shared/tipi' +import { MODELLI, ORDINE_MODELLI, modelloDaImpostazioni, type IdModello } from '@shared/modelli' import { api } from '../api' import { ricaricaImp, usaStato } from '../stato' import { I } from './Icone' -import { Interruttore } from './Controlli' -const ORDINE: Famiglia[] = ['qwen', 'klein', 'klein-base'] /** 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 ICONA: Record JSX.Element> = { qwen: I.stella, klein: I.fulmine, 'klein-base': I.ritratto } +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' } -export function ScegliModello(): JSX.Element { - const famiglia = usaStato((s) => s.imp?.famiglia || 'qwen') +export function SelettoreModello(): JSX.Element { + const imp = usaStato((s) => s.imp) const avvisa = usaStato((s) => s.avvisa) - const potenzia = usaStato((s) => s.imp?.potenziaKlein ?? true) - const [pronti, setPronti] = useState>({ qwen: true, klein: false, 'klein-base': false }) - const [aperto, setAperto] = useState(null) - const [scarico, setScarico] = useState<{ f: Famiglia; passo: string; perc: number } | null>(null) + 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 [aperto, setAperto] = useState(null) + const [scarico, setScarico] = useState<{ id: IdModello; passo: string; perc: number } | null>(null) + const radice = useRef(null) /** i file di Klein ci sono? (si guardano le cartelle: vale anche per chi li ha messi a mano) */ const ricarica = async (): Promise => { const f = await api.modelli.file() const comuni = (f.kleinTe?.length ?? 0) > 0 && (f.kleinVae?.length ?? 0) > 0 setPronti({ - qwen: true, + 'qwen-base': true, klein: comuni && !!f.klein?.some((n) => !/base/i.test(n)), 'klein-base': comuni && !!f.klein?.some((n) => /base/i.test(n)) }) @@ -37,32 +39,46 @@ export function ScegliModello(): JSX.Element { setScarico((s) => (s && d.totali ? { ...s, perc: Math.round((d.ricevuti / d.totali) * 100) } : s)) }) }, []) + // un clic fuori chiude il pannellino + useEffect(() => { + if (!aperto) return + const fuori = (e: MouseEvent): void => { + if (radice.current && !radice.current.contains(e.target as Node)) setAperto(null) + } + window.addEventListener('mousedown', fuori) + return () => window.removeEventListener('mousedown', fuori) + }, [aperto]) + + const salva = async (id: IdModello): Promise => { + await api.impostazioni.salva({ modelloScelto: id }) + await ricaricaImp() + } - const scegli = async (f: Famiglia): Promise => { - if (f !== 'qwen' && !pronti[f]) { - setAperto(aperto === f ? null : f) + const scegli = async (id: IdModello): Promise => { + if (id !== 'qwen-base' && !pronti[id]) { + setAperto(aperto === id ? null : id) return } setAperto(null) - await api.impostazioni.salva({ famiglia: f }) - await ricaricaImp() + if (id === scelto) return + await salva(id) + if (inCorso) avvisa(`${MODELLI[id].nome}: vale dal prossimo lavoro`, 'ok') } - const scarica = async (f: Exclude): Promise => { + 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' } - for (const id of SERVONO[f]) { - if (stato.find((v) => v.id === id)?.presente) continue - setScarico({ f, passo: nomi[id], perc: 0 }) - const d = await api.modelli.scarica(id) + for (const voce of SERVONO[id]) { + if (stato.find((v) => v.id === voce)?.presente) continue + setScarico({ id, passo: nomi[voce], perc: 0 }) + const d = await api.modelli.scarica(voce) if (d.stato !== 'fatto') throw new Error(d.errore || 'Download non riuscito') } await ricarica() - await api.impostazioni.salva({ famiglia: f }) - await ricaricaImp() + await salva(id) setAperto(null) - avvisa(`${FAMIGLIE[f].nome} pronto: ora disegna con lui`, 'ok') + avvisa(`${MODELLI[id].nome} pronto: ora disegna con lui`, 'ok') } catch (e) { avvisa((e as Error).message, 'errore') } finally { @@ -71,42 +87,42 @@ export function ScegliModello(): JSX.Element { } return ( -
-
Modello che disegna
-
- {ORDINE.map((f) => { - const Ic = ICONA[f] - const m = FAMIGLIE[f] - const su = f === famiglia +
+
+ {ORDINE_MODELLI.map((id) => { + const m = MODELLI[id] return ( - ) })}
- {aperto && aperto !== 'qwen' && !pronti[aperto] && ( -
- {FAMIGLIE[aperto].nome} -
{FAMIGLIE[aperto].sotto}. Servono il modello (INT8, adatto a 8 GB), il text encoder e il VAE: {PESO[aperto]}, una volta sola.
- {scarico?.f === aperto ? ( + {aperto && aperto !== 'qwen-base' && !pronti[aperto] && ( +
+ {MODELLI[aperto].nome} +
{MODELLI[aperto].sotto}. Servono il modello (INT8, adatto a 8 GB), il text encoder e il VAE: {PESO[aperto]}, una volta sola.
+ {scarico?.id === aperto ? (
Scarico {scarico.passo}… {scarico.perc}%
) : ( - + )}
)} - {famiglia !== 'qwen' && ( -
- void api.impostazioni.salva({ potenziaKlein }).then(ricaricaImp)} sotto="Black Forest Labs lo consiglia: Klein dà il meglio con didascalie lunghe. Lo scrive il Qwen3-VL (circa mezzo minuto in più per immagine)"> - Espandi il prompt prima di disegnare - -
I LoRA e il Turbo sono di Qwen: con Klein non servono. Migliora prompt, Idee e Estrai usano sempre il Qwen3-VL. Lo sfondo trasparente è solo di Qwen.
-
- )}
) } + +/** 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 +} diff --git a/src/renderer/src/componenti/ScegliQualita.tsx b/src/renderer/src/componenti/ScegliQualita.tsx deleted file mode 100644 index bb5a365..0000000 --- a/src/renderer/src/componenti/ScegliQualita.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// La scelta della qualità: i passi cambiano col modello (Qwen ha anche il Turbo; Klein ha 8/16/24 passi, Klein Base 25/50/64). -import type { JSX } from 'react' -import { FAMIGLIE } from '@shared/tipi' -import { usaStato, type Qualita } from '../stato' -import { Segmenti } from './Controlli' -import { I } from './Icone' -import { AvvisoTurbo } from './Turbo' - -export function ScegliQualita(p: { - valore: Qualita - cambia: (q: Qualita) => void - /** come si chiama (e cosa dice sotto) la qualità più bassa: in Crea è la «Bozza» a metà pixel, altrove «Veloce» */ - bozza?: { nome: string; sotto?: string; titolo?: string } - /** senza «Massima» (Laboratorio) */ - senzaMassima?: boolean -}): JSX.Element { - const f = usaStato((s) => s.imp?.famiglia || 'qwen') - const passi = FAMIGLIE[f].passi - const bozza = p.bozza || { nome: 'Veloce' } - const voci: { id: Qualita; nome: JSX.Element | string; sotto: string; titolo?: string }[] = [] - if (f === 'qwen') voci.push({ id: 'turbo', nome: <> Turbo, sotto: `${passi.turbo} passi`, titolo: 'LoRA Turbo8: circa 5 volte più veloce' }) - voci.push({ id: 'bozza', nome: bozza.nome, sotto: bozza.sotto || `${passi.bozza} passi`, titolo: bozza.titolo }) - voci.push({ id: 'alta', nome: 'Alta', sotto: `${passi.alta} passi` }) - if (!p.senzaMassima) voci.push({ id: 'massima', nome: 'Massima', sotto: `${passi.massima} passi` }) - // passando a Klein il Turbo non c'è più: vale come la qualità più bassa - const valore = f !== 'qwen' && p.valore === 'turbo' ? 'bozza' : p.valore - return ( -
- valore={valore} cambia={p.cambia} voci={voci} /> - {f === 'qwen' && valore === 'turbo' && } - {f !== 'qwen' &&
{f === 'klein' ? 'Klein è già distillato: 16 passi senza CFG sono la sua qualità piena.' : 'Klein Base: 50 passi con CFG 3,5, fatto per la massima qualità (e per i LoRA futuri).'}
} -
- ) -} diff --git a/src/renderer/src/componenti/SceltaLora.tsx b/src/renderer/src/componenti/SceltaLora.tsx index c68cf9a..cf37d10 100644 --- a/src/renderer/src/componenti/SceltaLora.tsx +++ b/src/renderer/src/componenti/SceltaLora.tsx @@ -29,7 +29,7 @@ export function Copertina({ l, grande }: { l: InfoLora; grande?: boolean }): JSX } export function SceltaLora(): JSX.Element { - const { lore: tutte, loraAttive, setLoraAttive, toggleLora, vai, imp } = usaStato() + const { lore: tutte, loraAttive, setLoraAttive, toggleLora, vaiOpzioni, imp } = usaStato() // il Turbo si accende dalla Qualità, non da qui const lore = tutte.filter((l) => l.file !== imp?.loraTurbo) const [aperto, setAperto] = useState(false) @@ -74,14 +74,14 @@ export function SceltaLora(): JSX.Element { - {lore.length === 0 && Nessun LoRA: aggiungili dalla pagina LoRA.} + {lore.length === 0 && Nessun LoRA: aggiungili da Opzioni → LoRA.}
{aperto && (
{lore.length === 0 && (
Non hai ancora LoRA. - +
)} {lore.map((l) => { diff --git a/src/renderer/src/componenti/Turbo.tsx b/src/renderer/src/componenti/Turbo.tsx deleted file mode 100644 index 3724b41..0000000 --- a/src/renderer/src/componenti/Turbo.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// Il Turbo: un LoRA di distillazione (Turbo8) che fa le immagini in 8 passi invece di 40. -// Qui c'è lo scaricamento con un clic (una volta sola, ~1,4 GB) e il riquadro che lo propone. -import type { JSX } from 'react' -import { create } from 'zustand' -import { api } from '../api' -import { ricaricaLore, usaStato } from '../stato' -import { turboPresente } from '../azioni' -import { I } from './Icone' -import type { Download } from '@shared/tipi' - -const usaDl = create<{ dl: Download | null }>(() => ({ dl: null })) -let collegato = false - -export async function scaricaTurbo(): Promise { - if (usaDl.getState().dl?.stato === 'in corso') return - if (!collegato) { - collegato = true - api.su.download((d) => { - if (/turbo/i.test(d.nome)) usaDl.setState({ dl: { ...d } }) - }) - } - usaDl.setState({ dl: { id: '', nome: 'Turbo', ricevuti: 0, totali: 0, velocita: 0, stato: 'in corso' } }) - let d: Download - try { - d = await api.modelli.scarica('lora-turbo') - } catch (e) { - usaDl.setState({ dl: null }) - usaStato.getState().avvisa(e instanceof Error ? e.message : 'Download non riuscito', 'errore') - return - } - usaDl.setState({ dl: d }) - if (d.stato === 'fatto') { - await ricaricaLore() - usaStato.getState().avvisa('Turbo pronto: ora le immagini escono in 8 passi', 'ok') - } else if (d.stato === 'errore') usaStato.getState().avvisa(d.errore || 'Download non riuscito', 'errore') -} - -export function useTurbo(): { presente: boolean; dl: Download | null } { - usaStato((s) => s.lore) - const dl = usaDl((s) => s.dl) - return { presente: turboPresente(), dl } -} - -/** il riquadro che compare quando si sceglie Turbo ma il LoRA non c'è ancora */ -export function AvvisoTurbo(): JSX.Element | null { - const { presente, dl } = useTurbo() - if (presente) return null - const va = dl?.stato === 'in corso' || dl?.stato === 'verifica' - const perc = dl?.totali ? Math.round((dl.ricevuti / dl.totali) * 100) : 0 - return ( -
-
- -
- Turbo: 8 passi invece di 40 -
Circa 5 volte più veloce, anche su 6 GB. Serve un LoRA da ~1,4 GB (Turbo8, si scarica una volta sola).
-
-
- {va ? ( - <> -
-
- {perc}% · {((dl?.velocita || 0) / 1e6).toFixed(1)} MB/s - {dl?.id && } -
- - ) : ( - - )} -
- ) -} diff --git a/src/renderer/src/fusion.ts b/src/renderer/src/fusion.ts index af9f5b1..ef9bfeb 100644 --- a/src/renderer/src/fusion.ts +++ b/src/renderer/src/fusion.ts @@ -20,10 +20,9 @@ export const FORMATI_FUSION: { id: string; nome: string; r?: [number, number] }[ export type Esito = { q: Richiesta } | { errore: string } /** Fusion: tutte le foto sono riferimenti, ne esce una immagine nuova in base al testo */ -export function richiestaFusion(s: StatoModifica, turboPronto: boolean): Esito { +export function richiestaFusion(s: StatoModifica): Esito { if (!s.riferimenti.length) return { errore: 'Aggiungi almeno una foto' } if (!s.prompt.trim()) return { errore: 'Scrivi cosa deve nascere dalle foto (es. "la donna di con la giacca di in una strada di Tokyo di notte")' } - if (s.qualita === 'turbo' && !turboPronto) return { errore: 'Per il Turbo scarica prima il suo LoRA (il pulsante sotto Qualità), o scegli Alta' } const mis = s.misura && s.misura.tipo === 'custom' ? s.misura : null const [a, b] = mis ? [mis.w, mis.h] : FORMATI_FUSION.find((f) => f.id === s.formatoFusion)?.r || [s.riferimenti[0].larghezza, s.riferimenti[0].altezza] const px = s.mp * 1048576 @@ -42,7 +41,7 @@ export function richiestaFusion(s: StatoModifica, turboPronto: boolean): Esito { immagini: s.riferimenti.map((r) => r.percorso), ...perQualita(s.qualita), quante: s.quante, - etichetta: 'Fusion · ' + s.prompt.trim().slice(0, 50), + etichetta: 'Unisci foto · ' + s.prompt.trim().slice(0, 50), // la misura esatta scelta: il modello lavora ai megapixel, poi il motore porta l'immagine a quella misura ...(mis ? { uscita: { w: mis.w, h: mis.h } } : {}) } @@ -109,10 +108,9 @@ export async function pianificaEsempio(s: StatoModifica): Promise<{ testo: strin } /** Esempio: l'esempio è la base (`image_1`), le altre foto sono i riferimenti (ritagliati se serviva); l'istruzione è il piano del modello (o quello che hai corretto) */ -export function richiestaEsempio(s: StatoModifica, turboPronto: boolean, piano: { testo: string; ritagli?: (string | null)[] }): Esito { +export function richiestaEsempio(s: StatoModifica, piano: { testo: string; ritagli?: (string | null)[] }): Esito { if (!s.esempio) return { errore: 'Metti la foto di esempio: com\'è il risultato che vuoi' } if (!s.riferimenti.length) return { errore: 'Aggiungi almeno una foto di riferimento (quello che vuoi portare nell\'esempio)' } - if (s.qualita === 'turbo' && !turboPronto) return { errore: 'Per il Turbo scarica prima il suo LoRA (il pulsante sotto Qualità), o scegli Alta' } return { q: { ...richiestaBase(), diff --git a/src/renderer/src/pagine/Crea.tsx b/src/renderer/src/pagine/Crea.tsx index fb3e587..691f96a 100644 --- a/src/renderer/src/pagine/Crea.tsx +++ b/src/renderer/src/pagine/Crea.tsx @@ -1,24 +1,21 @@ // Crea: dal testo all'immagine. import { useEffect, useMemo, useState, type JSX } from 'react' -import { usaStato, type Qualita } from '../stato' +import { usaStato } from '../stato' import { api, urlFile } from '../api' import { I } from '../componenti/Icone' import { AdattaFoto } from '../componenti/AdattaFoto' -import { Contatore, Cursore, Interruttore, SezioneChiudibile, Segmenti } from '../componenti/Controlli' +import { Interruttore, SezioneChiudibile, Segmenti } from '../componenti/Controlli' import { SceltaMisura, ScegliMp, Uscita, MISURA0, dimMp } from '../componenti/Misura' import { SceltaLora, conParoleLora } from '../componenti/SceltaLora' import { percentuale } from '../componenti/Coda' -import { BarraPreset, SchedaPreset } from '../componenti/Preset' +import { MenuStili, SchedaPreset } from '../componenti/Preset' import { VistaZoom } from '../componenti/VistaZoom' -import { AvvisoTurbo, useTurbo } from '../componenti/Turbo' import { FORMATI, RISOLUZIONI, dimensioniRis, durata, normalizza, stimaSecondi } from '../util' import { Riferimenti } from '../componenti/Riferimenti' -import { SISTEMA_DESCRIVI, SISTEMA_IDEA, SISTEMA_MIGLIORA, apriInModifica, fotoDaOpera, perQualita, richiestaBase, rifinisci, riusa, varia, ingrandisci } from '../azioni' +import { SISTEMA_DESCRIVI, SISTEMA_IDEA, SISTEMA_MIGLIORA, apriInModifica, fotoDaOpera, richiestaBase, rifaiCon, rifacibileIn, rifinisci, riusa, varia, ingrandisci } from '../azioni' import { presetDa } from '../preset' -import { passiDi } from '../stato' +import { MODELLI, modelloDaImpostazioni, parametriModello } from '@shared/modelli' import { Casella } from '../componenti/Casella' -import { ScegliQualita } from '../componenti/ScegliQualita' -import { ScegliModello } from '../componenti/ScegliModello' import type { Lavoro, Opera } from '@shared/tipi' @@ -29,10 +26,8 @@ const ar = (f: string): string => { export function Crea(): JSX.Element { const { crea, setCrea, imp, lavori, avvisa } = usaStato() - const turbo = useTurbo() const [precedente, setPrecedente] = useState(null) const [scrivendo, setScrivendo] = useState(false) - const [avanzate, setAvanzate] = useState(false) const [scelto, setScelto] = useState(null) const [opere, setOpere] = useState>({}) const [vedoInCorso, setVedoInCorso] = useState(true) @@ -42,23 +37,24 @@ export function Crea(): JSX.Element { void api.galleria.elenco({ modalita: 'tutte' }).then((l) => setRecenti(l.slice(0, 8))) }, [lavori.filter((l) => l.stato === 'fatto').length]) - const bozza = crea.qualita === 'bozza' - // la bozza lavora a metà dei pixel (lato corto / √2) - const dim = dimensioniRis(crea.formato, bozza ? Math.max(256, Math.round(crea.ris / Math.SQRT2)) : crea.ris) + 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 * (bozza ? 0.5 : 1), 4.19) : 0 + 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 - const { passi } = perQualita(crea.qualita) + // il modello si sceglie dalla barra in alto e porta con sé i passi (e il CFG): qui non c'è niente da regolare + const modello = MODELLI[modelloDaImpostazioni(imp)] + const passi = modello.passi + const quante = crea.quante <= 1 ? 1 : crea.quante <= 2 ? 2 : 4 const preset = presetDa(crea.preset) - const stima = stimaSecondi(imp?.tempi, (lav.w * lav.h) / 1048576, passi) * crea.quante + const stima = stimaSecondi(imp?.tempi, (lav.w * lav.h) / 1048576, passi) * quante // i lavori di questa pagina (creati, rifiniti, variati) const miei = useMemo(() => lavori.filter((l) => ['crea', 'rifinisci', 'varia'].includes(l.richiesta.modalita)), [lavori]) @@ -89,34 +85,31 @@ export function Crea(): JSX.Element { avvisa('Scrivi prima cosa vuoi vedere', 'errore') return } - if (crea.qualita === 'turbo' && !turbo.presente) { - avvisa('Per il Turbo scarica prima il suo LoRA (il pulsante sotto Qualità), o scegli Alta', 'errore') - return - } const q = { ...richiestaBase(), modalita: 'crea' as const, prompt: conParoleLora(crea.prompt.trim()), // Klein: la richiesta breve si espande in una didascalia dettagliata (se non è un preset, che il prompt lo scrive già lungo) potenzia: !preset, - negativo: crea.negativo, + // i valori ufficiali del modello (Klein sceglie CFG, sampler e scheduler da sé nel motore) + negativo: '', seed: crea.casuale ? -1 : crea.seed, - cfg: crea.cfg, - sampler: crea.sampler, - scheduler: crea.scheduler, + cfg: 1, + sampler: 'euler', + scheduler: 'simple', larghezza: lav.w, altezza: lav.h, megapixel: (lav.w * lav.h) / 1048576, // se il modello lavora a una misura diversa da quella che vuoi, il motore porta la foto alla misura finale ...(Math.abs(finale.w - lav.w) > 1 || Math.abs(finale.h - lav.h) > 1 ? { uscita: { w: finale.w, h: finale.h } } : {}), - quante: crea.quante, + quante, immagini: crea.riferimenti.map((r) => r.percorso), trasparente: crea.trasparente, - ...perQualita(crea.qualita), - etichetta: bozza ? 'Bozza' : preset ? preset.nome : undefined + ...parametriModello(modello.id), + etichetta: preset ? preset.nome : undefined } await api.lavori.accoda(q) - if (crea.quante > 1) avvisa(`${crea.quante} immagini in coda`, 'ok') + if (quante > 1) avvisa(`${quante} immagini in coda`, 'ok') } useEffect(() => { @@ -193,10 +186,11 @@ export function Crea(): JSX.Element { return (
-
-
+
+ +

@@ -229,7 +223,8 @@ export function Crea(): JSX.Element { {(imp?.famiglia || 'qwen') === 'qwen' &&
}

- +
+

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

{FORMATI.map((f) => { const k = 22 / Math.max(f.w, f.h) @@ -241,7 +236,7 @@ export function Crea(): JSX.Element { ) })}
-

Risoluzione finale {dim.w} × {dim.h} px{bozza ? ' · bozza' : ''}

+

Lato corto

{RISOLUZIONI.map((r) => ( ))}
-

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 /> - - - - setCrea({ qualita })} bozza={{ nome: 'Bozza', sotto: `${passiDi('bozza')} passi, ½ pixel`, titolo: 'Per esplorare: poi "Rifinisci" porta la bozza scelta in alta qualità' }} /> - {bozza &&
Esplori più idee in poco tempo; sulla bozza che ti piace premi Rifinisci.
} -
+
-
-
-

Quante

- setCrea({ quante })} /> -
-
+

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

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

Seed

-
-
- -
- setCrea({ trasparente })} sotto="PNG con sfondo trasparente (canale alfa nativo di Qwen-Image 2.1)"> - Sfondo trasparente - -
- -
-

setAvanzate(!avanzate)}> - {avanzate ? '▾' : '▸'} Avanzate -

- {avanzate && ( -
- {crea.qualita === 'turbo' &&
Col Turbo CFG, sampler e scheduler restano quelli suoi (1, euler, simple).
} - v.toFixed(1)} cambia={(cfg) => setCrea({ cfg })} /> -
- Prompt negativo {crea.cfg <= 1 && (conta solo con CFG sopra 1, e raddoppia il tempo)} - setCrea({ negativo })} minimo={60} placeholder="es. sfocato, mani storte, testo" /> -
-
-
- Sampler - -
-
- Scheduler - -
-
- + 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)}
@@ -364,14 +323,11 @@ export function Crea(): JSX.Element {
{op && !mostraInCorso && (
- {op.etichetta === 'Bozza' && ( - - )} - {op.etichetta !== 'Bozza' && } + {rifacibileIn(op).includes('klein-base') && } + {rifacibileIn(op).includes('qwen-base') && } + + {rifacibileIn(attuale).includes('klein-base') && } + {rifacibileIn(attuale).includes('qwen-base') && } {attuale.etichetta === 'Bozza' ? ( ) : ( diff --git a/src/renderer/src/pagine/Impostazioni.tsx b/src/renderer/src/pagine/Impostazioni.tsx index e20a089..b6d8123 100644 --- a/src/renderer/src/pagine/Impostazioni.tsx +++ b/src/renderer/src/pagine/Impostazioni.tsx @@ -1,11 +1,11 @@ // 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 { scaricaTurbo, useTurbo } from '../componenti/Turbo' import type { Download, Impostazioni as Imp, StatoVoce } from '@shared/tipi' import type { FileInutile } from '../api' @@ -13,9 +13,22 @@ 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). */ 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 turbo = useTurbo() const [file, setFile] = useState({ diffusione: [], encoder: [], vae: [], upscaler: [], klein: [], kleinTe: [], kleinVae: [] }) const [catalogo, setCatalogo] = useState([]) const [download, setDownload] = useState>({}) @@ -107,8 +120,12 @@ export function Impostazioni(): JSX.Element {
IngranditorePer "2×", "Rifinisci" e le misure grandi. NVIDIA RTX (RTX Video Super Resolution) usa i Tensor core della scheda: più nitido e veloce; se non c'è, si usa quello a modello
salva({ upscalerRtx: v === 'rtx' })} voci={[{ id: 'rtx', nome: 'NVIDIA RTX', sotto: 'consigliato' }, { id: 'modello', nome: 'A modello', sotto: '4x-UltraSharp' }]} />
- {voceScelta('FLUX 3 Klein · veloce', 'Il file di Klein (16 passi). Vuoto = lo trova da solo fra quelli scaricati', imp.kleinModello, ['', ...file.klein.filter((n) => !/base/i.test(n))], (kleinModello) => salva({ kleinModello }))} + {voceScelta('FLUX 3 Klein · veloce', 'Il file di Klein (8 passi). Vuoto = lo trova da solo fra quelli scaricati', imp.kleinModello, ['', ...file.klein.filter((n) => !/base/i.test(n))], (kleinModello) => salva({ kleinModello }))} {voceScelta('FLUX 3 Klein Base', 'Il file di Klein Base (50 passi, CFG)', imp.kleinBase, ['', ...file.klein.filter((n) => /base/i.test(n))], (kleinBase) => salva({ kleinBase }))} +
+
Espandi il prompt prima di disegnareCon Klein. Black Forest Labs lo consiglia: Klein dà il meglio con didascalie lunghe. Lo scrive il Qwen3-VL (circa mezzo minuto in più per immagine)
+ salva({ potenziaKlein })}>{(imp.potenziaKlein ?? true) ? 'Acceso' : 'Spento'} +
{voceScelta('Text encoder di Klein', 'Qwen3-VL 4B (diverso dall\'encoder 8B di Qwen)', imp.kleinEncoder, ['', ...file.kleinTe], (kleinEncoder) => salva({ kleinEncoder }))} {voceScelta('VAE di Klein', 'Il decoder di FLUX 3 Klein', imp.kleinVae, ['', ...file.kleinVae], (kleinVae) => salva({ kleinVae }))} {!imp.upscalerRtx && voceScelta('Ingranditore a modello', 'Il file in upscale_models', imp.upscaler, file.upscaler, (upscaler) => salva({ upscaler }))} @@ -185,7 +202,7 @@ export function Impostazioni(): JSX.Element {

alterna('PRESTAZIONI E MEMORIA')}>{chiusi['PRESTAZIONI E MEMORIA'] ? '▸' : '▾'} PRESTAZIONI E MEMORIA

-
Scarica da solo quello che mancaQuando l'app è ferma per un minuto, scarica i file consigliati che non hai (il LoRA Turbo, il traduttore…). I modelli grandi li scegli tu
+
Scarica da solo quello che mancaQuando l'app è ferma per un minuto, scarica i file consigliati che non hai (il traduttore…). I modelli grandi li scegli tu
salva({ scaricaInIdle })}>{imp.scaricaInIdle ? 'Sì' : 'No'}
@@ -203,18 +220,6 @@ export function Impostazioni(): JSX.Element { voci={[{ id: 'auto', nome: 'Auto' }, { id: 'bassa', nome: '6 GB' }, { id: 'normale', nome: '8-12 GB' }, { id: 'alta', nome: '16 GB+' }]} />
-
-
Turbo (8 passi)Il LoRA Turbo8: circa 5 volte più veloce. Si sceglie da Qualità in Crea e Modifica
-
- {turbo.presente ? ( - ✓ pronto · {imp.loraTurbo} - ) : turbo.dl?.stato === 'in corso' || turbo.dl?.stato === 'verifica' ? ( - Scarico… {turbo.dl.totali ? Math.round((turbo.dl.ricevuti / turbo.dl.totali) * 100) : 0}% - ) : ( - - )} -
-
Traduci in inglese da soloQwen-Image capisce meglio l'inglese: i prompt in italiano si traducono prima di disegnare. I testi fra virgolette restano identici
salva({ traduci })}>{imp.traduci ? 'Accesa' : 'Spenta'} @@ -236,6 +241,10 @@ export function Impostazioni(): JSX.Element {
Una cosa alla volta in VRAMQuando passi dal modello immagine all'LLM (migliora prompt, descrivi foto, Idee, Esempio…) o viceversa, scarica quello che non serve: la scheda tiene un modello solo. Costa qualche secondo a ogni cambio, ma sulle schede da 6-8 GB evita di finire nella RAM
salva({ unaCosaAllaVolta })}>{imp.unaCosaAllaVolta ? 'Accesa' : 'Spenta'}
+
+
Scrivi i testi col Qwen3-VL 4B anche con QwenMigliora prompt, descrivi foto, Idee, Estrai… con Qwen girano sull'encoder 8B (da 3 a 0,5 token al secondo, in buona parte sul processore). Col 4B di Klein sono 40-50 token al secondo (Migliora prompt in 5 secondi invece di 1-5 minuti), ma lo scrive un modello più piccolo e va scaricato; se il file manca resta l'8B. Con Klein il 4B scrive sempre. Il 4B e Qwen non stanno in VRAM insieme: a ogni cambio si scarica uno dei due
+ salva({ testoCon4B })}>{(imp.testoCon4B ?? true) ? 'Acceso' : 'Spento'} +
Scarica i modelli quando non li usoSe per un po' non fai niente, i modelli escono dalla VRAM e la scheda torna libera per i giochi e gli altri programmi. La prossima immagine impiega qualche secondo in più a ripartire
salva({ scaricaDopo })} voci={[{ id: 0, nome: 'Mai' }, { id: 60, nome: '1 min' }, { id: 120, nome: '2 min' }, { id: 300, nome: '5 min' }, { id: 900, nome: '15 min' }]} /> @@ -249,7 +258,7 @@ export function Impostazioni(): JSX.Element { Ci mette minuti per una foto? Su Windows, quando la VRAM finisce, il driver NVIDIA continua nella RAM del PC e tutto va 5-10 volte più piano senza dare errori. Apri Pannello di controllo NVIDIA → Gestisci impostazioni 3D → Impostazioni globali e metti Criterio di fallback della memoria di sistema CUDA su - Preferisci nessun fallback: così il motore sposta lui i pezzi in RAM, molto più in fretta. Poi: Turbo, + Preferisci nessun fallback: così il motore sposta lui i pezzi in RAM, molto più in fretta. Poi: scegli Klein veloce (8 passi), profilo 6 GB se la scheda è piccola, e chiudi browser e giochi che usano la GPU.
diff --git a/src/renderer/src/pagine/Modifica.tsx b/src/renderer/src/pagine/Modifica.tsx index 702f451..b14d566 100644 --- a/src/renderer/src/pagine/Modifica.tsx +++ b/src/renderer/src/pagine/Modifica.tsx @@ -1,36 +1,30 @@ -// Modifica: tutta la foto, solo una zona (la segni col pennello) o allargarla oltre i bordi. +// Modifica: scrivi cosa cambiare (tutta la foto, o solo la zona se l'hai segnata col pennello) o allarga la foto oltre i bordi. // Le azioni rapide (persone, vestiti, situazioni, camera, luce, stili, testi) mettono l'istruzione giusta; // dopo ogni modifica la nuova versione resta sulla tela, col confronto prima/dopo e il tasto "Continua da qui". import { useEffect, useMemo, useRef, useState, type JSX } from 'react' -import { usaStato, attesiModifica, scegliFoto, type ModoModifica, type Qualita, type FotoBase } from '../stato' +import { usaStato, attesiModifica, scegliFoto, type FotoBase } from '../stato' import { api, urlFile } from '../api' -import { I } from '../componenti/Icone' -import { Casella } from '../componenti/Casella' -import { ScegliQualita } from '../componenti/ScegliQualita' -import { ScegliModello } from '../componenti/ScegliModello' -import { Contatore, Cursore, Segmenti } from '../componenti/Controlli' -import { SceltaLora, conParoleLora } from '../componenti/SceltaLora' -import { TelaMaschera, type AnteprimaTela, type ComandiTela, type Strumento } from '../componenti/TelaMaschera' -import { AvvisoTurbo, useTurbo } from '../componenti/Turbo' -import { AvvisoLora } from '../componenti/AvvisoLora' -import { Riferimenti } from '../componenti/Riferimenti' -import { SceltaMisura, ScegliMp, MISURA0, conMisura, dimMp } from '../componenti/Misura' -import { Fusion, MAX_FUSION } from '../componenti/Fusion' -import { ControlliEstrai, PalcoEstrai } from '../componenti/Estrai' -import { FANTASIE, pianificaIdee, unisciRapide } from '../regista' +import { conParoleLora } from '../componenti/SceltaLora' +import { type AnteprimaTela, type ComandiTela, type Strumento } from '../componenti/TelaMaschera' +import { MISURA0, conMisura, dimMp } from '../componenti/Misura' +import { MAX_FUSION } from '../componenti/Fusion' +import { ControlliEstrai } from '../componenti/Estrai' +import { pianificaIdee, unisciRapide } from '../regista' +import { TelaModifica } from './modifica/Tela' +import { SchedeModifica } from './modifica/Schede' +import { NIENTE, bordiPreset, type PresetEspandi } from './modifica/SchedaEspandi' import { FORMATI_FUSION, chiaveEsempio, pianificaEsempio, richiestaEsempio, richiestaFusion } from '../fusion' import { ascoltatoriLavoro } from '../stato' -import { CAMERA0, ControlloCamera, componiCamera, conLora, type PosizioneCamera } from '../componenti/Camera' -import { daDataUrl, durata, immagineDaIncolla, normalizza, stimaSecondi } from '../util' +import { specialeOra, usaSpeciale } from '../workflow' +import { SchedaRicette } from './modifica/SchedaRicette' +import { CAMERA0, componiCamera, conLora, type PosizioneCamera } from '../componenti/Camera' +import { daDataUrl, immagineDaIncolla, normalizza, stimaSecondi } from '../util' import { SISTEMA_MIGLIORA_MODIFICA, fileLoraCamera, loraPer, perQualita, richiestaBase } from '../azioni' import { categoriePer, type Rapida } from '../rapide' -import { apriInModifica, fotoDaOpera } from '../azioni' import type { Bordi, Lavoro, Opera } from '@shared/tipi' const attesiFusion = new Set() -const NIENTE: Bordi = { sinistra: 0, sopra: 0, destra: 0, sotto: 0 } - export function Modifica(): JSX.Element { const { modifica: m, setModifica, imp, lavori, avvisa } = usaStato() const lore = usaStato((s) => s.lore) @@ -51,14 +45,19 @@ export function Modifica(): JSX.Element { const [categoria, setCategoria] = useState('persone') const [camera, setCamera] = useState(CAMERA0) const [scrivendo, setScrivendo] = useState(false) - const turbo = useTurbo() // Fusion: i risultati (foto nuove) e il lavoro che li sta creando const [ris, setRis] = useState([]) - const fusion = m.modo === 'fusion' - const esempioMode = m.modo === 'esempio' + // Unisci foto e Come l'esempio sono ricette, ma usano la tela di Modifica (la griglia di foto) e la colonna di destra come prima + const speciale = usaSpeciale() + const fusion = speciale === 'unisci' + const esempioMode = speciale === 'esempio' + /** nel catalogo delle ricette (e nelle ricette normali) Modifica cede il posto a SchedaRicette */ + const catalogo = m.modo === 'ricette' && !speciale const estraiMode = m.modo === 'estrai' - /** i modi con una griglia di foto al posto della tela: Fusion e Esempio */ + /** le ricette con una griglia di foto al posto della tela: Unisci foto e Come l'esempio */ const griglia = fusion || esempioMode + /** in Scrivi: se c'è una zona disegnata vale la zona, se no tutta la foto (si decide da quello che hai sulla tela) */ + const zona = m.modo === 'scrivi' && haMaschera const base = m.base const indice = base ? m.versioni.findIndex((v) => v.percorso === base.percorso) : -1 @@ -70,7 +69,7 @@ export function Modifica(): JSX.Element { const fusInCorso: Lavoro | undefined = lavori.find((l) => l.stato === 'in corso' && attesiFusion.has(l.id)) const fusInAttesa = lavori.filter((l) => (l.stato === 'in coda' || l.stato === 'in corso') && attesiFusion.has(l.id)).length const forzaRapida = rapida?.forza - const stima = stimaSecondi(imp?.tempi, m.mp, m.modo === 'zona' && forzaRapida ? Math.max(Math.min(passi, m.qualita === 'turbo' ? 4 : 8), Math.round(passi * forzaRapida)) : passi) * m.quante + const stima = stimaSecondi(imp?.tempi, m.mp, zona && forzaRapida ? Math.max(Math.min(passi, 8), Math.round(passi * forzaRapida)) : passi) * m.quante /** a che risoluzione esce la foto (se si sa già) */ const dimOut = ((): { w: number; h: number } | null => { const mis = m.misura ?? MISURA0 @@ -81,9 +80,9 @@ export function Modifica(): JSX.Element { } const rif = esempioMode ? m.esempio : base if (!rif || m.modo === 'espandi') return null - return m.modo === 'zona' || mis.tipo === 'originale' ? { w: rif.larghezza, h: rif.altezza } : dimMp(m.mp, rif.larghezza, rif.altezza) + return zona || mis.tipo === 'originale' ? { w: rif.larghezza, h: rif.altezza } : dimMp(m.mp, rif.larghezza, rif.altezza) })() - const categorie = categoriePer(m.modo === 'zona' ? 'zona' : 'tutta') + const categorie = categoriePer(zona ? 'zona' : 'tutta') const cat = categorie.find((c) => c.id === categoria) || categorie[0] const loraCamera = !!fileLoraCamera() && lore.length >= 0 @@ -116,9 +115,9 @@ export function Modifica(): JSX.Element { // entrando in Fusion con una foto già aperta, quella è la prima useEffect(() => { const s = usaStato.getState().modifica - if (s.modo === 'fusion' && !s.riferimenti.length && s.base) setModifica({ riferimenti: [s.base] }) - if (s.modo === 'esempio' && !s.esempio && s.base) setModifica({ esempio: s.base }) - }, [m.modo]) + if (speciale === 'unisci' && !s.riferimenti.length && s.base) setModifica({ riferimenti: [s.base] }) + if (speciale === 'esempio' && !s.esempio && s.base) setModifica({ esempio: s.base }) + }, [speciale]) // pennello proporzionato alla foto; foto nuova = niente confronto useEffect(() => { @@ -128,13 +127,20 @@ export function Modifica(): JSX.Element { // cambiando modo si torna alla tela (per disegnare la zona o vedere la cornice) useEffect(() => { setConfronto(false) - if (!categoriePer(m.modo === 'zona' ? 'zona' : 'tutta').some((c) => c.id === categoria)) setCategoria(m.modo === 'zona' ? 'zona-base' : 'persone') }, [m.modo]) + // con la zona o senza cambiano le idee che hanno senso + useEffect(() => { + if (!categoriePer(zona ? 'zona' : 'tutta').some((c) => c.id === categoria)) setCategoria(zona ? 'zona-base' : 'persone') + }, [zona]) + // Fusion ed Estrai non hanno la tela: la zona disegnata si perde, e con lei il segno + useEffect(() => { + if (griglia || estraiMode) setHaMaschera(false) + }, [griglia, estraiMode]) // un risultato nuovo: si apre il confronto prima/dopo (con "Continua da qui" bene in vista) const lunghezza = useRef(m.versioni.length) useEffect(() => { - if (m.versioni.length > lunghezza.current && m.versioni.length > 1 && usaStato.getState().modifica.modo !== 'fusion') setConfronto(true) + if (m.versioni.length > lunghezza.current && m.versioni.length > 1 && specialeOra() !== 'unisci') setConfronto(true) lunghezza.current = m.versioni.length }, [m.versioni.length]) @@ -160,7 +166,7 @@ export function Modifica(): JSX.Element { setModifica({ riferimenti: nuove }) } const daPc = async (): Promise => { - if (['fusion', 'esempio'].includes(usaStato.getState().modifica.modo)) { + if (specialeOra()) { const ps = await api.file.scegliImmagini(true) const f: FotoBase[] = [] for (const x of ps) { @@ -177,7 +183,7 @@ export function Modifica(): JSX.Element { if (p) void apri(p) } const daGalleria = async (): Promise => { - if (['fusion', 'esempio'].includes(usaStato.getState().modifica.modo)) { + if (specialeOra()) { aggiungiFusion(await scegliFoto(true)) return } @@ -199,7 +205,7 @@ export function Modifica(): JSX.Element { } /** la pagina torna com'era appena aperta, senza chiudere il programma */ const togliFoto = (): void => { - setModifica({ base: null, versioni: [], riferimenti: [], prompt: '', modo: 'tutta', bordi: { ...NIENTE }, misura: MISURA0 }) + setModifica({ base: null, versioni: [], riferimenti: [], prompt: '', modo: 'scrivi', bordi: { ...NIENTE }, misura: MISURA0 }) setConfronto(false) setRapida(null) setHaMaschera(false) @@ -210,14 +216,16 @@ export function Modifica(): JSX.Element { useEffect(() => { const incolla = async (e: ClipboardEvent): Promise => { if (usaStato.getState().pagina !== 'modifica') return + // nel catalogo delle ricette incolla SchedaRicette + if (usaStato.getState().modifica.modo === 'ricette' && !specialeOra()) return if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return const d = immagineDaIncolla(e) if (!d) return e.preventDefault() const f = await daDataUrl(await d) const s = usaStato.getState().modifica - if (s.modo === 'fusion') aggiungiFusion([f]) - else if (s.modo === 'esempio') { + if (specialeOra() === 'unisci') aggiungiFusion([f]) + else if (specialeOra() === 'esempio') { if (!s.esempio) setModifica({ esempio: f }) else aggiungiFusion([f]) } @@ -236,6 +244,7 @@ export function Modifica(): JSX.Element { useEffect(() => { const t = (e: KeyboardEvent): void => { if (usaStato.getState().pagina !== 'modifica') return + if (usaStato.getState().modifica.modo === 'ricette' && !specialeOra()) return if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) { if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault() @@ -246,7 +255,7 @@ export function Modifica(): JSX.Element { if (e.key === 'Enter' && e.ctrlKey) void manda() else if (e.ctrlKey && e.key.toLowerCase() === 'z') tela.current?.annulla() else if (e.ctrlKey && e.key.toLowerCase() === 'y') tela.current?.ripeti() - else if (m.modo === 'zona' && !e.ctrlKey) { + else if (m.modo === 'scrivi' && !e.ctrlKey) { const k = e.key.toLowerCase() if (k === 'b') setStrumento('pennello') else if (k === 'e') setStrumento('gomma') @@ -300,7 +309,7 @@ export function Modifica(): JSX.Element { async function manda(): Promise { const s = usaStato.getState().modifica - if (s.modo === 'fusion' || s.modo === 'esempio') return mandaGriglia() + if (specialeOra()) return mandaGriglia() if (!s.base) return if (s.modo !== 'espandi' && !s.prompt.trim() && !idee.length) { avvisa('Scrivi cosa cambiare (o scegli una o più idee)', 'errore') @@ -310,21 +319,20 @@ export function Modifica(): JSX.Element { avvisa(`Serve ${rapida.rifNome || 'una foto di riferimento'}: aggiungila in Riferimenti ()`, 'errore') return } - if (s.qualita === 'turbo' && !turbo.presente) { - avvisa('Per il Turbo scarica prima il suo LoRA (il pulsante sotto Qualità), o scegli Alta', 'errore') - return - } + // Scrivi: la zona disegnata (se c'è) decide se si cambia solo lì o tutta la foto + const disegnata = s.modo === 'scrivi' ? tela.current?.esporta() ?? null : null + const tipo: 'modifica' | 'zona' | 'espandi' = s.modo === 'espandi' ? 'espandi' : disegnata ? 'zona' : 'modifica' // le idee scelte: il modello guarda la foto e le mescola in una sola istruzione, inventando i dettagli let testo = s.prompt.trim() if (s.modo !== 'espandi' && idee.length) { if (s.fantasia === 'fedele' && idee.length === 1 && !testo) testo = idee[0].prompt else { - const chiave = JSON.stringify([s.base.percorso, idee.map((r) => r.id), testo, s.riferimenti.map((r) => r.percorso), s.fantasia, s.modo]) + const chiave = JSON.stringify([s.base.percorso, idee.map((r) => r.id), testo, s.riferimenti.map((r) => r.percorso), s.fantasia, tipo === 'zona' ? 'zona' : s.modo]) let piano = s.pianoIdee?.chiave === chiave ? s.pianoIdee.testo : '' if (!piano) { avvisa('Il modello guarda la foto e mescola le idee…', 'info') try { - piano = await pianificaIdee({ base: s.base, idee, nota: testo, riferimenti: s.riferimenti, fantasia: s.fantasia, zona: s.modo === 'zona' }) + piano = await pianificaIdee({ base: s.base, idee, nota: testo, riferimenti: s.riferimenti, fantasia: s.fantasia, zona: tipo === 'zona' }) } catch (er) { avvisa((er as Error).message, 'errore') return @@ -340,7 +348,7 @@ export function Modifica(): JSX.Element { const q = { ...base0, lora: [...base0.lora, ...extra], - modalita: (s.modo === 'tutta' ? 'modifica' : s.modo) as 'modifica' | 'zona' | 'espandi', + modalita: tipo, prompt: conParoleLora(testo), immagini: [s.base.percorso, ...s.riferimenti.map((r) => r.percorso)], megapixel: s.mp, @@ -348,20 +356,15 @@ export function Modifica(): JSX.Element { quante: s.quante, origine: s.base.operaId, // Klein: una richiesta breve scritta da te si espande; le istruzioni già costruite (idee, azioni rapide) no - potenzia: s.modo === 'tutta' && !rapida && !idee.length, + potenzia: tipo === 'modifica' && !rapida && !idee.length, etichetta: (rapida && rapida.id !== 'camera' ? rapida.nome + ' · ' : rapida ? 'Camera · ' : '') + (testo.slice(0, 50) || 'Espandi') } if (s.modo !== 'espandi') Object.assign(q, conMisura(s.misura, s.base)) - if (s.modo === 'tutta') Object.assign(q, { trasparente: !!rapida?.trasparente }) - if (s.modo === 'zona') { - const e = tela.current?.esporta() - if (!e) { - avvisa('Segna prima la zona da cambiare col pennello', 'errore') - return - } - const maschera = await api.file.salvaTemp(e.dataUrl) + if (tipo === 'modifica') Object.assign(q, { trasparente: !!rapida?.trasparente }) + if (disegnata) { + const maschera = await api.file.salvaTemp(disegnata.dataUrl) // tutto il resto (contesto, bordi, riempimento per "rimuovi") lo decide il motore dalla zona e dall'istruzione - Object.assign(q, { maschera, riquadro: e.riquadro, forza: rapida?.forza }) + Object.assign(q, { maschera, riquadro: disegnata.riquadro, forza: rapida?.forza }) } if (s.modo === 'espandi') { const b = s.bordi @@ -380,7 +383,7 @@ export function Modifica(): JSX.Element { async function mandaGriglia(): Promise { const s = usaStato.getState().modifica let e - if (s.modo === 'esempio') { + if (specialeOra() === 'esempio') { // il modello guarda l'esempio e le tue foto e scrive lui cosa sostituire con cosa; il piano resta qui sotto, correggibile const chiave = chiaveEsempio(s) let piano: { testo: string; ritagli?: (string | null)[] } = s.pianoEsempio?.chiave === chiave ? s.pianoEsempio : { testo: '' } @@ -394,8 +397,8 @@ export function Modifica(): JSX.Element { } setModifica({ pianoEsempio: { chiave, ...piano } }) } - e = richiestaEsempio(s, turbo.presente, piano) - } else e = richiestaFusion(s, turbo.presente) + e = richiestaEsempio(s, piano) + } else e = richiestaFusion(s) if ('errore' in e) { avvisa(e.errore, 'errore') return @@ -411,37 +414,21 @@ export function Modifica(): JSX.Element { setModifica({ prompt: v.slice(0, i) + testo + v.slice(i) }) } - const espandiPreset = (tipo: 'tutto' | 'largo' | 'alto' | 'quadrato'): void => { - if (!base) return - const { larghezza: W, altezza: H } = base - let b: Bordi = { ...NIENTE } - const t32 = (x: number): number => Math.round(x / 32) * 32 - if (tipo === 'tutto') { - const d = t32(Math.min(W, H) * 0.25) - b = { sinistra: d, sopra: d, destra: d, sotto: d } - } else if (tipo === 'largo') { - const nuovo = Math.max(W, Math.round((H * 16) / 9)) - const d = t32((nuovo - W) / 2) - b = { sinistra: d, sopra: 0, destra: d, sotto: 0 } - } else if (tipo === 'alto') { - const nuovo = Math.max(H, Math.round((W * 16) / 9)) - const d = t32((nuovo - H) / 2) - b = { sinistra: 0, sopra: d, destra: 0, sotto: d } - } else { - const lato = Math.max(W, H) - b = { sinistra: t32((lato - W) / 2), destra: t32((lato - W) / 2), sopra: t32((lato - H) / 2), sotto: t32((lato - H) / 2) } - } - setModifica({ bordi: b }) + const espandiPreset = (tipo: PresetEspandi): void => { + if (base) setModifica({ bordi: bordiPreset(tipo, base.larghezza, base.altezza) }) } - const disegna = m.modo === 'zona' + const disegna = m.modo === 'scrivi' const confrontoAttivo = confronto && !!precedente && !inCorso const mancaRif = !!rapida?.rif && m.riferimenti.length < rapida.rif /** per quali foto, idee e note vale il piano delle idee (come in manda()) */ - const chiaveIdee = base ? JSON.stringify([base.percorso, idee.map((r) => r.id), m.prompt.trim(), m.riferimenti.map((r) => r.percorso), m.fantasia, m.modo]) : '' + const chiaveIdee = base ? JSON.stringify([base.percorso, idee.map((r) => r.id), m.prompt.trim(), m.riferimenti.map((r) => r.percorso), m.fantasia, zona ? 'zona' : m.modo]) : '' + + const vista = { display: catalogo ? 'none' : 'contents' } return (
+
e.preventDefault()} @@ -450,8 +437,8 @@ export function Modifica(): JSX.Element { e.stopPropagation() const fs = Array.from(e.dataTransfer.files) if (!fs.length) return - if (['fusion', 'esempio'].includes(usaStato.getState().modifica.modo)) { - // Fusion / Esempio: tutte le foto trascinate sono riferimenti + if (specialeOra()) { + // Unisci / Esempio: tutte le foto trascinate sono riferimenti void (async () => { const f: FotoBase[] = [] for (const x of fs.slice(0, MAX_FUSION)) { @@ -480,360 +467,73 @@ export function Modifica(): JSX.Element { })() }} > -
-
- - valore={m.modo} - cambia={(modo) => setModifica({ modo })} - voci={[ - { id: 'tutta', nome: 'Tutta la foto' }, - { id: 'zona', nome: 'Solo una zona' }, - { id: 'espandi', nome: 'Espandi' }, - { id: 'fusion', nome: 'Fusion', titolo: 'Più foto, tutte riferimenti: ne esce una immagine nuova' }, - { id: 'esempio', nome: 'Esempio', titolo: 'Dai le foto da usare e una foto di esempio di come vuoi il risultato' }, - { id: 'estrai', nome: 'Estrai', titolo: 'Dalla foto estrai una persona o un oggetto, su sfondo pulito' } - ]} - /> -
- {disegna && base && !confrontoAttivo && ( - <> - - {([ - ['pennello', I.pennello, 'Pennello (B)'], - ['gomma', I.gomma, 'Gomma (E)'], - ['rettangolo', I.rettangolo, 'Rettangolo (R)'], - ['lazo', I.lazo, 'Lazo (L)'], - ['mano', I.mano, 'Sposta (H o Spazio)'] - ] as const).map(([id, Ic, t]) => ( - - ))} -
- `${v}px`} cambia={setPennello} /> -
- - - - - - - )} - - {base && !griglia && } - - - {base && !griglia && ( - - )} -
- - {estraiMode ? : null} - {estraiMode ? null : griglia ? ( - setModifica({ riferimenti })} - inserisci={inserisci} - risultati={ris} - inCorso={fusInCorso} - inAttesa={fusInAttesa} - daGalleria={daGalleria} - daPc={daPc} - riusa={(o) => aggiungiFusion([fotoDaOpera(o)])} - esempio={esempioMode ? { foto: m.esempio, cambia: (esempio) => setModifica({ esempio }), daGalleria: async () => { const [f] = await scegliFoto(false); if (f) setModifica({ esempio: f }) }, daPc: async () => { const [x] = await api.file.scegliImmagini(false); if (x) { try { setModifica({ esempio: await normalizza(x) }) } catch (e) { avvisa((e as Error).message, 'errore') } } } } : undefined} - apriInModifica={(o) => { apriInModifica(fotoDaOpera(o)); setModifica({ modo: 'tutta' }) }} - mostra={(o) => void api.galleria.mostra(o.id)} - /> - ) : null} - {griglia || estraiMode ? null : !base ? ( -
-
-
MODIFICA UNA FOTO
-

Trascina qui una foto, incollala con Ctrl+V, o scegline una.

-

Cambi tutta la foto con una frase o un'azione rapida, o segni col pennello solo la zona da cambiare: il resto resta identico al pixel.

-
- - -
-
-
- ) : ( - - {!inCorso && m.versioni.length > 1 && indice >= 0 && ( -
e.stopPropagation()} onWheel={(e) => e.stopPropagation()}> - - {indice === 0 ? 'Originale' : `v${indice + 1}`} - di {m.versioni.length} - - {precedente && ( - <> - - - {confrontoAttivo ? ( - - ) : ( - - )} - - )} -
- )} - {confrontoAttivo && ( -
Trascina la linea per confrontare · poi premi Continua da qui per modificare ancora{disegna ? ' (o per segnare un\'altra zona)' : ''}
- )} -
- )} - - {!griglia && !estraiMode && m.versioni.length > 0 && ( -
- {m.versioni.map((v, i) => ( -
{ setModifica({ base: v }); setConfronto(false) }} title={v.prompt}> - - {i === 0 ? 'Originale' : `v${i + 1}`} -
- ))} - {inAttesa > 0 && +{inAttesa} in arrivo…} - - {base && indice > 0 && !confrontoAttivo && Continua a modificare da qui: ogni modifica parte dalla versione scelta.} -
- )} +
- {estraiMode ? :
-
-
-
-

- {fusion ? 'Cosa creare con le foto' : esempioMode ? 'Cosa vuoi (facoltativo)' : m.modo === 'espandi' ? 'Cosa c\'è fuori (facoltativo)' : m.modo === 'zona' ? 'Cosa fare nella zona' : 'Cosa cambiare'} - {m.modo === 'zona' && {haMaschera ? zona segnata ✓ : 'segnala col pennello'}} -

- { - setModifica({ prompt: v }) - if (!v.trim()) setCameraRapida(null) - }} - placeholder={esempioMode ? 'Facoltativo, es. "sostituisci le iridi con quelle delle mie foto". Se lasci vuoto il modello capisce da solo cosa fare' : fusion ? 'es. "la ragazza di con la giacca di in una strada di Tokyo di notte"' : m.modo === 'zona' ? 'es. "una camicia di lino bianca" o "rimuovi la persona"' : m.modo === 'espandi' ? 'es. "una spiaggia al tramonto"' : 'es. "fallo di notte con la neve", "metti la maglia di "'} - minimo={96} - disabled={scrivendo} - /> -
- - {!fusion && (esempioMode ? m.esempio : base) && ( - - )} - {m.riferimenti.map((_, i) => ( - - ))} -
- {mancaRif && !griglia && ( -
- Serve {rapida?.rifNome || 'una foto di riferimento'}: aggiungila qui sotto in Riferimenti (nel testo è <image2>). -
- )} -
- - {esempioMode && m.qualita === 'turbo' && ( -
- Il Turbo è velocissimo ma segue meno i colori e i dettagli delle tue foto: per le iridi, i vestiti, i loghi… prova Alta. -
- )} - {esempioMode && ( -
-

Il piano del modello {m.pianoEsempio && }

- {m.pianoEsempio && m.pianoEsempio.chiave === chiaveEsempio(m) ? ( - <> - setModifica({ pianoEsempio: { ...m.pianoEsempio!, testo } })} /> - {m.pianoEsempio.ritagli?.some(Boolean) && ( -
- Le parti delle tue foto che contano (ritagliate dal modello) -
- {m.pianoEsempio.ritagli.map((r, i) => r && `} />)} -
-
- )} -
L'ha scritto il modello guardando le foto: puoi correggerlo. Si usa così com'è finché non cambi le foto o le note.
- - ) : ( -
Quando premi il pulsante il modello guarda l'esempio e le tue foto insieme e decide da solo cosa sostituire con cosa, e cosa lasciare com'è. Qui vedrai cosa ha deciso, e potrai correggerlo.
- )} -
- )} - - {m.modo !== 'espandi' && !griglia && ( -
-

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' && m.modo === 'tutta' && ( -
- - -
- )} - {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) &&
} -
- )} - - {m.modo === 'espandi' && base && ( -
-

Di quanto allargare {base.larghezza + m.bordi.sinistra + m.bordi.destra} × {base.altezza + m.bordi.sopra + m.bordi.sotto}

-
- - - - - -
- {(['sinistra', 'destra', 'sopra', 'sotto'] as const).map((k) => ( - `${v}px`} cambia={(v) => setModifica({ bordi: { ...m.bordi, [k]: v } })} /> - ))} -
- )} - - {!griglia &&
- setModifica({ riferimenti })} - inizio={2} - inserisci={inserisci} - principale={base?.percorso} - comeBase={comeBase} - titolo="Riferimenti" - esempio={'Es. "metti a la giacca di ". Persone, vestiti, oggetti, stili: fino a 9 foto in più (dalla galleria, dal PC o incollate con Ctrl+V).'} - /> -
} - - {m.modo === 'zona' && ( -
-
- Il modello guarda tutta la foto con la tua zona evidenziata e cambia solo lì: fuori dalla zona la foto resta identica al pixel. - Per togliere qualcosa scrivi "rimuovi…": la zona si ripulisce da sola prima di ridisegnarla. -
-
- )} - - {fusion && (() => { - const mis = m.misura && m.misura.tipo !== 'originale' ? m.misura : MISURA0 - const prima = m.riferimenti[0] ? { larghezza: m.riferimenti[0].larghezza, altezza: m.riferimenti[0].altezza } : null - return ( -
-

Dimensioni della foto nuova {mis.tipo === 'custom' ? `${mis.w} × ${mis.h}` : 'automatiche'}

- setModifica({ misura })} base={prima} mp={m.mp} ammetti={['auto', 'custom']} cambiaMp={(mp) => setModifica({ mp })} auto={(() => { - const [a, b] = FORMATI_FUSION.find((f) => f.id === (m.formatoFusion || 'prima'))?.r || (prima ? [prima.larghezza, prima.altezza] : [1, 1]) - return dimMp(m.mp, a, b) - })()} /> - {mis.tipo === 'auto' && ( -
- Proporzioni - setModifica({ formatoFusion })} voci={FORMATI_FUSION.map((f) => ({ id: f.id, nome: f.nome }))} /> -
- )} - -
- ) - })()} - - {m.modo !== 'espandi' && !fusion && ( -
-

Dimensioni {(m.misura ?? MISURA0).tipo === 'auto' ? 'come i megapixel' : (m.misura ?? MISURA0).tipo === 'originale' ? 'come la foto' : 'misura mia'}

- setModifica({ misura })} base={esempioMode ? m.esempio : base} mp={m.mp} cambiaMp={(mp) => setModifica({ mp })} - senzaPx={m.modo === 'zona'} auto={m.modo === 'zona' && base ? { w: base.larghezza, h: base.altezza, nota: 'resta com\'è, cambia solo la zona' } : undefined} /> -
- )} - -
-

Qualità {passi} passi · ~{durata(stima)}

-
- setModifica({ qualita })} /> -
-
- Quante versioni - setModifica({ quante })} /> -
- {m.modo === 'espandi' && ( -
- Megapixel a cui lavora il modello - setModifica({ mp })} /> -
- )} -
- - {(imp?.famiglia || 'qwen') === 'qwen' && ( -
-

LoRA

- -
- )} -
-
- -
{dimOut ? `${dimOut.w} × ${dimOut.h} px · ` : ''}Ctrl+Invio · ~{durata(stima)}
-
-
} + {estraiMode ? ( + + ) : ( + + )} +
+
) } diff --git a/src/renderer/src/pagine/Setup.tsx b/src/renderer/src/pagine/Setup.tsx index 0a3a705..222fd6e 100644 --- a/src/renderer/src/pagine/Setup.tsx +++ b/src/renderer/src/pagine/Setup.tsx @@ -37,7 +37,7 @@ export function Setup({ fatto, aggiorna }: { fatto: () => void; aggiorna?: boole const s = await api.setup.stato() setMotoreGia(s.motore) setCatalogo(s.modelli) - const presente = s.modelli.find((m) => m.tipo === 'diffusione' && m.presente) + const presente = s.modelli.find((m) => m.tipo === 'diffusione' && !m.turbo && m.presente) if (presente) setScelta(presente.id) else if (t[0]) setScelta('file:' + t[0].percorso) })().catch(() => undefined) @@ -47,7 +47,8 @@ export function Setup({ fatto, aggiorna }: { fatto: () => void; aggiorna?: boole }) }, []) - const diffusione = catalogo.filter((c) => c.tipo === 'diffusione') + // i Turbo di Qwen non si propongono più (restano nel catalogo per chi li ha già e per i PNG vecchi) + const diffusione = catalogo.filter((c) => c.tipo === 'diffusione' && !c.turbo) const lore = catalogo.filter((c) => c.tipo === 'lora' && !c.link) const necessari = catalogo.filter((c) => c.necessario) const sceltaVoce = scelta.startsWith('file:') ? null : catalogo.find((c) => c.id === scelta) @@ -134,7 +135,7 @@ export function Setup({ fatto, aggiorna }: { fatto: () => void; aggiorna?: boole diff --git a/src/renderer/src/pagine/modifica/SchedaEspandi.tsx b/src/renderer/src/pagine/modifica/SchedaEspandi.tsx new file mode 100644 index 0000000..aee9519 --- /dev/null +++ b/src/renderer/src/pagine/modifica/SchedaEspandi.tsx @@ -0,0 +1,50 @@ +// Modifica, scheda Espandi: di quanto allargare la foto oltre i bordi (scorciatoie e quattro cursori). +import { type JSX } from 'react' +import { usaStato } from '../../stato' +import { Cursore } from '../../componenti/Controlli' +import type { Bordi } from '@shared/tipi' + +export const NIENTE: Bordi = { sinistra: 0, sopra: 0, destra: 0, sotto: 0 } + +export type PresetEspandi = 'tutto' | 'largo' | 'alto' | 'quadrato' + +/** i bordi per una scorciatoia (multipli di 32) */ +export function bordiPreset(tipo: PresetEspandi, W: number, H: number): Bordi { + const t32 = (x: number): number => Math.round(x / 32) * 32 + if (tipo === 'tutto') { + const d = t32(Math.min(W, H) * 0.25) + return { sinistra: d, sopra: d, destra: d, sotto: d } + } + if (tipo === 'largo') { + const d = t32((Math.max(W, Math.round((H * 16) / 9)) - W) / 2) + return { sinistra: d, sopra: 0, destra: d, sotto: 0 } + } + if (tipo === 'alto') { + const d = t32((Math.max(H, Math.round((W * 16) / 9)) - H) / 2) + return { sinistra: 0, sopra: d, destra: 0, sotto: d } + } + const lato = Math.max(W, H) + return { sinistra: t32((lato - W) / 2), destra: t32((lato - W) / 2), sopra: t32((lato - H) / 2), sotto: t32((lato - H) / 2) } +} + +/** Espandi: di quanto allargare la foto */ +export function SchedaEspandi({ espandiPreset }: { espandiPreset: (tipo: PresetEspandi) => void }): JSX.Element | null { + const { modifica: m, setModifica } = usaStato() + const base = m.base + if (m.modo !== 'espandi' || !base) return null + return ( +
+

Di quanto allargare {base.larghezza + m.bordi.sinistra + m.bordi.destra} × {base.altezza + m.bordi.sopra + m.bordi.sotto}

+
+ + + + + +
+ {(['sinistra', 'destra', 'sopra', 'sotto'] as const).map((k) => ( + `${v}px`} cambia={(v) => setModifica({ bordi: { ...m.bordi, [k]: v } })} /> + ))} +
+ ) +} diff --git a/src/renderer/src/pagine/Laboratorio.tsx b/src/renderer/src/pagine/modifica/SchedaRicette.tsx similarity index 90% rename from src/renderer/src/pagine/Laboratorio.tsx rename to src/renderer/src/pagine/modifica/SchedaRicette.tsx index 8ae507d..55d5e3e 100644 --- a/src/renderer/src/pagine/Laboratorio.tsx +++ b/src/renderer/src/pagine/modifica/SchedaRicette.tsx @@ -1,26 +1,27 @@ -// Laboratorio: workflow già pronti, per tutto. Scegli una ricetta, dai la foto (o il testo), premi Avvia: +// Modifica, scheda Ricette (era il Laboratorio): workflow già pronti, per tutto. Scegli una ricetta, dai la foto (o il testo), premi Avvia: // il motore fa tutti i passi uno dopo l'altro e qui trovi la griglia dei risultati, con il prima/dopo e il foglio unico. +// Qui, e solo qui, si possono mettere più foto (il lotto). «Unisci foto» e «Come l'esempio» sono ricette speciali: +// usano la tela di Modifica (Modifica.tsx) e la colonna di destra di sempre. import { useEffect, useMemo, useState, type JSX } from 'react' -import { usaStato, scegliFoto, type FotoBase, type Qualita } from '../stato' -import { api, urlFile } from '../api' -import { I } from '../componenti/Icone' -import { Casella } from '../componenti/Casella' -import { ScegliQualita } from '../componenti/ScegliQualita' -import { Segmenti } from '../componenti/Controlli' -import { Riferimenti } from '../componenti/Riferimenti' -import { SceltaMisura, ScegliMp, MISURA0, type Misura } from '../componenti/Misura' -import { AvvisoLora } from '../componenti/AvvisoLora' -import { AvvisoTurbo, useTurbo } from '../componenti/Turbo' -import { daDataUrl, durata, immagineDaIncolla, normalizza, stimaSecondi } from '../util' -import { perQualita } from '../azioni' -import { CAMERA0, ControlloCamera } from '../componenti/Camera' -import { Omino } from '../componenti/Omino' -import { leggiPosa, posaDi, posaInTesto } from '../omino' -import { fileLoraCamera } from '../azioni' -import { POSA_TUA, VISTA_TUA, leggiCamera, WORKFLOW_BASE, avviaLotto, avviaWorkflow, creaFoglio, elencoWorkflow, inUtente, manca, passiDi, usaLab, valoriIniziali, workflowDa, type Workflow } from '../workflow' -import { usaRicette, type RicettaUtente } from '../ricette' -import { EditorRicetta } from '../componenti/EditorRicetta' -import { Catalogo, Dettaglio, Risultati, icona } from '../componenti/LabVista' +import { usaStato, scegliFoto, type FotoBase, type Qualita } from '../../stato' +import { api, urlFile } from '../../api' +import { I } from '../../componenti/Icone' +import { Casella } from '../../componenti/Casella' +import { Segmenti } from '../../componenti/Controlli' +import { Riferimenti } from '../../componenti/Riferimenti' +import { SceltaMisura, ScegliMp, MISURA0, type Misura } from '../../componenti/Misura' +import { AvvisoLora } from '../../componenti/AvvisoLora' +import { daDataUrl, durata, immagineDaIncolla, normalizza, stimaSecondi } from '../../util' +import { perQualita } from '../../azioni' +import { CAMERA0, ControlloCamera } from '../../componenti/Camera' +import { Omino } from '../../componenti/Omino' +import { leggiPosa, posaDi, posaInTesto } from '../../omino' +import { fileLoraCamera } from '../../azioni' +import { specialeOra, POSA_TUA, VISTA_TUA, leggiCamera, WORKFLOW_BASE, avviaLotto, avviaWorkflow, creaFoglio, elencoWorkflow, inUtente, manca, passiDi, usaLab, valoriIniziali, workflowDa, type Workflow } from '../../workflow' +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' @@ -37,10 +38,9 @@ const PERCHE: Record = { camera: 'Rifà la foto da un\'altra posizione della camera, molto più fedele.' } -export function Laboratorio(): JSX.Element { +export function SchedaRicette(): JSX.Element { const { imp, lavori, avvisa } = usaStato() const { runs, attiva, vista, scelto } = usaLab() - const turbo = useTurbo() // le foto da lavorare (anche tante: la ricetta parte per ognuna) const [fotos, setFotos] = useState([]) const foto = fotos[0] ?? null @@ -62,11 +62,6 @@ export function Laboratorio(): JSX.Element { const w = scelto ? workflowDa(scelto) : undefined const run = runs.find((r) => r.id === attiva) - // il Turbo, se c'è, è il modo giusto per fare serie di immagini - useEffect(() => { - if (turbo.presente) setQualita((q) => (q === 'alta' ? 'turbo' : q)) - }, [turbo.presente]) - const scegli = (id: string): void => { const nuovo = workflowDa(id)! usaLab.setState({ scelto: id, vista: 'catalogo' }) @@ -91,7 +86,7 @@ export function Laboratorio(): JSX.Element { // incolla una foto: la prima è la foto, le altre i riferimenti useEffect(() => { const incolla = async (e: ClipboardEvent): Promise => { - if (usaStato.getState().pagina !== 'laboratorio') return + if (usaStato.getState().pagina !== 'modifica' || usaStato.getState().modifica.modo !== 'ricette' || specialeOra()) return if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return const d = immagineDaIncolla(e) if (!d) return @@ -113,10 +108,6 @@ export function Laboratorio(): JSX.Element { const parti = async (): Promise => { if (!w) return - if (qualita === 'turbo' && !turbo.presente) { - avvisa('Per il Turbo scarica prima il suo LoRA, o scegli Alta', 'errore') - return - } if (!scelti.length) { avvisa('Segna almeno una cosa da fare', 'errore') return @@ -171,13 +162,61 @@ export function Laboratorio(): JSX.Element { } return ( -
-
+ <> +
+
+
+
+ + + + {vista === 'catalogo' && } + {vista === 'catalogo' && ricette.nascoste.length > 0 && } + {vista === 'catalogo' && setCerca(e.target.value)} style={{ width: 240 }} />} + {vista === 'risultati' && runs.length > 1 && ( + + )} +
+ + {vista === 'catalogo' || !run ? ( + + ) : ( + setAperto({ run: run.id, i })} + foglio={foglio} + faiFoglio={async () => { + setFoglio(true) + try { + await creaFoglio(run) + avvisa('Foglio creato: lo trovi in Galleria', 'ok') + } catch (e) { + avvisa((e as Error).message, 'errore') + } finally { + setFoglio(false) + } + }} + rifai={() => { + const wf = workflowDa(run.wfId) + if (wf) void avviaWorkflow(wf, { foto: run.base, rif: run.rif, valori: run.ctx.v, opzioni: run.opzioni }) + }} + /> + )} + +
+
+
{!w ? (
-

Laboratorio

-

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.

+

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…).

) : ( @@ -340,8 +379,7 @@ export function Laboratorio(): JSX.Element { )}
-

Qualità {perQualita(qualita).passi} passi · ~{durata(stima)} in tutto

- +

Passi {perQualita(qualita).passi} passi · ~{durata(stima)} in tutto

{!passi.some((x) => !x.crea) && (
Grandezza della foto @@ -362,53 +400,8 @@ export function Laboratorio(): JSX.Element { )}
-
-
- - - - {vista === 'catalogo' && } - {vista === 'catalogo' && ricette.nascoste.length > 0 && } - {vista === 'catalogo' && setCerca(e.target.value)} style={{ width: 240 }} />} - {vista === 'risultati' && runs.length > 1 && ( - - )} -
- - {vista === 'catalogo' || !run ? ( - - ) : ( - setAperto({ run: run.id, i })} - foglio={foglio} - faiFoglio={async () => { - setFoglio(true) - try { - await creaFoglio(run) - avvisa('Foglio creato: lo trovi in Galleria', 'ok') - } catch (e) { - avvisa((e as Error).message, 'errore') - } finally { - setFoglio(false) - } - }} - rifai={() => { - const wf = workflowDa(run.wfId) - if (wf) void avviaWorkflow(wf, { foto: run.base, rif: run.rif, valori: run.ctx.v, opzioni: run.opzioni }) - }} - /> - )} -
- {editor && setEditor(null)} salvata={(r) => { usaRicette.getState().salva(r); setEditor(null); scegli(r.id) }} />} {aperto && run && setAperto({ run: aperto.run, i })} chiudi={() => setAperto(null)} />} -
+ ) } diff --git a/src/renderer/src/pagine/modifica/SchedaScrivi.tsx b/src/renderer/src/pagine/modifica/SchedaScrivi.tsx new file mode 100644 index 0000000..c496011 --- /dev/null +++ b/src/renderer/src/pagine/modifica/SchedaScrivi.tsx @@ -0,0 +1,156 @@ +// 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 { 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 { usaSpeciale } from '../../workflow' +import type { Rapida, CategoriaRapide } from '../../rapide' + +export interface PropsScrivi { + prompt: RefObject + base: FotoBase | null + griglia: boolean + haMaschera: boolean + /** in Scrivi con una zona disegnata */ + zona: boolean + scrivendo: boolean + migliora: () => Promise + setCameraRapida: (r: Rapida | null) => void + inserisci: (testo: string) => void + mancaRif: boolean + rapida: Rapida | null | undefined + categorie: CategoriaRapide[] + cat: CategoriaRapide | undefined + setCategoria: (id: string) => void + idee: Rapida[] + setIdee: (i: Rapida[]) => void + usaRapida: (r: Rapida) => Promise + chiaveIdee: string + camera: PosizioneCamera + cambiaCamera: (c: PosizioneCamera) => void + loraCamera: boolean +} + +/** La scheda Scrivi: testo, Idee e (con una zona) la nota su cosa succede */ +export function SchedaScrivi(p: PropsScrivi): JSX.Element { + return ( + <> + + + {p.zona && ( +
+
+ Il modello guarda tutta la foto con la tua zona evidenziata e cambia solo lì: fuori dalla zona la foto resta identica al pixel. + Per togliere qualcosa scrivi "rimuovi…": la zona si ripulisce da sola prima di ridisegnarla. +
+
+ )} + + ) +} + + +/** «Cosa cambiare»: la casella del prompt, Migliora e i segnaposto delle foto */ +function SchedaTesto({ prompt, base, griglia, zona, scrivendo, migliora, setCameraRapida, inserisci, mancaRif, rapida }: PropsScrivi): JSX.Element { + const { modifica: m, setModifica } = usaStato() + const sp = usaSpeciale() + const fusion = sp === 'unisci' + const esempioMode = sp === 'esempio' + return ( +
+

+ {fusion ? 'Cosa creare con le foto' : esempioMode ? 'Cosa vuoi (facoltativo)' : m.modo === 'espandi' ? 'Cosa c\'è fuori (facoltativo)' : zona ? 'Cosa fare nella zona' : 'Cosa cambiare'} + {m.modo === 'scrivi' && {zona ? solo la zona segnata ✓ : 'tutta la foto · o segna una zona'}} +

+ { + setModifica({ prompt: v }) + if (!v.trim()) setCameraRapida(null) + }} + placeholder={esempioMode ? 'Facoltativo, es. "sostituisci le iridi con quelle delle mie foto". Se lasci vuoto il modello capisce da solo cosa fare' : fusion ? 'es. "la ragazza di con la giacca di in una strada di Tokyo di notte"' : zona ? 'es. "una camicia di lino bianca" o "rimuovi la persona"' : m.modo === 'espandi' ? 'es. "una spiaggia al tramonto"' : 'es. "fallo di notte con la neve", "metti la maglia di "'} + minimo={96} + disabled={scrivendo} + /> +
+ + {!fusion && (esempioMode ? m.esempio : base) && ( + + )} + {m.riferimenti.map((_, i) => ( + + ))} +
+ {mancaRif && !griglia && ( +
+ Serve {rapida?.rifNome || 'una foto di riferimento'}: aggiungila qui sotto in Riferimenti (nel testo è <image2>). +
+ )} +
+ ) +} + +/** 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/Schede.tsx b/src/renderer/src/pagine/modifica/Schede.tsx new file mode 100644 index 0000000..6b4c0d6 --- /dev/null +++ b/src/renderer/src/pagine/modifica/Schede.tsx @@ -0,0 +1,164 @@ +// Modifica, la colonna a destra: le schede (Scrivi, Espandi, Fusion/Esempio), riferimenti, dimensioni, versioni, LoRA e il pulsante. +// Lo stato vive in Modifica.tsx: qui si disegna e basta. +import { type JSX } from 'react' +import { usaStato, type FotoBase } from '../../stato' +import { urlFile } from '../../api' +import { I } from '../../componenti/Icone' +import { Casella } from '../../componenti/Casella' +import { ScegliModello } from '../../componenti/ScegliModello' +import { Contatore, Segmenti } from '../../componenti/Controlli' +import { SceltaLora } from '../../componenti/SceltaLora' +import { Riferimenti } from '../../componenti/Riferimenti' +import { SceltaMisura, ScegliMp, MISURA0, dimMp } from '../../componenti/Misura' +import { FORMATI_FUSION, chiaveEsempio } from '../../fusion' +import { durata } from '../../util' +import { usaSpeciale } from '../../workflow' +import { SchedaScrivi, type PropsScrivi } from './SchedaScrivi' +import { SchedaEspandi, type PresetEspandi } from './SchedaEspandi' + +interface Props extends PropsScrivi { + espandiPreset: (tipo: PresetEspandi) => void + comeBase: (i: number) => void + passi: number + stima: number + dimOut: { w: number; h: number } | null + manda: () => Promise +} + +export function SchedeModifica(p: Props): JSX.Element { + const { modifica: m, setModifica, imp } = usaStato() + const sp = usaSpeciale() + const fusion = sp === 'unisci' + const esempioMode = sp === 'esempio' + const { base, griglia, passi, stima, dimOut, manda } = p + + return ( +
+
+
+ + + + + {!griglia &&
+ setModifica({ riferimenti })} + inizio={2} + inserisci={p.inserisci} + principale={base?.percorso} + comeBase={p.comeBase} + titolo="Riferimenti" + esempio={'Es. "metti a la giacca di ". Persone, vestiti, oggetti, stili: fino a 9 foto in più (dalla galleria, dal PC o incollate con Ctrl+V).'} + /> +
} + + + + + {(imp?.famiglia || 'qwen') === 'qwen' && ( +
+

LoRA

+ +
+ )} +
+
+ +
{dimOut ? `${dimOut.w} × ${dimOut.h} px · ` : ''}Ctrl+Invio · ~{durata(stima)}
+
+
+ ) +} + +/** Esempio: «Il piano del modello» */ +function SchedaEsempio(): JSX.Element | null { + const { modifica: m, setModifica } = usaStato() + if (usaSpeciale() !== 'esempio') return null + return ( + <> +
+

Il piano del modello {m.pianoEsempio && }

+ {m.pianoEsempio && m.pianoEsempio.chiave === chiaveEsempio(m) ? ( + <> + setModifica({ pianoEsempio: { ...m.pianoEsempio!, testo } })} /> + {m.pianoEsempio.ritagli?.some(Boolean) && ( +
+ Le parti delle tue foto che contano (ritagliate dal modello) +
+ {m.pianoEsempio.ritagli.map((r, i) => r && `} />)} +
+
+ )} +
L'ha scritto il modello guardando le foto: puoi correggerlo. Si usa così com'è finché non cambi le foto o le note.
+ + ) : ( +
Quando premi il pulsante il modello guarda l'esempio e le tue foto insieme e decide da solo cosa sostituire con cosa, e cosa lasciare com'è. Qui vedrai cosa ha deciso, e potrai correggerlo.
+ )} +
+ + ) +} + +/** Dimensioni della foto che esce: per Fusion (foto nuova) o per gli altri modi */ +function SchedaDimensioni({ base, zona }: { base: FotoBase | null; zona: boolean }): JSX.Element { + const { modifica: m, setModifica } = usaStato() + const sp = usaSpeciale() + const fusion = sp === 'unisci' + const esempioMode = sp === 'esempio' + return ( + <> + {fusion && (() => { + const mis = m.misura && m.misura.tipo !== 'originale' ? m.misura : MISURA0 + const prima = m.riferimenti[0] ? { larghezza: m.riferimenti[0].larghezza, altezza: m.riferimenti[0].altezza } : null + return ( +
+

Dimensioni della foto nuova {mis.tipo === 'custom' ? `${mis.w} × ${mis.h}` : 'automatiche'}

+ setModifica({ misura })} base={prima} mp={m.mp} ammetti={['auto', 'custom']} cambiaMp={(mp) => setModifica({ mp })} auto={(() => { + const [a, b] = FORMATI_FUSION.find((f) => f.id === (m.formatoFusion || 'prima'))?.r || (prima ? [prima.larghezza, prima.altezza] : [1, 1]) + return dimMp(m.mp, a, b) + })()} /> + {mis.tipo === 'auto' && ( +
+ Proporzioni + setModifica({ formatoFusion })} voci={FORMATI_FUSION.map((f) => ({ id: f.id, nome: f.nome }))} /> +
+ )} + +
+ ) + })()} + + {m.modo !== 'espandi' && !fusion && ( +
+

Dimensioni {(m.misura ?? MISURA0).tipo === 'auto' ? 'come i megapixel' : (m.misura ?? MISURA0).tipo === 'originale' ? 'come la foto' : 'misura mia'}

+ setModifica({ misura })} base={esempioMode ? m.esempio : base} mp={m.mp} cambiaMp={(mp) => setModifica({ mp })} + senzaPx={zona} auto={zona && base ? { w: base.larghezza, h: base.altezza, nota: 'resta com\'è, cambia solo la zona' } : undefined} /> +
+ )} + + ) +} + +/** Passi del modello, quante versioni e (in Espandi) i megapixel */ +function SchedaQualita({ passi, stima }: { passi: number; stima: number }): JSX.Element { + const { modifica: m, setModifica } = usaStato() + return ( +
+

Versioni {passi} passi · ~{durata(stima)}

+
+ Quante versioni + setModifica({ quante })} /> +
+ {m.modo === 'espandi' && ( +
+ Megapixel a cui lavora il modello + setModifica({ mp })} /> +
+ )} +
+ ) +} diff --git a/src/renderer/src/pagine/modifica/SelettoreModo.tsx b/src/renderer/src/pagine/modifica/SelettoreModo.tsx new file mode 100644 index 0000000..bfa55ef --- /dev/null +++ b/src/renderer/src/pagine/modifica/SelettoreModo.tsx @@ -0,0 +1,22 @@ +// I modi di Modifica (Scrivi, Espandi, Ricette, Estrai): sta in cima alla pagina, anche quando si guarda il catalogo delle ricette. +import { type JSX } from 'react' +import { usaStato, type ModoModifica } from '../../stato' +import { Segmenti } from '../../componenti/Controlli' + +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' } + ]} + /> +
+ ) +} diff --git a/src/renderer/src/pagine/modifica/Tela.tsx b/src/renderer/src/pagine/modifica/Tela.tsx new file mode 100644 index 0000000..b768773 --- /dev/null +++ b/src/renderer/src/pagine/modifica/Tela.tsx @@ -0,0 +1,188 @@ +// Modifica, la parte a sinistra: barra degli attrezzi, tela (o griglia di Fusion/Esempio, o palco di Estrai) e striscia delle versioni. +// Lo stato vive in Modifica.tsx: qui si disegna e basta. +import { type JSX, type RefObject } from 'react' +import { usaStato, scegliFoto, type FotoBase } from '../../stato' +import { api, urlFile } from '../../api' +import { I } from '../../componenti/Icone' +import { Cursore } from '../../componenti/Controlli' +import { TelaMaschera, type AnteprimaTela, type ComandiTela, type Strumento } from '../../componenti/TelaMaschera' +import { Fusion } from '../../componenti/Fusion' +import { PalcoEstrai } from '../../componenti/Estrai' +import { normalizza } from '../../util' +import { usaLab, usaSpeciale } from '../../workflow' +import { SelettoreModo } from './SelettoreModo' +import { apriInModifica, fotoDaOpera } from '../../azioni' +import type { Lavoro, Opera } from '@shared/tipi' + +interface Props { + tela: RefObject + strumento: Strumento + setStrumento: (s: Strumento) => void + pennello: number + setPennello: (n: number) => void + setHaMaschera: (v: boolean) => void + confronto: boolean + setConfronto: (v: boolean) => void + confrontoAttivo: boolean + disegna: boolean + griglia: boolean + esempioMode: boolean + estraiMode: boolean + base: FotoBase | null + indice: number + precedente: FotoBase | null + successiva: FotoBase | null + inCorso: Lavoro | undefined + inAttesa: number + anteprima: AnteprimaTela | undefined + ris: Opera[] + fusInCorso: Lavoro | undefined + fusInAttesa: number + daGalleria: () => Promise + daPc: () => Promise + togliFoto: () => void + inserisci: (testo: string) => void + aggiungiFusion: (f: FotoBase[]) => void +} + +export function TelaModifica(p: Props): JSX.Element { + const { modifica: m, setModifica, avvisa } = usaStato() + const { tela, strumento, setStrumento, pennello, setPennello, setHaMaschera, confronto, setConfronto, confrontoAttivo, disegna, griglia, esempioMode, estraiMode, base, indice, precedente, successiva, inCorso, inAttesa, anteprima, ris, fusInCorso, fusInAttesa, daGalleria, daPc, togliFoto, inserisci, aggiungiFusion } = p + const qi = inCorso?.richiesta + const speciale = usaSpeciale() + + return ( + <> +
+ + {speciale && ( + + )} + {disegna && base && !confrontoAttivo && ( + <> + + Zona + {([ + ['pennello', I.pennello, 'Pennello (B)'], + ['gomma', I.gomma, 'Gomma (E)'], + ['rettangolo', I.rettangolo, 'Rettangolo (R)'], + ['lazo', I.lazo, 'Lazo (L)'], + ['mano', I.mano, 'Sposta (H o Spazio)'] + ] as const).map(([id, Ic, t]) => ( + + ))} +
+ `${v}px`} cambia={setPennello} /> +
+ + + + + + + )} + + {base && !griglia && } + + + {base && !griglia && ( + + )} +
+ + {estraiMode ? : null} + {estraiMode ? null : griglia ? ( + setModifica({ riferimenti })} + inserisci={inserisci} + risultati={ris} + inCorso={fusInCorso} + inAttesa={fusInAttesa} + daGalleria={daGalleria} + daPc={daPc} + riusa={(o) => aggiungiFusion([fotoDaOpera(o)])} + esempio={esempioMode ? { foto: m.esempio, cambia: (esempio) => setModifica({ esempio }), daGalleria: async () => { const [f] = await scegliFoto(false); if (f) setModifica({ esempio: f }) }, daPc: async () => { const [x] = await api.file.scegliImmagini(false); if (x) { try { setModifica({ esempio: await normalizza(x) }) } catch (e) { avvisa((e as Error).message, 'errore') } } } } : undefined} + apriInModifica={(o) => { apriInModifica(fotoDaOpera(o)); setModifica({ modo: 'scrivi' }) }} + mostra={(o) => void api.galleria.mostra(o.id)} + /> + ) : null} + {griglia || estraiMode ? null : !base ? ( +
+
+
MODIFICA UNA FOTO
+

Trascina qui una foto, incollala con Ctrl+V, o scegline una.

+

Scrivi cosa cambiare, o scegli un'idea: cambia tutta la foto. Se prima segni col pennello una zona, cambia solo quella e il resto resta identico al pixel.

+
+ + +
+
+
+ ) : ( + + {!inCorso && m.versioni.length > 1 && indice >= 0 && ( +
e.stopPropagation()} onWheel={(e) => e.stopPropagation()}> + + {indice === 0 ? 'Originale' : `v${indice + 1}`} + di {m.versioni.length} + + {precedente && ( + <> + + + {confrontoAttivo ? ( + + ) : ( + + )} + + )} +
+ )} + {confrontoAttivo && ( +
Trascina la linea per confrontare · poi premi Continua da qui per modificare ancora{disegna ? ' (o per segnare un\'altra zona)' : ''}
+ )} +
+ )} + + {!griglia && !estraiMode && m.versioni.length > 0 && ( +
+ {m.versioni.map((v, i) => ( +
{ setModifica({ base: v }); setConfronto(false) }} title={v.prompt}> + + {i === 0 ? 'Originale' : `v${i + 1}`} +
+ ))} + {inAttesa > 0 && +{inAttesa} in arrivo…} + + {base && indice > 0 && !confrontoAttivo && Continua a modificare da qui: ogni modifica parte dalla versione scelta.} +
+ )} + + ) +} diff --git a/src/renderer/src/ricetteBase.ts b/src/renderer/src/ricetteBase.ts index ebeab19..dccf49c 100644 --- a/src/renderer/src/ricetteBase.ts +++ b/src/renderer/src/ricetteBase.ts @@ -332,6 +332,20 @@ export const WORKFLOW_BASE: Workflow[] = [ 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.`) ] + }, + { + id: 'unisci', nome: 'Unisci foto', categoria: 'grafica', icona: 'foto', modo: 'speciale', + descrizione: 'Più foto sono tutti riferimenti: il modello le guarda insieme e crea una immagine nuova da quello che scrivi.', + foto: false, rif: 'serve', rifNome: 'le foto da fondere', + ingressi: [], + passi: () => [E('Foto nuova', 'Crea una immagine nuova partendo dalle foto di riferimento.')] + }, + { + id: 'esempio', nome: "Come l'esempio", categoria: 'grafica', icona: 'foto', modo: 'speciale', + descrizione: "Una foto mostra com'è il risultato che vuoi: le tue foto portano dentro i dettagli (persone, volti, vestiti, oggetti, stili) e il modello rifà l'esempio.", + foto: false, rif: 'serve', rifNome: 'le foto da cui prendere i dettagli', + ingressi: [], + passi: () => [E("Come l'esempio", "Rifà l'esempio usando i dettagli delle foto di riferimento.")] } ] diff --git a/src/renderer/src/stato.ts b/src/renderer/src/stato.ts index d950770..f647b19 100644 --- a/src/renderer/src/stato.ts +++ b/src/renderer/src/stato.ts @@ -1,18 +1,23 @@ // 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 { senzaTurbo } from '@shared/modelli' import { api } from './api' import type { Misura } from './componenti/Misura' -export type Pagina = 'crea' | 'modifica' | 'laboratorio' | 'album' | 'galleria' | 'lora' | 'impostazioni' -export type Qualita = 'turbo' | 'bozza' | 'alta' | 'massima' -export type ModoModifica = 'tutta' | 'zona' | 'espandi' | 'fusion' | 'esempio' | 'estrai' +// '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' +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) */ +export type ModoModifica = 'scrivi' | 'espandi' | 'ricette' | 'estrai' -export const PASSI_QUALITA: Record = { turbo: 8, bozza: 20, alta: 40, massima: 50 } +export const PASSI_QUALITA: Record = { bozza: 20, alta: 40, massima: 50 } /** il modello che disegna adesso (Qwen, Klein veloce, Klein Base) */ export const famigliaCorrente = (): Famiglia => usaStato.getState().imp?.famiglia || 'qwen' -/** i passi per una qualità col modello scelto (Klein non ha il Turbo: lì «turbo» vale come bozza) */ +/** i passi per una qualità col modello scelto (il Turbo di Qwen non c'è più) */ export const passiDi = (q: Qualita, f: Famiglia = famigliaCorrente()): number => FAMIGLIE[f].passi[q] export interface FotoBase { @@ -25,20 +30,16 @@ export interface FotoBase { export interface StatoCrea { prompt: string - negativo: string formato: string /** risoluzione: il lato corto in pixel (256p … 2048, il massimo nativo) */ ris: number /** megapixel a cui lavora il modello (0 = come la misura finale); più alti della misura = disegna grande e poi riduce */ mpLavoro: number - qualita: Qualita + /** 1, 2 o 4 immagini */ quante: number seed: number casuale: boolean trasparente: boolean - cfg: number - sampler: string - scheduler: string /** foto di riferimento (…): persone, vestiti, oggetti, stili. Non si salvano fra un avvio e l'altro */ riferimenti: FotoBase[] /** il preset scelto a sinistra ('' = libero) e i suoi campi */ @@ -90,6 +91,8 @@ export interface Avviso { interface Stato { pagina: Pagina + /** la scheda aperta in Opzioni */ + schedaOpzioni: SchedaOpzioni imp: Impostazioni | null motore: InfoMotore lavori: Lavoro[] @@ -110,6 +113,8 @@ interface Stato { /** il selettore di foto aperto (galleria o PC): chi lo apre aspetta la scelta */ selettore: { multi: boolean; ok: (f: FotoBase[]) => void } | null vai(p: Pagina): void + /** apre Opzioni su una scheda (i LoRA) */ + vaiOpzioni(scheda: SchedaOpzioni): void setImp(i: Impostazioni): void setCrea(m: Partial): void setModifica(m: Partial): void @@ -139,29 +144,36 @@ const leggiLista = (k: string): T[] => { } const CREA0: StatoCrea = { - prompt: '', negativo: '', formato: '1:1', ris: 1024, mpLavoro: 0, qualita: 'alta', quante: 1, seed: 0, casuale: true, trasparente: false, - cfg: 1, sampler: 'euler', scheduler: 'simple', riferimenti: [], preset: '', stile: '', campi: {}, misura: { tipo: 'auto' } + prompt: '', formato: '1:1', ris: 1024, mpLavoro: 0, quante: 1, seed: 0, casuale: true, trasparente: false, + riferimenti: [], preset: '', stile: '', campi: {}, misura: { tipo: 'auto' } } const MOD0: StatoModifica = { - base: null, versioni: [], riferimenti: [], esempio: null, modo: 'tutta', prompt: '', mp: 1, qualita: 'alta', quante: 1, forza: 1, contesto: 0.6, + 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 } -/** le scelte salvate dalla 0.1 avevano i megapixel al posto della risoluzione */ -function daVecchio(c: StatoCrea & { mp?: number }): StatoCrea { - const { mp, ...resto } = c +/** le scelte salvate dalla 0.1 avevano i megapixel al posto della risoluzione; fino alla 1.0 c'era la qualità «turbo» (ora «alta») */ +function daVecchio(c: StatoCrea & { mp?: number; qualita?: unknown; negativo?: unknown; cfg?: unknown; sampler?: unknown; scheduler?: unknown }): StatoCrea { + // qualità, negativo, CFG, sampler e scheduler non ci sono più in Crea (dalla 1.1 contano solo i valori del modello): si buttano + const { mp, qualita: _q, negativo: _n, cfg: _c, sampler: _s, scheduler: _sc, ...pulito } = c // appena si salva, il campo mp sparisce: la conversione avviene una volta sola - return mp ? { ...resto, ris: mp >= 4 ? 2048 : mp >= 2 ? 1440 : 1024 } : resto + return mp ? { ...pulito, ris: mp >= 4 ? 2048 : mp >= 2 ? 1440 : 1024 } : pulito } -/** la modalità "Nuova dalle foto" della 0.4.x ora si chiama Fusion */ -const daVecchioMod = (m: StatoModifica): StatoModifica => ((m.modo as string) === 'nuova' ? { ...m, modo: 'fusion' } : (m.modo as string) === 'personaggio' ? { ...m, modo: 'estrai' } : m) +/** «Tutta la foto» e «Solo una zona» sono diventate Scrivi; «Fusion» ed «Esempio» sono ricette (Unisci foto, Come l'esempio): si apre il catalogo; la qualità «turbo» della 1.0 ora è «alta» */ +const daVecchioMod = (v: StatoModifica): StatoModifica => { + const m = { ...v, qualita: senzaTurbo(v.qualita) } + const vecchio = m.modo as string + if (vecchio === 'tutta' || vecchio === 'zona') return { ...m, modo: 'scrivi' } + return vecchio === 'nuova' || vecchio === 'fusion' || vecchio === 'esempio' ? { ...m, modo: 'ricette' } : vecchio === 'personaggio' ? { ...m, modo: 'estrai' } : m +} let idAvviso = 0 export const usaStato = create((set, get) => ({ pagina: 'crea', + schedaOpzioni: 'generali', imp: null, motore: { stato: 'spento', porta: 0 }, lavori: [], @@ -177,6 +189,7 @@ export const usaStato = create((set, get) => ({ scarico: null, selettore: null, vai: (pagina) => set({ pagina }), + vaiOpzioni: (schedaOpzioni) => set({ pagina: 'impostazioni', schedaOpzioni }), setImp: (imp) => set({ imp }), setCrea: (m) => { const crea = { ...get().crea, ...m } diff --git a/src/renderer/src/stile.css b/src/renderer/src/stile.css index 2ef9c50..49365a5 100644 --- a/src/renderer/src/stile.css +++ b/src/renderer/src/stile.css @@ -433,20 +433,6 @@ input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; width: 17p .menu-tendina .menu button small { display: block; color: var(--spento); font-weight: 500; } /* ---------- preset (Crea) ---------- */ -.barra-preset { - width: 84px; flex: none; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 10px 0 16px; - overflow-y: auto; overflow-x: hidden; background: #100b1d; border-right: 1px solid #1d1636; -} -.barra-preset::-webkit-scrollbar { width: 4px; } -.barra-preset button { - width: 70px; min-height: 56px; flex: none; border: 1px solid transparent; background: transparent; border-radius: 11px; color: var(--tenue); cursor: pointer; - display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; font-size: 11.5px; font-weight: 600; padding: 6px 2px; line-height: 1.1; text-align: center; - transition: background .12s, color .12s, border-color .12s; -} -.barra-preset button svg { width: 22px; height: 22px; } -.barra-preset button:hover { background: var(--pannello); color: var(--testo); } -.barra-preset button.su { color: #1d1300; background: linear-gradient(160deg, #ffe27a, var(--oro) 50%, var(--oro2)); border-color: #ffe9a3; box-shadow: 0 6px 18px -8px rgba(255, 196, 40, .6); } -.barra-preset .riga-sep { width: 40px; height: 1px; background: var(--bordo); margin: 4px 0; flex: none; } .scheda-preset { background: linear-gradient(180deg, rgba(255,213,74,.07), transparent 70%); } .scheda-preset h3 { color: var(--oro); font-size: 13px; } .scheda-preset h3 .ic svg { width: 18px; height: 18px; vertical-align: -4px; } @@ -456,8 +442,6 @@ input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; width: 17p .campo-preset input, .campo-preset textarea { font-size: 14.5px; padding: 7px 10px; } /* ---------- Turbo ---------- */ -.turbo-box { margin-top: 10px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,213,74,.45); background: linear-gradient(135deg, rgba(255,213,74,.10), rgba(255,61,242,.06)); } -.turbo-box b { color: var(--oro); } .turbo-ic { width: 30px; height: 30px; flex: none; border-radius: 9px; display: grid; place-items: center; background: var(--oro); color: #1d1300; } .turbo-ic svg { width: 18px; height: 18px; } @@ -895,3 +879,24 @@ details.tipo-modelli > summary:hover { color: var(--oro); } .sel-modello-card button small { font-size: 11.5px; color: var(--spento); white-space: nowrap; } .sel-modello-card button em { position: absolute; top: 6px; right: 7px; font-style: normal; font-size: 10.5px; color: #1d1300; background: var(--oro2, #ffb627); padding: 1px 6px; border-radius: 6px; } .sel-modello-card button.da-scaricare { opacity: .85; } + +/* ---------- selettore del modello (nella barra in alto) ---------- */ +.sezione:empty { display: none; } +.barra-titolo .sel-modello { position: relative; margin: 0 0 0 6px; -webkit-app-region: no-drag; } +.sel-modello-pillole { display: flex; gap: 2px; padding: 2px; border-radius: 15px; background: #f4f1fb; border: 1px solid #e0d9f2; } +.sel-modello-pillole button { position: relative; height: 24px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: #4a4068; font-size: 13px; font-weight: 700; cursor: pointer; white-space: nowrap; } +.sel-modello-pillole button small { font-size: 11.5px; font-weight: 500; color: #7a6f9c; } +.sel-modello-pillole button:hover { background: #ebe6f6; } +.sel-modello-pillole button.su { background: linear-gradient(90deg, var(--viola), var(--magenta)); color: #fff; } +.sel-modello-pillole button.su small { color: #ece4ff; } +.sel-modello-pillole button.da-scaricare { color: #7a6f9c; } +.sel-modello-pillole .puntino { position: absolute; top: 3px; right: 5px; width: 7px; height: 7px; border-radius: 50%; background: #f59e0b; } +.sel-modello-pop { position: absolute; top: calc(100% + 8px); left: 0; z-index: 30; width: 330px; padding: 12px 14px; border-radius: 12px; background: var(--pannello); border: 1px solid var(--bordo2); color: var(--testo); box-shadow: 0 10px 30px #0008; } + +/* ---------- Opzioni a schede ---------- */ +.opzioni-schede { flex-direction: column; } +.schede-opzioni { display: flex; gap: 4px; padding: 10px 28px 0; border-bottom: 1px solid #1d1636; } +.schede-opzioni button { border: 0; background: transparent; color: var(--tenue); padding: 8px 16px; font-weight: 700; font-size: 14px; cursor: pointer; border-bottom: 2px solid transparent; } +.schede-opzioni button:hover { color: var(--testo); } +.schede-opzioni button.su { color: var(--oro); border-bottom-color: var(--oro); } +.corpo-opzioni { position: relative; flex: 1; min-height: 0; display: flex; } diff --git a/src/renderer/src/workflow.ts b/src/renderer/src/workflow.ts index ce69b98..3ef7f15 100644 --- a/src/renderer/src/workflow.ts +++ b/src/renderer/src/workflow.ts @@ -54,7 +54,7 @@ export interface Workflow { categoria: string icona: string descrizione: string - modo: 'serie' | 'catena' + modo: 'serie' | 'catena' | 'speciale' /** serve la foto da cambiare? se no, il workflow disegna da zero */ foto: boolean /** foto di riferimento: non servono, sono facoltative, ne serve almeno una, o ogni riferimento è un passo */ @@ -129,7 +129,7 @@ export function inUtente(w: Workflow, id = 'u-' + crypto.randomUUID().slice(0, 8 for (const i of w.ingressi) v[i.id] = i.tipo === 'scelta' ? i.predefinita : i.tipo === 'camera' || i.tipo === 'posa' ? '' : `{${i.id}}` const passi = w.passi({ v, nRif: w.rif === 'ognuno' ? 3 : 1, camera: !!fileLoraCamera() }) return { - id, nome: w.nome + ' (mia)', categoria: w.categoria, descrizione: w.descrizione, modo: w.modo, foto: w.foto, rif: w.rif, rifNome: w.rifNome, + id, nome: w.nome + ' (mia)', categoria: w.categoria, descrizione: w.descrizione, modo: w.modo === 'speciale' ? 'serie' : w.modo, foto: w.foto, rif: w.rif, rifNome: w.rifNome, ingressi: w.ingressi.filter((i) => i.tipo !== 'scelta' && i.tipo !== 'camera' && i.tipo !== 'posa'), passi: passi.map((p) => ({ etichetta: p.etichetta, prompt: p.prompt, crea: p.crea, trasparente: p.trasparente, lora: p.lora })), da: w.id @@ -197,6 +197,18 @@ interface StatoLab { export const usaLab = create(() => ({ runs: [], attiva: null, vista: 'catalogo', scelto: null })) +/** le due ricette che usano la tela di Modifica (la griglia di foto): Unisci foto e Come l'esempio */ +export type Speciale = 'unisci' | 'esempio' | null +export const specialeDi = (modo: string, scelto: string | null): Speciale => (modo === 'ricette' && (scelto === 'unisci' || scelto === 'esempio') ? scelto : null) +/** come specialeDi, ma si aggiorna da solo quando cambia il modo o la ricetta scelta */ +export function usaSpeciale(): Speciale { + const modo = usaStato((s) => s.modifica.modo) + const scelto = usaLab((s) => s.scelto) + return specialeDi(modo, scelto) +} +/** lo stesso, letto adesso (per i gestori di tasti e di lavori) */ +export const specialeOra = (): Speciale => specialeDi(usaStato.getState().modifica.modo, usaLab.getState().scelto) + const aggiorna = (id: string, f: (r: Run) => void): void => usaLab.setState((s) => ({ runs: s.runs.map((r) => (r.id === id ? (() => { const n = { ...r, passi: r.passi.map((p) => ({ ...p })) }; f(n); return n })() : r)) })) diff --git a/src/shared/modelli.ts b/src/shared/modelli.ts new file mode 100644 index 0000000..ce62a7f --- /dev/null +++ b/src/shared/modelli.ts @@ -0,0 +1,104 @@ +// I modelli che si scelgono nell'interfaccia: due famiglie, tre voci (D1 del piano, rivista da Cammo il 21/10/2026). +// qwen-base = Qwen-Image 2.1 pieno, 40 passi, coi LoRA: la qualità piena di Qwen. +// klein = FLUX 3 Klein distillato, 8 passi senza CFG: il modello veloce. +// klein-base = FLUX 3 Klein Base, 50 passi con CFG 3,5: la qualità piena di Klein. +// Niente Qwen veloce col LoRA Turbo8: a Cammo non piace. +// +// Il motore continua a ragionare per `Famiglia` (+ `turbo`, che resta nella Richiesta solo per i PNG e le ricette vecchie): +// questa è l'unica mappa fra le due cose. È pura (niente Electron, niente React), così `test/modelli.mjs` la prova con Node. +// 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 interface InfoModello { + id: IdModello + famiglia: Famiglia + /** sempre false: il Turbo di Qwen non c'è più (il campo c'è per dirlo chiaro a chi costruisce la richiesta) */ + turbo: false + passi: number + /** il CFG che vuole il modello (null = quello delle impostazioni, 1 per Qwen) */ + cfg: number | null + nome: string + corto: string + sotto: string + /** accetta i LoRA (fatti per Qwen-Image 2.1) */ + lora: boolean + /** è un modello distillato, fatto per esplorare: il risultato si può rifare in qualità piena */ + veloce: boolean +} + +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 } +} + +/** l'ordine delle pillole nell'interfaccia */ +export const ORDINE_MODELLI: IdModello[] = ['qwen-base', 'klein', 'klein-base'] +export const MODELLO_PREDEFINITO: IdModello = 'qwen-base' + +export const eModello = (x: unknown): x is IdModello => typeof x === 'string' && Object.prototype.hasOwnProperty.call(MODELLI, x) + +/** dalla famiglia (e dal vecchio Turbo) al modello: il Turbo di Qwen diventa Qwen base, tutto quello che non si riconosce pure */ +export function modelloDa(famiglia?: unknown, _turbo?: boolean): IdModello { + if (famiglia === 'klein') return 'klein' + if (famiglia === 'klein-base') return 'klein-base' + return 'qwen-base' +} + +export const famigliaDi = (id: IdModello): Famiglia => MODELLI[id].famiglia + +/** il modello di impostazioni lette da disco (anche vecchie, senza `modelloScelto`, o rovinate) */ +export function modelloDaImpostazioni(imp: { modelloScelto?: unknown; famiglia?: unknown } | null | undefined): IdModello { + if (imp && eModello(imp.modelloScelto)) return imp.modelloScelto + return modelloDa(imp?.famiglia) +} + +/** quando si salvano le impostazioni, `modelloScelto` e `famiglia` restano d'accordo (vince quello che cambia; se cambiano tutti e due, il modello) */ +export function allineaScelta(modifiche: { modelloScelto?: unknown; famiglia?: unknown }): { modelloScelto?: IdModello; famiglia?: Famiglia } { + if (eModello(modifiche.modelloScelto)) return { modelloScelto: modifiche.modelloScelto, famiglia: famigliaDi(modifiche.modelloScelto) } + if (modifiche.famiglia !== undefined) { + const id = modelloDa(modifiche.famiglia) + return { modelloScelto: id, famiglia: famigliaDi(id) } + } + return {} +} + +/** il modello con cui è stata fatta un'opera della galleria (anche dai PNG vecchi): `Opera.modello` è + * «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 3 Klein Base\b/i.test(m)) return 'klein-base' + if (/^FLUX 3 Klein\b/i.test(m)) return 'klein' + return 'qwen-base' +} + +/** quello che il modello mette nella Richiesta: famiglia, passi, niente Turbo (CFG e sampler di Klein li decide il motore) */ +export function parametriModello(id: IdModello): { famiglia: Famiglia; passi: number; turbo: false } { + const m = MODELLI[id] + return { famiglia: m.famiglia, passi: m.passi, turbo: false } +} + +/** «Rifai in qualità piena» (stesso seed, stesso prompt): da Klein veloce si va a Klein Base (il più vicino) o a Qwen base. + * I modelli già pieni non hanno niente sopra: lista vuota. */ +export function inQualitaPiena(id: IdModello): IdModello[] { + return MODELLI[id].veloce ? ['klein-base', 'qwen-base'] : [] +} + +/** le qualità salvate (localStorage, ricette tue, lavori vecchi): «turbo» non c'è più e diventa «alta» */ +export function senzaTurbo(q: Q | 'turbo'): Exclude | 'alta' { + return (q === 'turbo' ? 'alta' : q) as Exclude | 'alta' +} + +/** il denoiser era il Turbo GGUF (già distillato a 8 passi)? Per Qwen base serve quello pieno: lo stesso quant se c'è, + * se no il primo UC che c'è nella cartella (Q4, Q6, Q8). null = va bene così, o non c'è niente con cui sostituirlo. */ +export function denoiserSenzaTurbo(file: string, presenti: string[]): string | null { + if (!/turbo/i.test(file)) return null + const pieni = presenti.filter((f) => /\.gguf$/i.test(f) && /qwen.?image/i.test(f) && !/turbo/i.test(f) && !/mmproj/i.test(f)) + if (!pieni.length) return null + const quant = /(Q\d)/i.exec(file)?.[1]?.toUpperCase() + const stesso = quant ? pieni.find((f) => f.toUpperCase().includes(quant)) : undefined + if (stesso) return stesso + return [...pieni].sort((a, b) => a.localeCompare(b))[0] +} diff --git a/src/shared/tipi.ts b/src/shared/tipi.ts index 48f20d2..6a39c53 100644 --- a/src/shared/tipi.ts +++ b/src/shared/tipi.ts @@ -1,4 +1,6 @@ // Tipi condivisi fra il processo principale (Electron) e l'interfaccia. +import { MODELLI, type IdModello } from './modelli' +export type { IdModello } from './modelli' /** Cosa fa un lavoro. */ export type Modalita = @@ -35,11 +37,16 @@ export interface Riquadro { /** 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' -/** quello che distingue i modelli: passi per qualità (il Turbo è solo di Qwen), CFG e cosa sanno fare */ -export const FAMIGLIE: Record = { - qwen: { nome: 'Qwen-Image 2.1', corto: 'Qwen', sotto: '40 passi · LoRA · Turbo', passi: { turbo: 8, bozza: 20, alta: 40, massima: 50 }, cfg: null, lora: true }, - klein: { nome: 'FLUX 3 Klein', corto: 'Klein veloce', sotto: '16 passi · senza CFG · il più veloce', passi: { turbo: 8, bozza: 8, alta: 16, massima: 24 }, cfg: 1, lora: false }, - 'klein-base': { nome: 'FLUX 3 Klein Base', corto: 'Klein Base', sotto: '50 passi · CFG 3,5 · la qualità piena', passi: { turbo: 25, bozza: 25, alta: 50, massima: 64 }, cfg: 3.5, lora: false } +/** 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`). */ +const famiglia = (id: IdModello, bozza: number, massima: number): { nome: string; corto: string; sotto: string; passi: { turbo: number; bozza: number; alta: number; massima: number }; cfg: number | null; lora: boolean } => { + const m = MODELLI[id] + return { nome: m.nome, corto: m.corto, sotto: m.sotto, passi: { turbo: m.passi, bozza, alta: m.passi, massima }, cfg: m.cfg, lora: m.lora } +} +export const FAMIGLIE: Record> = { + qwen: famiglia('qwen-base', 20, 50), + klein: famiglia('klein', 8, 16), + 'klein-base': famiglia('klein-base', 25, 64) } /** Quello che l'interfaccia chiede: il principale lo trasforma nel grafo di ComfyUI. */ @@ -201,8 +208,10 @@ export interface Impostazioni { encoder: string vae: string upscaler: string - /** il modello che disegna adesso */ + /** la famiglia che disegna adesso (va sempre d'accordo con `modelloScelto`: `allineaScelta` in `modelli.ts`) */ famiglia: Famiglia + /** il modello scelto nell'interfaccia (qwen-base / klein / klein-base); `modello` invece è il file del denoiser di Qwen */ + modelloScelto: IdModello /** i file di FLUX 3 Klein (vuoto = li trova da solo nelle cartelle dei modelli) */ kleinModello: string kleinBase: string @@ -241,6 +250,9 @@ export interface Impostazioni { unaCosaAllaVolta: boolean /** Klein: espande i prompt brevi in didascalie dettagliate col Qwen3-VL (come l'upsampler ufficiale; +circa mezzo minuto per immagine) */ potenziaKlein: boolean + /** i lavori di testo (migliora prompt, descrivi foto, Idee, Estrai, traduzione LLM…) scrivono col Qwen3-VL 4B (veloce, tutto in VRAM) anche con Qwen. + * Spento di serie: con Qwen scrive il suo encoder 8B. Con Klein il 4B scrive sempre. */ + testoCon4B: boolean /** secondi senza lavori dopo i quali i modelli escono dalla VRAM (0 = mai) */ scaricaDopo: number } diff --git a/test/campioni-pup/out_39b7554f.txt b/test/campioni-pup/out_39b7554f.txt new file mode 100644 index 0000000..e0f9252 --- /dev/null +++ b/test/campioni-pup/out_39b7554f.txt @@ -0,0 +1,11 @@ +You're a prompt upsampler for text-to-image models. Your job is to expand short, vague prompts into detailed, self-contained captions that the model can use to generate high-quality images. + +Your output should be exactly one continuous block of prose — no greetings, no explanations, no Markdown or headings. The caption must include every explicit subject, attribute, count, and style from the original request. If no style is specified, assume a realistic photograph with natural lighting and photographic conventions (e.g., “Full-color outdoor photograph...”). + +Keep it under 350 words. Don’t mention canvas or aspect ratio. Add sensory details: time of day, weather, textures, colors, ambient sounds implied by setting. Include specific objects, their placement, and how they interact with each other. Avoid generic phrases like “beautiful,” “serene,” or “peaceful.” Instead, describe what’s visible and how it feels through visual cues. + +If you’re unsure about something, make an educated assumption based on context — but don’t invent things not hinted at in the prompt. Always stay faithful to the user’s intent. + +Example: +Input: “A quiet alley in Naples at dusk, laundry hanging between balconies, a scooter parked in front.” +Output: Full-color outdoor photograph, late afternoon light casting long shadows across a narrow cobblestone alley in Naples, soft golden hues fading into twilight as the sky transitions from warm orange near the horizon to deep indigo above; clotheslines strung taut between two weathered brick buildings, heavy cotton shirts, denim jeans, and towels fluttering gently in a breeze, some slightly damp from recent washing; a matte black scooter rests against the wall on the right side of the frame, its kickstand down, handlebars angled slightly upward, rear tire showing signs of wear; nearby, a small potted plant sits atop a windowsill, its leaves catching the last rays of sunlight; distant streetlights begin to glow faintly along the alley’s edge, illuminating dust motes dancing in the air; the scene feels lived-in, unpolished, yet tranquil, capturing everyday life in a Mediterranean city without tourists or noise — just quiet rhythm, fabric swaying, and the hum of unseen footsteps receding into shadow. \ No newline at end of file diff --git a/test/campioni-pup/out_da7d436a.txt b/test/campioni-pup/out_da7d436a.txt new file mode 100644 index 0000000..b9a5d9d --- /dev/null +++ b/test/campioni-pup/out_da7d436a.txt @@ -0,0 +1,3 @@ +Here’s the expanded, detailed caption for your image request: + +Full-color outdoor photograph capturing a tranquil, narrow alleyway in Naples during the soft golden hour of dusk, where the ambient light casts long, gentle shadows across cobblestone pavement and weathered brick walls. The scene is framed by two-story buildings with classic Mediterranean architecture—white-washed facades, wrought-iron balconies adorned with hanging laundry: crisp white shirts, faded denim pants, and colorful towels fluttering gently in the breeze, suspended between adjacent balconies as if caught mid-dry. A single scooter, painted in matte olive green with chrome accents, rests neatly parked against the wall on the right side of the alley, its kickstand down and handlebars slightly angled toward the viewer. The air feels still yet alive with subtle movement—the rustle of fabric, distant chatter from unseen residents, or perhaps the faint hum of evening traffic just beyond the alley’s end. Streetlights begin to glow softly along the far edge, casting warm pools of illumination that contrast with the fading natural light. The sky above transitions from deep indigo near the horizon into softer lavender and peach tones, suggesting the sun has just dipped below the rooftops. No people are visible; instead, the focus remains on textures — rough stone, smooth metal, frayed edges of clothing — evoking quiet intimacy and everyday life frozen in time. The overall mood is peaceful, nostalgic, and deeply rooted in authentic Italian urban charm. \ No newline at end of file diff --git a/test/confronta_grafi.mjs b/test/confronta_grafi.mjs new file mode 100644 index 0000000..01a2f59 --- /dev/null +++ b/test/confronta_grafi.mjs @@ -0,0 +1,189 @@ +// Confronto dei grafi di Qwen prima (commit 708b74d) e dopo il refactor dei modelli (HEAD). +// Legge entrambe le versioni di src/main/motore/grafi.ts da git (senza fare checkout nel repo), +// costruisce i grafi per qwen-base con le tre qualita (bozza, alta, massima) in Crea, Modifica +// (zona) ed Espandi, e li confronta in JSON canonico. +// +// Esecuzione: node --experimental-strip-types test/confronta_grafi.mjs +import { join, dirname } from 'node:path' +import { writeFileSync, mkdirSync } from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { execFileSync } from 'node:child_process' +import { tmpdir } from 'node:os' + +const REPO = join(dirname(fileURLToPath(import.meta.url)), '..') +const OLD_COMMIT = '708b74d' +const TEMP = join(tmpdir(), 'DaProdImage-grafi-confronto') +mkdirSync(TEMP, { recursive: true }) + +function normalizza(sorgente) { + let s = sorgente + s = s.replace(/^import type .*? from '@shared\/tipi'\r?\n/gm, '') + s = s.replace(/^import \{ modelloTurbo \} from '@shared\/tipi'\r?\n/m, 'const modelloTurbo = (file) => /turbo/i.test(file)\n') + s = s.replace( + "constructor(readonly memoria: Memoria = 'normale') {}", + "memoria: Memoria = 'normale'\n constructor(memoria: Memoria = 'normale') { this.memoria = memoria }" + ) + return s +} + +function estrai(commit) { + const sorgente = execFileSync('git', ['show', `${commit}:src/main/motore/grafi.ts`], { + encoding: 'utf8', + cwd: REPO, + }) + const percorso = join(TEMP, commit + '.ts') + writeFileSync(percorso, normalizza(sorgente)) + return percorso +} + +const moduloVecchio = await import(pathToFileURL(estrai(OLD_COMMIT)).href) +const moduloNuovo = await import(pathToFileURL(estrai('HEAD')).href) + +const PASSI_QUALITA = { bozza: 20, alta: 40, massima: 50 } + +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-UltraSharp.safetensors', + famiglia: 'qwen', + kleinModello: '', + kleinBase: '', + kleinEncoder: '', + kleinVae: '', + scaricaInIdle: false, + upscalerRtx: false, + porta: 8818, + cacheKV: 'gpu', + cacheTipo: 'default', + encoderSuCpu: false, + anteprimaLive: true, + riservaVram: 0, + argomentiExtra: '', + predefiniti: {}, + installato: true, + tokenCivitai: '', + tokenHF: '', + tempi: {}, + loraTurbo: '', + memoria: 'auto', + vramMB: 8192, + veloce: false, + traduci: false, + traduttore: 'dedicato', + unaCosaAllaVolta: true, + potenziaKlein: true, + scaricaDopo: 0, + modelloScelto: 'qwen-base', +} + +function ingressi(scenario) { + if (scenario === 'crea') return { immagini: [], maschera: undefined } + return { + immagini: [{ nome: 'foto.png', larghezza: 1024, altezza: 768 }], + maschera: 'mask.png', + } +} + +function richiesta(scenario, passi) { + const base = { + famiglia: 'qwen', + modoModifica: 'scrivi', + prompt: 'a calm landscape with a road', + negativo: '', + seed: 12345, + passi, + cfg: 1, + sampler: 'euler', + scheduler: 'simple', + uscita: undefined, + contesto: 0.8, + riempi: false, + forza: 1, + allarga: undefined, + sfuma: undefined, + bordi: undefined, + megapixel: (1024 * 768) / 1048576, + immagini: [], + lora: [], + } + if (scenario === 'crea') { + return { + ...base, + modalita: 'crea', + larghezza: 1024, + altezza: 768, + trasparente: false, + } + } + if (scenario === 'zona') { + return { + ...base, + modalita: 'zona', + megapixel: 1, + contesto: 0.8, + riquadro: { x: 100, y: 80, w: 200, h: 150 }, + trasparente: false, + } + } + return { + ...base, + modalita: 'espandi', + megapixel: 1, + bordi: { sinistra: 64, sopra: 0, destra: 64, sotto: 0 }, + } +} + +function canon(v) { + if (Array.isArray(v)) return '[' + v.map(canon).join(',') + ']' + if (v && typeof v === 'object') { + return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}' + } + return JSON.stringify(v) +} + +const scenari = [ + { id: 'crea', nome: 'Crea' }, + { id: 'zona', nome: 'Modifica zona' }, + { id: 'espandi', nome: 'Espandi' }, +] + +let totale = 0 +let ok = 0 +const righe = [] +const dettagli = [] +for (const q of Object.keys(PASSI_QUALITA)) { + for (const s of scenari) { + totale++ + const passi = PASSI_QUALITA[q] + const rv = richiesta(s.id, passi) + const iv = ingressi(s.id) + const vecchio = moduloVecchio.costruisci(rv, imp, iv, 'pref', 'normale', null) + const nuovo = moduloNuovo.costruisci(rv, imp, iv, 'pref', 'normale', null) + const uguale = canon(vecchio) === canon(nuovo) + if (uguale) ok++ + righe.push('| ' + q + ' | ' + s.nome + ' | ' + (uguale ? 'identici' : 'DIVERSI') + ' | ' + vecchio.passi + ' |') + if (!uguale) { + dettagli.push('Differenza rilevata in ' + q + ' / ' + s.id) + dettagli.push('VECCHIO: ' + canon(vecchio)) + dettagli.push('NUOVO: ' + canon(nuovo)) + } + } +} + +const esito = + '# Confronto grafi di Qwen - commit ' + OLD_COMMIT + ' vs HEAD\n\n' + + 'Generato da `test/confronta_grafi.mjs`. ' + + 'Casi verificati: ' + totale + ', identici: ' + ok + ', diversi: ' + (totale - ok) + '.\n\n' + + '| Qualita | Scenario | Esito | Passi nel grafo |\n' + + '|---|---|---|---:|\n' + + righe.join('\n') + + '\n\n' + + (dettagli.length ? dettagli.join('\n\n') + '\n' : 'Tutti i grafi di Qwen sono identici tra ' + OLD_COMMIT + ' e HEAD.\n') + +writeFileSync(join(REPO, 'docs', 'confronto-grafi-qwen-708b74d-vs-head.md'), esito) +console.log(esito) +process.exit(ok === totale ? 0 : 1) +EOF diff --git a/test/finto.js b/test/finto.js index 6249f0a..3655a81 100644 --- a/test/finto.js +++ b/test/finto.js @@ -26,7 +26,7 @@ const asc = {} const su = new Proxy({}, { get: (_, k) => (f) => { (asc[k] ||= []).push(f); return () => {} } }) const emetti = (k, ...a) => (asc[k] || []).forEach((f) => f(...a)) - const imp = { cartellaModelli: 'C:\\m', cartellaGalleria: 'C:\\g', modello: 'qwen-image-2.1-UC-Q4_K_M.gguf', encoder: 'qwen3vl_8b_int8_convrot.safetensors', vae: 'texture_fix.safetensors', upscaler: '4x-UltraSharp.pth', porta: 8818, cacheKV: 'auto', cacheTipo: 'default', encoderSuCpu: false, anteprimaLive: true, riservaVram: 0, argomentiExtra: '', predefiniti: { passi: 40, cfg: 1, sampler: 'euler', scheduler: 'simple', megapixel: 1 }, installato: true, tokenCivitai: '', tokenHF: '', tempi: { '1.00': 3.1 }, loraTurbo: 'turbo8_lora_step2500.safetensors', memoria: 'auto', vramMB: 6144, veloce: false } + const imp = { cartellaModelli: 'C:\\m', cartellaGalleria: 'C:\\g', modello: 'qwen-image-2.1-UC-Q4_K_M.gguf', famiglia: 'qwen', modelloScelto: 'qwen-base', encoder: 'qwen3vl_8b_int8_convrot.safetensors', vae: 'texture_fix.safetensors', upscaler: '4x-UltraSharp.pth', porta: 8818, cacheKV: 'auto', cacheTipo: 'default', encoderSuCpu: false, anteprimaLive: true, riservaVram: 0, argomentiExtra: '', predefiniti: { passi: 40, cfg: 1, sampler: 'euler', scheduler: 'simple', megapixel: 1 }, installato: true, tokenCivitai: '', tokenHF: '', tempi: { '1.00': 3.1 }, loraTurbo: 'turbo8_lora_step2500.safetensors', memoria: 'auto', vramMB: 6144, veloce: false } let n = 0 const v = (x) => async () => x window.daprod = { diff --git a/test/foto.mjs b/test/foto.mjs index 6ddabb3..2f01e7c 100644 --- a/test/foto.mjs +++ b/test/foto.mjs @@ -41,7 +41,7 @@ for (let i = 0; i < 120; i++) { log('motore: ' + (await win.locator('.barra-titolo .pillola').innerText())) await foto('10-crea') -for (const [n, nome] of [['11-modifica', 'Modifica'], ['12-galleria', 'Galleria'], ['13-lora', 'LoRA'], ['14-opzioni', 'Opzioni']]) { +for (const [n, nome] of [['11-modifica', 'Modifica'], ['12-galleria', 'Galleria'], ['13-album', 'Album'], ['14-opzioni', 'Opzioni']]) { await win.click(`.rotaia button:has-text("${nome}")`) await pausa(1200) await foto(n) diff --git a/test/modelli.mjs b/test/modelli.mjs new file mode 100644 index 0000000..28039c9 --- /dev/null +++ b/test/modelli.mjs @@ -0,0 +1,92 @@ +// Test della mappa dei modelli (src/shared/modelli.ts): tre voci (qwen-base, klein, klein-base), niente Turbo di Qwen. +// Controlla la mappa, le migrazioni delle impostazioni e delle qualità vecchie, i PNG vecchi e «Rifai in qualità piena». +// +// Esecuzione: node test/modelli.mjs +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' + +const QUI = dirname(fileURLToPath(import.meta.url)) + +// il codice vero, non una copia (Node con type stripping) +const M = await import(pathToFileURL(join(QUI, '..', 'src', 'shared', 'modelli.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}`) +} + +// 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('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]) +uguale('nessun modello col Turbo', Object.values(M.MODELLI).some((m) => m.turbo), false) +for (const id of M.ORDINE_MODELLI) uguale(`id dentro la voce ${id}`, M.MODELLI[id].id, id) +uguale('predefinito', M.MODELLO_PREDEFINITO, 'qwen-base') + +// dalla famiglia (e dal vecchio Turbo) al modello +uguale('qwen → qwen-base', M.modelloDa('qwen'), 'qwen-base') +uguale('qwen + turbo → qwen-base', M.modelloDa('qwen', true), 'qwen-base') +uguale('klein → klein', M.modelloDa('klein'), 'klein') +uguale('klein-base → klein-base', M.modelloDa('klein-base'), 'klein-base') +uguale('sconosciuta → qwen-base', M.modelloDa('sdxl'), 'qwen-base') +uguale('niente → qwen-base', M.modelloDa(undefined), 'qwen-base') + +// migrazione delle impostazioni (file vecchi, senza modelloScelto) +uguale('imp 1.0 qwen', M.modelloDaImpostazioni({ famiglia: 'qwen' }), 'qwen-base') +uguale('imp 1.0 klein', M.modelloDaImpostazioni({ famiglia: 'klein' }), 'klein') +uguale('imp 1.0 klein-base', M.modelloDaImpostazioni({ famiglia: 'klein-base' }), 'klein-base') +uguale('imp 0.x senza famiglia', M.modelloDaImpostazioni({}), 'qwen-base') +uguale('imp nulle', M.modelloDaImpostazioni(null), 'qwen-base') +uguale('imp 1.1: vince modelloScelto', M.modelloDaImpostazioni({ famiglia: 'qwen', modelloScelto: 'klein' }), 'klein') +uguale('imp con modelloScelto rovinato', M.modelloDaImpostazioni({ famiglia: 'klein-base', modelloScelto: 'qwen-veloce' }), 'klein-base') + +// salvataggio: modelloScelto e famiglia restano d'accordo +uguale('salva modelloScelto', M.allineaScelta({ modelloScelto: 'klein' }), { modelloScelto: 'klein', famiglia: 'klein' }) +uguale('salva famiglia (ScegliModello di oggi)', M.allineaScelta({ famiglia: 'klein-base' }), { modelloScelto: 'klein-base', famiglia: 'klein-base' }) +uguale('salva tutti e due: vince il modello', M.allineaScelta({ modelloScelto: 'qwen-base', famiglia: 'klein' }), { modelloScelto: 'qwen-base', famiglia: 'qwen' }) +uguale('salva altro: non tocca', M.allineaScelta({ porta: 8818 }), {}) + +// i PNG e le opere vecchie si riaprono +uguale('opera Klein Base', M.modelloDaOpera({ modello: 'FLUX 3 Klein Base · flux-3-klein-base-int8.safetensors' }), 'klein-base') +uguale('opera Klein', M.modelloDaOpera({ modello: 'FLUX 3 Klein · flux-3-klein-int8.safetensors' }), 'klein') +uguale('opera Qwen UC', M.modelloDaOpera({ modello: 'qwen-image-2.1-UC-Q4_K_M.gguf' }), 'qwen-base') +uguale('opera Qwen Turbo GGUF', M.modelloDaOpera({ modello: 'Qwen-Image-2.1-Turbo-Q4_K_M.gguf' }), 'qwen-base') +uguale('opera senza modello (PNG da fuori)', M.modelloDaOpera({ modello: '' }), 'qwen-base') +uguale('opera nulla', M.modelloDaOpera(undefined), 'qwen-base') + +// quello che va nella Richiesta +uguale('parametri qwen-base', M.parametriModello('qwen-base'), { famiglia: 'qwen', passi: 40, turbo: false }) +uguale('parametri klein', M.parametriModello('klein'), { famiglia: 'klein', passi: 8, turbo: false }) +uguale('parametri klein-base', M.parametriModello('klein-base'), { famiglia: 'klein-base', passi: 50, turbo: false }) + +// Rifai in qualità piena +uguale('piena da klein', M.inQualitaPiena('klein'), ['klein-base', 'qwen-base']) +uguale('piena da qwen-base', M.inQualitaPiena('qwen-base'), []) +uguale('piena da klein-base', M.inQualitaPiena('klein-base'), []) + +// le qualità salvate (localStorage, ricette) +uguale('turbo → alta', M.senzaTurbo('turbo'), 'alta') +for (const q of ['bozza', 'alta', 'massima']) uguale(`${q} resta`, M.senzaTurbo(q), q) + +// il denoiser Turbo GGUF torna a quello pieno +const presenti = ['qwen-image-2.1-UC-Q4_K_M.gguf', 'qwen-image-2.1-UC-Q8_0.gguf', 'Qwen-Image-2.1-Turbo-Q6_K.gguf', 'flux-3-klein-int8.safetensors'] +uguale('UC: niente da cambiare', M.denoiserSenzaTurbo('qwen-image-2.1-UC-Q4_K_M.gguf', presenti), null) +uguale('Turbo Q8 → UC Q8', M.denoiserSenzaTurbo('Qwen-Image-2.1-Turbo-Q8_0.gguf', presenti), 'qwen-image-2.1-UC-Q8_0.gguf') +uguale('Turbo Q6 senza UC Q6 → il primo UC', M.denoiserSenzaTurbo('Qwen-Image-2.1-Turbo-Q6_K.gguf', presenti), 'qwen-image-2.1-UC-Q4_K_M.gguf') +uguale('Turbo senza UC sul disco → resta', M.denoiserSenzaTurbo('Qwen-Image-2.1-Turbo-Q4_K_M.gguf', ['Qwen-Image-2.1-Turbo-Q4_K_M.gguf']), null) + +// FAMIGLIE (tipi.ts) prende i passi dalla mappa: si controlla leggendo il sorgente, perché tipi.ts importa './modelli' senza estensione (lo risolve Vite, Node no) +const { readFileSync } = await import('node:fs') +const tipi = readFileSync(join(QUI, '..', 'src', 'shared', 'tipi.ts'), 'utf8') +uguale('FAMIGLIE viene da MODELLI', /qwen: famiglia\('qwen-base'/.test(tipi) && /klein: famiglia\('klein'/.test(tipi) && /'klein-base': famiglia\('klein-base'/.test(tipi), true) + +console.log(falliti ? `\n${falliti} su ${fatti} NON vanno` : `TUTTI OK (${fatti} controlli)`) +process.exit(falliti ? 1 : 0) diff --git a/test/pulisci_didascalia.mjs b/test/pulisci_didascalia.mjs new file mode 100644 index 0000000..f6b864b --- /dev/null +++ b/test/pulisci_didascalia.mjs @@ -0,0 +1,102 @@ +// Test della pulizia della didascalia del prompt upsampler di Klein (Qwen3-VL 4B). +// Il 4B, quando scrive, apre spesso con una frase di cortesia o con le regole del sistema invece che con la didascalia +// («Here’s the expanded, detailed caption for your image request:», «You're a prompt upsampler for text-to-image models…»): +// se non si toglie, quella frase finisce nel prompt del modello e gli fa male. Qui si verifica che venga tolta. +// +// Esecuzione: node test/pulisci_didascalia.mjs +import { readFileSync, existsSync } from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' + +const QUI = dirname(fileURLToPath(import.meta.url)) + +// il codice vero, non una copia (Node con type stripping) +const { pulisciDidascalia, REGOLA } = await import( + pathToFileURL(join(QUI, '..', 'src', 'main', 'motore', 'didascalia.ts')).href +) + +// [nome, testo, deve iniziare con, non deve contenere] +const casi = [ + [ + 'preambolo "Here’s the expanded…"', + "Here’s the expanded, detailed caption for your image request:\n\nFull-color outdoor photograph capturing a tranquil, narrow alleyway in Naples during the soft golden hour of dusk, where the ambient light casts long, gentle shadows across cobblestone pavement.", + 'Full-color outdoor photograph', + 'expanded, detailed caption' + ], + [ + 'regole ricopiate', + "You're a prompt upsampler for text-to-image models. Your job is to expand short, vague prompts into detailed, self-contained captions that the model can use to generate high-quality images.\n\nYour output should be exactly one continuous block of prose — no greetings.\n\nFull-color indoor photograph of a kitchen bathed in soft daylight, a woman in a yellow sweater holding a white mug near a large window.", + 'Full-color indoor photograph', + 'prompt upsampler' + ], + [ + 'titolo in grassetto', + '**Image Idea:** Full-color outdoor photograph of a cat asleep on an old book, candlelight flickering across the fur.', + 'Full-color outdoor photograph', + 'Image Idea' + ], + [ + 'gia pulita (non deve cambiare)', + 'Full-color outdoor photograph of a quiet alley in Naples at dusk, laundry hanging between balconies, a scooter parked in front.', + 'Full-color outdoor photograph of a quiet alley', + '' + ], + [ + 'etichetta Caption:', + 'Caption: Full-color studio photograph of a glass vase on a wooden table, soft diffused light from the left.', + 'Full-color studio photograph', + 'Caption:' + ], + [ + 'preambolo lungo', + 'Here is your expanded detailed caption:\n\nFull-color outdoor photograph of a mountain range at sunrise, layered peaks in blue and grey haze, a small cabin in the foreground.', + 'Full-color outdoor photograph', + 'expanded detailed caption' + ], + ['troppo corto (non deve mangiare)', '#', '#', ''], + [ + 'istruzione etichettata', + 'Instruction: Remove the red car from the street and replace it with a bicycle, keeping the wet asphalt reflections consistent with the overcast evening light.', + 'Remove the red car', + 'Instruction:' + ], + [ + 'didascalia con 2 paragrafi buoni (non deve perderli)', + 'Full-color outdoor photograph of a quiet alley in Naples at dusk, laundry hanging between balconies, a scooter parked in front.\n\nThe cobblestones are slick with recent rain and reflect the warm glow of a single streetlamp above the doorway.', + 'Full-color outdoor photograph', + '' + ], + [ + 'didascalia con un paragrafo di regole in mezzo', + 'Full-color outdoor photograph of a quiet alley in Naples at dusk, laundry hanging between balconies, a scooter parked in front.\n\nYour output should be exactly one continuous block of prose with no greetings.\n\nThe cobblestones are slick with recent rain and reflect the warm glow of a single streetlamp above the doorway.', + 'Full-color outdoor photograph', + 'one continuous block' + ] +] + +let falliti = 0 +for (const [nome, testo, deve, nonDeve] of casi) { + const out = pulisciDidascalia(testo) + const parti = [] + if (!out.startsWith(deve)) parti.push(`inizia con ${JSON.stringify(deve)} invece di ${JSON.stringify(out.slice(0, 60))}`) + if (nonDeve && out.includes(nonDeve)) parti.push(`contiene ancora ${JSON.stringify(nonDeve)}`) + if (parti.length) falliti++ + console.log(`${parti.length ? 'FAIL' : 'OK '} | ${nome}`) + for (const p of parti) console.log(` ${p}`) + console.log(` -> ${JSON.stringify(out.slice(0, 100))}`) +} + +// i testi veri usciti dal motore Klein 4B (salvati dal bench) +for (const f of ['out_39b7554f.txt', 'out_da7d436a.txt']) { + const p = join(QUI, 'campioni-pup', f) + if (!existsSync(p)) continue + const testo = readFileSync(p, 'utf8') + const out = pulisciDidascalia(testo) + const male = /^(here|you(?:'re| are)|output only|sure|certainly|of course)/i.test(out) || REGOLA.test(out) + if (male) falliti++ + console.log(`${male ? 'FAIL' : 'OK '} | reale ${f} (${testo.length} -> ${out.length} caratteri)`) + console.log(` -> ${JSON.stringify(out.slice(0, 150))}`) +} + +console.log(falliti === 0 ? '\nTUTTI OK' : `\n${falliti} FALLITI`) +process.exit(falliti === 0 ? 0 : 1) diff --git a/test/ricette.mjs b/test/ricette.mjs new file mode 100644 index 0000000..d32b431 --- /dev/null +++ b/test/ricette.mjs @@ -0,0 +1,68 @@ +// Prova della scheda Ricette (SENZA Electron né motore): il catalogo dentro Modifica, Unisci foto e Come l'esempio come ricette. +// npm run build && python -m http.server 8765 --directory out/renderer & poi: node test/ricette.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({ executablePath: process.env.PLAYWRIGHT_BROWSERS_PATH ? process.env.PLAYWRIGHT_BROWSERS_PATH + '/chromium' : undefined }) +const pg = await b.newPage({ viewport: { width: 1480, height: 900 } }) +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) => { console.log(ok ? 'OK ' : 'MALE', nome); if (!ok) male++ } +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 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') + +// il catalogo +await pg.click('.attrezzi .segmenti button:has-text("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) + +// una ricetta normale: colonna di destra con i passi e Avvia +await pg.locator('.carta-wf').first().click() +await pausa(300) +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 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.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 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 pausa(300) +prova('Scrivi: pulsante Modifica', (await pg.locator('.colonna.destra button:has-text("Modifica")').count()) >= 1) +await b.close() +console.log(male || errori ? `FALLITA (${male} prove, ${errori} errori di pagina)` : 'TUTTO BENE') +process.exit(male || errori ? 1 : 0) diff --git a/test/spazio.mjs b/test/spazio.mjs new file mode 100644 index 0000000..1b41dce --- /dev/null +++ b/test/spazio.mjs @@ -0,0 +1,78 @@ +// Test della regola "una cosa alla volta in VRAM" (src/main/motore/spazio.ts, usata da fattiSpazio in lavori.ts): +// la chiave è per famiglia (immagine:qwen / immagine:klein / immagine:klein-base) e per LLM (testo:4b / testo:8b). +// +// Esecuzione: node test/spazio.mjs +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' + +const QUI = dirname(fileURLToPath(import.meta.url)) +const S = await import(pathToFileURL(join(QUI, '..', 'src', 'main', 'motore', 'spazio.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}`) +} + +// le chiavi +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('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') + +// una sequenza di lavori come in coda: [chiave, si libera?] +function sequenza(nome, passi) { + let ultimo = null + const liberati = [] + for (const k of passi) { + const { libera, ricorda } = S.cambioDiModello(ultimo, k) + liberati.push(libera) + ultimo = ricorda + } + return liberati +} + +// il caso per cui c'è T4: da Qwen a Klein (prima restavano tutti e due "immagine" e non partiva /free) +uguale('qwen → klein libera', sequenza('', ['immagine:qwen', 'immagine:klein']), [false, true]) +uguale('klein → klein-base libera (Rifai in qualità piena)', sequenza('', ['immagine:klein', 'immagine:klein-base']), [false, true]) +uguale('stessa famiglia non libera', sequenza('', ['immagine:klein', 'immagine:klein', 'immagine:klein']), [false, false, false]) +uguale('4B ↔ 8B libera', sequenza('', ['testo:4b', 'testo:8b', 'testo:4b']), [false, true, true]) +uguale('disegno ↔ testo libera (come prima)', sequenza('', ['immagine:qwen', 'testo:8b', 'immagine:qwen']), [false, true, true]) +uguale('il primo lavoro non libera mai', sequenza('', ['testo:4b']), [false]) + +// traduttore e ingranditore non contano e non cancellano il ricordo +uguale('cpu in mezzo: qwen, cpu, qwen', sequenza('', ['immagine:qwen', 'cpu', 'immagine:qwen']), [false, false, false]) +uguale('cpu in mezzo: qwen, cpu, klein', sequenza('', ['immagine:qwen', 'cpu', 'immagine:klein']), [false, false, true]) +uguale('ingranditore in mezzo: klein, leggero, klein', sequenza('', ['immagine:klein', 'leggero', 'immagine:klein']), [false, false, false]) +uguale('ingranditore in mezzo: klein, leggero, testo:4b', sequenza('', ['immagine:klein', 'leggero', 'testo:4b']), [false, false, true]) +uguale('cpu all\'inizio: non ricorda niente', S.cambioDiModello(null, 'cpu'), { libera: false, ricorda: null }) + +// chi scrive nei lavori di testo: scriveCol4B(modalita, famiglia, encoderKlein della richiesta, testoCon4B, il 4B c'è) +uguale('4B: Klein scrive col 4B', S.scriveCol4B('descrivi', 'klein', undefined, false, true), true) +uguale('4B: Klein Base scrive col 4B', S.scriveCol4B('descrivi', 'klein-base', undefined, false, true), true) +uguale('4B: Qwen con impostazione spenta = encoder 8B', S.scriveCol4B('descrivi', 'qwen', undefined, false, true), false) +uguale('4B: Qwen con impostazione accesa = 4B', S.scriveCol4B('descrivi', 'qwen', undefined, true, true), true) +uguale('4B: Qwen accesa ma il 4B manca = 8B', S.scriveCol4B('descrivi', 'qwen', undefined, true, false), false) +uguale('4B: Klein senza il 4B = encoder di prima', S.scriveCol4B('descrivi', 'klein', undefined, false, false), false) +uguale('4B: encoderKlein false esclude anche con Qwen accesa', S.scriveCol4B('descrivi', 'qwen', false, true, true), false) +uguale('4B: encoderKlein false esclude con Klein', S.scriveCol4B('descrivi', 'klein', false, false, true), false) +uguale('4B: un lavoro che disegna non scrive', S.scriveCol4B('crea', 'qwen', undefined, true, true), false) +// Qwen + 4B insieme: disegno Qwen, testo 4B, disegno Qwen = si libera due volte (non stanno in VRAM insieme su 8 GB) +uguale('qwen, testo:4b, qwen: libera ogni volta', sequenza('', ['immagine:qwen', 'testo:4b', 'immagine:qwen']), [false, true, true]) +uguale('testo:4b, testo:4b, qwen', sequenza('', ['testo:4b', 'testo:4b', 'immagine:qwen']), [false, false, true]) + +// le fasi mostrate mentre si libera +uguale('fase da qwen', S.faseScarico('immagine:qwen'), 'Scarico Qwen-Image') +uguale('fase da klein', S.faseScarico('immagine:klein'), 'Scarico FLUX 3 Klein') +uguale('fase da klein-base', S.faseScarico('immagine:klein-base'), 'Scarico FLUX 3 Klein Base') +uguale('fase da testo', [S.faseScarico('testo:4b'), S.faseScarico('testo:8b')], ['Scarico l\'LLM', 'Scarico l\'LLM']) + +console.log(`${fatti - falliti}/${fatti} ok`) +if (falliti) process.exit(1)