diff --git a/CHANGELOG.md b/CHANGELOG.md index c9bb861..6c869af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 1.3.0 — 2026-10-28 + +Un giro di pulizia: i LoRA di Klein funzionano davvero, Klein parte dai 16 passi indicati da Black Forest Labs, Crea si usa subito (scrivi, scegli il formato, premi CREA) e le Opzioni sono corte, con tutto il resto in «Avanzate». + +### LoRA di Klein + +- **I LoRA FLUX 3 in formato Diffusers/SimpleTuner ora funzionano.** Esistono due formati. Quelli fatti con AI Toolkit usano i nomi di ComfyUI (`diffusion_model.content_mode_blocks…`) e andavano già. Quelli di Diffusers e SimpleTuner (`transformer.mode_blocks…`, `single_transformer_blocks…ff.linear_in`: per esempio il LoRA delle angolazioni di akhaliq) il motore non li conosce: caricava il file senza errori ma scartava tutte le chiavi, e l'app li scambiava per «FLUX.1, non compatibile». Ora l'app li riconosce e, quando li importi o li scarichi, li converte ai nomi di ComfyUI: riscrive solo l'intestazione del `.safetensors`, i pesi non cambiano di un byte, e l'originale resta accanto come `.diffusers.bak`. Se alpha è diverso dal rango aggiunge i tensori `alpha`. Provato sul motore vero con un LoRA reale: prima «0 patches attached», dopo «172 patches attached», nessuna chiave scartata. +- **Forza di partenza 0,75** per i LoRA di Klein (il valore indicato da BFL; da 0,5 a 1 va bene). +- Corretto un riconoscimento sbagliato già presente: i LoRA di FLUX.2 in formato Diffusers (`single_transformer_blocks…to_qkv_mlp_proj`) venivano scambiati per LoRA di Qwen 2.1. +- Prove: `test/lora-converti.mjs` (mappa dei nomi, pesi identici byte per byte, alpha, file che non è un LoRA di Klein) e nuovi casi in `test/lora.mjs`. + +### Klein + +- **Klein veloce parte da 16 passi** (erano 8): è quello che consiglia BFL. Misurato su RTX 4060, 1024×1024, stesso seme: circa 6 s a 8 passi, 10 s a 16; a 16 volti, mani, tessuti e testi escono più puliti. Le qualità «bozza» (8) e «massima» (24) restano. +- Non fatto, da valutare: il prompt upsampler ufficiale di BFL (Qwen3.5 9B, 6,7 GB in W4A8, troppo per 8 GB insieme al resto) e i LoRA di distillazione di terzi per Klein Base a 4-8 passi (di due giorni fa). + +### Interfaccia + +- **Crea si usa subito.** In cima il prompt, poi Formato e Misura; **Genere, Stile e Altro** (sfondo, foto di riferimento, quante immagini, seed) sono sezioni chiuse che si aprono con un clic. Prima, 12 tessere dei generi spingevano il prompt sotto la piega. +- **Modifica**: tolte le tessere «Estrai» ed «Espandi» che duplicavano le schede sopra la tela; le categorie delle Idee non partono più aperte (un clic apre, un altro chiude). +- **Opzioni corte.** In cima: Motore, Modelli (pronto o da scaricare), Come lavora (scheda video, traduzione, arricchimento del prompt di Klein, anteprima, libera la scheda dopo), Cartelle, Aggiornamenti. Tutto il resto (file dei modelli, download, file inutilizzati, memoria e velocità, account, log) sta in **Avanzate**, chiuso di serie, con testi di una riga. + ## 1.2.1 — 2026-10-23 Tre correzioni dell'interfaccia Orbita e una semplificazione: i modelli si scelgono dalla barra in alto e sono due (Qwen e Klein 3), l'Editor box non si blocca più dopo qualche riquadro, e i LoRA hanno due scaffali, Qwen e Flux, che ora funzionano anche con Klein. diff --git a/CLAUDE.md b/CLAUDE.md index 4927c63..bb9a1e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +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. +- **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, 16 passi: quelli di BFL, fino alla 1.2.1 erano 8; 16 costa 4 s in più a 1 MP e dà dettagli più puliti), `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**). @@ -63,6 +63,7 @@ interfaccia**, installato e guidato dall'app. - Non provato con la GPU qui: qualità, INT8 su 6-8 GB, `ReferenceLatent` in catena (alternativa `ReferenceMultiLatent`). - **1.2.1: i modelli sono tre pillole** (`qwen-base`, `klein`, `klein-base`: «Qwen», «Klein 3», «Klein 3 Base») e si scelgono **solo dalla barra in alto** (`App.tsx` → `SelettoreModello` in `componenti/ScegliModello.tsx`, da qualsiasi pagina; nei pannelli di Crea/Modifica/Box non c'è un secondo selettore). **FLUX.2 Klein 4B è stato tolto** (niente famiglia `klein4b`, niente voci `klein4b-*` nel catalogo): le impostazioni vecchie passano a Qwen in `modelloDaImpostazioni`, i suoi file in `models/` si vedono come «tolto dal catalogo» (`idle.ts`) e `modelli:file` non li scambia per file di Klein 3 (`eFlux2`). - **LoRA a due scaffali** (1.2.1): `GruppoLora = 'qwen' | 'flux'` (`shared/loraBase.ts`: `gruppoDiFamiglia`, `gruppoDa`, `vaBeneCon`; Klein veloce e Klein Base hanno gli stessi). File in `loras/qwen` e `loras/flux` (`lora.ts` `scaffale()`: i nomi dei tensori decidono, se non dicono niente vale la scheda da cui si carica, SDXL & co. restano fuori in «Altri» e si assegnano a mano con `InfoLora.gruppo`). **`elencoLora()` deve portare `famiglie` e `gruppo`**: nella 1.2.0 non lo faceva e il renderer dava «non compatibile» a ogni LoRA di Klein. Lo store ha **due pile** (`loraPile`, `loraAttive` = quella del modello scelto; `setLoraPila`/`toggleLoraPer` per scaffale); `richiestaBase()` manda la pila del modello, `loraPer`/`fileLoraCamera` (azioni rapide, Camera) solo LoRA che vanno col modello, e `soloLoraDelModello` in `lavori.ts` toglie quelli dell'altro scaffale. Prove: `test/lora-scaffali.mjs`, `test/box-lora-ui.mjs`. + **Formato dei LoRA di Klein (1.3):** i LoRA FLUX 3 esistono in due formati. AI Toolkit usa i nomi di ComfyUI (`diffusion_model.content_mode_blocks.image.N.q_proj`, `single_blocks.N.mlp_in`…) e il motore li carica; Diffusers/SimpleTuner usano `transformer.mode_blocks.image.N.attn.to_q`, `single_transformer_blocks.N.ff.linear_in`, e ComfyUI **non li conosce** (tutte le chiavi «not loaded», 0 patch: il LoRA non fa niente). `shared/loraBase.ts` li riconosce (`formato: 'diffusers'`), `shared/loraConverti.ts` ha la mappa dei nomi e `main/loraConverti.ts` riscrive solo l'intestazione del .safetensors (l'originale resta come `.diffusers.bak`; se alpha ≠ rango aggiunge i tensori `.alpha`). Si converte in `importaLora`, `scaricaLora` e in `elencoLora` per i file messi a mano; la forza di partenza di un LoRA di Klein è 0,75 (BFL). Prova: `node test/lora-converti.mjs`. Verificato sul motore vero con il LoRA delle angolazioni di akhaliq: prima 0 patch, dopo 172. ## I grafi (src/main/motore/grafi.ts) - Pipeline ufficiale: `UnetLoaderGGUF` → LoRA (`LoraLoaderModelOnly`) → `QwenImage21Cache`; `CLIPLoader` tipo `qwen_image` diff --git a/README.md b/README.md index b50d80b..05ac5da 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ 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. -- **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à. +- **Il modello lo scegli in alto**: **Klein veloce** (16 passi, quelli indicati da Black Forest Labs) 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. @@ -119,7 +119,7 @@ Modifica → Estrai e nelle ricette. Diventa un manichino grigio che il modello ## Tre 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. +Nella barra in alto scegli **chi disegna**: **Qwen base** (Qwen-Image 2.1, 40 passi, con i LoRA), **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. Il Turbo di Qwen non c'è più: Klein veloce è il modo rapido. 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, @@ -172,7 +172,7 @@ se la scheda è da 6 GB, il profilo **6 GB** in Opzioni. L'app ti avvisa da sola 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 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**, +- 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 in circa 10 secondi a 16 passi (6 a 8 passi)**, 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 diff --git a/package-lock.json b/package-lock.json index 6fca28d..9c2861f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-image", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-image", - "version": "1.2.1", + "version": "1.3.0", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/package.json b/package.json index 03f2a24..0a0f876 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-image", "productName": "DaProd Image", - "version": "1.2.1", + "version": "1.3.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", @@ -14,7 +14,7 @@ "build": "electron-vite build", "start": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", - "test": "node test/modelli.mjs && node test/catalogo.mjs && node test/spazio.mjs && node test/lora.mjs && node test/lora-scaffali.mjs && node test/regioni.mjs && node test/adatta.mjs && node test/grafi_v2.mjs && node test/confronta_grafi.mjs && node test/asset.mjs", + "test": "node test/modelli.mjs && node test/catalogo.mjs && node test/spazio.mjs && node test/lora.mjs && node test/lora-scaffali.mjs && node test/lora-converti.mjs && node test/regioni.mjs && node test/adatta.mjs && node test/grafi_v2.mjs && node test/confronta_grafi.mjs && node test/asset.mjs", "prendi-uv": "node scripts/prendi-uv.mjs", "dist": "npm run prendi-uv && npm run build && electron-builder --win --x64", "prova": "node test/prove.mjs", diff --git a/src/main/lora.ts b/src/main/lora.ts index 1deeeb6..de7585a 100644 --- a/src/main/lora.ts +++ b/src/main/lora.ts @@ -7,6 +7,7 @@ import { copyFileSync, existsSync, openSync, readFileSync, readSync, closeSync, import { basename, extname, join, relative, resolve, sep } from 'node:path' import type { Famiglia, GruppoLora, InfoLora } from '@shared/tipi' import { analizzaChiavi, gruppoDa } from '@shared/loraBase' +import { convertiSulPosto } from './loraConverti' import { DATI, assicura, scriviAtomico } from './percorsi' import { impostazioni } from './impostazioni' import { scarica } from './installa/scarica' @@ -56,14 +57,14 @@ function intestazione(percorso: string): Record | null { } /** Qwen-Image 2.1 è a flusso singolo (solo img_mlp); Qwen-Image 1.x ha txt_mlp/add_q_proj; SD/SDXL/Flux hanno altri nomi */ -function analizza(percorso: string): { compatibile: InfoLora['compatibile']; base?: string; famiglie?: Famiglia[]; parole?: string; nome?: string } { +function analizza(percorso: string): { compatibile: InfoLora['compatibile']; base?: string; famiglie?: Famiglia[]; formato?: 'diffusers'; parole?: string; nome?: string } { const h = intestazione(percorso) if (!h) return { compatibile: '?' } const chiavi = Object.keys(h).filter((k) => k !== '__metadata__') const meta = (h.__metadata__ || {}) as Record const forme: Record = {} for (const k of chiavi) forme[k] = (h[k] as { shape?: number[] } | undefined)?.shape - const { compatibile, famiglie, base: baseRiconosciuta } = analizzaChiavi(chiavi, forme) + const { compatibile, famiglie, formato, base: baseRiconosciuta } = analizzaChiavi(chiavi, forme) let base = baseRiconosciuta const archi = meta['modelspec.architecture'] || meta.ss_base_model_version || '' if (archi) base = base ? `${base} (${archi})` : archi @@ -82,7 +83,7 @@ function analizza(percorso: string): { compatibile: InfoLora['compatibile']; bas /* niente */ } } - return { compatibile, base, famiglie, parole, nome: meta['modelspec.title'] || meta.ss_output_name } + return { compatibile, base, famiglie, formato, parole, nome: meta['modelspec.title'] || meta.ss_output_name } } export function elencoLora(): InfoLora[] { @@ -110,9 +111,11 @@ export function elencoLora(): InfoLora[] { let nota = n[file] // (le note di prima della 1.2.1 possono dire 'klein4b', un modello che l'app non ha più: si rianalizzano) if (!nota || nota.compatibile === undefined || nota.famiglie === undefined || nota.famiglie.some((f) => !FAMIGLIE_VALIDE.includes(f))) { + // un LoRA di Klein alla Diffusers messo a mano nella cartella: si porta ai nomi di ComfyUI prima di leggerlo + portaInComfy(p) const a = analizza(p) const delCatalogo = CATALOGO.find((x) => x.tipo === 'lora' && x.file === v) - nota = { nome: delCatalogo?.nome || a.nome || basename(v, extname(v)), parole: a.parole || '', forza: 1, aggiunta: st.mtimeMs, ...nota, compatibile: a.compatibile, base: a.base, famiglie: a.famiglie } + nota = { nome: delCatalogo?.nome || a.nome || basename(v, extname(v)), parole: a.parole || '', forza: a.famiglie?.includes('klein') ? 0.75 : 1, aggiunta: st.mtimeMs, ...nota, compatibile: a.compatibile, base: a.base, famiglie: a.famiglie } n[file] = nota cambiate = true } @@ -154,6 +157,19 @@ function scaffale(percorso: string, scelto?: GruppoLora): { cartella: string; gr return { cartella: gruppo ? join(cartellaLora(), gruppo) : cartellaLora(), gruppo, riconosciuto: !!riconosciuto } } +/** un LoRA di Klein nel formato Diffusers/SimpleTuner (`transformer.mode_blocks…`) ComfyUI non lo legge: lo si porta ai suoi nomi sul posto + * (l'originale resta accanto come `.diffusers.bak`). Ritorna true se l'ha convertito; se non ce la fa lascia il file com'è e dice perché. */ +function portaInComfy(percorso: string): boolean { + try { + if (analizza(percorso).formato !== 'diffusers') return false + convertiSulPosto(percorso) + return true + } catch (e) { + console.error('conversione del LoRA non riuscita:', percorso, (e as Error).message) + return false + } +} + /** `gruppo`: lo scaffale della scheda da cui si carica (Qwen o Flux). Se i nomi dei tensori dicono un altro modello, vince quello. */ export function importaLora(percorsi: string[], gruppo?: GruppoLora): string[] { const c = cartellaLora() @@ -164,7 +180,10 @@ export function importaLora(percorsi: string[], gruppo?: GruppoLora): string[] { const s = scaffale(p, gruppo) assicura(s.cartella) const dest = join(s.cartella, basename(p)) - if (!existsSync(dest)) copyFileSync(p, dest) + if (!existsSync(dest)) { + copyFileSync(p, dest) + portaInComfy(dest) + } const file = relative(c, dest) aggiornaLora(file, { aggiunta: Date.now(), ...(s.gruppo && !s.riconosciuto ? { gruppo: s.gruppo } : {}) }) fatti.push(file) @@ -253,8 +272,9 @@ export async function scaricaLora(link: string, avanz: (r: number, t: number, v: assicura(cartellaLora()) const dest = join(cartellaLora(), file) await scarica({ url: d.url, destinazione: dest, intestazioni: d.intestazioni, segnale, avanzamento: avanz }) - // arrivato, si guarda di che modello è e lo si mette nel suo scaffale + // arrivato, si guarda di che modello è e lo si mette nel suo scaffale (e se è un LoRA di Klein alla Diffusers lo si porta ai nomi di ComfyUI) const s = scaffale(dest, gruppo) + portaInComfy(dest) let finale = dest if (s.gruppo) { assicura(s.cartella) diff --git a/src/main/loraConverti.ts b/src/main/loraConverti.ts new file mode 100644 index 0000000..5e61701 --- /dev/null +++ b/src/main/loraConverti.ts @@ -0,0 +1,117 @@ +// Porta un LoRA di FLUX 3 Klein dal formato Diffusers/SimpleTuner a quello di ComfyUI (vedi `shared/loraConverti.ts`). +// Si riscrive solo l'intestazione del .safetensors: i pesi restano com'erano (si copiano a blocchi, nessun torch). +// Niente Electron: lo prova `node test/lora-converti.mjs`. +import { closeSync, openSync, readSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs' +import { chiaveComfy, rangoEAlpha } from '@shared/loraConverti' + +interface Tensore { + dtype: string + shape: number[] + data_offsets: [number, number] +} +type Intestazione = Record> + +function leggiIntestazione(fd: number): { h: Intestazione; inizioDati: number } { + const b8 = Buffer.alloc(8) + readSync(fd, b8, 0, 8, 0) + const n = Number(b8.readBigUInt64LE(0)) + if (n <= 0 || n > 100_000_000) throw new Error('intestazione del LoRA non valida') + const b = Buffer.alloc(n) + readSync(fd, b, 0, n, 8) + return { h: JSON.parse(b.toString('utf8')) as Intestazione, inizioDati: 8 + n } +} + +/** Converte `da` in un file nuovo `a` (si scrive in un file accanto e poi lo si rinomina: un LoRA a metà non resta mai in giro). + * Ritorna quanti tensori sono stati rinominati e quanti lasciati fuori (quelli che non si sanno portare, di solito nessuno). */ +export function convertiLoraKlein(da: string, a: string): { rinominati: number; scartati: number } { + const fd = openSync(da, 'r') + const tmp = a + '.converto' + let out: number | undefined + try { + const { h, inizioDati } = leggiIntestazione(fd) + const meta = (h.__metadata__ || {}) as Record + const ra = rangoEAlpha(meta) + const nuovo: Intestazione = {} + let rinominati = 0 + let scartati = 0 + const moduli = new Set() + for (const [k, v] of Object.entries(h)) { + if (k === '__metadata__') continue + const nk = chiaveComfy(k) + if (!nk) { + scartati++ + continue + } + nuovo[nk] = v as Tensore + rinominati++ + if (nk.endsWith('.lora_A.weight')) moduli.add(nk.slice(0, -'.lora_A.weight'.length)) + } + if (!rinominati) throw new Error('nessun tensore riconosciuto: non è un LoRA di FLUX 3 Klein') + // dopo l'ultimo peso (se lo scale non è 1) si aggiunge un tensore `alpha` per modulo, come fa AI Toolkit + let fine = Math.max(...Object.values(nuovo).map((t) => (t as Tensore).data_offsets[1])) + const alphaBytes: Buffer[] = [] + if (ra && Math.abs(ra.alpha - ra.r) > 1e-6) { + for (const m of moduli) { + const buf = Buffer.alloc(4) + buf.writeFloatLE(ra.alpha, 0) + nuovo[`${m}.alpha`] = { dtype: 'F32', shape: [], data_offsets: [fine, fine + 4] } + alphaBytes.push(buf) + fine += 4 + } + } + nuovo.__metadata__ = { ...meta, ss_base_model_version: 'flux3', converted_from: 'diffusers', format: 'pt' } + let testo = JSON.stringify(nuovo) + testo += ' '.repeat((8 - (Buffer.byteLength(testo) % 8)) % 8) + const intest = Buffer.from(testo, 'utf8') + const len = Buffer.alloc(8) + len.writeBigUInt64LE(BigInt(intest.length), 0) + out = openSync(tmp, 'w') + writeSync(out, len) + writeSync(out, intest) + // i pesi originali, a blocchi da 32 MB (dall'inizio dei dati fino all'ultimo byte usato) + const totale = Math.max(...Object.values(h).filter((t): t is Tensore => !!(t as Tensore).data_offsets).map((t) => t.data_offsets[1])) + const blocco = Buffer.alloc(32 * 1024 * 1024) + for (let pos = 0; pos < totale; ) { + const n = Math.min(blocco.length, totale - pos) + const letti = readSync(fd, blocco, 0, n, inizioDati + pos) + if (letti <= 0) throw new Error('il file del LoRA è più corto di quello che dice la sua intestazione') + writeSync(out, blocco, 0, letti) + pos += letti + } + for (const b of alphaBytes) writeSync(out, b) + closeSync(out) + out = undefined + renameSync(tmp, a) + return { rinominati, scartati } + } catch (e) { + try { + if (out !== undefined) closeSync(out) + out = undefined + unlinkSync(tmp) + } catch { + /* niente */ + } + throw e + } finally { + closeSync(fd) + if (out !== undefined) closeSync(out) + } +} + +/** Converte il file sul posto: l'originale resta accanto come `.diffusers.bak` (la scansione dei LoRA non lo vede). */ +export function convertiSulPosto(percorso: string): { rinominati: number; scartati: number } { + const nuovo = percorso + '.nuovo' + const r = convertiLoraKlein(percorso, nuovo) + unlinkSyncSafe(percorso + '.diffusers.bak') + renameSync(percorso, percorso + '.diffusers.bak') + renameSync(nuovo, percorso) + return r +} + +function unlinkSyncSafe(p: string): void { + try { + if (statSync(p)) unlinkSync(p) + } catch { + /* non c'è */ + } +} diff --git a/src/renderer/src/orbita.css b/src/renderer/src/orbita.css index ee0d77c..f753593 100644 --- a/src/renderer/src/orbita.css +++ b/src/renderer/src/orbita.css @@ -349,3 +349,16 @@ body.meno-movimento .palco::before { animation: none; } /* impostazioni: il banner aurora in cima */ .banner-aurora { max-width: 920px; height: 110px; margin: 0 auto 18px; border-radius: 16px; border: 1px solid var(--bordo-vetro); background: url('../../../resources/gui/sfondi/aurora.webp') center / cover no-repeat; } @media (prefers-reduced-motion: reduce) { .mascotte-vuoto, .mascotte-setup { animation: none; } } + +/* 1.3: dentro una sezione, titoletti piccoli al posto di un'altra sezione (Crea: «Altro», Formato + Misura) */ +.sezione h4.sotto { display: flex; justify-content: space-between; margin: 14px 0 6px; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--tenue); font-weight: 700; } +.sezione h4.sotto .dx { text-transform: none; letter-spacing: 0; font-weight: 500; } + +/* 1.3: Opzioni, «Avanzate» chiuso di serie */ +.impostazioni details.avanzate { margin: 18px 0; } +.impostazioni details.avanzate > summary { cursor: pointer; list-style: none; padding: 12px 16px; border: 1px solid var(--bordo-vetro); border-radius: 12px; background: var(--vetro); font-family: var(--font-titoli, inherit); font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; font-size: 14px; color: var(--stella); } +.impostazioni details.avanzate > summary::-webkit-details-marker { display: none; } +.impostazioni details.avanzate > summary::before { content: '▸ '; } +.impostazioni details.avanzate[open] > summary::before { content: '▾ '; } +.impostazioni details.avanzate[open] > summary { margin-bottom: 14px; } +.impostazioni .voce .distintivo { justify-self: start; } diff --git a/src/renderer/src/pagine/Crea.tsx b/src/renderer/src/pagine/Crea.tsx index bb07e16..cec930a 100644 --- a/src/renderer/src/pagine/Crea.tsx +++ b/src/renderer/src/pagine/Crea.tsx @@ -202,7 +202,7 @@ export function Crea(): JSX.Element {
COSA COSTRUIAMO OGGI?
-

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

+

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

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

{recenti.length > 0 && (
@@ -259,16 +259,10 @@ export function Crea(): JSX.Element {
-
-

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

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

Atmosfera

- +

Genere {preset.nome}

+
)} @@ -280,9 +274,9 @@ export function Crea(): JSX.Element { setCrea({ prompt: p })} - minimo={preset ? 110 : 150} + minimo={preset ? 90 : 120} disabled={scrivendo} />
@@ -303,38 +297,39 @@ export function Crea(): JSX.Element {
- - setCrea({ stileId })} /> - {stileAttivo && setCrea({ intensita })} />} - -

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

-
- -
-

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

+

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

-
-

Sfondo

- -
+ + + {preset && preset.stili.length > 1 && ( + <> +

Atmosfera

+ + + )} +
-
+ + setCrea({ stileId })} /> + {stileAttivo && setCrea({ intensita })} />} + + + 0 && `${crea.riferimenti.length} foto`, quante > 1 && `${quante} immagini`].filter(Boolean).join(' · ') || 'sfondo, foto, quante'}> +

Sfondo

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

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

+

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

{!crea.casuale && (
@@ -342,7 +337,7 @@ export function Crea(): JSX.Element { setCrea({ seed: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 130 }} />
)} -
+ diff --git a/src/renderer/src/pagine/Impostazioni.tsx b/src/renderer/src/pagine/Impostazioni.tsx index 53d2135..5229938 100644 --- a/src/renderer/src/pagine/Impostazioni.tsx +++ b/src/renderer/src/pagine/Impostazioni.tsx @@ -1,16 +1,17 @@ -// Impostazioni: modelli, prestazioni, cartelle, motore. +// Opzioni: in cima poche cose chiare (motore, modelli, come lavora, cartelle, aggiornamenti); tutto il resto sta in «Avanzate», chiuso. import { useEffect, useRef, useState, type JSX } from 'react' import { usaStato, ricaricaImp } from '../stato' import { api, type FileModelli } from '../api' import { I } from '../componenti/Icone' import { Interruttore, Segmenti } from '../componenti/Controlli' import { byte } from '../util' +import { MODELLI, ORDINE_MODELLI, type IdModello } from '@shared/modelli' import type { Download, Impostazioni as Imp, StatoVoce } from '@shared/tipi' import type { FileInutile } from '../api' import { LaboratorioUncensored } from '../componenti/LaboratorioUncensored' const GRUPPI_MODELLI: [StatoVoce['tipo'], string][] = [ - ['diffusione', 'Modello di diffusione (denoiser)'], ['encoder', 'Text encoder (l\'LLM)'], ['klein', 'FLUX 3 Klein · modelli'], ['klein-te', 'FLUX 3 Klein · text encoder'], ['klein-vae', 'FLUX 3 Klein · VAE'], ['traduttore', 'Traduttore'], ['vae', 'VAE'], ['upscaler', 'Ingrandimento'], ['anteprima', 'Anteprime dal vivo'] + ['diffusione', 'Qwen · modelli'], ['encoder', 'Qwen · text encoder'], ['klein', 'Klein · modelli'], ['klein-te', 'Klein · text encoder'], ['klein-vae', 'Klein · VAE'], ['traduttore', 'Traduttore'], ['vae', 'VAE di Qwen'], ['upscaler', 'Ingrandimento'], ['anteprima', 'Anteprime dal vivo'] ] /** Opzioni: le impostazioni di sempre (i LoRA hanno la pagina loro, nella rotaia). */ @@ -30,19 +31,19 @@ function OpzioniGenerali(): JSX.Element { const [log, setLog] = useState([]) const [versione, setVersione] = useState('') const fineLog = useRef(null) - // i gruppi si possono chiudere (si ricorda come li hai lasciati); i più lunghi partono chiusi - const [chiusi, setChiusi] = useState>(() => { + // i gruppi di «Avanzate» si possono chiudere (si ricorda come li hai lasciati); di serie è tutto chiuso + const [aperti, setAperti] = useState>(() => { try { - return { 'SCARICA ALTRI MODELLI': true, 'FILE INUTILIZZATI': true, 'VALORI DI PARTENZA': true, MOTORE: true, ...JSON.parse(localStorage.getItem('dpi-opzioni-chiusi') || '{}') } + return JSON.parse(localStorage.getItem('dpi-opzioni-aperti') || '{}') } catch { - return { 'SCARICA ALTRI MODELLI': true, 'FILE INUTILIZZATI': true } + return {} } }) const alterna = (t: string): void => - setChiusi((c) => { + setAperti((c) => { const n = { ...c, [t]: !c[t] } try { - localStorage.setItem('dpi-opzioni-chiusi', JSON.stringify(n)) + localStorage.setItem('dpi-opzioni-aperti', JSON.stringify(n)) } catch { /* niente */ } @@ -50,8 +51,8 @@ function OpzioniGenerali(): JSX.Element { }) const [inutili, setInutili] = useState([]) useEffect(() => { - if (!chiusi['FILE INUTILIZZATI']) void api.modelli.inutilizzati().then(setInutili) - }, [chiusi['FILE INUTILIZZATI'], catalogo]) + if (aperti['FILE INUTILIZZATI']) void api.modelli.inutilizzati().then(setInutili) + }, [aperti['FILE INUTILIZZATI'], catalogo]) const ricarica = async (): Promise => { setFile(await api.modelli.file()) @@ -82,7 +83,7 @@ function OpzioniGenerali(): JSX.Element { await ricarica() if (d.stato === 'fatto') { avvisa(`${v.nome} scaricato`, 'ok') - // il nome vero del file lo conosciamo solo adesso (Turbo ed encoder GGUF di terzi) + // il nome vero del file lo conosciamo solo adesso (ed encoder GGUF di terzi) const n = (await api.modelli.stato()).find((x) => x.id === v.id) || v if (v.tipo === 'diffusione' && confirm(`Usare ${v.nome} da ora?`)) await salva({ modello: n.file }) if (v.tipo === 'encoder' && confirm(`Usare ${v.nome} come text encoder da ora?`)) await salva({ encoder: n.file }) @@ -91,220 +92,92 @@ function OpzioniGenerali(): JSX.Element { const voceScelta = (titolo: string, sotto: string, valore: string, lista: string[], cambia: (v: string) => void): JSX.Element => (
-
{titolo}{sotto}
+
{titolo}{sotto && {sotto}}
) + const voceInterruttore = (titolo: string, sotto: string, acceso: boolean, cambia: (v: boolean) => void): JSX.Element => ( +
+
{titolo}{sotto}
+ {acceso ? 'Sì' : 'No'} +
+ ) + /** un gruppo di «Avanzate»: titolo che si apre e si chiude, chiuso di serie */ + const gruppo = (titolo: string, corpo: JSX.Element): JSX.Element => ( +
+

alterna(titolo)}>{aperti[titolo] ? '▾' : '▸'} {titolo}

+ {aperti[titolo] &&
{corpo}
} +
+ ) const vramGB = motore.vramTotale ? motore.vramTotale / 1073741824 : imp.vramMB ? imp.vramMB / 1024 : 0 - const vramUsata = motore.vramTotale && motore.vramLibera !== undefined ? 1 - motore.vramLibera / motore.vramTotale : 0 + // i modelli che si possono usare adesso (Klein: modello, text encoder e VAE nelle cartelle) + const comuni = (file.kleinTe?.length ?? 0) > 0 && (file.kleinVae?.length ?? 0) > 0 + const pronti: Record = { + 'qwen-base': true, + klein: comuni && !!file.klein?.some((n) => !/base/i.test(n)), + 'klein-base': comuni && !!file.klein?.some((n) => /base/i.test(n)) + } + const statoMotore = motore.stato === 'pronto' ? 'pronto' : motore.stato === 'avvio' ? 'avvio' : motore.stato === 'errore' ? 'errore' : '' return (
-
-
-

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

-
- {voceScelta('Modello di diffusione', 'Il cuore: Qwen-Image 2.1 (GGUF o safetensors)', imp.modello, file.diffusione, (modello) => salva({ modello }))} - {voceScelta('Text encoder (l\'LLM)', 'Qwen3-VL 8B: legge il prompt, guarda le foto e traduce dall\'italiano. Gli .gguf (Q4/Q6/Q8) si scaricano qui sotto', imp.encoder, file.encoder, (encoder) => salva({ encoder }))} - {voceScelta('VAE', 'Texture-Fix = trame più pulite', imp.vae, file.vae, (vae) => salva({ vae }))} -
-
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 (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'} +

MOTORE

+
+
+ {motore.stato} + {motore.gpu && {motore.gpu}} + {motore.vramTotale ? VRAM {byte(motore.vramTotale - (motore.vramLibera || 0))} / {byte(motore.vramTotale)} : null} + + +
- {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 }))} + {motore.messaggio && motore.stato === 'errore' &&
{motore.messaggio}
}
-

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

-
- {GRUPPI_MODELLI.map(([tipo, titolo]) => { - const voci = catalogo.filter((v) => v.tipo === tipo && !v.uncensored) - if (!voci.length) return null +

MODELLI

+
+ {ORDINE_MODELLI.map((id) => { + const m = MODELLI[id] return ( -
- {titolo} · {voci.filter((x) => x.presente).length}/{voci.length} presenti - {voci.map((v) => { - const d = download[v.nome] - const vaGiu = d && (d.stato === 'in corso' || d.stato === 'verifica') - return ( -
-
- {v.nome} {v.turbo && 8 passi} {(v.file === imp.modello || v.file === imp.encoder) && v.file && in uso} {v.consigliato && consigliato} · {v.circa ? 'circa ' : ''}{byte(v.byte)} -
-
- {v.presente ? ( - ✓ presente - ) : vaGiu ? ( - <> - {d.totali ? Math.round((d.ricevuti / d.totali) * 100) : 0}% · {(d.velocita / 1e6).toFixed(0)} MB/s - - - ) : ( - - )} -
- {v.descrizione} - {vaGiu &&
} +
+
{id === 'qwen-base' ? 'Qwen-Image 2.1' : id === 'klein' ? 'FLUX 3 Klein' : 'FLUX 3 Klein Base'}{m.sotto}
+ {pronti[id] ? ✓ pronto : si scarica dalla barra in alto}
) - })} -
- ) })}
-

alterna('FILE INUTILIZZATI')}>{chiusi['FILE INUTILIZZATI'] ? '▸' : '▾'} FILE INUTILIZZATI

-
-
- File nella cartella dei modelli che non stai usando: vecchi VAE e modelli tolti dal catalogo, o scaricati ma non scelti. Vanno nel Cestino (si recuperano). -
- {inutili.length === 0 &&
Niente da togliere: ogni file è in uso.
} - {inutili.map((f) => ( -
-
{f.nome} · {f.cartella} · {byte(f.byte)} · {f.motivo}
- -
- ))} - {inutili.length > 0 &&
In tutto {byte(inutili.reduce((a, f) => a + f.byte, 0))}
} -
-
- -
-

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

-
+

COME LAVORA

+
-
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'} +
Scheda video{imp.memoria === 'auto' ? `Auto${vramGB ? ` · ${vramGB.toFixed(1)} GB` : ''}` : 'Scelta a mano'}
+ salva({ memoria })} voci={[{ id: 'auto', nome: 'Auto' }, { id: 'bassa', nome: '6 GB' }, { id: 'normale', nome: '8-12 GB' }, { id: 'alta', nome: '16 GB+' }]} />
+ {voceInterruttore('Traduci in inglese', 'I prompt in italiano si traducono da soli', imp.traduci, (traduci) => salva({ traduci }))} + {voceInterruttore('Klein: arricchisci il prompt', 'Klein rende meglio con descrizioni lunghe (circa 5 s in più)', imp.potenziaKlein ?? true, (potenziaKlein) => salva({ potenziaKlein }))} + {voceInterruttore('Anteprima dal vivo', "Vedi l'immagine nascere", imp.anteprimaLive, (anteprimaLive) => salva({ anteprimaLive }))}
-
- Profilo della scheda video - - {imp.memoria === 'auto' - ? `Auto: ${vramGB ? `${vramGB.toFixed(1)} GB → ${vramGB < 6.84 ? '6 GB' : vramGB < 13.7 ? '8-12 GB' : '16 GB+'}` : 'si decide quando parte il motore'}` - : '6 GB: VAE a tessere e margine di VRAM fisso; 8-12 GB: a tessere solo oltre i 2 MP'} - -
- salva({ memoria })} - voci={[{ id: 'auto', nome: 'Auto' }, { id: 'bassa', nome: '6 GB' }, { id: 'normale', nome: '8-12 GB' }, { id: 'alta', nome: '16 GB+' }]} - /> -
-
-
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'} -
- {imp.traduci && ( -
-
- Chi traduce - - {imp.traduttore === 'dedicato' - ? 'Un modello fatto solo per tradurre (Opus-MT, ~300 MB): sul processore, senza VRAM e senza rifiuti. Si scarica da solo la prima volta' - : 'Lo stesso Qwen3-VL che legge il prompt (già caricato, niente da scaricare): più lento e può ammorbidire prompt espliciti'} - -
- salva({ traduttore })} voci={[{ id: 'dedicato', nome: 'Traduttore' }, { id: 'llm', nome: 'Qwen3-VL' }]} /> -
- )} -
-
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' }]} /> -
-
-
Accelerazione fp16--fast fp16_accumulation: qualche secondo in meno sulle RTX, qualità quasi uguale
- salva({ veloce })}>{imp.veloce ? 'Accesa' : 'Spenta'} -
-
-
- 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: scegli Klein veloce (8 passi), - profilo 6 GB se la scheda è piccola, e chiudi browser e giochi che usano la GPU. -
-
-
-
Cache KV di Qwen 2.1Riusa il prompt fra un passo e l'altro. Auto = VRAM libera, poi RAM.
- salva({ cacheKV })} voci={[{ id: 'auto', nome: 'Auto' }, { id: 'gpu', nome: 'GPU' }, { id: 'cpu', nome: 'RAM' }, { id: 'off', nome: 'Spenta' }]} /> -
-
-
Precisione della cacheint8 dimezza la memoria quasi senza perdite (utile con molte foto di riferimento)
- salva({ cacheTipo })} voci={[{ id: 'default', nome: 'Piena' }, { id: 'int8', nome: 'int8' }, { id: 'int4', nome: 'int4' }]} /> -
-
-
Text encoder sul processoreLascia tutta la VRAM al disegno; il prompt si legge più lentamente
- salva({ encoderSuCpu })}>{imp.encoderSuCpu ? 'Sul processore' : 'Automatico (GPU quando c\'è posto)'} -
-
-
Anteprima dal vivoVedi l'immagine nascere passo per passo
- salva({ anteprimaLive })}>{imp.anteprimaLive ? 'Accesa' : 'Spenta'} -
-
-
VRAM da lasciare liberaGB per Windows e gli altri programmi (0 = decide il motore)
- salva({ riservaVram: Number(e.target.value) || 0 })} /> -
-
-
Argomenti extra del motorePer esperti (es. --use-sage-attention, --fast)
- usaStato.setState({ imp: { ...imp, argomentiExtra: e.target.value } })} onBlur={(e) => salva({ argomentiExtra: e.target.value })} /> +
Libera la scheda video dopoSe non fai niente per un po'
+ salva({ scaricaDopo })} voci={[{ id: 0, nome: 'Mai' }, { id: 60, nome: '1 min' }, { id: 300, nome: '5 min' }, { id: 900, nome: '15 min' }]} />
-

alterna('VALORI DI PARTENZA')}>{chiusi['VALORI DI PARTENZA'] ? '▸' : '▾'} VALORI DI PARTENZA

-
+

CARTELLE

+
-
Passi e campionatoreLa pipeline ufficiale: 40 passi, euler, simple, CFG 1
-
- salva({ predefiniti: { ...imp.predefiniti, passi: Number(e.target.value) || 40 } })} /> - passi (per "Alta") - -
-
-
-
- -
-

alterna('CARTELLE')}>{chiusi['CARTELLE'] ? '▸' : '▾'} CARTELLE

-
-
-
GalleriaDove finiscono le immagini
+
Immagini
@@ -312,7 +185,7 @@ function OpzioniGenerali(): JSX.Element {
-
ModelliDentro: diffusion_models, text_encoders, vae, loras, upscale_models
+
Modelli
@@ -322,40 +195,6 @@ function OpzioniGenerali(): JSX.Element {
-
-

alterna('ACCOUNT (FACOLTATIVI)')}>{chiusi['ACCOUNT (FACOLTATIVI)'] ? '▸' : '▾'} ACCOUNT (FACOLTATIVI)

-
-
-
Token CivitaiPer scaricare i LoRA che chiedono l'accesso
- usaStato.setState({ imp: { ...imp, tokenCivitai: e.target.value } })} onBlur={(e) => salva({ tokenCivitai: e.target.value.trim() })} /> -
-
-
Token Hugging FacePer i repository privati o con accesso
- usaStato.setState({ imp: { ...imp, tokenHF: e.target.value } })} onBlur={(e) => salva({ tokenHF: e.target.value.trim() })} /> -
-
-
- -
-

MOTORE

-
-
- {motore.stato} - {motore.gpu && {motore.gpu}} - {motore.vramTotale ? VRAM {byte(motore.vramTotale - (motore.vramLibera || 0))} / {byte(motore.vramTotale)} ({Math.round(vramUsata * 100)}%) : null} - {motore.versione && ComfyUI {motore.versione} · porta {motore.porta}} - - - - {motore.stato === 'pronto' ? : } - - -
- {motore.messaggio && motore.stato === 'errore' &&
{motore.messaggio}
} -
{log.join('\n')}
-
-
-

AGGIORNAMENTI

@@ -363,7 +202,7 @@ function OpzioniGenerali(): JSX.Element { Versione {versione} {{ - nessuno: "Controllo automatico all'avvio e ogni 6 ore.", + nessuno: 'Controllo automatico.', sviluppo: 'In sviluppo gli aggiornamenti sono spenti.', controllo: 'Controllo…', aggiornato: "È l'ultima versione.", @@ -380,23 +219,172 @@ function OpzioniGenerali(): JSX.Element { Controlla ora )} -
{agg.stato === 'scarico' &&
} - {agg.note && (agg.stato === 'pronto' || agg.stato === 'scarico') && ( -
{agg.note}
- )} -
- L'app si aggiorna dalle release di GitHub (scarica solo i pezzi cambiati). Motore, modelli, galleria e LoRA restano dove sono; se una versione nuova cambia il motore, lo aggiorna da sola al primo avvio. -
+
{ if ((e.currentTarget as HTMLDetailsElement).open !== !!aperti.AVANZATE) alterna('AVANZATE') }}> + Avanzate file dei modelli, memoria, account, log + + + + {gruppo('FILE DEI MODELLI', ( + <> + {voceScelta('Qwen · modello', '', imp.modello, file.diffusione, (modello) => salva({ modello }))} + {voceScelta('Qwen · text encoder', '', imp.encoder, file.encoder, (encoder) => salva({ encoder }))} + {voceScelta('Qwen · VAE', '', imp.vae, file.vae, (vae) => salva({ vae }))} + {voceScelta('Klein', 'Vuoto = lo trova da solo', imp.kleinModello, ['', ...file.klein.filter((n) => !/base/i.test(n))], (kleinModello) => salva({ kleinModello }))} + {voceScelta('Klein Base', 'Vuoto = lo trova da solo', imp.kleinBase, ['', ...file.klein.filter((n) => /base/i.test(n))], (kleinBase) => salva({ kleinBase }))} + {voceScelta('Klein · text encoder', '', imp.kleinEncoder, ['', ...file.kleinTe], (kleinEncoder) => salva({ kleinEncoder }))} + {voceScelta('Klein · VAE', '', imp.kleinVae, ['', ...file.kleinVae], (kleinVae) => salva({ kleinVae }))} +
+
IngranditorePer «2×», «Rifinisci» e le misure grandi
+ salva({ upscalerRtx: v === 'rtx' })} voci={[{ id: 'rtx', nome: 'NVIDIA RTX', sotto: 'consigliato' }, { id: 'modello', nome: 'A modello', sotto: '4x-UltraSharp' }]} /> +
+ {!imp.upscalerRtx && voceScelta('Ingranditore a modello', '', imp.upscaler, file.upscaler, (upscaler) => salva({ upscaler }))} + + ))} + + {gruppo('SCARICA ALTRI MODELLI', ( + <> + {GRUPPI_MODELLI.map(([tipo, titolo]) => { + const voci = catalogo.filter((v) => v.tipo === tipo && !v.uncensored) + if (!voci.length) return null + return ( +
+ {titolo} · {voci.filter((x) => x.presente).length}/{voci.length} presenti + {voci.map((v) => { + const d = download[v.nome] + const vaGiu = d && (d.stato === 'in corso' || d.stato === 'verifica') + return ( +
+
+ {v.nome} {(v.file === imp.modello || v.file === imp.encoder) && v.file && in uso} {v.consigliato && consigliato} · {v.circa ? 'circa ' : ''}{byte(v.byte)} +
+
+ {v.presente ? ( + ✓ presente + ) : vaGiu ? ( + <> + {d.totali ? Math.round((d.ricevuti / d.totali) * 100) : 0}% · {(d.velocita / 1e6).toFixed(0)} MB/s + + + ) : ( + + )} +
+ {vaGiu &&
} +
+ ) + })} +
+ ) + })} + + ))} + + {gruppo('FILE INUTILIZZATI', ( + <> + {inutili.length === 0 &&
Niente da togliere: ogni file è in uso.
} + {inutili.map((f) => ( +
+
{f.nome} · {f.cartella} · {byte(f.byte)} · {f.motivo}
+ +
+ ))} + {inutili.length > 0 &&
In tutto {byte(inutili.reduce((a, f) => a + f.byte, 0))} (vanno nel Cestino, si recuperano)
} + + ))} + + {gruppo('MEMORIA E VELOCITÀ', ( + <> + {voceInterruttore('Scarica da solo quello che manca', 'Quando l\'app è ferma, i file consigliati (il traduttore…)', imp.scaricaInIdle, (scaricaInIdle) => salva({ scaricaInIdle }))} + {imp.traduci && ( +
+
Chi traduce{imp.traduttore === 'dedicato' ? 'Un modello solo per tradurre (Opus-MT), sul processore' : 'Lo stesso Qwen3-VL: più lento, può ammorbidire'}
+ salva({ traduttore })} voci={[{ id: 'dedicato', nome: 'Traduttore' }, { id: 'llm', nome: 'Qwen3-VL' }]} /> +
+ )} + {voceInterruttore('Una cosa alla volta in VRAM', 'Scarica il modello che non serve: evita di finire nella RAM', imp.unaCosaAllaVolta, (unaCosaAllaVolta) => salva({ unaCosaAllaVolta }))} + {voceInterruttore('Testi col Qwen3-VL 4B anche con Qwen', 'Migliora prompt e Idee molto più veloci; se manca resta l\'8B', imp.testoCon4B ?? true, (testoCon4B) => salva({ testoCon4B }))} + {voceInterruttore('Accelerazione fp16', 'Qualche secondo in meno sulle RTX', imp.veloce, (veloce) => salva({ veloce }))} +
+
Cache KV di QwenAuto = VRAM libera, poi RAM
+ salva({ cacheKV })} voci={[{ id: 'auto', nome: 'Auto' }, { id: 'gpu', nome: 'GPU' }, { id: 'cpu', nome: 'RAM' }, { id: 'off', nome: 'Spenta' }]} /> +
+
+
Precisione della cacheint8 dimezza la memoria
+ salva({ cacheTipo })} voci={[{ id: 'default', nome: 'Piena' }, { id: 'int8', nome: 'int8' }, { id: 'int4', nome: 'int4' }]} /> +
+ {voceInterruttore('Text encoder sul processore', 'Lascia la VRAM al disegno; il prompt si legge più piano', imp.encoderSuCpu, (encoderSuCpu) => salva({ encoderSuCpu }))} +
+
VRAM da lasciare liberaGB per Windows (0 = decide il motore)
+ salva({ riservaVram: Number(e.target.value) || 0 })} /> +
+
+
Argomenti extra del motorePer esperti
+ usaStato.setState({ imp: { ...imp, argomentiExtra: e.target.value } })} onBlur={(e) => salva({ argomentiExtra: e.target.value })} /> +
+
+
+ Una foto ci mette minuti? Nel Pannello di controllo NVIDIA → Impostazioni 3D → globali, metti Criterio di fallback della memoria di sistema CUDA su Preferisci nessun fallback. +
+
+ + ))} + + {gruppo('VALORI DI PARTENZA DI QWEN', ( +
+
PassiUfficiali: 40, euler, simple, CFG 1
+
+ salva({ predefiniti: { ...imp.predefiniti, passi: Number(e.target.value) || 40 } })} /> + +
+
+ ))} + + {gruppo('ACCOUNT (FACOLTATIVI)', ( + <> +
+
Token CivitaiPer i LoRA che chiedono l'accesso
+ usaStato.setState({ imp: { ...imp, tokenCivitai: e.target.value } })} onBlur={(e) => salva({ tokenCivitai: e.target.value.trim() })} /> +
+
+
Token Hugging FacePer Klein e i repository con accesso
+ usaStato.setState({ imp: { ...imp, tokenHF: e.target.value } })} onBlur={(e) => salva({ tokenHF: e.target.value.trim() })} /> +
+ + ))} + + {gruppo('LOG DEL MOTORE', ( +
+
+ {motore.versione && ComfyUI {motore.versione} · porta {motore.porta}} + + {motore.stato === 'pronto' ? : } + + + +
+
{log.join('\n')}
+
+ ))} +
+
-

DAPROD IMAGE

- Qwen-Image 2.1 (Qwen Research License) · ComfyUI (GPL-3.0) · ComfyUI-GGUF (Apache-2.0) + DaProd Image · Qwen-Image 2.1 (Qwen Research License) · FLUX 3 Klein (FLUX Kommunity) · ComfyUI (GPL-3.0)
diff --git a/src/renderer/src/pagine/Modifica.tsx b/src/renderer/src/pagine/Modifica.tsx index b14d566..d840bc4 100644 --- a/src/renderer/src/pagine/Modifica.tsx +++ b/src/renderer/src/pagine/Modifica.tsx @@ -42,7 +42,8 @@ export function Modifica(): JSX.Element { setIdee([]) setCameraRapida(null) } - const [categoria, setCategoria] = useState('persone') + // nessuna categoria di Idee aperta all'inizio: si apre con un clic e si richiude con un altro + const [categoria, setCategoria] = useState('') const [camera, setCamera] = useState(CAMERA0) const [scrivendo, setScrivendo] = useState(false) // Fusion: i risultati (foto nuove) e il lavoro che li sta creando @@ -83,7 +84,7 @@ export function Modifica(): JSX.Element { return zona || mis.tipo === 'originale' ? { w: rif.larghezza, h: rif.altezza } : dimMp(m.mp, rif.larghezza, rif.altezza) })() const categorie = categoriePer(zona ? 'zona' : 'tutta') - const cat = categorie.find((c) => c.id === categoria) || categorie[0] + const cat = categorie.find((c) => c.id === categoria) const loraCamera = !!fileLoraCamera() && lore.length >= 0 // l'anteprima dal vivo si posa sulla foto (se il lavoro è su questa foto): la zona si vede nascere al suo posto @@ -130,7 +131,7 @@ export function Modifica(): JSX.Element { }, [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') + if (!categoriePer(zona ? 'zona' : 'tutta').some((c) => c.id === categoria)) setCategoria('') }, [zona]) // Fusion ed Estrai non hanno la tela: la zona disegnata si perde, e con lei il segno useEffect(() => { @@ -516,7 +517,7 @@ export function Modifica(): JSX.Element { rapida={rapida} categorie={categorie} cat={cat} - setCategoria={setCategoria} + setCategoria={(id) => setCategoria(categoria === id ? '' : id)} idee={idee} setIdee={setIdee} usaRapida={usaRapida} diff --git a/src/renderer/src/pagine/modifica/SchedaIdee.tsx b/src/renderer/src/pagine/modifica/SchedaIdee.tsx index 869bdd3..6d58eac 100644 --- a/src/renderer/src/pagine/modifica/SchedaIdee.tsx +++ b/src/renderer/src/pagine/modifica/SchedaIdee.tsx @@ -1,6 +1,6 @@ // Modifica: le Idee come griglia di tessere con icona (nessun chip di testo). Le categorie sono tessere; aprendone una compaiono le sue azioni, // anch'esse tessere (un'icona e una parola, il nome lungo nel tooltip). Le scelte si sommano e il modello le mescola. -// «Estrai» è la tessera grande in cima; la categoria Stili ha gli scaffali dei 28 stili con l'intensità; il comando Camera resta in Prospettiva. +// (Estrai ed Espandi non sono più qui: stanno già nelle schede sopra la tela.) La categoria Stili ha gli scaffali dei 28 stili con l'intensità; il comando Camera resta in Prospettiva. import { type JSX } from 'react' import { usaStato } from '../../stato' import { I } from '../../componenti/Icone' @@ -42,10 +42,6 @@ export function SchedaIdee({ griglia, zona, categorie, cat, setCategoria, idee, return (

Idee sommale: il modello le mescola

- - } asset={chiaveModoModifica('estrai')} nome="Estrai" sotto="persone e oggetti, un clic" titolo="Estrai una persona o un oggetto dalla foto: un clic sulla cosa, poi scegli l'uscita" onClick={() => setModifica({ modo: 'estrai' })} /> - } asset={chiaveModoModifica('espandi')} nome="Espandi" sotto="allarga oltre i bordi" titolo="Allarga la foto oltre i bordi" onClick={() => setModifica({ modo: 'espandi' })} /> - {categorie.map((c) => { const Ic = (I as Record JSX.Element>)[c.icona] || I.crea diff --git a/src/shared/loraBase.ts b/src/shared/loraBase.ts index ad01c24..10a4409 100644 --- a/src/shared/loraBase.ts +++ b/src/shared/loraBase.ts @@ -2,6 +2,7 @@ // Qwen-Image 2.1 transformer_blocks.N.attn.to_q / img_mlp (flusso singolo) // Qwen-Image 1.x txt_mlp, add_q_proj… (due flussi) // FLUX 3 Klein single_blocks.N.(qkv|q_proj|attn_out|mlp_in|mlp_out), content_mode_blocks, txt_mode_blocks (ComfyUI li mappa: comfy/lora.py, ramo Flux3) +// e il formato Diffusers/SimpleTuner `transformer.mode_blocks…` (`formato: 'diffusers'`: si converte importandolo, `loraConverti.ts`) // FLUX.1 / FLUX.2 (Klein 4B compreso, tolto dall'app nella 1.2.1) / SD / SDXL: non compatibili con nessuno dei modelli dell'app import type { Famiglia, GruppoLora, InfoLora } from './tipi' @@ -10,15 +11,24 @@ export interface AnalisiLora { base?: string /** per quali famiglie dell'app è buono (vuoto = nessuna / non si capisce) */ famiglie: Famiglia[] + /** 'diffusers' = nomi dei tensori alla Diffusers/SimpleTuner (`transformer.mode_blocks…`): ComfyUI non li conosce, l'app li converte quando li importa */ + formato?: 'diffusers' } const SEP = '[._]' const blocco = (nome: string, resto: string): RegExp => new RegExp(`${nome}${SEP}\\d+${SEP}(?:${resto})`) +/** un LoRA di FLUX 3 Klein salvato da Diffusers o SimpleTuner: `transformer.mode_blocks.image.N.attn.to_q`, `transformer.txt_mode_blocks`, + * `transformer.single_transformer_blocks.N.ff.linear_in` (FLUX.2 ha invece `to_qkv_mlp_proj` nei blocchi singoli e `transformer_blocks`) */ +export const eKleinDiffusers = (tutte: string): boolean => + /(?:^|\n)transformer\.(?:mode_blocks\.|txt_mode_blocks\.)/.test(tutte) || + (/(?:^|\n)transformer\.single_transformer_blocks\.\d+\.(?:attn\.to_[qkv]\.|ff\.linear_(?:in|out)\.)/.test(tutte) && !/to_qkv_mlp_proj|transformer_blocks\.\d+\.ff_context/.test(tutte)) + /** `chiavi`: i nomi dei tensori; `forme`: (facoltativo, oggi non serve più: serviva a distinguere FLUX.2 4B da 9B) */ export function analizzaChiavi(chiavi: string[], _forme?: Record): AnalisiLora { const tutte = chiavi.join('\n') if (!chiavi.length) return { compatibile: '?', famiglie: [] } + if (eKleinDiffusers(tutte)) return { compatibile: 'si', base: 'FLUX 3 Klein (Diffusers)', famiglie: ['klein', 'klein-base'], formato: 'diffusers' } if (/txt_mlp|add_q_proj|txt_mod|img_mod\./.test(tutte) && !/double_blocks/.test(tutte)) return { compatibile: 'no', base: 'Qwen-Image 1.x', famiglie: [] } // FLUX 3 Klein: i blocchi «modali» sono solo suoi; `single_blocks` con qkv/attn_out/mlp_in/mlp_out pure (FLUX.1 e FLUX.2 usano linear1/linear2) if (/(content_mode_blocks|txt_mode_blocks)/.test(tutte) || blocco('single_blocks', 'qkv|q_proj|k_proj|v_proj|attn_out|mlp_in|mlp_out').test(tutte)) { @@ -31,7 +41,7 @@ export function analizzaChiavi(chiavi: string[], _forme?: Record= 0 && maxDoppio <= 4 ? 'FLUX.2' : 'FLUX.1 / FLUX.2 grande', famiglie: [] } } if (/lora_unet_|down_blocks|input_blocks|lora_te/.test(tutte)) return { compatibile: 'no', base: 'Stable Diffusion / SDXL', famiglie: [] } - if (/transformer_blocks\.\d+\.(attn\.to_q|img_mlp)/.test(tutte)) return { compatibile: 'si', base: 'Qwen-Image 2.1', famiglie: ['qwen'] } + if (/(?:^|[^a-z_])transformer_blocks\.\d+\.(attn\.to_q|img_mlp)/.test(tutte)) return { compatibile: 'si', base: 'Qwen-Image 2.1', famiglie: ['qwen'] } return { compatibile: '?', famiglie: [] } } diff --git a/src/shared/loraConverti.ts b/src/shared/loraConverti.ts new file mode 100644 index 0000000..f7e142d --- /dev/null +++ b/src/shared/loraConverti.ts @@ -0,0 +1,53 @@ +// Dai nomi dei tensori di un LoRA di FLUX 3 Klein fatto con Diffusers/SimpleTuner (`transformer.mode_blocks.image.0.attn.to_q.lora_A.weight`) +// ai nomi che ComfyUI conosce (`diffusion_model.content_mode_blocks.image.0.q_proj.lora_A.weight`, quelli di AI Toolkit). File puro: lo prova `node test/lora.mjs`. +// La mappa è ricavata confrontando LoRA veri dello stesso modello nei due formati (Raume/alphajace-klein-lora, convertito da Diffusers, e gli ausboss di AI Toolkit). +// +// transformer.mode_blocks..N.attn.to_q|to_k|to_v → diffusion_model.content_mode_blocks..N.q_proj|k_proj|v_proj +// transformer.txt_mode_blocks.N... → diffusion_model.txt_mode_blocks.N... +// transformer.single_transformer_blocks.N... → diffusion_model.single_blocks.N... +// ...attn.to_out.0 → attn_out · ff.linear_in → mlp_in · ff.linear_out → mlp_out +// ...modulations..linear → ...modulations..lin + +const SUFFISSI = ['lora_A.weight', 'lora_B.weight', 'lora_A.default.weight', 'lora_B.default.weight'] + +const DENTRO: [RegExp, string][] = [ + [/\.attn\.to_q$/, '.q_proj'], + [/\.attn\.to_k$/, '.k_proj'], + [/\.attn\.to_v$/, '.v_proj'], + [/\.attn\.to_out\.0$/, '.attn_out'], + [/\.ff\.linear_in$/, '.mlp_in'], + [/\.ff\.linear_out$/, '.mlp_out'] +] + +/** il nome del modulo (senza `.lora_A.weight`) nel formato di ComfyUI; null se non si sa cosa farne */ +export function moduloComfy(modulo: string): string | null { + let m = modulo.replace(/^transformer\./, '') + if (/^mode_blocks\./.test(m)) m = 'content_' + m + else if (/^single_transformer_blocks\./.test(m)) m = m.replace(/^single_transformer_blocks\./, 'single_blocks.') + else if (!/^(txt_mode_blocks|single_blocks|content_mode_blocks)\./.test(m) && !/modulations\.|^(time_in|ref_time_in|txt_in|emb_in|final_layer)\b/.test(m)) return null + for (const [da, a] of DENTRO) if (da.test(m)) return 'diffusion_model.' + m.replace(da, a) + m = m.replace(/(modulations\.[a-z_]+)\.linear$/, '$1.lin') + return 'diffusion_model.' + m +} + +/** la nuova chiave di un tensore (`transformer.….lora_A.weight` → `diffusion_model.….lora_A.weight`); null = non è un peso di LoRA che si sa portare */ +export function chiaveComfy(chiave: string): string | null { + for (const s of SUFFISSI) { + if (!chiave.endsWith('.' + s)) continue + const m = moduloComfy(chiave.slice(0, -(s.length + 1))) + return m ? `${m}.${s.replace('.default', '')}` : null + } + return null +} + +/** rango e alpha scritti da PEFT nell'intestazione (`lora_adapter_metadata`): con alpha ≠ rango ComfyUI vuole un tensore `.alpha` per modulo */ +export function rangoEAlpha(meta: Record | undefined): { r: number; alpha: number } | null { + try { + const m = JSON.parse(meta?.lora_adapter_metadata || '') as Record + const r = Number(m['transformer.r'] ?? m.r) + const alpha = Number(m['transformer.lora_alpha'] ?? m.lora_alpha) + return r > 0 && alpha > 0 ? { r, alpha } : null + } catch { + return null + } +} diff --git a/src/shared/modelli.ts b/src/shared/modelli.ts index 7ec33b4..808323b 100644 --- a/src/shared/modelli.ts +++ b/src/shared/modelli.ts @@ -1,6 +1,6 @@ // 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 = FLUX 3 Klein distillato, 16 passi senza CFG (quelli indicati da BFL; fino alla 1.2.1 erano 8): 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. Dalla 1.2.1 niente FLUX.2 Klein 4B: sono rimasti Qwen e FLUX 3 Klein. // @@ -30,7 +30,7 @@ export interface InfoModello { export const MODELLI: Record = { 'qwen-base': { id: 'qwen-base', famiglia: 'qwen', turbo: false, passi: 40, cfg: null, nome: 'Qwen-Image 2.1', corto: 'Qwen', sotto: '40 passi · LoRA', lora: true, veloce: false }, - klein: { id: 'klein', famiglia: 'klein', turbo: false, passi: 8, cfg: 1, nome: 'FLUX 3 Klein', corto: 'Klein veloce', sotto: '8 passi · senza CFG · il più veloce', lora: true, veloce: true }, + klein: { id: 'klein', famiglia: 'klein', turbo: false, passi: 16, cfg: 1, nome: 'FLUX 3 Klein', corto: 'Klein veloce', sotto: '16 passi · senza CFG · il più veloce', lora: true, veloce: true }, 'klein-base': { id: 'klein-base', famiglia: 'klein-base', turbo: false, passi: 50, cfg: 3.5, nome: 'FLUX 3 Klein Base', corto: 'Klein Base', sotto: '50 passi · CFG 3,5 · la qualità piena', lora: true, veloce: false } } diff --git a/src/shared/tipi.ts b/src/shared/tipi.ts index 98434b1..5903401 100644 --- a/src/shared/tipi.ts +++ b/src/shared/tipi.ts @@ -86,7 +86,7 @@ const famiglia = (id: IdModello, bozza: number, massima: number): { nome: string } export const FAMIGLIE: Record> = { qwen: famiglia('qwen-base', 20, 50), - klein: famiglia('klein', 8, 16), + klein: famiglia('klein', 8, 24), 'klein-base': famiglia('klein-base', 25, 64) } diff --git a/test/asset-ui.mjs b/test/asset-ui.mjs index bf49cff..c96b572 100644 --- a/test/asset-ui.mjs +++ b/test/asset-ui.mjs @@ -33,7 +33,9 @@ const aspetta = async (sel, n) => { return caricate(sel) } -// Crea: 11 generi + Libero con l'icona 3D; lo stato vuoto con la mascotte e il pianeta +// Crea: 11 generi + Libero con l'icona 3D (dentro «Genere», chiuso di serie dalla 1.3); lo stato vuoto con la mascotte e il pianeta +await pg.click('.studio-corpo h3.chiudibile:has-text("Genere"):visible') +await pg.click('.studio-corpo h3.chiudibile:has-text("Stile"):visible') let c = await aspetta('.tessera-o .ic-img', 12) console.log('Crea, generi:', c) assert.ok(c.tot >= 12 && c.ok === c.tot, 'le 12 tessere dei generi hanno l\'immagine') @@ -44,7 +46,7 @@ assert.equal(c.ok, 1, 'pianeta nello stato vuoto') assert.equal((await caricate('.strip-modi .ic-modo')).ok, 4, 'i 4 modi nella strip') await pg.screenshot({ path: `${out}/a1-crea.png` }) -// le anteprime degli stili in Crea (sezione Stile, aperta di serie): a tutta tessera +// le anteprime degli stili in Crea (sezione Stile, aperta qui sopra): a tutta tessera c = await aspetta('.tessera-o.foto .pieno', 28) console.log('Crea, stili:', c) assert.equal(c.ok, 28, 'le 28 anteprime degli stili in Crea') @@ -56,10 +58,11 @@ await pg.click('.strip-modi button[data-modo="modifica"]') await pg.click('.attrezzi button:has-text("Dal PC")') await pg.waitForTimeout(800) c = await aspetta('[data-id^="cat-"] .ic-img', 8) -console.log('Modifica, categorie + estrai/espandi:', c) -assert.ok(c.tot >= 10 && c.ok === c.tot, 'le 8 categorie e Estrai/Espandi hanno l\'immagine') +console.log('Modifica, categorie:', c) +assert.ok(c.tot >= 8 && c.ok === c.tot, 'le 8 categorie hanno l\'immagine') for (const [cat, prefisso, n] of [['persone', 'ctl-animale-', 5], ['persone', 'ctl-espressione-', 6], ['vestiti', 'ctl-costume-', 9], ['situazioni', 'ctl-luogo-', 10], ['situazioni', 'ctl-meteo-', 6], ['situazioni', 'ctl-stagione-', 4]]) { - await pg.click(`button[data-id="cat-${cat}"]`) + // dalla 1.3 una categoria si apre e si richiude con lo stesso clic: si clicca solo se non è già aperta + if (!(await pg.locator(`button[data-id="cat-${cat}"]`).first().evaluate((e) => e.classList.contains('su')))) await pg.click(`button[data-id="cat-${cat}"]`) await pg.waitForTimeout(400) const k = await aspetta(`[data-id^="${prefisso}"] .ic-img`, n) console.log(` ${prefisso}`, k) diff --git a/test/lora-converti.mjs b/test/lora-converti.mjs new file mode 100644 index 0000000..6aa4101 --- /dev/null +++ b/test/lora-converti.mjs @@ -0,0 +1,105 @@ +// Conversione dei LoRA di FLUX 3 Klein dal formato Diffusers/SimpleTuner a quello di ComfyUI (src/shared/loraConverti.ts + src/main/loraConverti.ts). +// Niente Electron né motore: si crea un .safetensors piccolo con i nomi veri di un LoRA Diffusers, lo si converte e si guarda che +// - i nomi siano quelli che ComfyUI conosce (la mappa viene dal confronto di LoRA veri nei due formati) +// - i pesi non cambino di un byte, e con alpha ≠ rango compaia un tensore `.alpha` per modulo +// node test/lora-converti.mjs +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' + +const require = createRequire(import.meta.url) +buildSync({ + stdin: { contents: "export * from './src/main/loraConverti'; export * from './src/shared/loraConverti'", resolveDir: process.cwd() }, + bundle: true, platform: 'node', format: 'cjs', outfile: 'test/.out/lora-converti.cjs', alias: { '@shared': resolve('src/shared') } +}) +const C = require(resolve('test/.out/lora-converti.cjs')) + +// la mappa dei nomi +const M = (k) => C.chiaveComfy(k) +assert.equal(M('transformer.mode_blocks.image.3.attn.to_q.lora_A.weight'), 'diffusion_model.content_mode_blocks.image.3.q_proj.lora_A.weight') +assert.equal(M('transformer.mode_blocks.image_cond.0.attn.to_out.0.lora_B.weight'), 'diffusion_model.content_mode_blocks.image_cond.0.attn_out.lora_B.weight') +assert.equal(M('transformer.txt_mode_blocks.4.ff.linear_in.lora_A.weight'), 'diffusion_model.txt_mode_blocks.4.mlp_in.lora_A.weight') +assert.equal(M('transformer.single_transformer_blocks.27.ff.linear_out.lora_B.weight'), 'diffusion_model.single_blocks.27.mlp_out.lora_B.weight') +assert.equal(M('transformer.single_transformer_blocks.2.attn.to_v.lora_A.weight'), 'diffusion_model.single_blocks.2.v_proj.lora_A.weight') +assert.equal(M('transformer.early_stream_modulations.image_cond.linear.lora_A.weight'), 'diffusion_model.early_stream_modulations.image_cond.lin.lora_A.weight') +assert.equal(M('transformer.mode_blocks.image.0.attn.to_k.lora_A.default.weight'), 'diffusion_model.content_mode_blocks.image.0.k_proj.lora_A.weight') +assert.equal(M('transformer.qualcosa_di_strano.lora_A.weight'), null) +assert.equal(M('transformer.mode_blocks.image.0.attn.to_k.alpha'), null) +assert.deepEqual(C.rangoEAlpha({ lora_adapter_metadata: '{"transformer.r": 16, "transformer.lora_alpha": 32}' }), { r: 16, alpha: 32 }) +assert.equal(C.rangoEAlpha({}), null) + +// un file vero in piccolo: due moduli, pesi riconoscibili byte per byte +function crea(percorso, chiavi, meta) { + const h = { __metadata__: meta } + let pos = 0 + const dati = [] + for (const k of chiavi) { + const b = Buffer.alloc(8) + b.fill(dati.length + 1) + h[k] = { dtype: 'BF16', shape: [2, 2], data_offsets: [pos, pos + 8] } + pos += 8 + dati.push(b) + } + let t = JSON.stringify(h) + t += ' '.repeat((8 - (Buffer.byteLength(t) % 8)) % 8) + const l = Buffer.alloc(8) + l.writeBigUInt64LE(BigInt(Buffer.byteLength(t))) + writeFileSync(percorso, Buffer.concat([l, Buffer.from(t), ...dati])) + return dati +} +function leggi(percorso) { + const b = readFileSync(percorso) + const n = Number(b.readBigUInt64LE(0)) + const h = JSON.parse(b.subarray(8, 8 + n).toString()) + return { h, dati: b.subarray(8 + n) } +} + +const tmp = mkdtempSync(join(tmpdir(), 'daprod-conv-')) +const chiavi = [ + 'transformer.mode_blocks.image.0.attn.to_q.lora_A.weight', + 'transformer.mode_blocks.image.0.attn.to_q.lora_B.weight', + 'transformer.single_transformer_blocks.1.ff.linear_in.lora_A.weight', + 'transformer.single_transformer_blocks.1.ff.linear_in.lora_B.weight' +] +const orig = join(tmp, 'a.safetensors') +const dati = crea(orig, chiavi, { format: 'pt', lora_adapter_metadata: '{"transformer.r": 16, "transformer.lora_alpha": 16}' }) +const r = C.convertiSulPosto(orig) +assert.deepEqual(r, { rinominati: 4, scartati: 0 }) +assert.ok(existsSync(orig + '.diffusers.bak'), "l'originale resta accanto") +const c = leggi(orig) +const nomi = Object.keys(c.h).filter((k) => k !== '__metadata__') +assert.deepEqual(nomi.sort(), [ + 'diffusion_model.content_mode_blocks.image.0.q_proj.lora_A.weight', + 'diffusion_model.content_mode_blocks.image.0.q_proj.lora_B.weight', + 'diffusion_model.single_blocks.1.mlp_in.lora_A.weight', + 'diffusion_model.single_blocks.1.mlp_in.lora_B.weight' +]) +// i pesi sono quelli di prima, byte per byte +chiavi.forEach((k, i) => { + const t = c.h[C.chiaveComfy(k)] + assert.deepEqual(c.dati.subarray(t.data_offsets[0], t.data_offsets[1]), dati[i], k) +}) +assert.equal(c.h.__metadata__.ss_base_model_version, 'flux3') + +// alpha ≠ rango: un tensore alpha per modulo, F32 scalare, col valore giusto +const b = join(tmp, 'b.safetensors') +crea(b, chiavi, { lora_adapter_metadata: '{"transformer.r": 16, "transformer.lora_alpha": 8}' }) +C.convertiSulPosto(b) +const cb = leggi(b) +const alphas = Object.keys(cb.h).filter((k) => k.endsWith('.alpha')) +assert.deepEqual(alphas.sort(), ['diffusion_model.content_mode_blocks.image.0.q_proj.alpha', 'diffusion_model.single_blocks.1.mlp_in.alpha']) +for (const k of alphas) { + assert.deepEqual(cb.h[k].shape, []) + assert.equal(cb.h[k].dtype, 'F32') + assert.equal(cb.dati.readFloatLE(cb.h[k].data_offsets[0]), 8) +} + +// un file che non è un LoRA di Klein: errore chiaro e il file resta com'è +const x = join(tmp, 'x.safetensors') +crea(x, ['foo.bar.lora_A.weight'], {}) +assert.throws(() => C.convertiSulPosto(x), /nessun tensore riconosciuto/) +assert.ok(!existsSync(x + '.nuovo') && !existsSync(x + '.diffusers.bak'), 'niente file a metà') +console.log('TUTTI OK (conversione dei LoRA Diffusers → ComfyUI)') diff --git a/test/lora.mjs b/test/lora.mjs index f8dc794..aabbb9b 100644 --- a/test/lora.mjs +++ b/test/lora.mjs @@ -27,6 +27,11 @@ uguale('Qwen 2.1', ris(['transformer_blocks.3.attn.to_q.lora_A.weight', 'transfo uguale('Qwen 1.x', ris(['transformer_blocks.0.txt_mlp.net.0.lora_A.weight']), ['no', 'Qwen-Image 1.x', []]) uguale('SDXL', ris(['lora_unet_down_blocks_0_attentions_0.lora_down.weight']), ['no', 'Stable Diffusion / SDXL', []]) uguale('Klein 3 (single_blocks qkv)', ris(['diffusion_model.single_blocks.2.qkv.lora_A.weight']), ['si', 'FLUX 3 Klein', ['klein', 'klein-base']]) +uguale('Klein 3 Diffusers (mode_blocks)', ris(['transformer.mode_blocks.image.0.attn.to_q.lora_A.weight', 'transformer.mode_blocks.image.0.ff.linear_in.lora_B.weight']), ['si', 'FLUX 3 Klein (Diffusers)', ['klein', 'klein-base']]) +uguale('Klein 3 Diffusers: formato', L.analizzaChiavi(['transformer.txt_mode_blocks.1.attn.to_out.0.lora_A.weight']).formato, 'diffusers') +uguale('Klein 3 AI Toolkit: formato', L.analizzaChiavi(['diffusion_model.single_blocks.2.q_proj.lora_A.weight', 'diffusion_model.content_mode_blocks.image.0.q_proj.lora_A.weight']).formato, undefined) +uguale('Klein 3 Diffusers solo blocchi singoli', ris(['transformer.single_transformer_blocks.3.attn.to_q.lora_A.weight', 'transformer.single_transformer_blocks.3.ff.linear_in.lora_B.weight']), ['si', 'FLUX 3 Klein (Diffusers)', ['klein', 'klein-base']]) +uguale('FLUX.2 Diffusers (to_qkv_mlp_proj) non è Klein 3', ris(['transformer.single_transformer_blocks.3.attn.to_qkv_mlp_proj.lora_A.weight', 'transformer.transformer_blocks.0.ff.linear_in.lora_A.weight'])[0], '?') uguale('Klein 3 (content_mode_blocks)', ris(['diffusion_model.content_mode_blocks.0.attn.lora_A.weight']), ['si', 'FLUX 3 Klein', ['klein', 'klein-base']]) const doppi = (n) => Array.from({ length: n }, (_, i) => `diffusion_model.double_blocks.${i}.img_attn.qkv.lora_A.weight`) diff --git a/test/modelli.mjs b/test/modelli.mjs index 6206088..14117f9 100644 --- a/test/modelli.mjs +++ b/test/modelli.mjs @@ -25,7 +25,7 @@ function uguale(nome, avuto, atteso) { uguale('tre modelli, in ordine (niente Klein 4B dalla 1.2.1)', 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', [M.MODELLI.klein.famiglia, M.MODELLI.klein.passi, M.MODELLI.klein.cfg, M.MODELLI.klein.veloce], ['klein', 16, 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) @@ -67,7 +67,7 @@ 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', M.parametriModello('klein'), { famiglia: 'klein', passi: 16, turbo: false }) uguale('parametri klein-base', M.parametriModello('klein-base'), { famiglia: 'klein-base', passi: 50, turbo: false }) // Rifai in qualità piena