diff --git a/CHANGELOG.md b/CHANGELOG.md index fc08c64..edab873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.0.0 — 2026-10-16 + +La versione fine: **FLUX 3 Klein**, meno sorprese ovunque, e tutto quello che era rimasto trascurato messo a posto. Niente funzioni nuove oltre a Klein: solo cose che già c'erano, più solide. + +- **FLUX 3 Klein** accanto a Qwen: in cima a Crea, Modifica ed Estrai c'è il selettore **Modello che disegna** con tre schede, **Qwen-Image 2.1** (40 passi, LoRA), + **Klein** (la versione veloce, distillata: 16 passi) e **Klein Base** (quella normale: 50 passi e CFG). Le schede dicono quali sono già scaricate; se manca qualcosa, + un clic scarica il modello, il suo text encoder (Qwen3-VL 4B) e il VAE. Con Klein le foto di riferimento entrano come immagini di riferimento, il prompt viene + **potenziato** dal Qwen3-VL (come consiglia BFL; si spegne in Opzioni) e la qualità (Bozza → Massima) sceglie i passi della famiglia. Klein non usa i LoRA di Qwen + né il Turbo: l'app li nasconde. Il **motore passa alla versione 7** con **ComfyUI 0.41.2**: al primo avvio si aggiorna da solo. +- **Estrai col Qwen3-VL**: il rilevatore piccolo (Florence) è tolto, a identificare persone e oggetti torna il Qwen3-VL (di nuovo con solo il pulsante). +- **Crea: i LoRA si aggiungono**, non solo si tolgono: il selettore completo è sempre sotto il prompt (con Qwen). +- **Megapixel e misura finale separati ovunque**: puoi lavorare a 4 MP e far uscire la foto a 480p (o all'inverso), in Crea, Modifica, Estrai, Fusion, Laboratorio. + Sotto il selettore si legge sempre «La foto esce a…». +- **Ingranditore NVIDIA RTX**: l'installazione non fallisce più con «ENOENT… rename»: lo zip si estrae in una cartella di lavoro, si prende la cartella giusta e si + sposta con più tentativi (e, se Windows la tiene occupata, si copia). Stesso metodo per ComfyUI. +- **Modelli scaricati quando non servono**: dopo 120 secondi senza lavori (Opzioni → Prestazioni, cambiabile) l'app libera la VRAM; il lavoro dopo li riporta su. +- **Più robusta, in tutto**: una sola finestra dell'app alla volta; un errore in una pagina non lascia la finestra bianca (si vede cosa è successo e si ricarica); + gli errori nascosti diventano avvisi in italiano; file di impostazioni, indice della galleria e album scritti in modo atomico (con copia di riserva dell'album); + la galleria si ricostruisce dai PNG senza perdere i preferiti; il motore non resta orfano se l'app si chiude male; log del motore a rotazione; download che non + si pestano i piedi (e con messaggi chiari su spazio, permessi e 401/403/404); rete con timeout; localStorage rovinato che non blocca più l'avvio; ricette tue + senza foto che creano davvero; esportazione album fermata che dice «Fermato»; Fusion che mostra sempre l'ultimo risultato; file che non sono foto rifiutati subito. +- **Galleria e interfaccia**: miniature generate a gruppi (non blocca), selezione che segue i filtri, plurali giusti, storia della maschera con un tetto di memoria, + Spazio per spostare solo in Modifica, album con più di 8 foto per spread a griglia. + ## 0.11.0 — 2026-10-15 Estrai senza partenze a sorpresa e molto più veloce, una cosa alla volta in VRAM, i token al secondo, i megapixel ovunque. diff --git a/CLAUDE.md b/CLAUDE.md index 4543392..be4d0a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # DaProd Image — leggi questo per primo -App desktop (Electron + React + TypeScript) per **Qwen-Image-2.1** in locale su una NVIDIA da 6-8 GB: crea, modifica +App desktop (Electron + React + TypeScript) per **Qwen-Image-2.1** (e **FLUX 3 Klein**) in locale su una NVIDIA da 6-8 GB: crea, modifica tutta la foto o solo una zona, espande, rifinisce, LoRA con un clic, galleria. Il motore è **ComfyUI senza la sua interfaccia**, installato e guidato dall'app. @@ -17,11 +17,11 @@ 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`: ComfyUI a un commit preciso (zip da codeload, estratto col `tar.exe` +- **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` 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 - conosce l'architettura `qwen_image21`) **con una correzione nostra** in `ops.py`: da ComfyUI 0.39 la Linear riceve + conosce l'architettura `qwen_image21`) **con una correzione nostra** in `ops.py` (più `comfy_force_forward = True` per 0.41): da ComfyUI 0.39 la Linear riceve `input_act`/`residual`, senza la correzione il KSampler si ferma. Vedi `DAPROD.md` lì dentro. Se si alza ComfyUI, si riprova una generazione vera prima di pubblicare. - I modelli (catalogo con sha256 in `src/main/installa/catalogo.ts`) stanno in `%LOCALAPPDATA%\DaProdImage\models` @@ -48,6 +48,18 @@ interfaccia**, installato e guidato dall'app. - `lavori.ts` è **la coda**: un lavoro alla volta al motore, fasi dai nodi (`fasi` del grafo), stima dai secondi per passo misurati (`impostazioni.tempi`), risultato spostato in galleria. +## FLUX 3 Klein (famiglie di modelli) +- `Famiglia = 'qwen' | 'klein' | 'klein-base'` (`shared/tipi.ts`, `FAMIGLIE` = passi per qualità), `Richiesta.famiglia`, `impostazioni.famiglia`. + 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**). + `motore/klein.ts` (`fileKlein`) trova i file (impostazioni → `risolti.json` → scansione cartelle, int8 prima di bf16) o dà un errore in italiano. +- Grafo (`grafi.ts`, `condiziona`/`latenteVuota`/`campiona`): `UNETLoader` + `CLIPLoader` tipo `flux3` + `VAELoader`, `CLIPTextEncode`, foto di riferimento + `VAEEncode` → `ReferenceLatent` (positivo e negativo, in catena), `EmptyFlux2LatentImage`, KSampler euler/simple (distillato cfg 1, base cfg 3,5). + Niente LoRA, Turbo, cache KV; i tag `` diventano ordinali. **I grafi Qwen non devono cambiare**: si controlla con un confronto sul grafo vecchio prima di ogni modifica. +- `motore/pup.ts` = i prompt upsampler ufficiali; `lavori.ts` `potenzia()` li manda al Qwen3-VL prima del disegno (`impostazioni.potenziaKlein`, cache per prompt). +- Non provato con la GPU qui: qualità, INT8 su 6-8 GB, `ReferenceLatent` in catena (alternativa `ReferenceMultiLatent`). + ## I grafi (src/main/motore/grafi.ts) - Pipeline ufficiale: `UnetLoaderGGUF` → LoRA (`LoraLoaderModelOnly`) → `QwenImage21Cache`; `CLIPLoader` tipo `qwen_image` (Qwen3-VL 8B); `TextEncodeQwenImage21`; KSampler **euler/simple, CFG 1, 40 passi** (quelli "standard"). @@ -95,7 +107,7 @@ interfaccia**, installato e guidato dall'app. qualunque pagina la mandi (il `conParoleLora` del renderer fa la stessa cosa, non raddoppia). **Esempio** (`fusion.ts`): `pianificaEsempio` fa un foglio con l'esempio (image 1) e i riferimenti numerati e lo dà al Qwen3-VL (`descrivi`, `SISTEMA_PIANO`), che scrive l'istruzione di modifica decidendo da solo cosa sostituire con cosa (niente regole per persone o altro); il piano sta in `pianoEsempio` (con `chiaveEsempio`: foto + nota), si corregge nell'interfaccia, e `richiestaEsempio` lo manda come prompt con l'esempio come `image_1`. **Galleria**: tasto destro (`MenuContestuale`), `galleria:elimina` con `definitivo`, `galleria:copia` con più id = file negli appunti con PowerShell `Set-Clipboard`. **Laboratorio**: un `Ingresso` di tipo `camera` mostra il comando Camera (valore JSON, `leggiCamera`) e le ricette aggiungono il passo `VISTA_TUA`. -- **Una cosa alla volta in VRAM** (`lavori.ts`, `fattiSpazio` + `cliente.scaricaModelli`, `impostazioni.unaCosaAllaVolta`): ogni lavoro è `immagine`, `testo` (LLM e rilevatore) o `cpu` (traduttore); quando cambia +- **Una cosa alla volta in VRAM** (`lavori.ts`, `fattiSpazio` + `cliente.scaricaModelli`, `impostazioni.unaCosaAllaVolta`): ogni lavoro è `immagine`, `testo` (LLM) o `cpu` (traduttore); quando cambia si manda `/free` e si aspetta che `torch_vram_total` scenda (il motore scarica a coda ferma: senza l'attesa lo farebbe dopo il lavoro successivo). **Token/s**: il TextGenerate manda un `progress` per token, `lavori.ts` ne ricava `Lavoro.tokSec` (media dal primo messaggio) e `BarraStato` lo mostra. - **Casella** (`componenti/Casella.tsx`): la textarea dei prompt (cresce col testo, cestino); il tasto destro nei campi di testo è nel main (`index.ts`, evento `context-menu`). @@ -134,9 +146,7 @@ interfaccia**, installato e guidato dall'app. un modello fra i primi senza ripetere; `disegna.ts` disegna su canvas (schermo e stampa: cornice, ombra, z, elementi); `esporta.ts` JPEG/PDF (`pdf.ts`, senza librerie). Le foto grandi non restano in cache. - **Estrai** (modo di Modifica, `componenti/Estrai.tsx` + `estrai.ts` + `riquadri.ts`): a foto caricata `trovaElementi` chiede al Qwen3-VL (`descrivi` con la foto intera) - un JSON `{bbox_2d 0–1000, label, kind: person|object}` (`leggiElementi`, tollera fence e JSON troncato, toglie i riquadri doppi). La ricerca parte **solo col pulsante**. Di serie la fa il - **rilevatore piccolo** (`modalita: 'trova'` → nodo `DaProdTrova` in `engine/custom_nodes/DaProd-Nodi/trova.py`: Florence-2 base ``, legge i gettoni `` a mano, poi esce dalla VRAM; modello `trova-florence` - in `models/detect/florence-2-base-ft`, scaricato al primo uso da `lavori.ts`), con `impostazioni.trova` = `'llm'` o se il rilevatore fallisce si ripiega sul Qwen3-VL (`descrivi`, `SISTEMA_ELEMENTI`). Si sceglie con un clic (più clic = il riquadro più grande + un JSON `{bbox_2d 0–1000, label, kind: person|object}` (`leggiElementi`, tollera fence e JSON troncato, toglie i riquadri doppi). La ricerca parte **solo col pulsante** e la fa il Qwen3-VL (`descrivi`, `SISTEMA_ELEMENTI`, `maxToken 800`; niente rilevatore a parte: Florence è stato provato e tolto). Si sceglie con un clic (più clic = il riquadro più grande che contiene il punto) o riquadrando a mano; il tipo (persona/oggetto) si corregge. `richiestaEstrai` ritaglia la cosa (+12%) in un temp e fa una **Crea** con quella come ``: **persona** = inquadratura (intera/vita/volto) + posa (T/A/in piedi/come/omino: manichino ``, ritaglio ``); **oggetto** = vista (come/davanti/¾/alto/lato), completa le parti coperte. Proporzioni (`proporzioniEstrai`, auto = dal riquadro per gli oggetti) e `dimensioniEstrai` sono le stesse cifre che l'interfaccia mostra. I risultati stanno in `usaEstrai` (non nelle versioni). @@ -144,3 +154,9 @@ interfaccia**, installato e guidato dall'app. ricostruisce dai file e un PNG trascinato in Galleria torna con prompt, seed e LoRA. - LoRA: dall'intestazione safetensors si capisce se sono per Qwen 2.1 (`transformer_blocks.N.attn.to_q`/`img_mlp`) o per altro (txt_mlp = Qwen 1.x, lora_unet = SD/SDXL, double_blocks = Flux). Link Hugging Face e Civitai. + +## Robustezza (1.0.0) +- Zip in `installatore.ts` `estraiZip` (cartella di lavoro, rename con tentativi poi copia); `percorsi.ts` `scriviAtomico` per impostazioni, indice galleria, album (+`.bak`). +- `index.ts`: istanza unica, `errori.log` in DATI, pulizia dei temp vecchi; `processo.ts`: `motore.pid` + `chiudiOrfano`, log a rotazione. `impostazioni.scaricaDopo` = secondi di quiete prima di liberare la VRAM. +- Renderer: `main.tsx` ha il `Salvagente` (ErrorBoundary) e il gestore `unhandledrejection`; `leggiLocale`/`leggiLista` tollerano localStorage rovinato. +- `test/finto.js` deve avere gli stessi campi del vero main (es. `modelli.file` con `klein*`): se mancano, l'avviso di errore compare e si vede. diff --git a/README.md b/README.md index f191ca6..bd248af 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,11 @@ a 300 dpi con l'abbondanza. 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. +## Due modelli: Qwen e FLUX 3 Klein + +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. + ## Estrai In Modifica: dai una foto e il modello trova **persone e oggetti**; clicchi quello che vuoi (si illumina) e lo ottieni da solo su sfondo @@ -141,7 +146,8 @@ se la scheda è da 6 GB, il profilo **6 GB** in Opzioni. L'app ti avvisa da sola - Il motore è **ComfyUI** 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). + (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 prima di "rimuovi" e accordano i colori dopo il VAE, così l'incollaggio non si vede. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 5011a9a..9c417f5 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -15,6 +15,6 @@ export default defineConfig({ root: 'src/renderer', resolve: { alias: { '@shared': resolve('src/shared'), '@': resolve('src/renderer/src') } }, plugins: [react()], - build: { rollupOptions: { input: resolve('src/renderer/index.html') } } + build: { minify: 'esbuild', rollupOptions: { input: resolve('src/renderer/index.html') } } } }) diff --git a/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md b/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md index 5efeb79..594ebd8 100644 --- a/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md +++ b/engine/custom_nodes/ComfyUI-GGUF/DAPROD.md @@ -6,3 +6,8 @@ Copia di [leejet/ComfyUI-GGUF](https://github.com/leejet/ComfyUI-GGUF) al commit Una sola modifica, in `ops.py` (`GGMLOps.Linear.forward_ggml_cast_weights`): da ComfyUI 0.39 la `Linear` riceve anche `input_act`, `act_weight`, `act_eps`, `residual`, `residual_scale`; senza questa correzione il 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. diff --git a/engine/custom_nodes/ComfyUI-GGUF/ops.py b/engine/custom_nodes/ComfyUI-GGUF/ops.py index 200481f..01248f1 100644 --- a/engine/custom_nodes/ComfyUI-GGUF/ops.py +++ b/engine/custom_nodes/ComfyUI-GGUF/ops.py @@ -229,6 +229,9 @@ class GGMLOps(comfy.ops.manual_cast): Dequantize weights on the fly before doing the compute """ class Linear(GGMLLayer, comfy.ops.manual_cast.Linear): + # DaProd Image: da ComfyUI 0.41 `linear_input_act()` salta il forward del modulo se è acceso `--fast fp16_accumulation` e l'attivazione + # è fp16; con pesi GGUF quel percorso leggerebbe i byte quantizzati. Con questo flag passa sempre da forward(). + comfy_force_forward = True def __init__(self, in_features, out_features, bias=True, device=None, dtype=None): torch.nn.Module.__init__(self) # TODO: better workaround for reserved memory spike on windows diff --git a/engine/custom_nodes/DaProd-Nodi/__init__.py b/engine/custom_nodes/DaProd-Nodi/__init__.py index 8296685..c44fb94 100644 --- a/engine/custom_nodes/DaProd-Nodi/__init__.py +++ b/engine/custom_nodes/DaProd-Nodi/__init__.py @@ -1,4 +1,4 @@ -# I nodi di DaProd Image: la modifica di una zona (solo torch), la traduzione del prompt e il rilevatore di persone e oggetti (transformers, già con ComfyUI). +# I nodi di DaProd Image: la modifica di una zona (solo torch), e la traduzione del prompt (transformers, già con ComfyUI). # # - DaProdRiempiZona: riempie la zona con i colori che ha attorno (push-pull: medie a scale sempre # più piccole e poi si risale). Serve per "rimuovi": se il modello vede ancora l'oggetto nella foto @@ -10,7 +10,6 @@ import torch.nn.functional as F from .traduzione import DaProdTraduci -from .trova import DaProdTrova def _maschera_come(maschera, h, w): @@ -116,11 +115,9 @@ def esegui(self, image, reference, mask, strength): "DaProdRiempiZona": DaProdRiempiZona, "DaProdAccordaColori": DaProdAccordaColori, "DaProdTraduci": DaProdTraduci, - "DaProdTrova": DaProdTrova, } NODE_DISPLAY_NAME_MAPPINGS = { "DaProdRiempiZona": "DaProd · Riempi la zona", "DaProdAccordaColori": "DaProd · Accorda i colori", "DaProdTraduci": "DaProd · Traduci il prompt", - "DaProdTrova": "DaProd · Trova persone e oggetti", } diff --git a/engine/custom_nodes/DaProd-Nodi/trova.py b/engine/custom_nodes/DaProd-Nodi/trova.py deleted file mode 100644 index 40e5461..0000000 --- a/engine/custom_nodes/DaProd-Nodi/trova.py +++ /dev/null @@ -1,82 +0,0 @@ -# DaProdTrova: trova persone e oggetti in una foto con un modello piccolo (Florence-2 base, ~0,23 miliardi di parametri, MIT) invece del -# Qwen3-VL da 8 miliardi: un secondo invece di mezzo minuto. Risponde con lo stesso JSON che si chiederebbe all'LLM: -# [{"bbox_2d":[x1,y1,x2,y2],"label":"...","kind":"person"|"object"}] (coordinate 0–1000 sull'intera foto) -# Il modello scrive il compito come testo con i riquadri in gettoni (N = 0–999, già in millesimi della foto): si leggono -# direttamente, così non dipende da come cambia post_process_generation fra una versione di transformers e l'altra. -# Finito, il modello esce dalla memoria (una cosa alla volta in VRAM). -import gc -import json -import os -import re - -PERSONA = re.compile(r"\b(person|people|man|men|woman|women|boy|girl|child|children|kid|baby|toddler|guy|lady|human|player|dancer|bride|groom)\b", re.I) -RIQUADRO = re.compile(r"([^<>]*?)((?:){4})") - - -def leggi_riquadri(testo): - """il testo generato da Florence-2 per : 'etichettaetichetta…' → elenco nel formato dell'app""" - testo = re.sub(r"|", "", testo) - fuori = [] - for m in RIQUADRO.finditer(testo): - etichetta = m.group(1).strip() - n = [int(x) for x in re.findall(r"", m.group(2))] - x1, y1, x2, y2 = n - if x2 <= x1 or y2 <= y1: - continue - fuori.append( - { - "bbox_2d": [x1, y1, x2, y2], - "label": etichetta or "object", - "kind": "person" if PERSONA.search(etichetta) else "object", - } - ) - return fuori - - -_cache = {} - - -class DaProdTrova: - @classmethod - def INPUT_TYPES(s): - return {"required": {"image": ("IMAGE",), "modello": ("STRING", {"default": "florence-2-base-ft"})}} - - RETURN_TYPES = ("STRING",) - FUNCTION = "trova" - OUTPUT_NODE = True - CATEGORY = "DaProd" - - def trova(self, image, modello): - import folder_paths - import torch - from PIL import Image - from transformers import AutoProcessor, Florence2ForConditionalGeneration # nativo da transformers 4.56 - - cartella = os.path.join(folder_paths.models_dir, "detect", modello) - if not os.path.isdir(cartella): - raise RuntimeError("manca il rilevatore in " + cartella) - - a = (image[0].clamp(0, 1) * 255).byte().cpu().numpy() - img = Image.fromarray(a).convert("RGB") - - cuda = torch.cuda.is_available() - dtype = torch.float16 if cuda else torch.float32 - proc = AutoProcessor.from_pretrained(cartella) - mod = Florence2ForConditionalGeneration.from_pretrained(cartella, torch_dtype=dtype).eval() - try: - if cuda: - mod = mod.to("cuda") - dati = proc(text="", images=img, return_tensors="pt") - dati = {k: (v.to(mod.device, dtype) if v.is_floating_point() else v.to(mod.device)) for k, v in dati.items()} - with torch.inference_mode(): - ids = mod.generate(**dati, max_new_tokens=1024, num_beams=3, do_sample=False) - testo = proc.batch_decode(ids, skip_special_tokens=False)[0] - finally: - del mod - gc.collect() - if cuda: - torch.cuda.empty_cache() - - elenco = leggi_riquadri(testo) - t = json.dumps(elenco, ensure_ascii=False) - return {"ui": {"text": [t]}, "result": (t,)} diff --git a/engine/versioni.json b/engine/versioni.json index 73f0c96..2721388 100644 --- a/engine/versioni.json +++ b/engine/versioni.json @@ -1,9 +1,9 @@ { - "motore": 6, + "motore": 7, "python": "3.13", "comfyui": { - "commit": "b26625f23a888367b92153b28d93e159e83e677b", - "versione": "0.39.0" + "commit": "b53f77a74e21091cbdebfdf7b85fc80326932646", + "versione": "0.41.2" }, "torch": { "indice": "https://download.pytorch.org/whl/cu130", diff --git a/package-lock.json b/package-lock.json index db2f3d4..3c51d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-image", - "version": "0.11.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-image", - "version": "0.11.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/package.json b/package.json index ec11748..84082a4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-image", "productName": "DaProd Image", - "version": "0.11.0", + "version": "1.0.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/src/main/album.ts b/src/main/album.ts index 9eee63f..aa79a2a 100644 --- a/src/main/album.ts +++ b/src/main/album.ts @@ -1,7 +1,7 @@ // Gli album: ogni album è una cartella in DATI/album/ con album.json (il progetto), le foto (JPEG grande per la // stampa e una miniatura per lavorare svelti). Le foto entrano da qualsiasi formato (anche RAW): la conversione la fa // l'interfaccia, qui si salva, si leggono le date di scatto e si scrive il PDF. -import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' import { basename, join } from 'node:path' import { randomUUID } from 'node:crypto' import { DATI, TEMP, assicura } from './percorsi' @@ -20,7 +20,12 @@ export interface RiassuntoAlbum { copertina?: string } -const cartella = (id: string): string => join(ALBUM, id.replace(/[^\w-]/g, '')) +/** la cartella di un album: un id che dopo la pulizia resta vuoto sarebbe la cartella di TUTTI gli album (e eliminaAlbum li cancellerebbe) */ +const cartella = (id: string): string => { + const pulito = String(id ?? '').replace(/[^\w-]/g, '') + if (!pulito) throw new Error('album non valido') + return join(ALBUM, pulito) +} export function elencoAlbum(): RiassuntoAlbum[] { assicura(ALBUM) @@ -37,21 +42,33 @@ export function elencoAlbum(): RiassuntoAlbum[] { } export function caricaAlbum(id: string): unknown { - try { - return JSON.parse(readFileSync(join(cartella(id), 'album.json'), 'utf8')) - } catch { - return null + const c = cartella(id) + // se il progetto è rovinato (interruzione mentre si scriveva) si ripiega sull'ultima copia buona + for (const nome of ['album.json', 'album.json.bak']) { + try { + return JSON.parse(readFileSync(join(c, nome), 'utf8')) + } catch { + /* si prova l'altro */ + } } + return null } export function salvaAlbum(a: { id: string }): void { const c = cartella(a.id) assicura(c) - // si scrive in un file a parte e poi lo si rinomina: un'interruzione non rovina il progetto + // si scrive in un file a parte e poi lo si rinomina: un'interruzione non rovina il progetto. L'ultima copia buona resta in album.json.bak const tmp = join(c, 'album.json.tmp') + const dest = join(c, 'album.json') writeFileSync(tmp, JSON.stringify(a)) - copyFileSync(tmp, join(c, 'album.json')) - rmSync(tmp, { force: true }) + if (existsSync(dest)) { + try { + copyFileSync(dest, dest + '.bak') + } catch { + /* la copia di sicurezza è un di più */ + } + } + renameSync(tmp, dest) } export function eliminaAlbum(id: string): void { @@ -79,7 +96,8 @@ function dataUrlInFile(dataUrl: string, dest: string): void { export function salvaFotoAlbum(idAlbum: string, idFoto: string, grande: string, mini: string): { grande: string; mini: string } { const c = join(cartella(idAlbum), 'foto') assicura(c) - const id = idFoto.replace(/[^\w-]/g, '') + const id = String(idFoto ?? '').replace(/[^\w-]/g, '') + if (!id) throw new Error('foto non valida') const g = join(c, `${id}.jpg`) const m = join(c, `${id}.mini.jpg`) dataUrlInFile(grande, g) diff --git a/src/main/galleria.ts b/src/main/galleria.ts index be3624b..386df0b 100644 --- a/src/main/galleria.ts +++ b/src/main/galleria.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import type { FiltroGalleria, Opera } from '@shared/tipi' import { DATI, MINIATURE, assicura } from './percorsi' import { impostazioni } from './impostazioni' -import { dimensioniPng, leggiMetadati, scriviMetadati } from './png' +import { dimensioniPng, leggiMetadati, leggiMetadatiDaFile, scriviMetadati } from './png' const INDICE = join(DATI, 'galleria.json') let opere: Opera[] | null = null @@ -26,20 +26,40 @@ function carica(): Opera[] { if (opere) return opere try { opere = JSON.parse(readFileSync(INDICE, 'utf8')) as Opera[] - opere = opere.filter((o) => existsSync(o.file)) + // le opere di un disco scollegato non si buttano: se la cartella della galleria non c'è, l'indice resta com'è + if (existsSync(impostazioni().cartellaGalleria)) opere = opere.filter((o) => existsSync(o.file)) } catch { opere = ricostruisci() } return opere } +/** scrive l'indice in un file a parte e poi lo rinomina; su Windows l'antivirus a volte tiene il file: si riprova, e alla fine si scrive diretto */ +function scriviIndice(): void { + if (!opere) return + assicura(DATI) + const testo = JSON.stringify(opere) + for (let t = 0; t < 3; t++) { + try { + writeFileSync(INDICE + '.tmp', testo) + renameSync(INDICE + '.tmp', INDICE) + return + } catch { + /* si riprova */ + } + } + try { + writeFileSync(INDICE, testo) + } catch { + /* il prossimo salvataggio ci riprova */ + } +} + function salvaPresto(): void { if (timerSalva) clearTimeout(timerSalva) timerSalva = setTimeout(() => { timerSalva = null - assicura(DATI) - writeFileSync(INDICE + '.tmp', JSON.stringify(opere)) - renameSync(INDICE + '.tmp', INDICE) + scriviIndice() }, 300) } @@ -47,11 +67,11 @@ export function salvaOra(): void { if (!timerSalva || !opere) return clearTimeout(timerSalva) timerSalva = null - writeFileSync(INDICE, JSON.stringify(opere)) + scriviIndice() } /** rilegge la cartella della galleria e ricostruisce l'indice dai PNG */ -function ricostruisci(): Opera[] { +function ricostruisci(preferite: Set = new Set()): Opera[] { const radice = impostazioni().cartellaGalleria const out: Opera[] = [] const guarda = (c: string, prof: number): void => { @@ -65,18 +85,19 @@ function ricostruisci(): Opera[] { const p = join(c, n) if (n.toLowerCase().endsWith('.png')) { try { - const buf = readFileSync(p) - const meta = leggiMetadati(buf) as Partial | null - if (!meta) continue - const dim = dimensioniPng(buf) + // si legge solo l'inizio e la fine del PNG, non tutto il file + const letto = leggiMetadatiDaFile(p) + const meta = letto?.meta as Partial | null | undefined + if (!letto || !meta) continue out.push({ ...(meta as Opera), id: meta.id || randomUUID(), file: p, miniatura: '', - larghezza: dim?.larghezza || 0, - altezza: dim?.altezza || 0, - preferita: !!meta.preferita + larghezza: letto.larghezza, + altezza: letto.altezza, + // i preferiti stanno solo nell'indice: se si ricostruisce si tengono quelli di prima + preferita: !!meta.preferita || preferite.has(p) }) } catch { /* niente */ @@ -151,7 +172,20 @@ export function elenco(f: FiltroGalleria = {}): Opera[] { const t = f.testo.toLowerCase() l = l.filter((o) => o.prompt.toLowerCase().includes(t) || (o.etichetta || '').toLowerCase().includes(t) || String(o.seed) === t) } - return l.map((o) => (o.miniatura && existsSync(o.miniatura) ? o : { ...o, miniatura: miniatura(o) })) + // le miniature mancanti si fanno a piccoli gruppi (decodificare foto grandi blocca la finestra): le altre arrivano al giro dopo + let fatte = 0 + let restano = false + const risultato = l.map((o) => { + if (o.miniatura && existsSync(o.miniatura)) return o + if (fatte >= 8) { + restano = true + return { ...o, miniatura: o.file } + } + fatte++ + return { ...o, miniatura: miniatura(o) } + }) + if (restano) setTimeout(avvisa, 80) + return risultato } export const opera = (id: string): Opera | undefined => carica().find((o) => o.id === id) @@ -168,9 +202,8 @@ export function preferita(id: string, si: boolean): void { export async function elimina(ids: string[], definitivo = false): Promise { const lista = carica() for (const id of ids) { - const i = lista.findIndex((o) => o.id === id) - if (i < 0) continue - const o = lista[i] + const o = lista.find((x) => x.id === id) + if (!o) continue try { if (definitivo) unlinkSync(o.file) else await shell.trashItem(o.file) // nel cestino: si può recuperare @@ -182,7 +215,9 @@ export async function elimina(ids: string[], definitivo = false): Promise } catch { /* niente */ } - lista.splice(i, 1) + // dopo l'await l'elenco può essere cambiato (un lavoro finito aggiunge in testa): si cerca di nuovo + const j = lista.indexOf(o) + if (j >= 0) lista.splice(j, 1) } salvaPresto() avvisa() @@ -230,7 +265,8 @@ export function importaFoto(png: string, nome: string): Opera { } export const ricaricaGalleria = (): void => { + const preferite = new Set((opere || []).filter((o) => o.preferita).map((o) => o.file)) opere = null - ricostruisci() + ricostruisci(preferite) avvisa() } diff --git a/src/main/idle.ts b/src/main/idle.ts index 9e39908..8481a9a 100644 --- a/src/main/idle.ts +++ b/src/main/idle.ts @@ -69,7 +69,9 @@ const TOLTI = ['qwen-image-2.1-UC-Q5_K_M.gguf', 'qwen3vl_8b_bf16.safetensors'] /** i file dei modelli che non sono in uso: non nelle impostazioni, non nel catalogo (o tolti dal catalogo): si possono buttare */ export function fileInutilizzati(): FileInutile[] { const imp = impostazioni() - const inUso = new Set([imp.modello, imp.encoder, imp.vae, imp.upscaler, imp.loraTurbo].map((x) => x.toLowerCase())) + // i file di Klein (scelti a mano o scaricati dall'app) sono in uso quanto quelli di Qwen + const klein = ['klein-int8', 'klein-bf16', 'klein-base-int8', 'klein-base-bf16', 'klein-te', 'klein-vae'].map((id) => risoltoSalvato(id)?.file || '') + const inUso = new Set([imp.modello, imp.encoder, imp.vae, imp.upscaler, imp.loraTurbo, imp.kleinModello, imp.kleinBase, imp.kleinEncoder, imp.kleinVae, ...klein].filter(Boolean).map((x) => x.toLowerCase())) const noti = new Set(statoModelli().filter((v) => !v.link).map((v) => (risoltoSalvato(v.id)?.file || v.file).toLowerCase()).filter(Boolean)) const mmproj = (n: string): boolean => /mmproj|\.mmproj\./i.test(n) const trovati: FileInutile[] = [] diff --git a/src/main/impostazioni.ts b/src/main/impostazioni.ts index 71f9145..6308aa6 100644 --- a/src/main/impostazioni.ts +++ b/src/main/impostazioni.ts @@ -13,6 +13,11 @@ const PREDEFINITE: Impostazioni = { encoder: 'qwen3vl_8b_int8_convrot.safetensors', vae: 'texture_fix_vae_for_qwen_image_2.1_bf16.safetensors', upscaler: '4x-UltraSharp.pth', + famiglia: 'qwen', + kleinModello: '', + kleinBase: '', + kleinEncoder: '', + kleinVae: '', upscalerRtx: true, scaricaInIdle: true, porta: 8818, @@ -35,7 +40,8 @@ const PREDEFINITE: Impostazioni = { traduci: true, traduttore: 'dedicato', unaCosaAllaVolta: true, - trova: 'veloce' + potenziaKlein: true, + scaricaDopo: 120 } let attuali: Impostazioni | null = null diff --git a/src/main/index.ts b/src/main/index.ts index 3f168e3..84876ee 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,7 @@ // DaProd Image — processo principale: finestra, protocollo daprod:// per le immagini, IPC verso l'interfaccia. import { execFile } from 'node:child_process' import { app, BrowserWindow, clipboard, ClipboardItem, dialog, ipcMain, Menu, nativeImage, net, protocol, shell, type IpcMainInvokeEvent } from 'electron' -import { copyFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { appendFileSync, copyFileSync, existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { basename, extname, join, normalize } from 'node:path' import { pathToFileURL } from 'node:url' import { randomUUID } from 'node:crypto' @@ -24,6 +24,47 @@ import { avviaAggiornamenti, controllaAggiornamenti, installaAggiornamento, stat // corsEnabled: le (canvas) e la mask-image della zona leggono da qui con il CORS protocol.registerSchemesAsPrivileged([{ scheme: 'daprod', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true } }]) +// una sola copia dell'app alla volta: due copie lancerebbero due motori sulla scheda (VRAM doppia) e scriverebbero la stessa galleria +const unica = app.requestSingleInstanceLock() +if (!unica) app.quit() +else + app.on('second-instance', () => { + if (!finestra) return + if (finestra.isMinimized()) finestra.restore() + finestra.focus() + }) + +// un errore che nessuno ha gestito non deve aprire il riquadro «A JavaScript error occurred»: si scrive nel registro e si va avanti +const registraErrore = (e: unknown): void => { + try { + appendFileSync(join(DATI, 'errori.log'), `${new Date().toISOString()} ${(e as Error)?.stack || String(e)}\n`) + } catch { + /* niente */ + } +} +process.on('uncaughtException', registraErrore) +process.on('unhandledRejection', registraErrore) + +/** toglie dalle cartelle temporanee i file più vecchi di qualche giorno (fogli, maschere, copie delle foto): senza, crescono per sempre */ +function pulisciVecchi(cartella: string, giorni: number, salta: string[] = []): void { + const limite = Date.now() - giorni * 86_400_000 + let voci: string[] = [] + try { + voci = readdirSync(cartella) + } catch { + return + } + for (const n of voci) { + if (salta.includes(n)) continue + const p = join(cartella, n) + try { + if (statSync(p).mtimeMs < limite) rmSync(p, { recursive: true, force: true }) + } catch { + /* in uso: la prossima volta */ + } + } +} + let finestra: BrowserWindow | null = null /** true quando si sta già chiudendo (il motore è fermo o si sta fermando) */ let chiusura = false @@ -122,7 +163,16 @@ function registraIpc(): void { const nuove = salvaImpostazioni(m) const motore = ['cartellaModelli', 'anteprimaLive', 'riservaVram', 'argomentiExtra', 'porta', 'memoria', 'veloce'] as const if (motore.some((k) => k in m && JSON.stringify(m[k]) !== JSON.stringify(prima[k])) && infoMotore().stato === 'pronto') { - void riavviaMotore().then(() => connetti()) + // le impostazioni del motore valgono al riavvio: se sta lavorando, si aspetta la fine del lavoro (non lo si uccide a metà) + const riavviaQuandoLibero = (): void => { + if (elencoLavori().some((l) => l.stato === 'in corso')) { + setTimeout(riavviaQuandoLibero, 1500) + return + } + scollega() + void riavviaMotore().then(() => connetti()).catch(() => undefined) + } + riavviaQuandoLibero() } if ('cartellaGalleria' in m && m.cartellaGalleria !== prima.cartellaGalleria) ricaricaGalleria() return nuove @@ -154,11 +204,21 @@ function registraIpc(): void { return [] } } + const tutti = [...leggi('diffusion_models', /\.(gguf|safetensors)$/i), ...leggi('unet', /\.(gguf|safetensors)$/i)] + const enc = leggi('text_encoders', /\.(safetensors|gguf)$/i) + const vae = leggi('vae', /\.safetensors$/i) + // i file di Klein sono altri: nelle scelte di Qwen non devono comparire + const isKlein = (n: string): boolean => /klein/i.test(n) + const isKleinTe = (n: string): boolean => /(qwen3|qwen_3).*4b/i.test(n) || isKlein(n) + const isKleinVae = (n: string): boolean => isKlein(n) || /flux.?[23]/i.test(n) return { - diffusione: [...leggi('diffusion_models', /\.(gguf|safetensors)$/i), ...leggi('unet', /\.(gguf|safetensors)$/i)], - encoder: leggi('text_encoders', /\.(safetensors|gguf)$/i), - vae: leggi('vae', /\.safetensors$/i), - upscaler: leggi('upscale_models', /\.(pth|safetensors)$/i) + diffusione: tutti.filter((n) => !isKlein(n)), + encoder: enc.filter((n) => !isKleinTe(n)), + vae: vae.filter((n) => !isKleinVae(n)), + upscaler: leggi('upscale_models', /\.(pth|safetensors)$/i), + klein: tutti.filter(isKlein), + kleinTe: enc.filter(isKleinTe), + kleinVae: vae.filter(isKleinVae) } }) gestisci('modelli:inutilizzati', () => fileInutilizzati()) @@ -386,6 +446,10 @@ function registraIpc(): void { } app.whenReady().then(() => { + if (!unica) return + // prima di avviare il motore (la sua cache delle foto caricate è ancora vuota) + pulisciVecchi(TEMP, 3, ['comfy', 'wic-png.ps1']) + pulisciVecchi(join(ENTRATA, 'daprod'), 3) app.setAppUserModelId('it.daprod.image') protocol.handle('daprod', async (req) => { const u = new URL(req.url) @@ -431,6 +495,7 @@ app.whenReady().then(() => { app.on('before-quit', (e) => { salvaOra() + annullaSetup() if (chiusura) return if (infoMotore().stato === 'spento' || infoMotore().stato === 'non installato') return e.preventDefault() diff --git a/src/main/installa/catalogo.ts b/src/main/installa/catalogo.ts index f02f788..838302a 100644 --- a/src/main/installa/catalogo.ts +++ b/src/main/installa/catalogo.ts @@ -5,6 +5,7 @@ const UC = 'https://huggingface.co/abenzerps/Qwen-Image-2.1-Uncensored-GGUF/reso const TURBO = 'AtomicChat/Qwen-Image-2.1-Turbo-GGUF' const ENC_AC = 'AtomicChat/Qwen-Image-2.1-Turbo-Uncensored-GGUF' const ENC_MR = 'mradermacher/Qwen3-VL-8B-Instruct-Uncensored-V2-GGUF' +const KLEIN = 'black-forest-labs/flux-3-image-klein-comfy' export const CATALOGO: VoceCatalogo[] = [ // --- modello di diffusione (uno solo serve; solo Q4, Q6 e Q8) --- @@ -76,13 +77,28 @@ 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$'] }, - // --- rilevatore: un modello piccolo (Florence-2, 0,23 miliardi di parametri, MIT) che trova persone e oggetti in un secondo, al posto dell'LLM --- + // --- 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). + // 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-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)']] + ] as const).map(([id, nome, descrizione, byte, cerca]): VoceCatalogo => ({ + id, tipo: 'klein', nome, descrizione, file: '', cartella: 'diffusion_models', url: '', byte, circa: true, repo: KLEIN, cerca: [...cerca] + })), + { + id: 'klein-te', tipo: 'klein-te', nome: 'Text encoder di FLUX 3 Klein (Qwen3-VL 4B)', + descrizione: 'Legge il prompt per Klein. È un Qwen3-VL da 4 miliardi di parametri, diverso dal nostro encoder 8B (che resta per migliora prompt, Idee, Estrai…).', + file: '', cartella: 'text_encoders', url: '', byte: 4_500_000_000, circa: true, + repo: KLEIN, cerca: ['^qwen3vl_4b_int8', '^(?=.*(qwen3|qwen_3))(?=.*vl)(?=.*4b)(?!.*(prompt|upsampl|9b))', '^(?=.*(qwen3|qwen_3))(?=.*4b)(?!.*(prompt|upsampl|9b))'] + }, { - id: 'trova-florence', tipo: 'rilevatore', nome: 'Rilevatore persone e oggetti (Florence-2 base)', - descrizione: 'Un modello piccolo che trova persone e oggetti in una foto in un attimo (Estrai). Prima lo faceva il Qwen3-VL, molto più lento. Si scarica da solo al primo uso.', - file: '', cartella: 'detect/florence-2-base-ft', url: '', byte: 460_000_000, circa: true, consigliato: true, - repo: 'florence-community/Florence-2-base-ft', cerca: ['^model\\.safetensors$', '^pytorch_model\\.bin$'], - accanto: ['^config\\.json$', '^generation_config\\.json$', '^preprocessor_config\\.json$', '^processor_config\\.json$', '^tokenizer_config\\.json$', '^tokenizer\\.json$', '^vocab\\.json$', '^merges\\.txt$', '^special_tokens_map\\.json$', '^added_tokens\\.json$', '^chat_template\\.json$'] + id: 'klein-vae', tipo: 'klein-vae', nome: 'VAE di FLUX 3 Klein (FLUX.2)', + descrizione: 'Il decoder delle immagini di Klein (piccolo).', + file: '', cartella: 'vae', url: '', byte: 170_000_000, circa: true, + repo: KLEIN, cerca: ['^flux2-vae\\.safetensors$', '^(?=.*flux)(?=.*vae)', '^(?=.*vae)(?!.*qwen)'] }, // --- VAE --- { diff --git a/src/main/installa/installatore.ts b/src/main/installa/installatore.ts index 8fdecd0..d79b679 100644 --- a/src/main/installa/installatore.ts +++ b/src/main/installa/installatore.ts @@ -3,9 +3,9 @@ // Python 3.13 (con uv) → ComfyUI (zip al commit fissato) → nodi GGUF (la nostra copia corretta) // → venv con PyTorch CUDA e i pacchetti → modelli → prova del motore. import { spawn, execFile } from 'node:child_process' -import { cpSync, existsSync, linkSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, writeFileSync, createReadStream, createWriteStream } from 'node:fs' +import { cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, writeFileSync, createReadStream, createWriteStream } from 'node:fs' import { totalmem } from 'node:os' -import { basename, join, parse } from 'node:path' +import { basename, dirname, join, parse } from 'node:path' import { app } from 'electron' import type { ControlloSistema, PassoSetup, StatoVoce, VoceCatalogo } from '@shared/tipi' import { CATALOGO, voce } from './catalogo' @@ -214,6 +214,37 @@ function esegui(cmd: string, args: string[], riga: (s: string) => void, cwd = MO }) } +/** apre uno zip di GitHub e mette il progetto in `dest`. Si estrae in una cartella tutta sua e si prende quella che c'è dentro: il nome + * della cartella radice dipende da GitHub (maiuscole, commit corto o intero), indovinarlo ha già fatto fallire un'installazione. + * Su Windows Defender a volte tiene bloccata la cartella appena estratta: se il rename fallisce si riprova, e alla fine si copia. */ +async function estraiZip(zip: string, dest: string, riga: (s: string) => void): Promise { + const tmp = join(MOTORE, '_estratto-' + basename(dest)) + rmSync(tmp, { recursive: true, force: true }) + mkdirSync(tmp, { recursive: true }) + // il tar.exe di Windows (bsdtar) apre gli zip; un altro tar nel PATH (es. quello di Git) no + await esegui(join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'), ['-xf', zip, '-C', tmp], riga) + const cartelle = readdirSync(tmp, { withFileTypes: true }).filter((d) => d.isDirectory()) + const radice = cartelle.length === 1 ? join(tmp, cartelle[0].name) : tmp + riga(`estratto in ${basename(radice)} (${readdirSync(radice).length} voci)`) + if (readdirSync(radice).length === 0) throw new Error('lo zip è vuoto') + rmSync(dest, { recursive: true, force: true }) + mkdirSync(dirname(dest), { recursive: true }) + for (let t = 0; ; t++) { + try { + renameSync(radice, dest) + break + } catch (e) { + if (t >= 4) { + riga('rename non riuscito (' + (e as Error).message + '): copio') + cpSync(radice, dest, { recursive: true }) + break + } + await new Promise((r) => setTimeout(r, 700)) + } + } + rmSync(tmp, { recursive: true, force: true }) +} + let inCorso: AbortController | null = null export function annullaSetup(): void { @@ -290,12 +321,8 @@ export async function installa(opz: OpzioniSetup, notifica: Notifica, provaMotor }) p('comfyui').dettaglio = 'estraggo…' notifica(passi) - const estratto = join(MOTORE, `ComfyUI-${v.comfyui.commit}`) - rmSync(estratto, { recursive: true, force: true }) - // il tar.exe di Windows (bsdtar) apre gli zip; un altro tar nel PATH (es. quello di Git) no - await esegui(join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'), ['-xf', zip, '-C', MOTORE], riga('comfyui')) - rmSync(COMFY, { recursive: true, force: true }) - renameSync(estratto, COMFY) + await estraiZip(zip, COMFY, riga('comfyui')) + if (!existsSync(join(COMFY, 'main.py'))) throw new Error('lo zip di ComfyUI non contiene main.py') rmSync(zip, { force: true }) scriviStato({ ...leggiStato(), comfyui: v.comfyui.commit, nodi: 0 }) finisci('comfyui') @@ -349,11 +376,8 @@ export async function installa(opz: OpzioniSetup, notifica: Notifica, provaMotor notifica(passi) const zip = join(TEMP, `rtx-${rtx.commit.slice(0, 7)}.zip`) await scarica({ url: `https://codeload.github.com/${rtx.repo}/zip/${rtx.commit}`, destinazione: zip, segnale }) - const estratto = join(MOTORE, `${rtx.nome}-${rtx.commit}`) - rmSync(estratto, { recursive: true, force: true }) - await esegui(join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'), ['-xf', zip, '-C', MOTORE], riga('pacchetti')) - rmSync(cartellaRtx, { recursive: true, force: true }) - renameSync(estratto, cartellaRtx) + await estraiZip(zip, cartellaRtx, riga('pacchetti')) + if (!existsSync(join(cartellaRtx, '__init__.py'))) throw new Error('lo zip non contiene il nodo (manca __init__.py)') rmSync(zip, { force: true }) await esegui(UV, ['pip', 'install', '--python', PYTHON, rtx.pacchetto], riga('pacchetti')) scriviStato({ ...leggiStato(), rtx: rtx.commit }) diff --git a/src/main/installa/risolvi.ts b/src/main/installa/risolvi.ts index d4ee4ba..c805a41 100644 --- a/src/main/installa/risolvi.ts +++ b/src/main/installa/risolvi.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { VoceCatalogo } from '@shared/tipi' -import { DATI } from '../percorsi' +import { DATI, scriviAtomico } from '../percorsi' import { impostazioni } from '../impostazioni' export interface FileRisolto { @@ -34,7 +34,7 @@ function leggi(): Record { export const risoltoSalvato = (id: string): Risolto | undefined => leggi()[id] function ricorda(id: string, r: Risolto): void { - writeFileSync(FILE, JSON.stringify({ ...leggi(), [id]: r }, null, 2)) + scriviAtomico(FILE, JSON.stringify({ ...leggi(), [id]: r }, null, 2)) } /** il nome senza la quantizzazione, come lo toglie ComfyUI-GGUF (`strip_quant_suffix`) per cercare l'mmproj */ @@ -65,7 +65,8 @@ function scegli(files: VoceHF[], cerca: string[], escludiMmproj: boolean, ext = const candidati = files.filter((f) => ext.test(f.path) && (/mmproj/i.test(f.path) ? !escludiMmproj : escludiMmproj)) for (const re of cerca) { const r = new RegExp(re, 'i') - const trovato = candidati.find((f) => r.test(tronco(f.path))) + // si prova col nome del file e poi col percorso intero (alcuni repo mettono il tipo nella cartella: int8/…) + const trovato = candidati.find((f) => r.test(tronco(f.path))) || candidati.find((f) => r.test(f.path)) if (trovato) return trovato } return undefined @@ -83,9 +84,12 @@ export async function risolviVoce(v: VoceCatalogo): Promise { if (!v.repo || !v.cerca) throw new Error(`${v.nome}: non è una voce da cercare`) const files = await elenco(v.repo) // i pesi sono un .gguf (denoiser, encoder) o, per il traduttore, un .safetensors / .bin - const pesi = v.tipo === 'traduttore' || v.tipo === 'rilevatore' ? /\.(safetensors|bin)$/i : /\.gguf$/i + const pesi = v.tipo === 'traduttore' || v.tipo === 'klein' || v.tipo === 'klein-te' || v.tipo === 'klein-vae' ? /\.(safetensors|bin)$/i : /\.gguf$/i const principale = scegli(files, v.cerca, true, pesi) - if (!principale) throw new Error(`Nel repo ${v.repo} non trovo il file ${v.quant || ''} (${v.cerca.join(' / ')})`) + if (!principale) { + const elencoFile = files.filter((f) => pesi.test(f.path)).map((f) => f.path).slice(0, 40).join(', ') + throw new Error(`Nel repo ${v.repo} non trovo il file giusto per «${v.nome}». File del repo: ${elencoFile || '(nessuno)'}`) + } const nome = tronco(principale.path) const r: Risolto = aFile(v.repo, principale, nome) if (v.cercaMmproj) { diff --git a/src/main/installa/scarica.ts b/src/main/installa/scarica.ts index 216d998..f1e763f 100644 --- a/src/main/installa/scarica.ts +++ b/src/main/installa/scarica.ts @@ -32,19 +32,41 @@ export async function sha256File(percorso: string): Promise { return h.digest('hex') } -export async function scarica(o: OpzioniScarica): Promise { +// due richieste per lo stesso file (doppio clic, idle + primo uso) scriverebbero nello stesso .part: la seconda aspetta la prima +const inCorso = new Map>() + +export function scarica(o: OpzioniScarica): Promise { + const k = o.destinazione.toLowerCase() + const gia = inCorso.get(k) + if (gia) return gia + const p = scaricaVero(o).finally(() => inCorso.delete(k)) + inCorso.set(k, p) + return p +} + +/** il messaggio per l'utente di un errore di scrittura sul disco */ +function erroreDiScrittura(e: NodeJS.ErrnoException): Error { + if (e.code === 'ENOSPC') return new Error('Non c\'è più spazio sul disco: libera un po\' di spazio e riprova (il download riparte da dove si era fermato).') + if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') return new Error('Non riesco a scrivere il file: è in uso o protetto (l\'antivirus?). Riprova fra un attimo.') + return new Error('Non riesco a scrivere il file sul disco: ' + e.message) +} + +async function scaricaVero(o: OpzioniScarica): Promise { assicura(dirname(o.destinazione)) const parte = o.destinazione + '.part' let gia = existsSync(parte) ? statSync(parte).size : 0 const hash = createHash('sha256') - const intestazioni: Record = { 'User-Agent': 'DaProdImage/0.1', ...(o.intestazioni || {}) } + const intestazioni: Record = { 'User-Agent': 'DaProdImage', ...(o.intestazioni || {}) } if (gia > 0) intestazioni.Range = `bytes=${gia}-` const r = await fetch(o.url, { headers: intestazioni, redirect: 'follow', signal: o.segnale }) if (r.status === 416) { // il .part è già completo } else if (!r.ok) { - throw new Error(`download non riuscito (${r.status} ${r.statusText}) da ${new URL(o.url).host}`) + const host = new URL(o.url).host + if (r.status === 401 || r.status === 403) throw new Error(`${host} non mi lascia scaricare questo file (${r.status}): se è un modello con licenza da accettare, accettala sul sito e metti il tuo token in Opzioni.`) + if (r.status === 404) throw new Error(`Il file non c'è più su ${host} (404): serve un aggiornamento dell'app.`) + throw new Error(`Download non riuscito (${r.status} ${r.statusText}) da ${host}`) } if (r.status === 200 && gia > 0) { // il server non riprende: si ricomincia @@ -56,6 +78,11 @@ export async function scarica(o: OpzioniScarica): Promise { if (r.status !== 416 && r.body) { const scrivi = createWriteStream(parte, { flags: gia > 0 && r.status === 206 ? 'a' : 'w' }) + // senza questo, un disco pieno diventa un errore non gestito nel processo principale + let erroreDisco: NodeJS.ErrnoException | null = null + scrivi.on('error', (e) => { + erroreDisco = e + }) let ricevuti = gia let ultimo = Date.now() let byteFinestra = 0 @@ -65,10 +92,11 @@ export async function scarica(o: OpzioniScarica): Promise { for (;;) { const { done, value } = await lettore.read() if (done) break + if (erroreDisco) throw erroreDiScrittura(erroreDisco) hash.update(value) ricevuti += value.length byteFinestra += value.length - if (!scrivi.write(value)) await new Promise((ok) => scrivi.once('drain', () => ok())) + if (!scrivi.write(value)) await new Promise((ok) => { scrivi.once('drain', () => ok()); scrivi.once('error', () => ok()) }) const ora = Date.now() if (ora - ultimo >= 400) { const v = (byteFinestra * 1000) / (ora - ultimo) @@ -79,8 +107,14 @@ export async function scarica(o: OpzioniScarica): Promise { } } } finally { - await new Promise((ok) => scrivi.end(() => ok())) + await new Promise((ok) => { + if (scrivi.destroyed) return ok() + scrivi.once('close', () => ok()) + scrivi.once('error', () => ok()) + scrivi.end() + }) } + if (erroreDisco) throw erroreDiScrittura(erroreDisco) o.avanzamento?.(ricevuti, totali, velocita) if (totali && ricevuti < totali) throw new Error('download interrotto: riprova, riparte da dove si era fermato') } diff --git a/src/main/lora.ts b/src/main/lora.ts index 7b161ad..65f4964 100644 --- a/src/main/lora.ts +++ b/src/main/lora.ts @@ -3,9 +3,9 @@ // e dall'intestazione safetensors si capisce se sono per Qwen-Image 2.1. import { shell } from 'electron' import { copyFileSync, existsSync, openSync, readFileSync, readSync, closeSync, readdirSync, statSync, writeFileSync } from 'node:fs' -import { basename, extname, join, relative } from 'node:path' +import { basename, extname, join, relative, resolve, sep } from 'node:path' import type { InfoLora } from '@shared/tipi' -import { DATI, assicura } from './percorsi' +import { DATI, assicura, scriviAtomico } from './percorsi' import { impostazioni } from './impostazioni' import { scarica } from './installa/scarica' import { CATALOGO } from './installa/catalogo' @@ -24,7 +24,7 @@ function note(): Record { } function scriviNote(n: Record): void { assicura(DATI) - writeFileSync(NOTE, JSON.stringify(n, null, 2)) + scriviAtomico(NOTE, JSON.stringify(n, null, 2)) } /** legge l'intestazione JSON di un .safetensors (solo i primi byte) */ @@ -160,7 +160,12 @@ export function importaLora(percorsi: string[]): string[] { } export async function eliminaLora(file: string): Promise { - await shell.trashItem(join(cartellaLora(), file)) + // solo un LoRA dentro la cartella dei LoRA: un percorso con «..» manderebbe nel Cestino un file qualsiasi del disco + const dentro = resolve(cartellaLora()) + const p = resolve(dentro, file) + if (!p.toLowerCase().startsWith(dentro.toLowerCase() + sep) || !/\.(safetensors|ckpt|pt)$/i.test(p)) throw new Error('questo file non è un LoRA') + // se il file non c'è più (cancellato a mano) si toglie comunque la nota + if (existsSync(p)) await shell.trashItem(p) const n = note() delete n[file] scriviNote(n) @@ -170,15 +175,19 @@ export async function eliminaLora(file: string): Promise { async function risolvi(link: string): Promise<{ url: string; nome?: string; file?: string; parole?: string; anteprima?: string; intestazioni: Record }> { const imp = impostazioni() const u = new URL(link.trim()) + if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('il link deve cominciare con https://') const intestazioni: Record = {} - if (u.hostname.endsWith('huggingface.co')) { + // il token va solo a quel sito (non a «evilhuggingface.co») + const dominio = (d: string): boolean => u.hostname === d || u.hostname.endsWith('.' + d) + if (dominio('huggingface.co')) { if (imp.tokenHF) intestazioni.Authorization = 'Bearer ' + imp.tokenHF // /utente/repo/blob/main/file → /resolve/ const url = u.toString().replace('/blob/', '/resolve/') if (!/\/resolve\//.test(url)) { // pagina del repo: si cerca il primo .safetensors const repo = u.pathname.split('/').filter(Boolean).slice(0, 2).join('/') - const r = await fetch(`https://huggingface.co/api/models/${repo}/tree/main?recursive=true`, { headers: intestazioni }) + const r = await fetch(`https://huggingface.co/api/models/${repo}/tree/main?recursive=true`, { headers: intestazioni, signal: AbortSignal.timeout(20_000) }) + if (!r.ok) throw new Error(r.status === 401 || r.status === 403 ? 'Hugging Face non mi lascia entrare in quel repo: controlla il token in Opzioni.' : r.status === 404 ? 'Non trovo quel repository su Hugging Face.' : `Hugging Face non risponde (${r.status}).`) const files = (await r.json()) as { path: string; type: string }[] const tutti = files.filter((f) => f.type === 'file' && f.path.endsWith('.safetensors')) // #file=v2,1500,1000: le preferenze, in ordine (il checkpoint consigliato dall'autore); se niente combacia, il primo @@ -189,7 +198,7 @@ async function risolvi(link: string): Promise<{ url: string; nome?: string; file } return { url, file: basename(u.pathname), intestazioni } } - if (u.hostname.endsWith('civitai.com') || u.hostname.endsWith('civitai.green')) { + if (dominio('civitai.com') || dominio('civitai.green')) { const auth = imp.tokenCivitai ? { Authorization: 'Bearer ' + imp.tokenCivitai } : undefined let versione = u.searchParams.get('modelVersionId') const mm = u.pathname.match(/\/models\/(\d+)/) @@ -198,9 +207,14 @@ async function risolvi(link: string): Promise<{ url: string; nome?: string; file type Versione = { name?: string; trainedWords?: string[]; images?: { url: string }[]; files?: { name: string; downloadUrl: string; primary?: boolean }[]; model?: { name?: string } } let info: Versione | null = null if (versione) { - info = (await (await fetch(`https://civitai.com/api/v1/model-versions/${versione}`, { headers: auth })).json()) as Versione + const r = await fetch(`https://civitai.com/api/v1/model-versions/${versione}`, { headers: auth, signal: AbortSignal.timeout(20_000) }) + if (!r.ok) throw new Error(`Civitai non risponde (${r.status}): controlla il link, o metti il token API in Opzioni.`) + info = (await r.json()) as Versione } else if (mm) { - const m = (await (await fetch(`https://civitai.com/api/v1/models/${mm[1]}`, { headers: auth })).json()) as { name: string; modelVersions: Versione[] } + const r = await fetch(`https://civitai.com/api/v1/models/${mm[1]}`, { headers: auth, signal: AbortSignal.timeout(20_000) }) + if (!r.ok) throw new Error(`Civitai non risponde (${r.status}): controlla il link, o metti il token API in Opzioni.`) + const m = (await r.json()) as { name: string; modelVersions: Versione[] } + if (!m.modelVersions?.length) throw new Error('Civitai: quel modello non ha versioni scaricabili') info = { ...m.modelVersions[0], model: { name: m.name } } } if (!info?.files?.length) throw new Error('Civitai non mi dà il file (forse serve il token API nelle Impostazioni)') diff --git a/src/main/motore/cliente.ts b/src/main/motore/cliente.ts index 53d129b..e15c10c 100644 --- a/src/main/motore/cliente.ts +++ b/src/main/motore/cliente.ts @@ -28,6 +28,8 @@ export const suAnteprima = (f: AscoltatoreAnteprima): (() => void) => { } export function connetti(): Promise { + // già collegato (o in collegamento): niente socket in più + if (ws && ws.readyState <= WebSocket.OPEN) return Promise.resolve() chiuso = false return new Promise((ok, ko) => { const s = new WebSocket(`ws://${indirizzo()}/ws?clientId=${idCliente}`) @@ -43,7 +45,7 @@ export function connetti(): Promise { } s.onclose = () => { if (ws === s) ws = null - if (!chiuso) setTimeout(() => connetti().catch(() => undefined), 1500) + if (!chiuso) setTimeout(() => { if (!chiuso) connetti().catch(() => undefined) }, 1500) } s.onmessage = (ev) => { if (typeof ev.data === 'string') { @@ -103,9 +105,11 @@ export async function accoda(prompt: Record, promptId: string): const r = await fetch(`http://${indirizzo()}/prompt`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ prompt, client_id: idCliente, prompt_id: promptId }) + body: JSON.stringify({ prompt, client_id: idCliente, prompt_id: promptId }), + signal: AbortSignal.timeout(60_000) }) - const j = (await r.json()) as Record + // se il motore risponde con una pagina d'errore (non JSON) si dice chiaro, non «Unexpected token <» + const j = (await r.json().catch(() => ({ error: { message: `il motore ha risposto in modo strano (${r.status})` } }))) as Record if (!r.ok) throw new Error(erroreLeggibile(j)) return j.prompt_id as string } @@ -125,7 +129,7 @@ export async function caricaImmagine(percorso: string): Promise { form.append('subfolder', 'daprod') form.append('type', 'input') form.append('overwrite', 'true') - const r = await fetch(`http://${indirizzo()}/upload/image`, { method: 'POST', body: form }) + const r = await fetch(`http://${indirizzo()}/upload/image`, { method: 'POST', body: form, signal: AbortSignal.timeout(120_000) }) if (!r.ok) throw new Error('non riesco a passare la foto al motore (' + r.status + ')') const j = (await r.json()) as { name: string; subfolder: string } const rif = j.subfolder ? `${j.subfolder}/${j.name}` : j.name @@ -135,14 +139,14 @@ export async function caricaImmagine(percorso: string): Promise { /** le uscite di un lavoro dalla cronologia del motore (se il WebSocket non le ha portate, es. risultato in cache) */ export async function usciteDa(promptId: string, nodo: string): Promise<{ immagini: { filename: string; subfolder: string; type: string }[]; testo?: string }> { - const r = await fetch(`http://${indirizzo()}/history/${promptId}`) + const r = await fetch(`http://${indirizzo()}/history/${promptId}`, { signal: AbortSignal.timeout(20_000) }) const j = (await r.json()) as Record }> const o = j[promptId]?.outputs?.[nodo] return { immagini: o?.images || [], testo: o?.text?.join('\n') } } export async function interrompi(): Promise { - await fetch(`http://${indirizzo()}/interrupt`, { method: 'POST' }).catch(() => undefined) + await fetch(`http://${indirizzo()}/interrupt`, { method: 'POST', signal: AbortSignal.timeout(10_000) }).catch(() => undefined) } /** libera la VRAM (scarica i modelli) */ @@ -150,14 +154,15 @@ export async function liberaMemoria(): Promise { await fetch(`http://${indirizzo()}/free`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ unload_models: true, free_memory: true }) + body: JSON.stringify({ unload_models: true, free_memory: true }), + signal: AbortSignal.timeout(10_000) }).catch(() => undefined) } /** i byte che PyTorch tiene riservati sulla scheda adesso (null se il motore non lo dice) */ async function vramRiservata(): Promise { try { - const j = (await (await fetch(`http://${indirizzo()}/system_stats`)).json()) as { devices?: { type?: string; torch_vram_total?: number; torch_vram_free?: number }[] } + const j = (await (await fetch(`http://${indirizzo()}/system_stats`, { signal: AbortSignal.timeout(5000) })).json()) as { devices?: { type?: string; torch_vram_total?: number; torch_vram_free?: number }[] } const d = j.devices?.find((x) => x.type === 'cuda') || j.devices?.[0] return d && d.torch_vram_total !== undefined ? d.torch_vram_total : null } catch { @@ -185,7 +190,7 @@ export async function scaricaModelli(): Promise { let nodiCache: Record | null = null export async function nodiDisponibili(): Promise> { if (nodiCache) return nodiCache - const r = await fetch(`http://${indirizzo()}/object_info`) + const r = await fetch(`http://${indirizzo()}/object_info`, { signal: AbortSignal.timeout(60_000) }) nodiCache = (await r.json()) as Record return nodiCache } diff --git a/src/main/motore/grafi.ts b/src/main/motore/grafi.ts index 8973caf..bd66a93 100644 --- a/src/main/motore/grafi.ts +++ b/src/main/motore/grafi.ts @@ -2,7 +2,7 @@ // modello GGUF → (Turbo) → LoRA → cache KV di Qwen-Image 2.1 · text encoder Qwen3-VL · VAE // e i parametri della pipeline ufficiale (euler/simple, cfg 1, 40 passi; 8 col Turbo). import { modelloTurbo } from '@shared/tipi' -import type { Impostazioni, Richiesta, Riquadro } from '@shared/tipi' +import type { Famiglia, Impostazioni, Richiesta, Riquadro } from '@shared/tipi' import type { ChiaviRtx } from './cliente' /** il profilo di memoria: con poca VRAM il VAE lavora a tessere (niente picchi che finiscono nella RAM condivisa) */ @@ -66,7 +66,21 @@ function caricaClip(g: Grafo, imp: Impostazioni): Rif { return r(g.add('CLIPLoader', { clip_name: imp.encoder, type: 'qwen_image', device: imp.encoderSuCpu ? 'cpu' : 'default' }, titolo)) } +/** il modello che disegna questa richiesta */ +export const famigliaDi = (q: Richiesta, imp: Impostazioni): Famiglia => q.famiglia ?? imp.famiglia ?? 'qwen' + +/** FLUX 3 Klein: UNETLoader (i pesi INT8/BF16 li riconosce da soli ComfyUI), text encoder Qwen3-VL 4B di tipo flux3, VAE di Klein. Niente LoRA né cache KV. */ +function baseKlein(g: Grafo, imp: Impostazioni, fam: Famiglia): { model: Rif; clip: Rif; vae: Rif } { + const file = fam === 'klein-base' ? imp.kleinBase : imp.kleinModello + const model = r(g.add('UNETLoader', { unet_name: file, weight_dtype: 'default' }, 'Carico il modello')) + const clip = r(g.add('CLIPLoader', { clip_name: imp.kleinEncoder, type: 'flux3', device: imp.encoderSuCpu ? 'cpu' : 'default' }, 'Carico il text encoder')) + const vae = r(g.add('VAELoader', { vae_name: imp.kleinVae }, 'Carico il VAE')) + return { model, clip, vae } +} + function base(g: Grafo, q: Richiesta, imp: Impostazioni): { model: Rif; clip: Rif; vae: Rif } { + const fam = famigliaDi(q, imp) + if (fam !== 'qwen') return baseKlein(g, imp, fam) const carico = 'Carico il modello' const unet = imp.modello.toLowerCase().endsWith('.gguf') ? g.add('UnetLoaderGGUF', { unet_name: imp.modello }, carico) @@ -85,7 +99,71 @@ function base(g: Grafo, q: Richiesta, imp: Impostazioni): { model: Rif; clip: Ri return { model, clip, vae } } -function campiona(g: Grafo, q: Richiesta, model: Rif, enc: string, latente: Rif, passi: number, denoise = 1): Rif { +/** il testo e le foto di riferimento per il modello, nella forma che ognuno vuole: Qwen li legge tutti col suo encoder (che dà anche la latente + * della foto da modificare); Klein codifica il testo e mette ogni foto come riferimento (VAEEncode → ReferenceLatent). */ +interface Condizioni { + pos: Rif + neg: Rif + /** la latente da cui partire, se il modello la dà (Qwen in modifica) */ + lat?: Rif +} + +const ORDINALI = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth'] + +function condiziona( + g: Grafo, + fam: Famiglia, + ctx: { clip: Rif; vae: Rif }, + o: { + prompt: string + neg: string + /** Qwen: lato di lavoro per le foto (0 = come il latente) */ + resolution: number + /** Qwen: passa il VAE (serve se ci sono foto) */ + conVae: boolean + /** le foto (già caricate): è la prima */ + immagini: Rif[] + /** Klein: la prima foto si porta a questa misura (è la foto che si modifica); le altre stanno sotto 1 MP */ + misuraPrima?: { w: number; h: number } + /** Qwen: le foto dall'indice `scalaDa` in poi si portano a questo numero di megapixel prima di darle all'encoder (se manca, vanno com'è) */ + mpRiferimenti?: number + scalaDa?: number + fase: string + } +): Condizioni { + if (fam === 'qwen') { + const inputs: Record = { clip: ctx.clip, prompt: o.prompt, negative_prompt: o.neg, resolution: o.resolution } + if (o.conVae) inputs.vae = ctx.vae + o.immagini.forEach((im, i) => { + inputs[`images.image_${i + 1}`] = o.mpRiferimenti !== undefined && i >= (o.scalaDa ?? 1) ? r(g.add('ImageScaleToTotalPixels', { image: im, upscale_method: 'lanczos', megapixels: o.mpRiferimenti, resolution_steps: 32 })) : im + }) + const enc = g.add('TextEncodeQwenImage21', inputs, o.fase) + return { pos: r(enc, 0), neg: r(enc, 1), lat: r(enc, 2) } + } + // Klein non ha i tag : le foto si citano a parole («the second image»), come fa il suo prompt upsampler. Il negativo è il testo vuoto, o quello scritto + const parole = o.prompt.replace(//g, (_m, n: string) => `the ${ORDINALI[Number(n) - 1] || 'last'} image`) + let pos = r(g.add('CLIPTextEncode', { clip: ctx.clip, text: parole }, o.fase)) + let neg = r(g.add('CLIPTextEncode', { clip: ctx.clip, text: o.neg || '' })) + o.immagini.slice(0, 10).forEach((im, i) => { + const pronta = + i === 0 && o.misuraPrima + ? r(g.add('ImageScale', { image: im, upscale_method: 'lanczos', width: o.misuraPrima.w, height: o.misuraPrima.h, crop: 'center' })) + : r(g.add('ImageScaleToTotalPixels', { image: im, upscale_method: 'lanczos', megapixels: 1, resolution_steps: 16 })) + const lat = r(g.add('VAEEncode', { pixels: pronta, vae: ctx.vae }, i === 0 ? 'Preparo le foto' : undefined)) + pos = r(g.add('ReferenceLatent', { conditioning: pos, latent: lat })) + neg = r(g.add('ReferenceLatent', { conditioning: neg, latent: lat })) + }) + return { pos, neg } +} + +/** la latente vuota per disegnare da zero a quella misura */ +function latenteVuota(g: Grafo, fam: Famiglia, w: number, h: number): Rif { + return r(g.add(fam === 'qwen' ? 'EmptyLatentImage' : 'EmptyFlux2LatentImage', { width: w, height: h, batch_size: 1 })) +} + +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) + const cfg = fam === 'qwen' ? q.cfg : fam === 'klein' ? 1 : q.cfg > 1 ? q.cfg : 3.5 return r( g.add( 'KSampler', @@ -93,11 +171,11 @@ function campiona(g: Grafo, q: Richiesta, model: Rif, enc: string, latente: Rif, model, seed: q.seed, steps: passi, - cfg: q.cfg, - sampler_name: q.sampler, - scheduler: q.scheduler, - positive: r(enc, 0), - negative: r(enc, 1), + cfg, + sampler_name: fam === 'qwen' ? q.sampler : 'euler', + scheduler: fam === 'qwen' ? q.scheduler : 'simple', + positive: c.pos, + negative: c.neg, latent_image: latente, denoise }, @@ -226,13 +304,6 @@ export function costruisci(q: Richiesta, imp: Impostazioni, ing: Ingressi, prefi return { prompt: g.nodi, uscita, fasi: g.fasi, megapixel: 0, passi: 1 } } - // il rilevatore piccolo (Florence-2): riquadri di persone e oggetti in un attimo, senza Qwen - if (q.modalita === 'trova') { - const img = r(g.add('LoadImage', { image: ing.immagini[0].nome }, 'Guardo la foto')) - const uscita = g.add('DaProdTrova', { image: img, modello: 'florence-2-base-ft' }, 'Cerco persone e oggetti') - return { prompt: g.nodi, uscita, fasi: g.fasi, megapixel: 0, passi: 1 } - } - if (q.modalita === 'descrivi') { const clip = caricaClip(g, imp) const inputs: Record = { @@ -250,6 +321,7 @@ export function costruisci(q: Richiesta, imp: Impostazioni, ing: Ingressi, prefi use_default_template: true, system_prompt: q.sistema || '' } + if (q.presenza !== undefined) inputs['sampling_mode.presence_penalty'] = q.presenza if (ing.immagini[0]) inputs.image = r(g.add('LoadImage', { image: ing.immagini[0].nome }, 'Guardo la foto')) const gen = g.add('TextGenerate', inputs, 'Scrivo') const uscita = g.add('PreviewAny', { source: r(gen, 0) }) @@ -268,26 +340,20 @@ export function costruisci(q: Richiesta, imp: Impostazioni, ing: Ingressi, prefi } const { model, clip, vae } = base(g, q, imp) + const fam = famigliaDi(q, imp) const leggo = 'Leggo il prompt e le foto' if (q.modalita === 'crea') { - const testo = q.trasparente ? AVVOLGI_TRASPARENTE(q.prompt) : q.prompt - // con dei riferimenti (…) Qwen-Image 2.1 li guarda mentre disegna da zero: persone, vestiti, oggetti, stili - const inputs: Record = { clip, prompt: testo, negative_prompt: neg, resolution: 1024 } - if (ing.immagini.length) { - inputs.vae = vae - ing.immagini.slice(0, 16).forEach((im, i) => { - const caricata = r(g.add('LoadImage', { image: im.nome }, 'Carico i riferimenti')) - inputs[`images.image_${i + 1}`] = r(g.add('ImageScaleToTotalPixels', { image: caricata, upscale_method: 'lanczos', megapixels: 1, resolution_steps: 32 })) - }) - } - const enc = g.add('TextEncodeQwenImage21', inputs, leggo) - const lat = r(g.add('EmptyLatentImage', { width: q.larghezza, height: q.altezza, batch_size: 1 })) + const testo = q.trasparente && fam === 'qwen' ? AVVOLGI_TRASPARENTE(q.prompt) : q.prompt + // con dei riferimenti (…) il modello li guarda mentre disegna da zero: persone, vestiti, oggetti, stili + const caricate = ing.immagini.slice(0, fam === 'qwen' ? 16 : 10).map((im) => r(g.add('LoadImage', { image: im.nome }, 'Carico i riferimenti'))) + const cond = condiziona(g, fam, { clip, vae }, { prompt: testo, neg, resolution: 1024, conVae: caricate.length > 0, immagini: caricate, mpRiferimenti: 1, scalaDa: 0, fase: leggo }) + const lat = latenteVuota(g, fam, q.larghezza, q.altezza) // il Turbo è stato allenato con la scaletta del rumore che dipende dalla grandezza (come nel suo workflow) - const m = conTurbo(q, imp) && !modelloTurbo(imp.modello) + const m = fam === 'qwen' && conTurbo(q, imp) && !modelloTurbo(imp.modello) ? r(g.add('ModelSamplingFlux', { model, max_shift: 0.6935, base_shift: 0.5, width: q.larghezza, height: q.altezza })) : model - const ks = campiona(g, q, m, enc, lat, q.passi) + const ks = campiona(g, q, fam, m, cond, lat, q.passi) const mp = (q.larghezza * q.altezza) / 1048576 const uscita = salva(g, allaMisura(g, q, imp, sviluppa(g, q, ks, vae, mp), q.larghezza, q.altezza, rtx), prefisso) return { prompt: g.nodi, uscita, fasi: g.fasi, megapixel: mp, passi: q.passi } @@ -296,13 +362,11 @@ export function costruisci(q: Richiesta, imp: Impostazioni, ing: Ingressi, prefi if (q.modalita === 'modifica') { // tutta la foto: image_1 è la foto, le altre sono riferimenti (…); il latente segue image_1 const res = Math.round((Math.sqrt(q.megapixel) * 1024) / 32) * 32 - const inputs: Record = { clip, prompt: q.prompt, negative_prompt: neg, resolution: res, vae } - ing.immagini.slice(0, 16).forEach((im, i) => { - inputs[`images.image_${i + 1}`] = r(g.add('LoadImage', { image: im.nome }, 'Carico le foto')) - }) - const enc = g.add('TextEncodeQwenImage21', inputs, leggo) - const ks = campiona(g, q, model, enc, r(enc, 2), q.passi) const d = dimensioniPer(q.megapixel, ing.immagini[0].larghezza, ing.immagini[0].altezza) + const caricate = ing.immagini.slice(0, fam === 'qwen' ? 16 : 10).map((im) => r(g.add('LoadImage', { image: im.nome }, 'Carico le foto'))) + const cond = condiziona(g, fam, { clip, vae }, { prompt: q.prompt, neg, resolution: res, conVae: true, immagini: caricate, misuraPrima: d, fase: leggo }) + const lat = cond.lat ?? latenteVuota(g, fam, d.w, d.h) + const ks = campiona(g, q, fam, model, cond, lat, q.passi) const mp = (d.w * d.h) / 1048576 const uscita = salva(g, allaMisura(g, q, imp, sviluppa(g, q, ks, vae, mp), d.w, d.h, rtx), prefisso) const area = { x: 0, y: 0, w: ing.immagini[0].larghezza, h: ing.immagini[0].altezza } @@ -327,13 +391,13 @@ export function costruisci(q: Richiesta, imp: Impostazioni, ing: Ingressi, prefi img = ingrandisciImg(g, imp, rtx, img, f) } img = r(g.add('ImageScale', { image: img, upscale_method: 'lanczos', width: W, height: H, crop: 'disabled' })) - const testo = q.trasparente ? AVVOLGI_TRASPARENTE(q.prompt) : q.prompt - const enc = g.add('TextEncodeQwenImage21', { clip, prompt: testo, negative_prompt: neg, resolution: 1024 }, leggo) + const testo = q.trasparente && fam === 'qwen' ? AVVOLGI_TRASPARENTE(q.prompt) : q.prompt + const cond = condiziona(g, fam, { clip, vae }, { prompt: testo, neg, resolution: 1024, conVae: false, immagini: [], fase: leggo }) const mp = (W * H) / 1048576 const lat = codifica(g, img, vae, mp) const forza = q.forza ?? (q.modalita === 'rifinisci' ? 0.45 : 0.65) const passi = passiPer(q.passi, forza, minimo) - const ks = campiona(g, q, model, enc, lat, passi, forza) + const ks = campiona(g, q, fam, model, cond, lat, passi, forza) const uscita = salva(g, allaMisura(g, q, imp, sviluppa(g, q, ks, vae, mp), W, H, rtx), prefisso) return { prompt: g.nodi, uscita, fasi: g.fasi, megapixel: mp, passi } } @@ -408,11 +472,11 @@ export function costruisci(q: Richiesta, imp: Impostazioni, ing: Ingressi, prefi risoluzione = Math.round((Math.sqrt(Math.min(q.megapixel, (im.larghezza * im.altezza) / 1048576)) * 1024) / 32) * 32 } // i riferimenti li porto io a ~1 MP - const inputs: Record = { clip, prompt, negative_prompt: neg, resolution: risoluzione, vae, 'images.image_1': vista } const riferimenti = ing.immagini.slice(1, 15) - riferimenti.forEach((ri, i) => { + const fotoEncoder: Rif[] = [vista] + riferimenti.forEach((ri) => { const caricata = r(g.add('LoadImage', { image: ri.nome }, 'Carico i riferimenti')) - inputs[`images.image_${i + 2}`] = r(g.add('ImageScaleToTotalPixels', { image: caricata, upscale_method: 'lanczos', megapixels: Math.min(1, q.megapixel), resolution_steps: 32 })) + fotoEncoder.push(r(g.add('ImageScaleToTotalPixels', { image: caricata, upscale_method: 'lanczos', megapixels: Math.min(1, q.megapixel), resolution_steps: 32 }))) }) if (q.modalita === 'zona') { // Il modello guarda SEMPRE la foto intera: come ultima immagine riceve tutta la foto con la zona evidenziata in @@ -429,23 +493,24 @@ export function costruisci(q: Richiesta, imp: Impostazioni, ing: Ingressi, prefi const conVelo = r(g.add('ImageCompositeMasked', { destination: fotoP, source: rosso, x: 0, y: 0, resize_source: false, mask: velo })) const panoramica = r(g.add('ImageCompositeMasked', { destination: conVelo, source: rosso, x: 0, y: 0, resize_source: false, mask: bordo }, 'Guardo tutta la foto')) const n = riferimenti.length + 2 - inputs[`images.image_${n}`] = panoramica + fotoEncoder.push(panoramica) const dove = zona && !tutta ? doveNelRiquadro(zona, box) : null const istruzione = q.prompt.trim().replace(/[.\s]+$/, '') - inputs.prompt = - `${istruzione}. Make this change only inside the area highlighted in red in , which shows the whole photo` + - (tutta ? '; is that same photo.' : `; is a close-up of that part of the photo${dove ? `, with the area in ${dove}` : ''}.`) + + const nomeFoto = (k: number): string => (fam === 'qwen' ? `` : `the ${ORDINALI[k - 1] || 'last'} image`) + prompt = + `${istruzione}. Make this change only inside the area highlighted in red in ${nomeFoto(n)}, which shows the whole photo` + + (tutta ? `; ${nomeFoto(1)} is that same photo.` : `; ${nomeFoto(1)} is a close-up of that part of the photo${dove ? `, with the area in ${dove}` : ''}.`) + ' Keep everything outside that area exactly the same, and do not add any red tint or outline.' + - (riempi ? ' The smudged, blurry patch in is only a placeholder: redraw it sharp and natural, matching the perspective, lighting and texture of the surroundings.' : '') + (riempi ? ` The smudged, blurry patch in ${nomeFoto(1)} is only a placeholder: redraw it sharp and natural, matching the perspective, lighting and texture of the surroundings.` : '') } - const enc = g.add('TextEncodeQwenImage21', inputs, leggo) + const cond = condiziona(g, fam, { clip, vae }, { prompt, neg, resolution: risoluzione, conVae: true, immagini: fotoEncoder, misuraPrima: { w: box.tw, h: box.th }, fase: leggo }) const mp = (box.tw * box.th) / 1048576 const lat0 = codifica(g, sorgente, vae, mp) const lat = r(g.add('SetLatentNoiseMask', { samples: lat0, mask: mLatente })) const dd = r(g.add('DifferentialDiffusion', { model, strength: 1 })) const forza = q.forza ?? 1 const passi = passiPer(q.passi, forza, minimo) - const ks = campiona(g, q, dd, enc, lat, passi, forza) + const ks = campiona(g, q, fam, dd, cond, lat, passi, forza) // il doppio passaggio nel VAE sposta un filo i colori: si misurano appena fuori dalla zona e si rimettono a posto const fatto = r(g.add('DaProdAccordaColori', { image: sviluppa(g, { ...q, trasparente: false }, ks, vae, mp), reference: grande, mask: mDura, strength: 1 })) const indietro = r(g.add('ImageScale', { image: fatto, upscale_method: 'lanczos', width: box.w, height: box.h, crop: 'disabled' }, 'Incollo')) diff --git a/src/main/motore/klein.ts b/src/main/motore/klein.ts new file mode 100644 index 0000000..97bbac8 --- /dev/null +++ b/src/main/motore/klein.ts @@ -0,0 +1,51 @@ +// FLUX 3 Klein: quali file usare. Le impostazioni possono dirlo; se sono vuote si usano quelli scaricati dall'app (risolti.json) e, +// in mancanza, si cerca nelle cartelle dei modelli (chi ha già i file li mette lì a mano). +import { existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type { Famiglia, Impostazioni } from '@shared/tipi' +import { risoltoSalvato } from '../installa/risolvi' + +export interface FileKlein { + modello: string + encoder: string + vae: string +} + +const lista = (imp: Impostazioni, sotto: string): string[] => { + try { + return readdirSync(join(imp.cartellaModelli, sotto)).filter((f) => /\.(safetensors|gguf)$/i.test(f) && !f.endsWith('.part')) + } catch { + return [] + } +} +const sul = (imp: Impostazioni, sotto: string, nome: string | undefined): string => (nome && existsSync(join(imp.cartellaModelli, sotto, nome)) ? nome : '') +/** fra i candidati, il primo che soddisfa le espressioni in ordine di preferenza */ +const preferisci = (nomi: string[], ordine: RegExp[]): string => { + for (const re of ordine) { + const n = nomi.find((x) => re.test(x)) + if (n) return n + } + return '' +} + +const NON_PESI = /vae|qwen|text_enc|mmproj/i + +/** i file di Klein (o di Klein Base) da usare adesso, o l'errore da mostrare in italiano se mancano */ +export function fileKlein(imp: Impostazioni, famiglia: Famiglia): FileKlein | string { + const base = famiglia === 'klein-base' + const idModelli = base ? ['klein-base-int8', 'klein-base-bf16'] : ['klein-int8', 'klein-bf16'] + const scelto = base ? imp.kleinBase : imp.kleinModello + let modello = sul(imp, 'diffusion_models', scelto) + if (!modello) for (const id of idModelli) modello = modello || sul(imp, 'diffusion_models', risoltoSalvato(id)?.file) + if (!modello) { + const tutti = [...lista(imp, 'diffusion_models'), ...lista(imp, 'unet')].filter((n) => /klein/i.test(n) && !NON_PESI.test(n) && /base/i.test(n) === base) + modello = preferisci(tutti, [/int8/i, /bf16/i, /fp8/i, /./]) + } + let encoder = sul(imp, 'text_encoders', imp.kleinEncoder) || sul(imp, 'text_encoders', risoltoSalvato('klein-te')?.file) + if (!encoder) encoder = preferisci(lista(imp, 'text_encoders'), [/^qwen3vl_4b_int8/i, /(qwen3|qwen_3).*vl.*4b.*(int8|fp8|q8)/i, /(qwen3|qwen_3).*4b.*(int8|fp8|q8)/i, /(qwen3|qwen_3).*vl.*4b/i, /(qwen3|qwen_3).*4b/i, /klein/i]) + let vae = sul(imp, 'vae', imp.kleinVae) || sul(imp, 'vae', risoltoSalvato('klein-vae')?.file) + if (!vae) vae = preferisci(lista(imp, 'vae'), [/^flux2-vae\./i, /klein/i, /flux.?[23]/i, /^ae\./i, /flux/i]) + const mancano = [!modello && (base ? 'il modello Klein Base' : 'il modello Klein'), !encoder && 'il text encoder (Qwen3-VL 4B)', !vae && 'il VAE'].filter(Boolean) + if (mancano.length) return `FLUX 3 Klein non è pronto: mancano ${mancano.join(', ')}. Scaricali dal selettore del modello (o da Opzioni → Scarica altri modelli).` + return { modello, encoder, vae } +} diff --git a/src/main/motore/lavori.ts b/src/main/motore/lavori.ts index e870d87..b9081d4 100644 --- a/src/main/motore/lavori.ts +++ b/src/main/motore/lavori.ts @@ -9,9 +9,11 @@ import { USCITA } from '../percorsi' import { impostazioni, profiloMemoria, registraTempo, stimaPasso } from '../impostazioni' import { costruisci, type GrafoPronto, type Ingressi } from './grafi' import { SISTEMA_TRADUCI, daTradurre, pulisciTraduzione, tokenPer } from './traduci' -import { accoda, caricaImmagine, chiaviRtx, collegato, connetti, interrompi, scaricaModelli, suAnteprima, suMessaggio, usciteDa, type MessaggioComfy } from './cliente' +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 { PUP_I2I, PUP_T2I } from './pup' +import { fileKlein } from './klein' import { elencoLora } from '../lora' import { scaricaVoce, statoModelli } from '../installa/installatore' @@ -38,7 +40,7 @@ const casuale = (): number => Math.floor(Math.random() * 2 ** 48) /** i LoRA accesi si usano sempre: la loro parola chiave entra nel prompt da sola (se non c'è già), da qualsiasi pagina arrivi la richiesta */ function conParoleLora(q: Richiesta): Richiesta { - if (q.modalita === 'descrivi' || !q.prompt?.trim() || !q.lora?.length) return q + if (q.modalita === 'descrivi' || !q.prompt?.trim() || !q.lora?.length || (q.famiglia ?? impostazioni().famiglia) !== 'qwen') return q let info try { info = elencoLora() @@ -209,7 +211,7 @@ 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 (e il rilevatore), o niente di importante (il traduttore sta sul processore) */ +/** che cosa occupa la scheda: il modello immagine, l'LLM, o niente di importante (il traduttore sta sul processore) */ type Occupa = 'immagine' | 'testo' | 'cpu' let ultimoOccupa: Occupa | null = null @@ -272,21 +274,40 @@ async function traduttorePronto(l: Lavoro): Promise { } } -/** il rilevatore (Florence-2) è sul disco? se manca lo scarica (una volta sola, ~460 MB); false se non ci riesce */ -async function rilevatorePronto(l: Lavoro): Promise { - const stato = () => statoModelli().find((v) => v.id === 'trova-florence')?.presente - if (stato()) return true - try { - l.fase = 'Scarico il rilevatore' - avvisa(l) - await scaricaVoce('trova-florence', (r, t) => { - l.fase = `Scarico il rilevatore${t ? ` · ${Math.round((r / t) * 100)}%` : ''}` - avvisa(l, false) - }) - return !!stato() - } catch { - return false - } +// le didascalie già scritte (più immagini dallo stesso prompt = un'espansione sola) +const potenziati = new Map() + +/** Klein: espande una richiesta breve in una didascalia dettagliata (come l'upsampler ufficiale); in inglese anche se la richiesta era in italiano. + * Se non riesce si ripiega sulla traduzione normale. */ +async function potenzia(l: Lavoro, ing: Ingressi): Promise { + const q = l.richiesta + const p = q.prompt.trim() + const conFoto = ing.immagini.length > 0 + const chiave = `${conFoto}|${q.larghezza}x${q.altezza}|${p}` + const gia = potenziati.get(chiave) + if (gia) return gia + const imp = impostazioni() + l.fase = 'Potenzio il prompt' + avvisa(l) + // il testo che l'upsampler vede: con foto, la richiesta di modifica; da zero, la richiesta e la forma della tela (TARGET CANVAS) + const mcd = (a: number, b: number): number => (b ? mcd(b, a % b) : a) + const g0 = mcd(q.larghezza || 1, q.altezza || 1) + const utente = conFoto + ? `Reference images follow in order (the first image, the second image, ...).\nUser request: ${p.replace(//g, (_m, n: string) => `the image number ${n}`)}\nOutput the caption.` + : `${q.larghezza > 0 && q.modalita === 'crea' ? `TARGET CANVAS: ${Math.round(q.larghezza / g0)}:${Math.round(q.altezza / g0)}\n` : ''}${p}` + const g = costruisci( + { ...q, modalita: 'descrivi', prompt: utente, sistema: conFoto ? PUP_I2I : PUP_T2I, maxToken: conFoto ? 320 : 600, temperatura: 0.15, presenza: conFoto ? 0 : 1.5, seed: 0, 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, '') + 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) + l.passo = 0 + l.passiTotali = 0 + return t } /** 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'è */ @@ -318,6 +339,21 @@ async function inInglese(l: Lavoro): Promise { return t } +// quando non c'è niente da fare, dopo un po' i modelli escono dalla VRAM (la scheda torna libera per gli altri programmi) +let timerScarico: ReturnType | null = null +function programmaScarico(): void { + if (timerScarico) clearTimeout(timerScarico) + timerScarico = null + const sec = impostazioni().scaricaDopo + if (!sec || sec <= 0) return + timerScarico = setTimeout(() => { + timerScarico = null + if (corrente || lavori.some((x) => x.stato === 'in coda') || infoMotore().stato !== 'pronto') return + ultimoOccupa = null + void scaricaModelli().then(() => aggiornaVram()) + }, sec * 1000) +} + let girando = false async function prossimo(): Promise { if (girando) return @@ -330,10 +366,13 @@ async function prossimo(): Promise { } } finally { girando = false + programmaScarico() } } async function esegui(l: Lavoro): Promise { + if (timerScarico) clearTimeout(timerScarico) + timerScarico = null corrente = l l.stato = 'in corso' l.inizio = Date.now() @@ -350,21 +389,40 @@ async function esegui(l: Lavoro): Promise { await avviaMotore() } if (!collegato()) await connetti() - if (l.richiesta.modalita === 'trova' && !(await rilevatorePronto(l))) throw new Error('non riesco a scaricare il rilevatore: in Opzioni scegli «Qwen3-VL» per Estrai') const ing = await prepara(l.richiesta) - const inglese = await inInglese(l) + // FLUX 3 Klein: i suoi file (scelti, scaricati dall'app o messi a mano) e, se serve, il prompt espanso + const fam = l.richiesta.famiglia ?? imp.famiglia + let impLavoro = imp + const usaKlein = fam !== 'qwen' && l.richiesta.modalita !== 'descrivi' && l.richiesta.modalita !== 'traduci' && l.richiesta.modalita !== 'ingrandisci' + if (usaKlein) { + const f = fileKlein(imp, fam) + if (typeof f === 'string') throw new Error(f) + // FLUX 3 vuole ComfyUI 0.41 o più: se il motore è ancora quello vecchio si dice chiaro + const nodi = await nodiDisponibili().catch(() => ({}) as Record) + if (!nodi['EmptyFlux2LatentImage'] || !nodi['ReferenceLatent']) throw new Error('Il motore è ancora della versione vecchia e non conosce FLUX 3: chiudi e riapri l\'app, si aggiorna da solo.') + impLavoro = { ...imp, kleinModello: f.modello, kleinBase: f.modello, kleinEncoder: f.encoder, kleinVae: f.vae } + } + let inglese: string + if (usaKlein && imp.potenziaKlein && l.richiesta.potenzia && l.richiesta.prompt.trim() && (l.richiesta.modalita === 'crea' || l.richiesta.modalita === 'modifica')) { + try { + inglese = await potenzia(l, ing) + } catch (e) { + if ((e as Error).message === 'annullato') throw e + inglese = await inInglese(l) + } + } else inglese = await inInglese(l) if (inglese !== l.richiesta.prompt) l.promptInglese = inglese tempiPasso = [] ultimoPasso = 0 // l'ingranditore NVIDIA RTX, se il motore ce l'ha e non l'hai spento in Opzioni const rtx = imp.upscalerRtx ? await chiaviRtx().catch(() => null) : null - const grafo = costruisci({ ...l.richiesta, prompt: inglese }, imp, ing, l.id.slice(0, 8), profiloMemoria(infoMotore().vramTotale), rtx) + const grafo = costruisci({ ...l.richiesta, prompt: inglese }, impLavoro, ing, l.id.slice(0, 8), profiloMemoria(infoMotore().vramTotale), rtx) l.areaAnteprima = grafo.area l.megapixel = grafo.megapixel || undefined l.passiTotali = grafo.passi l.stima = grafo.megapixel ? Math.round(stimaPasso(grafo.megapixel) * grafo.passi + 6) : undefined - const testo = l.richiesta.modalita === 'descrivi' || l.richiesta.modalita === 'trova' - const uscita = await lancia(grafo, testo ? 'testo' : 'immagine', l, l.richiesta.modalita === 'descrivi') + const testo = l.richiesta.modalita === 'descrivi' + const uscita = await lancia(grafo, testo ? 'testo' : 'immagine', l, testo) if (testo) { l.testo = (uscita?.testo || '').trim() } else { @@ -384,7 +442,7 @@ async function esegui(l: Lavoro): Promise { sampler: q.sampler, scheduler: q.scheduler, lora: q.lora, - modello: imp.modello, + modello: usaKlein ? (fam === 'klein-base' ? 'FLUX 3 Klein Base · ' : 'FLUX 3 Klein · ') + impLavoro.kleinModello : imp.modello, forza: q.forza, trasparente: q.trasparente, origine: q.origine, @@ -426,7 +484,7 @@ async function esegui(l: Lavoro): Promise { /** esegue subito un lavoro di testo e aspetta il risultato (migliora prompt, descrivi foto) */ export async function scriviTesto(q: Richiesta): Promise { - const [l] = accodaRichiesta({ ...q, modalita: q.modalita === 'trova' ? 'trova' : 'descrivi', quante: 1 }) + const [l] = accodaRichiesta({ ...q, modalita: 'descrivi', quante: 1 }) return new Promise((ok, ko) => { const via = suCambioLavori(() => { if (l.stato === 'fatto') { diff --git a/src/main/motore/processo.ts b/src/main/motore/processo.ts index 0a3c387..a19d494 100644 --- a/src/main/motore/processo.ts +++ b/src/main/motore/processo.ts @@ -1,10 +1,10 @@ // Il processo di ComfyUI: lo avvia, guarda quando è pronto, tiene le ultime righe del log, lo chiude. import { spawn, execFile, type ChildProcess } from 'node:child_process' import { createServer } from 'node:net' -import { appendFileSync } from 'node:fs' +import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { InfoMotore } from '@shared/tipi' -import { COMFY, ENTRATA, LOG, PYTHON, TEMP, USCITA, UTENTE_COMFY, assicura } from '../percorsi' +import { COMFY, ENTRATA, LOG, MOTORE, PYTHON, TEMP, USCITA, UTENTE_COMFY, assicura } from '../percorsi' import { impostazioni, profiloMemoria, salvaImpostazioni } from '../impostazioni' import { motoreInstallato } from '../installa/installatore' @@ -62,10 +62,43 @@ async function trovaPorta(da: number): Promise { const attendi = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) +// il numero del processo del motore si scrive su disco: se l'app viene chiusa a forza (Task Manager, crash, installer) il motore resta vivo +// con la scheda occupata; al prossimo avvio lo si riconosce e lo si chiude, invece di lanciarne un secondo +const FILE_PID = join(MOTORE, 'motore.pid') +function chiudiOrfano(): Promise { + return new Promise((ok) => { + let pid = 0 + try { + pid = Number(readFileSync(FILE_PID, 'utf8').trim()) || 0 + } catch { + /* nessun motore precedente */ + } + rmSync(FILE_PID, { force: true }) + if (!pid) return ok() + execFile('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { windowsHide: true }, (_e, out) => { + if (!/python/i.test(String(out))) return ok() + aggiungiRiga(`> un motore rimasto acceso da prima (pid ${pid}): lo chiudo`) + execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }, () => ok()) + }) + }) +} + +/** il registro del motore non cresce per sempre: oltre 5 MB si archivia (e si tiene solo l'ultimo) */ +function ruotaLog(): void { + const f = join(LOG, 'motore.log') + try { + if (existsSync(f) && statSync(f).size > 5 * 1024 * 1024) { + rmSync(join(LOG, 'motore.old.log'), { force: true }) + renameSync(f, join(LOG, 'motore.old.log')) + } + } catch { + /* niente */ + } +} export async function aggiornaVram(): Promise { if (info.stato !== 'pronto') return try { - const r = await fetch(`http://${indirizzo()}/system_stats`) + const r = await fetch(`http://${indirizzo()}/system_stats`, { signal: AbortSignal.timeout(5000) }) const j = (await r.json()) as { system: { comfyui_version: string }; devices: { name: string; vram_total: number; vram_free: number }[] } const d = j.devices[0] // la VRAM della scheda si ricorda: serve a scegliere il profilo di memoria prima del prossimo avvio @@ -92,6 +125,8 @@ export function avviaMotore(): Promise { } const imp = impostazioni() assicura(ENTRATA, USCITA, UTENTE_COMFY, LOG, join(TEMP, 'comfy'), imp.cartellaModelli) + ruotaLog() + await chiudiOrfano() const porta = await trovaPorta(imp.porta) cambia({ stato: 'avvio', porta, messaggio: 'Avvio il motore…' }) const args = [ @@ -115,12 +150,24 @@ export function avviaMotore(): Promise { if (imp.veloce && !/--fast\b/.test(imp.argomentiExtra)) args.push('--fast', 'fp16_accumulation') if (imp.argomentiExtra.trim()) args.push(...imp.argomentiExtra.trim().split(/\s+/)) aggiungiRiga('> python ' + args.join(' ')) - const p = spawn(PYTHON, args, { - cwd: COMFY, - windowsHide: true, - env: { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } - }) + // un altro Python installato dall'utente non deve influenzare quello del motore + const ambiente = { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } as NodeJS.ProcessEnv + delete ambiente.PYTHONPATH + delete ambiente.PYTHONHOME + const p = spawn(PYTHON, args, { cwd: COMFY, windowsHide: true, env: ambiente }) proc = p + if (p.pid) { + try { + writeFileSync(FILE_PID, String(p.pid)) + } catch { + /* niente */ + } + } + let erroreAvvio = '' + // se python.exe manca o l'antivirus lo ha messo in quarantena, spawn dà un errore: senza questo si aspetterebbero 4 minuti per niente + p.on('error', (e) => { + erroreAvvio = e.message + }) let coda = '' const leggi = (b: Buffer): void => { coda += b.toString('utf8') @@ -133,6 +180,7 @@ export function avviaMotore(): Promise { let uscito = false p.on('exit', (c) => { uscito = true + rmSync(FILE_PID, { force: true }) if (proc === p) { proc = null const ultime = righe.slice(-6).join('\n') @@ -142,10 +190,16 @@ export function avviaMotore(): Promise { // pronto quando risponde const inizio = Date.now() for (;;) { + if (erroreAvvio) throw new Error(`Non riesco ad avviare il motore (Python non parte): ${erroreAvvio}`) + // fermato apposta mentre partiva (Opzioni, chiusura dell'app): non è un errore + if (proc !== p && !uscito) throw new Error('annullato') if (uscito) throw new Error('il motore si è chiuso all\'avvio: guarda il log del motore') - if (Date.now() - inizio > 240_000) throw new Error('il motore non risponde dopo 4 minuti') + if (Date.now() - inizio > 600_000) { + await fermaMotore() + throw new Error('il motore non risponde dopo 10 minuti: l\'ho fermato, riprova (il primo avvio dopo un aggiornamento può essere lento)') + } try { - const r = await fetch(`http://127.0.0.1:${porta}/system_stats`) + const r = await fetch(`http://127.0.0.1:${porta}/system_stats`, { signal: AbortSignal.timeout(4000) }) if (r.ok) break } catch { /* ancora no */ @@ -157,7 +211,8 @@ export function avviaMotore(): Promise { })() return avvioInCorso .catch((e) => { - if (info.stato !== 'non installato') cambia({ stato: 'errore', messaggio: (e as Error).message }) + // se l'arresto l'ha voluto l'utente lo stato resta «spento» + if (info.stato !== 'non installato' && info.stato !== 'spento') cambia({ stato: 'errore', messaggio: (e as Error).message }) throw e }) .finally(() => { @@ -176,6 +231,8 @@ export async function fermaMotore(): Promise { export async function riavviaMotore(): Promise { await fermaMotore() + // se il motore stava ancora partendo, si aspetta che quel tentativo finisca prima di ripartire + await avvioInCorso?.catch(() => undefined) await attendi(800) await avviaMotore() } diff --git a/src/main/motore/pup.ts b/src/main/motore/pup.ts new file mode 100644 index 0000000..9dbc860 --- /dev/null +++ b/src/main/motore/pup.ts @@ -0,0 +1,9 @@ +// Il «prompt upsampler» di FLUX 3 Klein: Black Forest Labs dice di non usare il modello senza (un prompt scritto a mano dà risultati più deboli, +// perché è allenato sulle didascalie lunghe che l'upsampler scrive). L'upsampler vero è un Qwen3.5 da 9 miliardi; qui si usano gli stessi +// prompt di sistema (dal repo ufficiale github.com/black-forest-labs/flux-3-image-klein, Apache 2.0) col Qwen3-VL che l'app ha già. +// t2i = da zero, i2i = con foto da modificare o da usare come riferimenti. +export const PUP_T2I = + "You are the prompt upsampler of a text-to-image model. Expand the user's short request into ONE detailed, self-contained caption: the exact text the image model receives. Output only the caption, with no greetings, explanations, refusals, questions, Markdown, headings, JSON, coordinates, ids or angle-bracket tokens, and no quotes around it. If the input is not clearly an image request, caption a visualization of its concept. The request may begin with a \"TARGET CANVAS: W:H\" line: compose for that canvas (wide spreads content sideways, tall stacks it) and never mention the ratio or the canvas.\n\nFidelity and planning: keep every explicit subject, attribute, count, spatial constraint, style cue and piece of wording; never contradict, substitute, drop or narrow the request, and keep an abstract concept abstract. Decide everything left open as ONE concrete, consistent plan; never offer alternatives or hedge. Never re-express a stated constraint through another mechanism; add camera optics (depth of field, bokeh, blur) only when asked for or implied. Without a stated style, plan an actual photograph and open with the medium convention (\"Full-color outdoor photograph, ...\", \"Full-color indoor photograph, ...\", \"Studio photograph, ...\", \"Close-up photograph, ...\"); keep \"Photorealistic\" for renders, and turn \"photorealistic\" or \"realistic\" requests for photos into the photograph convention. Give a photograph a few ordinary, non-ideal specifics drawn from the scene's own objects instead of the stock-photo ideal. Do not invent names, brands, logos or celebrities; keep user-named ones. A long request keeps its content but not its wording: drop appraisal and sales vocabulary, turn moods into observable physical facts, and add only new concrete content with positions. Time of day is lighting, not rendered text.\n\nCaption format: one continuous prose block in the generative voice, never \"this image shows\", \"we can see\" or \"is visible\". Lead with medium and style, then scene, lighting and camera, then the subjects with concrete colors, materials and state. Every person in focus gets an observable profile: ethnicity, gender, age range, skin tone (\"fair skin\", \"brown skin\"), hair and clothing. Make the layout reconstructable from words: frame-relative positions (frame-left, frame-right, upper, lower, foreground, midground, background), sentence-initial anchors (\"On the left, ...\", \"In the background, ...\"), relations (holds, faces, behind) and a consistent order; describe up to about ten instances one by one, beyond that give the exact count and arrangement. Out-of-focus or distant planes are shapes, colors and silhouettes, never identities or readable text, and the background takes about a sixth of the caption. Be definite: no hedging adverbs, no quality or emotion judgments, no closing sentence about mood; state negations as what is present. Rendered text: transcribe every wording verbatim inside double quotes in its original language and script, keep the line breaks of multi-line text, and give its typography, color and placement; author concrete, domain-correct wording when the request needs text it does not supply, and name canonical structures (calendar, keyboard, periodic table) exactly with their full layout. All prose is English; only quoted text keeps its language. Typical length is 100 to 350 words, more for complex scenes.\n\nOutput only the caption." + +export const PUP_I2I = + "You are the prompt upsampler of an image-editing model. You see the reference image(s), attached in order (the first image, the second image, ...), and the user's short request. Expand it into ONE detailed, self-contained instruction: the caption the editing model receives together with the same images. Output only the instruction as one continuous English prose block, with no greetings, explanations, Markdown, JSON, coordinates, ids or angle-bracket tokens. Read a vague or conversational request as the most plausible edit of the supplied images.\n\nPick the voice by task shape. When one reference stays and gets bounded changes, write an imperative editing instruction on it (\"Remove the ...\", \"Change the ... to ...\", \"Zoom in on ...\"); a whole-image transformation opens with its scope (\"Convert the entire image into ...\", \"Apply a ... filter over the entire scene\", \"Horizontally flip the entire image so that ...\", \"Shift the camera viewpoint to ...\", then where the major elements should now be). When the references are drawn into a newly composed frame (combining subjects, a new scene, a garment, object or style donor), write a generation request (\"Create a photograph of ...\", \"Create an anime-style illustration of ...\", \"Generate a new image featuring ...\") that says what to take from each image, by ordinal, and what is newly generated.\n\nKeep every explicit edit, constraint, count, position, style cue and piece of wording, and decide what is left open as one concrete plan. Ground everything in what is actually visible: identify each referent unambiguously by its real appearance, never mention content that is not there, and use every image the user attached. State every end state concretely (which color, how large, which pose, which material) and give new content enough appearance to be generated. Cover the knock-on effects of the change (shadows, reflections, occlusion, perspective, scale, lighting) and say what fills a removed region. A zoom-in names what stays in frame and which neighbors are cropped out; an outpainting continues the scene in the same medium. Describe the change, not the whole scene, and close an editing instruction with one compact clause naming what stays unchanged; transferred people keep their identity, face and accessories unless asked otherwise. Transcribe on-image text verbatim in its original language inside double quotes, with line breaks as \\n, and match the original font, size and color when replacing it. Name recognizable public figures, brands and landmarks you can see. Directions are frame-relative; use no evaluative or quality words. Typical length is 50 to 150 words.\n\nOutput only the instruction." diff --git a/src/main/percorsi.ts b/src/main/percorsi.ts index 65c8b5b..cf3423e 100644 --- a/src/main/percorsi.ts +++ b/src/main/percorsi.ts @@ -2,7 +2,7 @@ // i modelli di partenza accanto (la cartella si può cambiare), la galleria in Immagini\DaProd Image. import { app } from 'electron' import { join } from 'node:path' -import { mkdirSync } from 'node:fs' +import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' const locale = process.env.LOCALAPPDATA || join(app.getPath('home'), 'AppData', 'Local') @@ -31,6 +31,19 @@ export const UV = join(BASE, app.isPackaged ? 'bin' : join('resources', 'bin'), export const NODI = join(BASE, 'engine', 'custom_nodes') export const VERSIONI = join(BASE, 'engine', 'versioni.json') +/** scrive un file di testo in modo che un'interruzione non lo lasci a metà: prima un file a parte, poi lo si rinomina */ +export function scriviAtomico(percorso: string, testo: string): void { + const tmp = percorso + '.tmp' + writeFileSync(tmp, testo) + try { + renameSync(tmp, percorso) + } catch { + // su Windows il rename può fallire se qualcuno tiene aperto il file: si scrive diretto + writeFileSync(percorso, testo) + rmSync(tmp, { force: true }) + } +} + export function assicura(...cartelle: string[]): void { for (const c of cartelle) mkdirSync(c, { recursive: true }) } diff --git a/src/main/png.ts b/src/main/png.ts index f98fac6..7b55001 100644 --- a/src/main/png.ts +++ b/src/main/png.ts @@ -1,5 +1,6 @@ // Le impostazioni di ogni immagine viaggiano dentro il PNG (chunk iTXt "daprod", UTF-8): // trascini una foto fatta con DaProd Image e ritrovi prompt, seed, LoRA. +import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs' import { crc32 } from 'node:zlib' const FIRMA = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) @@ -34,6 +35,38 @@ export function scriviMetadati(png: Buffer, meta: unknown): Buffer { return Buffer.concat(pezzi) } +/** le impostazioni e le dimensioni di un PNG leggendo solo l'inizio e la fine del file (il chunk `daprod` sta prima di IEND): + * ricostruire la galleria non deve caricare in memoria centinaia di PNG interi */ +export function leggiMetadatiDaFile(percorso: string): { meta: Record | null; larghezza: number; altezza: number } | null { + let fd = -1 + try { + fd = openSync(percorso, 'r') + const grandezza = fstatSync(fd).size + const testa = Buffer.alloc(Math.min(32, grandezza)) + readSync(fd, testa, 0, testa.length, 0) + const dim = dimensioniPng(testa) + if (!dim) return null + const n = Math.min(grandezza, 256 * 1024) + const coda = Buffer.alloc(n) + readSync(fd, coda, 0, n, grandezza - n) + const i = coda.lastIndexOf(Buffer.from('iTXtdaprod\0', 'latin1')) + if (i >= 4) { + // il chunk comincia 4 byte prima del suo tipo (la lunghezza): lo si rimette in piedi per leggerlo come un PNG normale + const lung = coda.readUInt32BE(i - 4) + const chunkIntero = coda.subarray(i - 4, i + 8 + lung + 4) + const finto = Buffer.concat([FIRMA, chunkIntero]) + return { meta: leggiMetadati(finto), larghezza: dim.larghezza, altezza: dim.altezza } + } + // chunk non trovato nella coda (file strano): si legge tutto + const tutto = readFileSync(percorso) + return { meta: leggiMetadati(tutto), larghezza: dim.larghezza, altezza: dim.altezza } + } catch { + return null + } finally { + if (fd >= 0) closeSync(fd) + } +} + export function leggiMetadati(png: Buffer): Record | null { if (!png.subarray(0, 8).equals(FIRMA)) return null let o = 8 diff --git a/src/preload/index.ts b/src/preload/index.ts index 54d5346..d47dee5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,23 @@ // Il ponte fra l'interfaccia e il processo principale: solo queste funzioni, niente Node nel renderer. import { contextBridge, ipcRenderer, webUtils } from 'electron' -const chiama = (canale: string) => (...a: unknown[]) => ipcRenderer.invoke(canale, ...a) +/** gli errori di Electron arrivano come «Error invoking remote method 'x': Error: ENOSPC…»: si toglie la parte tecnica e si dice in italiano */ +function messaggioUtente(e: unknown): string { + const t = String((e as Error)?.message ?? e).replace(/^Error invoking remote method '[^']*': (Error: )?/, '') + if (/ENOSPC/.test(t)) return 'Non c\'è più spazio sul disco.' + if (/ENOENT/.test(t)) return 'Non trovo il file o la cartella: è stato spostato o cancellato?' + if (/EPERM|EBUSY|EACCES/.test(t)) return 'Il file è in uso o protetto: riprova fra un attimo.' + if (/fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|UND_ERR|ENOTFOUND|network/i.test(t)) return 'Non riesco a collegarmi (rete assente, o il motore è spento o bloccato).' + if (/AbortError|aborted/i.test(t)) return 'annullato' + return t +} +const chiama = (canale: string) => async (...a: unknown[]) => { + try { + return await ipcRenderer.invoke(canale, ...a) + } catch (e) { + throw new Error(messaggioUtente(e)) + } +} const ascolta = (canale: string) => (f: (...a: unknown[]) => void) => { const h = (_e: unknown, ...a: unknown[]): void => f(...a) ipcRenderer.on(canale, h) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 28f06d3..fe4aa3a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -68,11 +68,16 @@ export function App(): JSX.Element { useEffect(() => { void (async () => { - await collegaEventi() - const s = await api.setup.stato() - setInstallato(s.installato) - setAggiornaMotore(!s.installato && !!usaStato.getState().imp?.installato) - setPronto(true) + try { + await collegaEventi() + const s = await api.setup.stato() + setInstallato(s.installato) + setAggiornaMotore(!s.installato && !!usaStato.getState().imp?.installato) + } catch (e) { + usaStato.getState().avvisa(`Non riesco a leggere lo stato dell'app: ${e instanceof Error ? e.message : e}`, 'errore') + } finally { + setPronto(true) + } })() }, []) diff --git a/src/renderer/src/album/costruisci.ts b/src/renderer/src/album/costruisci.ts index 63243f8..5082fe6 100644 --- a/src/renderer/src/album/costruisci.ts +++ b/src/renderer/src/album/costruisci.ts @@ -71,7 +71,7 @@ export function raggruppa(foto: FotoAlbum[], o: OpzioniAuto): { ids: string[]; p for (let k = gruppi.length - 1; k > 0; k--) { const g = gruppi[k] const prima = gruppi[k - 1] - if (!g.pieno && g.ids.length < min && !prima.pieno && prima.ids.length + g.ids.length <= max + 1) { + if (!g.pieno && g.ids.length < min && !prima.pieno && prima.ids.length + g.ids.length <= max) { prima.ids.push(...g.ids) gruppi.splice(k, 1) } diff --git a/src/renderer/src/album/esporta.ts b/src/renderer/src/album/esporta.ts index 521debe..14b1131 100644 --- a/src/renderer/src/album/esporta.ts +++ b/src/renderer/src/album/esporta.ts @@ -78,6 +78,6 @@ export async function esporta(a: Album, o: OpzioniEsporta, avanza: (fatto: numbe const dest = `${o.cartella}\\${o.nome}.pdf` usciti.push(await api.album.pdf(dest, pdf)) } - avanza(totale, totale, 'Fatto') + avanza(fermo.si ? usciti.length : totale, totale, fermo.si ? 'Fermato' : 'Fatto') return usciti } diff --git a/src/renderer/src/album/layout.ts b/src/renderer/src/album/layout.ts index b78b47f..b6b5dee 100644 --- a/src/renderer/src/album/layout.ts +++ b/src/renderer/src/album/layout.ts @@ -137,6 +137,21 @@ export function candidati(ars: number[], r: Rett, o: OpzioniLayout, quanti = 16) const chiave = [ars.map((a) => a.toFixed(3)).join(','), r.w.toFixed(1), r.h.toFixed(1), o.spazio, o.stile, o.piega, o.regina, o.taglioMax].join('|') const gia = cache.get(chiave) if (gia) return gia + // oltre 8 foto gli alberi sono centinaia di migliaia (più di un secondo di attesa a ogni modifica): si usa una griglia ordinata + if (n > 8) { + const colonne = Math.max(1, Math.round(Math.sqrt((n * r.w) / r.h))) + const righe = Math.ceil(n / colonne) + const celle: Cella[] = [] + for (let i = 0; i < n; i++) { + const riga = Math.floor(i / colonne) + const dentro = Math.min(colonne, n - riga * colonne) + const w = r.w / dentro + celle.push({ x: r.x + (i - riga * colonne) * w, y: r.y + (riga * r.h) / righe, w, h: r.h / righe, foto: i }) + } + const g = [celle] + cache.set(chiave, g) + return g + } const tutti = alberi(0, n - 1, ars, new Map()) const trovati: { c: number; celle: Cella[]; firma: string }[] = [] for (const t of tutti) { diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts index baa88f6..ae61b6e 100644 --- a/src/renderer/src/api.ts +++ b/src/renderer/src/api.ts @@ -32,6 +32,10 @@ export interface FileModelli { encoder: string[] vae: string[] upscaler: string[] + /** i file di FLUX 3 Klein (modelli, text encoder, VAE) */ + klein: string[] + kleinTe: string[] + kleinVae: string[] } export interface ModelloTrovato { percorso: string diff --git a/src/renderer/src/azioni.ts b/src/renderer/src/azioni.ts index e7dda2e..130dc5c 100644 --- a/src/renderer/src/azioni.ts +++ b/src/renderer/src/azioni.ts @@ -2,7 +2,7 @@ import { modelloTurbo } from '@shared/tipi' import type { Opera, Richiesta } from '@shared/tipi' import { api } from './api' -import { PASSI_QUALITA, usaStato, type FotoBase, type Qualita } from './stato' +import { famigliaCorrente, passiDi, usaStato, type FotoBase, type Qualita } from './stato' import { dimensioniRis } from './util' import type { ChiaveLora } from './rapide' @@ -26,8 +26,10 @@ export const SISTEMA_DESCRIVI = export function richiestaBase(): Richiesta { const { imp, loraAttive } = usaStato.getState() const d = imp?.predefiniti || { passi: 40, cfg: 1, sampler: 'euler', scheduler: 'simple', megapixel: 1 } + const famiglia = imp?.famiglia || 'qwen' return { modalita: 'crea', + famiglia, prompt: '', negativo: '', seed: -1, @@ -39,7 +41,8 @@ export function richiestaBase(): Richiesta { altezza: 1024, megapixel: d.megapixel, immagini: [], - lora: loraAttive.filter((l) => l.forza !== 0), + // i LoRA sono fatti per Qwen: con Klein non valgono + lora: famiglia === 'qwen' ? loraAttive.filter((l) => l.forza !== 0) : [], quante: 1 } } @@ -47,14 +50,18 @@ 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 */ export function perQualita(q: Qualita): Pick & Partial> { - if (q === 'turbo') return { passi: PASSI_QUALITA.turbo, turbo: true, cfg: 1, sampler: 'euler', scheduler: 'simple' } - return { passi: PASSI_QUALITA[q], turbo: false } + // 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 } } /** la qualità per i lavori "di servizio" (rifinisci, varia): Turbo se è acceso in Crea */ diff --git a/src/renderer/src/componenti/AvvisoLora.tsx b/src/renderer/src/componenti/AvvisoLora.tsx index b82cccb..6fe8f6d 100644 --- a/src/renderer/src/componenti/AvvisoLora.tsx +++ b/src/renderer/src/componenti/AvvisoLora.tsx @@ -17,7 +17,17 @@ async function scarica(v: StatoVoce): Promise { api.su.download((d) => usaScarichi.setState((s) => ({ dl: { ...s.dl, [d.nome]: d } }))) } usaScarichi.setState((s) => ({ dl: { ...s.dl, [v.nome]: { id: '', nome: v.nome, ricevuti: 0, totali: 0, velocita: 0, stato: 'in corso' } } })) - const d = await api.modelli.scarica(v.id) + let d: Download + try { + d = await api.modelli.scarica(v.id) + } catch (e) { + usaScarichi.setState((s) => { + const { [v.nome]: _tolto, ...resto } = s.dl + return { dl: resto } + }) + usaStato.getState().avvisa(e instanceof Error ? e.message : 'Download non riuscito', 'errore') + return + } usaScarichi.setState((s) => ({ dl: { ...s.dl, [v.nome]: d } })) await ricaricaLore() if (d.stato === 'fatto') usaStato.getState().avvisa(`${v.nome}: pronto`, 'ok') @@ -29,8 +39,8 @@ export function AvvisoLora(p: { id: string; perche: string }): JSX.Element | nul const dl = usaScarichi((s) => s.dl) usaStato((s) => s.lore) useEffect(() => { - void api.modelli.stato().then((l) => setVoce(l.find((x) => x.id === p.id) || null)) - }, [p.id, dl]) + void api.modelli.stato().then((l) => setVoce(l.find((x) => x.id === p.id) || null)).catch(() => undefined) + }, [p.id, dl[voce?.nome ?? '']?.stato]) if (!voce || voce.presente) return null const d = dl[voce.nome] const va = d?.stato === 'in corso' || d?.stato === 'verifica' diff --git a/src/renderer/src/componenti/Coda.tsx b/src/renderer/src/componenti/Coda.tsx index 07dcde8..5ac1e8a 100644 --- a/src/renderer/src/componenti/Coda.tsx +++ b/src/renderer/src/componenti/Coda.tsx @@ -18,7 +18,7 @@ export function percentuale(l: Lavoro): number | null { export function RigaLavoro({ l }: { l: Lavoro }): JSX.Element { const p = percentuale(l) const mostra = l.anteprima || (l.risultati[0] ? null : null) - const titolo = l.richiesta.etichetta || l.richiesta.prompt || ETICHETTE_MODALITA[l.richiesta.modalita] + const titolo = l.richiesta.etichetta || l.richiesta.prompt || (ETICHETTE_MODALITA[l.richiesta.modalita] ?? l.richiesta.modalita) return (
@@ -26,7 +26,7 @@ export function RigaLavoro({ l }: { l: Lavoro }): JSX.Element {
- {ETICHETTE_MODALITA[l.richiesta.modalita]} · {titolo} + {(ETICHETTE_MODALITA[l.richiesta.modalita] ?? l.richiesta.modalita)} · {titolo}
{l.stato === 'in corso' && (
@@ -116,7 +116,7 @@ export function Coda(): JSX.Element | null { function TesseraLavoro({ l, opera, apri }: { l: Lavoro; opera?: Opera; apri: () => void }): JSX.Element { const p = percentuale(l) - const titolo = l.richiesta.etichetta || l.richiesta.prompt || ETICHETTE_MODALITA[l.richiesta.modalita] + const titolo = l.richiesta.etichetta || l.richiesta.prompt || (ETICHETTE_MODALITA[l.richiesta.modalita] ?? l.richiesta.modalita) const attivo = l.stato === 'in coda' || l.stato === 'in corso' const src = opera ? urlFile(opera.miniatura || opera.file) : l.anteprima || (l.richiesta.immagini[0] ? urlFile(l.richiesta.immagini[0]) : '') return ( @@ -143,7 +143,7 @@ function TesseraLavoro({ l, opera, apri }: { l: Lavoro; opera?: Opera; apri: ()
{titolo} - {l.stato === 'errore' ? l.errore?.split('\n')[0] : l.stato === 'in corso' ? (l.fase === 'Disegno' && l.passiTotali ? `passo ${l.passo}/${l.passiTotali}${l.stima ? ` · ~${durata(l.stima)}` : ''}` : l.fase) : l.stato === 'fatto' && l.fine && l.inizio ? durata((l.fine - l.inizio) / 1000) : ETICHETTE_MODALITA[l.richiesta.modalita]} + {l.stato === 'errore' ? l.errore?.split('\n')[0] : l.stato === 'in corso' ? (l.fase === 'Disegno' && l.passiTotali ? `passo ${l.passo}/${l.passiTotali}${l.stima ? ` · ~${durata(l.stima)}` : ''}` : l.fase) : l.stato === 'fatto' && l.fine && l.inizio ? durata((l.fine - l.inizio) / 1000) : (ETICHETTE_MODALITA[l.richiesta.modalita] ?? l.richiesta.modalita)}
@@ -160,7 +160,7 @@ function Grande({ l, opera, chiudi }: { l: Lavoro; opera?: Opera; chiudi: () => return () => window.removeEventListener('keydown', t) }, []) const src = opera ? urlFile(opera.file) : l.anteprima || (l.richiesta.immagini[0] ? urlFile(l.richiesta.immagini[0]) : '') - const titolo = l.richiesta.etichetta || l.richiesta.prompt || ETICHETTE_MODALITA[l.richiesta.modalita] + const titolo = l.richiesta.etichetta || l.richiesta.prompt || (ETICHETTE_MODALITA[l.richiesta.modalita] ?? l.richiesta.modalita) return (
e.target === e.currentTarget && chiudi()}>
e.target === e.currentTarget && chiudi()}> @@ -168,7 +168,7 @@ function Grande({ l, opera, chiudi }: { l: Lavoro; opera?: Opera; chiudi: () =>
- {ETICHETTE_MODALITA[l.richiesta.modalita].toUpperCase()} + {(ETICHETTE_MODALITA[l.richiesta.modalita] ?? l.richiesta.modalita).toUpperCase()}
diff --git a/src/renderer/src/componenti/Estrai.tsx b/src/renderer/src/componenti/Estrai.tsx index 38c250d..6b4fbf3 100644 --- a/src/renderer/src/componenti/Estrai.tsx +++ b/src/renderer/src/componenti/Estrai.tsx @@ -2,21 +2,23 @@ // riquadrarne uno a mano), a destra quello che hai estratto. I comandi stanno nella colonna di destra e cambiano se hai scelto // una persona (posa, inquadratura) o un oggetto (vista). import { useEffect, useRef, useState, type JSX } from 'react' -import { usaStato, type FotoBase, type Qualita, PASSI_QUALITA } from '../stato' +import { usaStato, passiDi, type FotoBase } from '../stato' import { api, urlFile } from '../api' import { I } from './Icone' import { VistaZoom } from './VistaZoom' import { AdattaFoto } from './AdattaFoto' import { percentuale } from './Coda' import { Contatore, Interruttore, Segmenti } from './Controlli' -import { AvvisoTurbo, useTurbo } from './Turbo' +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' import { apriInModifica, fotoDaOpera } from '../azioni' import { durata, stimaSecondi } from '../util' import { - FORMATI_ESTRAI, INQUADRATURE, POSE_ESTRAI, SFONDI_ESTRAI, VISTE_OGGETTO, attesiEstrai, dimensioniEstrai, impostaEstrai, proporzioniEstrai, richiestaEstrai, + FORMATI_ESTRAI, INQUADRATURE, misuraEstrai, POSE_ESTRAI, SFONDI_ESTRAI, VISTE_OGGETTO, attesiEstrai, dimensioniEstrai, impostaEstrai, proporzioniEstrai, richiestaEstrai, trovaElementi, usaEstrai, type Elemento } from '../estrai' @@ -164,9 +166,10 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { 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, PASSI_QUALITA[m.qualita]) * m.quante + const stima = stimaSecondi(usaStato.getState().imp?.tempi, m.mp, passiDi(m.qualita)) * m.quante const r = proporzioniEstrai(s, el, p.base) const dim = dimensioniEstrai(m.mp, r) + const fin = misuraEstrai(s.lato, dim) const persona = el?.tipo === 'persona' const estrai = async (): Promise => { @@ -193,6 +196,7 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { return (
+

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

{el ? ( @@ -269,20 +273,18 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { Grandezza della foto setModifica({ mp })} ar={{ w: r[0], h: r[1] }} />
- +
+ Misura finale (lato lungo) +
+ {[0, 480, 720, 1024, 1536, 2048].map((l) => ( + + ))} +
+
+
- Qualità · {PASSI_QUALITA[m.qualita]} passi · ~{durata(stima)} - - valore={m.qualita} - cambia={(qualita) => setModifica({ qualita })} - voci={[ - { id: 'turbo', nome: <> Turbo, sotto: '8 passi', titolo: 'LoRA Turbo8: circa 5 volte più veloce' }, - { id: 'bozza', nome: 'Veloce', sotto: '20 passi' }, - { id: 'alta', nome: 'Alta', sotto: '40 passi' }, - { id: 'massima', nome: 'Massima', sotto: '50 passi' } - ]} - /> - {m.qualita === 'turbo' && } + Qualità · {passiDi(m.qualita)} passi · ~{durata(stima)} + setModifica({ qualita })} />
Quante versioni @@ -294,7 +296,7 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { -
{dim.w} × {dim.h} px · ~{durata(stima)}
+
{fin.w} × {fin.h} px · ~{durata(stima)}
) diff --git a/src/renderer/src/componenti/Fusion.tsx b/src/renderer/src/componenti/Fusion.tsx index e86b477..7b610cc 100644 --- a/src/renderer/src/componenti/Fusion.tsx +++ b/src/renderer/src/componenti/Fusion.tsx @@ -33,7 +33,7 @@ export function Fusion(p: { useEffect(() => { if (p.risultati[0]) setSel(p.risultati[0].id) }, [p.risultati[0]?.id]) - const vedo = p.risultati.find((o) => o.id === sel) + const vedo = p.risultati.find((o) => o.id === sel) ?? p.risultati[0] const vivo = p.inCorso?.anteprima const sposta = (i: number, d: number): void => { const l = [...p.foto] diff --git a/src/renderer/src/componenti/Misura.tsx b/src/renderer/src/componenti/Misura.tsx index b11a876..d07334d 100644 --- a/src/renderer/src/componenti/Misura.tsx +++ b/src/renderer/src/componenti/Misura.tsx @@ -17,6 +17,7 @@ const FORMATI_MISURA: { id: string; r: [number, number] }[] = [ ] /** le risoluzioni più usate */ const RISOLUZIONI_USUALI: { nome: string; w: number; h: number; titolo: string }[] = [ + { nome: '480p', w: 854, h: 480, titolo: '854 × 480' }, { nome: 'HD', w: 1280, h: 720, titolo: '1280 × 720' }, { nome: 'Full HD', w: 1920, h: 1080, titolo: '1920 × 1080' }, { nome: '2K', w: 2560, h: 1440, titolo: '2560 × 1440' }, @@ -40,16 +41,17 @@ const MP_USUALI: { id: number; sotto: string }[] = [ ] /** quanto è grande la foto che il modello disegna, in megapixel: sotto ogni scelta c'è la misura vera in pixel (se si conoscono le proporzioni) */ -export function ScegliMp(p: { mp: number; cambia: (mp: number) => void; /** le proporzioni della foto che esce */ ar?: { w: number; h: number } | null; voci?: number[] }): JSX.Element { +export function ScegliMp(p: { mp: number; cambia: (mp: number) => void; /** le proporzioni della foto che esce */ ar?: { w: number; h: number } | null; voci?: number[]; /** una prima voce «Auto» (valore 0): il modello lavora alla misura finale */ auto?: string }): JSX.Element { const voci = MP_USUALI.filter((x) => !p.voci || p.voci.includes(x.id)) + const auto = p.auto ? [{ id: 0, nome: 'Auto', sotto: p.auto, titolo: p.auto }] : [] return ( { + voci={[...auto, ...voci.map((x) => { const d = p.ar ? dimMp(x.id, p.ar.w, p.ar.h) : null return { id: x.id, nome: `${testoMp(x.id)} MP`, sotto: d ? `${d.w}×${d.h}` : x.sotto, titolo: `${testoMp(x.id)} megapixel${d ? ` = ${d.w} × ${d.h} px` : ''} · ${x.sotto}` } - })} + })]} /> ) } @@ -87,6 +89,8 @@ export function SceltaMisura(p: { auto?: { w: number; h: number; nota?: string } /** niente misure in pixel sotto i megapixel (per le zone, dove non valgono per tutta la foto) */ senzaPx?: boolean + /** non mostrare la riga «La foto esce a…» (la mostra già chi usa il componente) */ + senzaUscita?: boolean }): JSX.Element { const [blocca, setBlocca] = useState(true) const ar = p.base ? p.base.larghezza / p.base.altezza : 1 @@ -174,7 +178,7 @@ export function SceltaMisura(p: {
)} - {uscita && } + {uscita && !p.senzaUscita && } {finale && (
Lavora a ~{mpTesto} MP e porta a {finale.larghezza} × {finale.altezza} ({mpFinale.toFixed(1).replace('.', ',')} MP) diff --git a/src/renderer/src/componenti/ScegliModello.tsx b/src/renderer/src/componenti/ScegliModello.tsx new file mode 100644 index 0000000..0a07889 --- /dev/null +++ b/src/renderer/src/componenti/ScegliModello.tsx @@ -0,0 +1,112 @@ +// 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' +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 } + +export function ScegliModello(): JSX.Element { + const famiglia = usaStato((s) => s.imp?.famiglia || 'qwen') + 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) + + /** 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, + klein: comuni && !!f.klein?.some((n) => !/base/i.test(n)), + 'klein-base': comuni && !!f.klein?.some((n) => /base/i.test(n)) + }) + } + useEffect(() => { + void ricarica() + return api.su.download((d: Download) => { + setScarico((s) => (s && d.totali ? { ...s, perc: Math.round((d.ricevuti / d.totali) * 100) } : s)) + }) + }, []) + + const scegli = async (f: Famiglia): Promise => { + if (f !== 'qwen' && !pronti[f]) { + setAperto(aperto === f ? null : f) + return + } + setAperto(null) + await api.impostazioni.salva({ famiglia: f }) + await ricaricaImp() + } + + const scarica = async (f: 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) + if (d.stato !== 'fatto') throw new Error(d.errore || 'Download non riuscito') + } + await ricarica() + await api.impostazioni.salva({ famiglia: f }) + await ricaricaImp() + setAperto(null) + avvisa(`${FAMIGLIE[f].nome} pronto: ora disegna con lui`, 'ok') + } catch (e) { + avvisa((e as Error).message, 'errore') + } finally { + setScarico(null) + } + } + + return ( +
+
Modello che disegna
+
+ {ORDINE.map((f) => { + const Ic = ICONA[f] + const m = FAMIGLIE[f] + const su = f === famiglia + 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 ? ( +
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.
+
+ )} +
+ ) +} diff --git a/src/renderer/src/componenti/ScegliQualita.tsx b/src/renderer/src/componenti/ScegliQualita.tsx new file mode 100644 index 0000000..bb5a365 --- /dev/null +++ b/src/renderer/src/componenti/ScegliQualita.tsx @@ -0,0 +1,34 @@ +// 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 19315a6..c68cf9a 100644 --- a/src/renderer/src/componenti/SceltaLora.tsx +++ b/src/renderer/src/componenti/SceltaLora.tsx @@ -28,27 +28,6 @@ export function Copertina({ l, grande }: { l: InfoLora; grande?: boolean }): JSX ) } -/** i LoRA accesi, sempre in vista (una riga di chip con la forza): così non si dimentica cosa è attivo anche con la sezione LoRA chiusa */ -export function LoraAttivi(): JSX.Element | null { - const { lore, loraAttive, toggleLora, imp } = usaStato() - const attive = loraAttive.filter((a) => a.file !== imp?.loraTurbo) - if (!attive.length) return null - return ( -
- LoRA attivi: - {attive.map((a) => { - const info = lore.find((l) => l.file === a.file) - return ( - - {info?.nome || a.file} · {a.forza.toFixed(2)} - - - ) - })} -
- ) -} - export function SceltaLora(): JSX.Element { const { lore: tutte, loraAttive, setLoraAttive, toggleLora, vai, imp } = usaStato() // il Turbo si accende dalla Qualità, non da qui @@ -130,7 +109,8 @@ export function SceltaLora(): JSX.Element { /** aggiunge al prompt le parole chiave dei LoRA attivi che non ci sono già */ export function conParoleLora(prompt: string): string { - const { lore, loraAttive } = usaStato.getState() + const { lore, loraAttive, imp } = usaStato.getState() + if (imp && imp.famiglia !== 'qwen') return prompt const extra: string[] = [] for (const a of loraAttive) { const p = lore.find((l) => l.file === a.file)?.parole?.trim() diff --git a/src/renderer/src/componenti/TelaMaschera.tsx b/src/renderer/src/componenti/TelaMaschera.tsx index cff9549..9157da6 100644 --- a/src/renderer/src/componenti/TelaMaschera.tsx +++ b/src/renderer/src/componenti/TelaMaschera.tsx @@ -1,6 +1,7 @@ // La tela della pagina Modifica: la foto, e sopra la maschera che si disegna (pennello, gomma, // rettangolo, lazo). Rotella = zoom, spazio o tasto centrale = sposta. La maschera ha la // grandezza vera della foto, così combacia al pixel con quello che riceve il motore. +import { usaStato } from '../stato' import { useCallback, useEffect, useImperativeHandle, useRef, useState, type JSX, type ReactNode, type Ref } from 'react' import type { Bordi, Riquadro } from '@shared/tipi' @@ -110,6 +111,9 @@ export function TelaMaschera(p: Props): JSX.Element { useEffect(() => { const giu = (e: KeyboardEvent): void => { + // Modifica resta sempre montata: fuori da lì (o su un pulsante) lo Spazio deve fare quello che fa di solito + const su = e.target as HTMLElement | null + if (usaStato.getState().pagina !== 'modifica' || su?.closest('button, select, [role="button"], a')) return if (e.code === 'Space' && !(e.target instanceof HTMLTextAreaElement) && !(e.target instanceof HTMLInputElement)) { setSpazio(true) e.preventDefault() @@ -130,6 +134,9 @@ export function TelaMaschera(p: Props): JSX.Element { const t = tela.current! storia.current.push(ctx().getImageData(0, 0, t.width, t.height)) if (storia.current.length > 15) storia.current.shift() + // una foto da 24 MP pesa 96 MB per passo: si tiene la storia entro ~256 MB + let tot = storia.current.reduce((a, d) => a + d.data.byteLength, 0) + while (storia.current.length > 1 && tot > 256e6) tot -= storia.current.shift()!.data.byteLength futuro.current = [] } diff --git a/src/renderer/src/componenti/Turbo.tsx b/src/renderer/src/componenti/Turbo.tsx index 7be5ea7..3724b41 100644 --- a/src/renderer/src/componenti/Turbo.tsx +++ b/src/renderer/src/componenti/Turbo.tsx @@ -20,7 +20,14 @@ export async function scaricaTurbo(): Promise { }) } usaDl.setState({ dl: { id: '', nome: 'Turbo', ricevuti: 0, totali: 0, velocita: 0, stato: 'in corso' } }) - const d = await api.modelli.scarica('lora-turbo') + 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() diff --git a/src/renderer/src/componenti/VistaZoom.tsx b/src/renderer/src/componenti/VistaZoom.tsx index ace2be0..66a6f24 100644 --- a/src/renderer/src/componenti/VistaZoom.tsx +++ b/src/renderer/src/componenti/VistaZoom.tsx @@ -17,6 +17,8 @@ export function VistaZoom(p: { src: string; scacchi?: boolean; children?: ReactN const [vista, setVista] = useState(null) const trascina = useRef<{ cx: number; cy: number; x: number; y: number } | null>(null) const mosso = useRef(false) + // con la cattura del puntatore il target del pointerup è sempre il contenitore: si ricorda dove è cominciato il clic + const daSfondo = useRef(false) const margine = p.margine ?? 24 useEffect(() => { @@ -64,6 +66,7 @@ export function VistaZoom(p: { src: string; scacchi?: boolean; children?: ReactN ref={box} onPointerDown={(e) => { if (e.button !== 0 && e.button !== 1) return + daSfondo.current = e.target === e.currentTarget ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) trascina.current = { cx: e.clientX, cy: e.clientY, x: v.x, y: v.y } mosso.current = false @@ -79,7 +82,7 @@ export function VistaZoom(p: { src: string; scacchi?: boolean; children?: ReactN const era = trascina.current trascina.current = null // un clic (senza trascinare) sullo sfondo vuoto, non sulla foto né sui comandi - if (era && !mosso.current && p.onFuori && e.target === e.currentTarget) p.onFuori() + if (era && !mosso.current && p.onFuori && daSfondo.current) p.onFuori() }} onDoubleClick={(e) => { const r = box.current!.getBoundingClientRect() diff --git a/src/renderer/src/estrai.ts b/src/renderer/src/estrai.ts index 740b425..44adabf 100644 --- a/src/renderer/src/estrai.ts +++ b/src/renderer/src/estrai.ts @@ -74,6 +74,8 @@ interface StatoEstrai { sfondo: SfondoId ombra: boolean formato: FormatoId + /** lato lungo della foto che esce (0 = come la disegna il modello); più piccolo dei megapixel scelti = disegna grande e poi riduce */ + lato: number nota: string /** la posa disegnata con l'omino */ omino: Posa @@ -81,7 +83,7 @@ interface StatoEstrai { export const usaEstrai = create(() => ({ foto: null, elementi: [], scelto: null, cercando: false, ris: [], - posa: 'tpose', inquadratura: 'intera', vista: 'come', sfondo: 'bianco', ombra: true, formato: 'auto', nota: '', omino: posaDi('t') + posa: 'tpose', inquadratura: 'intera', vista: 'come', sfondo: 'bianco', ombra: true, formato: 'auto', lato: 0, nota: '', omino: posaDi('t') })) export const impostaEstrai = (p: Partial): void => usaEstrai.setState(p) @@ -94,26 +96,14 @@ const SISTEMA_ELEMENTI = 'Output ONLY a JSON array, at most 12 items, the most prominent first: [{"bbox_2d":[x1,y1,x2,y2],"label":"2-5 words: color and name","kind":"person" or "object"}]. ' + 'Coordinates are integers from 0 to 1000, relative to the image width and height (x1,y1 = top-left corner, x2,y2 = bottom-right corner). A person box contains the whole visible body; an object box is tight around the whole object. No other text.' -/** col rilevatore piccolo (Florence-2); con «Qwen3-VL» in Opzioni, o se il rilevatore non c'è, si chiede all'LLM (molto più lento) */ -async function chiediRiquadri(foto: FotoBase): Promise { - if (usaStato.getState().imp?.trova !== 'llm') { - try { - return await api.testo.scrivi({ ...richiestaBase(), modalita: 'trova', prompt: '', immagini: [foto.percorso], etichetta: 'Cerco persone e oggetti' }) - } catch (e) { - usaStato.getState().avvisa(`Il rilevatore non risponde (${(e as Error).message.split('\n')[0]}): uso il Qwen3-VL, più lento`, 'info') - } - } - return api.testo.scrivi({ - ...richiestaBase(), modalita: 'descrivi', prompt: 'Detect all the people and objects in this image.', immagini: [foto.percorso], - sistema: SISTEMA_ELEMENTI, maxToken: 900, temperatura: 0.1, seed: 7, etichetta: 'Cerco persone e oggetti' - }) -} - /** cerca persone e oggetti nella foto (solo quando premi il pulsante) */ export async function trovaElementi(foto: FotoBase): Promise { impostaEstrai({ cercando: true, foto: foto.percorso, elementi: [], scelto: null }) try { - const t = await chiediRiquadri(foto) + const t = await api.testo.scrivi({ + ...richiestaBase(), modalita: 'descrivi', prompt: 'Detect all the people and objects in this image.', immagini: [foto.percorso], + sistema: SISTEMA_ELEMENTI, maxToken: 800, temperatura: 0.1, seed: 7, etichetta: 'Cerco persone e oggetti' + }) const elementi = leggiElementi(t || '', foto.larghezza, foto.altezza) if (usaEstrai.getState().foto !== foto.percorso) return // se c'è una sola persona e nient'altro di grosso, è già scelta @@ -185,12 +175,20 @@ export function dimensioniEstrai(mp: number, r: [number, number]): { w: number; return { w: m32(Math.sqrt(px * (r[0] / r[1]))), h: m32(Math.sqrt(px / (r[0] / r[1]))) } } +/** la misura della foto che esce: quella del modello, o (con `lato`) le stesse proporzioni col lato lungo scelto */ +export function misuraEstrai(lato: number, d: { w: number; h: number }): { w: number; h: number } { + if (!lato) return d + const s = lato / Math.max(d.w, d.h) + return { w: Math.max(64, Math.round(d.w * s)), h: Math.max(64, Math.round(d.h * s)) } +} + /** estrae la cosa scelta su sfondo pulito, come dicono le scelte */ export async function richiestaEstrai(foto: FotoBase, el: Elemento, o: { qualita: Qualita; mp: number; quante: number }): Promise { const s = usaEstrai.getState() const sfondo = SFONDI_ESTRAI.find((x) => x.id === s.sfondo) || SFONDI_ESTRAI[0] const r = proporzioniEstrai(s, el, foto) const { w, h } = dimensioniEstrai(o.mp, r) + const fin = misuraEstrai(s.lato, { w, h }) const ritaglio = await ritaglia(foto, el.box) const chi = el.nome ? ` (${el.nome.replace(/["<>]/g, '')})` : '' const ombra = s.sfondo === 'trasparente' ? '' : s.ombra ? 'Soft contact shadow on the floor only.' : 'No shadow.' @@ -247,6 +245,8 @@ export async function richiestaEstrai(foto: FotoBase, el: Elemento, o: { qualita ...perQualita(o.qualita), quante: o.quante, trasparente, + // se il lato scelto è diverso dalla misura del modello, il motore porta la foto lì + ...(s.lato && (Math.abs(fin.w - w) > 1 || Math.abs(fin.h - h) > 1) ? { uscita: fin } : {}), etichetta: `Estrai · ${nome}` } } diff --git a/src/renderer/src/fusion.ts b/src/renderer/src/fusion.ts index 69b3db1..af9f5b1 100644 --- a/src/renderer/src/fusion.ts +++ b/src/renderer/src/fusion.ts @@ -35,6 +35,7 @@ export function richiestaFusion(s: StatoModifica, turboPronto: boolean): Esito { ...richiestaBase(), modalita: 'crea', prompt: conParoleLora(s.prompt.trim()), + potenzia: true, larghezza: w, altezza: h, megapixel: (w * h) / 1048576, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 2fbc8f5..c4beac9 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,5 +1,37 @@ import { createRoot } from 'react-dom/client' import './stile.css' import { App } from './App' +import { Component, type ReactNode } from 'react' +import { usaStato } from './stato' -createRoot(document.getElementById('app')!).render() +/** se una pagina va in errore non resta la finestra bianca: si vede cosa è successo e si può ricaricare */ +class Salvagente extends Component<{ children: ReactNode }, { errore: string | null }> { + state = { errore: null as string | null } + static getDerivedStateFromError(e: unknown): { errore: string } { + return { errore: e instanceof Error ? e.message : String(e) } + } + render(): ReactNode { + if (!this.state.errore) return this.props.children + return ( +
+

Qualcosa è andato storto

+

{this.state.errore}

+

Le tue foto e la galleria sono al sicuro. Ricarica l'interfaccia per ripartire.

+ +
+ ) + } +} + +// un errore in una promessa non attesa non deve passare in silenzio +window.addEventListener('unhandledrejection', (e) => { + const r = e.reason + usaStato.getState().avvisa(r instanceof Error ? r.message : String(r), 'errore') + e.preventDefault() +}) + +createRoot(document.getElementById('app')!).render( + + + +) diff --git a/src/renderer/src/pagine/Crea.tsx b/src/renderer/src/pagine/Crea.tsx index b4fd9e9..fb3e587 100644 --- a/src/renderer/src/pagine/Crea.tsx +++ b/src/renderer/src/pagine/Crea.tsx @@ -5,8 +5,8 @@ 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 { SceltaMisura, ScegliMp, MISURA0 } from '../componenti/Misura' -import { SceltaLora, LoraAttivi, conParoleLora } from '../componenti/SceltaLora' +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 { VistaZoom } from '../componenti/VistaZoom' @@ -15,7 +15,10 @@ import { FORMATI, RISOLUZIONI, dimensioniRis, durata, normalizza, stimaSecondi } import { Riferimenti } from '../componenti/Riferimenti' import { SISTEMA_DESCRIVI, SISTEMA_IDEA, SISTEMA_MIGLIORA, apriInModifica, fotoDaOpera, perQualita, richiestaBase, rifinisci, riusa, varia, ingrandisci } from '../azioni' import { presetDa } from '../preset' +import { passiDi } from '../stato' import { Casella } from '../componenti/Casella' +import { ScegliQualita } from '../componenti/ScegliQualita' +import { ScegliModello } from '../componenti/ScegliModello' import type { Lavoro, Opera } from '@shared/tipi' @@ -25,7 +28,7 @@ const ar = (f: string): string => { } export function Crea(): JSX.Element { - const { crea, setCrea, imp, lavori, avvisa, loraAttive } = usaStato() + const { crea, setCrea, imp, lavori, avvisa } = usaStato() const turbo = useTurbo() const [precedente, setPrecedente] = useState(null) const [scrivendo, setScrivendo] = useState(false) @@ -42,9 +45,20 @@ export function Crea(): JSX.Element { 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) + // 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 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) const preset = presetDa(crea.preset) - const stima = stimaSecondi(imp?.tempi, (dim.w * dim.h) / 1048576, passi) * crea.quante + const stima = stimaSecondi(imp?.tempi, (lav.w * lav.h) / 1048576, passi) * crea.quante // i lavori di questa pagina (creati, rifiniti, variati) const miei = useMemo(() => lavori.filter((l) => ['crea', 'rifinisci', 'varia'].includes(l.richiesta.modalita)), [lavori]) @@ -79,15 +93,12 @@ export function Crea(): JSX.Element { avvisa('Per il Turbo scarica prima il suo LoRA (il pulsante sotto Qualità), o scegli Alta', 'errore') return } - // misura precisa: il modello lavora ai megapixel scelti con le proporzioni di quella misura, poi si porta lì - const mis = crea.misura?.tipo === 'custom' ? crea.misura : null - const px = dim.w * dim.h - const m16 = (x: number): number => Math.max(256, Math.round(x / 16) * 16) - const lav = mis ? { w: m16(Math.sqrt(px * (mis.w / mis.h))), h: m16(Math.sqrt(px / (mis.w / mis.h))) } : dim 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, seed: crea.casuale ? -1 : crea.seed, cfg: crea.cfg, @@ -96,7 +107,8 @@ export function Crea(): JSX.Element { larghezza: lav.w, altezza: lav.h, megapixel: (lav.w * lav.h) / 1048576, - ...(mis ? { uscita: { w: mis.w, h: mis.h } } : {}), + // 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, immagini: crea.riferimenti.map((r) => r.percorso), trasparente: crea.trasparente, @@ -184,6 +196,7 @@ export function Crea(): JSX.Element {
+

@@ -197,7 +210,6 @@ export function Crea(): JSX.Element { minimo={preset ? 110 : 150} disabled={scrivendo} /> -
)}
+ {(imp?.famiglia || 'qwen') === 'qwen' &&
}

@@ -228,7 +241,7 @@ export function Crea(): JSX.Element { ) })}
-

Risoluzione {dim.w} × {dim.h} px · {((dim.w * dim.h) / 1048576).toFixed(2).replace('.', ',')} MP{bozza ? ' · bozza' : ''}

+

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

{RISOLUZIONI.map((r) => ( ))}
- Oppure scegli per megapixel (anche 4 MP con la risoluzione bassa che vuoi, poi sotto la porti alla misura) - Math.abs(x - (dim.w * dim.h) / 1048576) / x < 0.09) ?? -1} - cambia={(mp) => { - const f = FORMATI.find((x) => x.id === crea.formato) || FORMATI[0] - const lungo = Math.max(f.w, f.h) / Math.min(f.w, f.h) - setCrea({ ris: Math.max(256, Math.round(Math.sqrt((mp * 1048576) / lungo) / 16) * 16) }) - }} - ar={{ w: dim.w, h: dim.h }} - /> +

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 }} /> + setCrea({ misura })} base={null} mp={Math.round((dim.w * dim.h) / 1e5) / 10} ammetti={['auto', 'custom']} auto={{ w: dim.w, h: dim.h }} senzaUscita /> - - valore={crea.qualita} - cambia={(qualita) => setCrea({ qualita })} - voci={[ - { id: 'turbo', nome: <> Turbo, sotto: '8 passi · 5× veloce', titolo: 'LoRA Turbo8: 8 passi invece di 40, CFG 1. Il modo più veloce, anche su 6 GB' }, - { id: 'bozza', nome: 'Bozza', sotto: '20 passi, ½ pixel', titolo: 'Per esplorare: poi "Rifinisci" porta la bozza scelta in alta qualità' }, - { id: 'alta', nome: 'Alta', sotto: '40 passi' }, - { id: 'massima', nome: 'Massima', sotto: '50 passi' } - ]} - /> - {crea.qualita === 'turbo' && } + 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.
}
@@ -294,10 +291,6 @@ export function Crea(): JSX.Element {
- a.file !== imp?.loraTurbo).length ? `${loraAttive.filter((a) => a.file !== imp?.loraTurbo).length} attivi` : undefined} aperta={false}> - - -
setCrea({ trasparente })} sotto="PNG con sfondo trasparente (canale alfa nativo di Qwen-Image 2.1)"> Sfondo trasparente diff --git a/src/renderer/src/pagine/Galleria.tsx b/src/renderer/src/pagine/Galleria.tsx index 0472541..311eab2 100644 --- a/src/renderer/src/pagine/Galleria.tsx +++ b/src/renderer/src/pagine/Galleria.tsx @@ -88,6 +88,15 @@ export function Galleria(): JSX.Element { } useEffect(carica, [filtro]) useEffect(() => api.su.galleria(carica), [filtro]) + // le scelte seguono quello che si vede: filtrando o cercando, le foto nascoste non restano scelte (e non si eliminano senza vederle) + useEffect(() => { + setScelte((s) => { + if (!s.size) return s + const visibili = new Set(opere.map((o) => o.id)) + const n = new Set([...s].filter((i) => visibili.has(i))) + return n.size === s.size ? s : n + }) + }, [opere]) const gruppi = useMemo(() => { const g: { titolo: string; opere: Opera[] }[] = [] @@ -214,14 +223,14 @@ export function Galleria(): JSX.Element { {scelte.size > 0 ? ( <> - {scelte.size} scelte + {scelte.size} {scelte.size === 1 ? 'scelta' : 'scelte'} ) : ( - {importo ? `Importo ${importo} file…` : `${opere.length} immagini`} + {importo ? `Importo ${importo} file…` : `${opere.length} ${opere.length === 1 ? 'immagine' : 'immagini'}`} )} cambiaLato(Number(e.target.value))} title="Grandezza delle miniature (anche Ctrl+rotella)" /> @@ -293,7 +302,7 @@ function GruppoGiorno(p: { titolo: string; opere: Opera[]; scelte: Set; > {p.scelte.has(o.id) && ✓} - {o.etichetta === 'Bozza' ? 'BOZZA' : ETICHETTE_MODALITA[o.modalita]?.toUpperCase()} + {o.etichetta === 'Bozza' ? 'BOZZA' : (ETICHETTE_MODALITA[o.modalita] ?? o.modalita).toUpperCase()}
@@ -379,7 +388,7 @@ function Visore({ opere }: { opere: Opera[] }): JSX.Element | null {
{attuale.etichetta && attuale.etichetta !== 'Bozza' && <>
Effetto
{attuale.etichetta}
} -
Tipo
{ETICHETTE_MODALITA[attuale.modalita]}
+
Tipo
{(ETICHETTE_MODALITA[attuale.modalita] ?? attuale.modalita)}
Dimensioni
{attuale.larghezza} × {attuale.altezza}
Seed
{attuale.seed}
Passi
{attuale.passi} · cfg {attuale.cfg} · {attuale.sampler}/{attuale.scheduler}
diff --git a/src/renderer/src/pagine/Impostazioni.tsx b/src/renderer/src/pagine/Impostazioni.tsx index 49ca99e..e20a089 100644 --- a/src/renderer/src/pagine/Impostazioni.tsx +++ b/src/renderer/src/pagine/Impostazioni.tsx @@ -10,13 +10,13 @@ import type { Download, Impostazioni as Imp, StatoVoce } from '@shared/tipi' import type { FileInutile } from '../api' const GRUPPI_MODELLI: [StatoVoce['tipo'], string][] = [ - ['diffusione', 'Modello di diffusione (denoiser)'], ['encoder', 'Text encoder (l\'LLM)'], ['traduttore', 'Traduttore'], ['rilevatore', 'Rilevatore (Estrai)'], ['vae', 'VAE'], ['upscaler', 'Ingrandimento'], ['anteprima', 'Anteprime dal vivo'] + ['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'] ] export function Impostazioni(): JSX.Element { const { imp, motore, avvisa, agg } = usaStato() const turbo = useTurbo() - const [file, setFile] = useState({ diffusione: [], encoder: [], vae: [], upscaler: [] }) + const [file, setFile] = useState({ diffusione: [], encoder: [], vae: [], upscaler: [], klein: [], kleinTe: [], kleinVae: [] }) const [catalogo, setCatalogo] = useState([]) const [download, setDownload] = useState>({}) const [log, setLog] = useState([]) @@ -85,8 +85,8 @@ export function Impostazioni(): JSX.Element {
{titolo}{sotto}
) @@ -107,6 +107,10 @@ 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 Base', 'Il file di Klein Base (50 passi, CFG)', imp.kleinBase, ['', ...file.klein.filter((n) => /base/i.test(n))], (kleinBase) => salva({ kleinBase }))} + {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 }))}
@@ -233,11 +237,8 @@ export function Impostazioni(): JSX.Element { salva({ unaCosaAllaVolta })}>{imp.unaCosaAllaVolta ? 'Accesa' : 'Spenta'}
-
- Chi trova persone e oggetti (Estrai) - {imp.trova === 'veloce' ? 'Un modello piccolo fatto per riconoscere gli oggetti (Florence-2, ~460 MB): meno di un secondo. Si scarica da solo la prima volta' : 'Il Qwen3-VL: più preciso con le descrizioni, ma molto più lento'} -
- salva({ trova })} voci={[{ id: 'veloce', nome: 'Rilevatore' }, { id: 'llm', nome: 'Qwen3-VL' }]} /> +
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
diff --git a/src/renderer/src/pagine/Laboratorio.tsx b/src/renderer/src/pagine/Laboratorio.tsx index ef9c48f..8ae507d 100644 --- a/src/renderer/src/pagine/Laboratorio.tsx +++ b/src/renderer/src/pagine/Laboratorio.tsx @@ -5,6 +5,7 @@ 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' @@ -340,16 +341,7 @@ export function Laboratorio(): JSX.Element {

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

- - valore={qualita} - cambia={setQualita} - voci={[ - { id: 'turbo', nome: <> Turbo, sotto: '8 passi' }, - { id: 'bozza', nome: 'Veloce', sotto: '20 passi' }, - { id: 'alta', nome: 'Alta', sotto: '40 passi' } - ]} - /> - {qualita === 'turbo' && } + {!passi.some((x) => !x.crea) && (
Grandezza della foto @@ -378,7 +370,7 @@ export function Laboratorio(): JSX.Element { {vista === 'catalogo' && } - {vista === 'catalogo' && ricette.nascoste.length > 0 && } + {vista === 'catalogo' && ricette.nascoste.length > 0 && } {vista === 'catalogo' && setCerca(e.target.value)} style={{ width: 240 }} />} {vista === 'risultati' && runs.length > 1 && ( setFiltro(e.target.value)} style={{ width: 220 }} /> diff --git a/src/renderer/src/pagine/Modifica.tsx b/src/renderer/src/pagine/Modifica.tsx index a83631d..702f451 100644 --- a/src/renderer/src/pagine/Modifica.tsx +++ b/src/renderer/src/pagine/Modifica.tsx @@ -6,6 +6,8 @@ import { usaStato, attesiModifica, scegliFoto, type ModoModifica, type Qualita, 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' @@ -345,6 +347,8 @@ export function Modifica(): JSX.Element { ...perQualita(s.qualita), 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, 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)) @@ -617,6 +621,7 @@ export function Modifica(): JSX.Element { {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'} @@ -800,17 +805,7 @@ export function Modifica(): JSX.Element {

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

- - valore={m.qualita} - cambia={(qualita) => setModifica({ qualita })} - voci={[ - { id: 'turbo', nome: <> Turbo, sotto: '8 passi', titolo: 'LoRA Turbo8: circa 5 volte più veloce' }, - { id: 'bozza', nome: 'Veloce', sotto: '20 passi' }, - { id: 'alta', nome: 'Alta', sotto: '40 passi' }, - { id: 'massima', nome: 'Massima', sotto: '50 passi' } - ]} - /> - {m.qualita === 'turbo' && } + setModifica({ qualita })} />
Quante versioni @@ -824,10 +819,12 @@ export function Modifica(): JSX.Element { )}
-
-

LoRA

- -
+ {(imp?.famiglia || 'qwen') === 'qwen' && ( +
+

LoRA

+ +
+ )}