diff --git a/.gitignore b/.gitignore index aba016f..7f3d7ee 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ src-tauri/gen/android/app/src/main/assets/ src-tauri/gen/android/keystore.properties *.keystore *.jks +public/ai/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b75d09..49f7ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ Tutte le versioni notevoli del banco di montaggio. Le date sono in formato AAAA- Ogni versione pubblicata ha la sua [release GitHub](https://github.com/cammo22/DaProdVideo/releases) con le app per Windows (portatile e installabile), Mac e Android, e va online su [GitHub Pages](https://cammo22.github.io/DaProdVideo/) come versione prova. +## [1.3.0] — 2026-10-07 · Tutto si trova, l'AI non si ferma 🔍✨ + +La versione per chi usa DaProd Video tutti i giorni: le funzioni AI che davano errore ora partono (e se una strada non va ne prendono un'altra da sole), e ogni strumento si trova in un attimo, con la spiegazione di come si usa. + +### L'AI che non si ferma +- **I sottotitoli col motore NVIDIA non partivano nell'app**: l'app scaricava la versione 0.1.0 di NeMo-Speech.cpp, che **non ha mai avuto i programmi pronti da scaricare**, e finiva in "lo scarico non è riuscito". Ora usa la **0.2.0** (controllata riga per riga: nomi degli archivi, impronte, comandi `transcribe`, `synthesize`, `pull`). +- **Se il motore NVIDIA non parte, i sottotitoli passano da soli a Whisper** (prima ci si fermava con un errore). Se la versione per la scheda video si ferma, si riprova **sul processore**. +- **"Togli lo sfondo" dava "Could not locate file"** sul computer senza scheda video: la libreria cercava una versione del modello (q8) che per MODNet e BiRefNet non esiste. Ora il tipo del modello si dice sempre e, se un tipo manca, **si prova il successivo** (q8 → fp16 → fp32). Vale anche per Whisper e la traduzione. +- **Se la scheda video si ferma a metà lavoro** (WebGPU), il modello si ricarica sul processore e lo stesso pezzo si rifà: prima tutta la trascrizione si fermava. +- **La libreria AI e il suo motore stanno dentro l'app** (transformers.js 4.3.1 e ONNX Runtime): niente più download dalla CDN a ogni avvio, che su reti lente o aziendali faceva fallire tutto. La CDN resta come riserva. +- **Un worker AI che si blocca non blocca più la funzione per sempre**: prima, dopo un errore di memoria, sottotitoli e traduzione restavano appesi fino al riavvio del programma. +- **Gli errori si capiscono**: "Failed to fetch" diventa "serve internet la prima volta", "Could not locate file" diventa "il modello non si trova, provane un altro", la memoria finita suggerisce il modello più leggero, e così via. +- La voce in cinese del motore NVIDIA non c'è nelle versioni che NVIDIA pubblica già pronte: ora lo si dice chiaro invece di un errore tecnico. + +### Il Centro AI (menu **AI**, pulsante **✨ AI** in alto) +- **Tutte le funzioni intelligenti in un posto**: sottotitoli automatici, traduzione, voce AI, togli lo sfondo, segui un oggetto, montaggio automatico, colore automatico. Per ognuna: **cosa fa, come si usa in tre passi, cosa serve** ("prima servono i sottotitoli", "solo nell'app"…), se il **modello è già sul computer** e il pulsante **Usa** che porta dritto al punto giusto (e lo fa lampeggiare). +- **🔍 Controlla l'AI**: prova davvero il motore AI su questo computer (senza scaricare niente), dice se c'è la scheda video, se Hugging Face si raggiunge, quali modelli sono già scaricati e com'è messo il motore NVIDIA, con cosa fare se qualcosa non va. +- **Impostazioni**: la scheda video per l'AI si può spegnere (su alcuni computer c'è ma sbaglia), **Libera lo spazio** dei modelli scaricati, **togli il motore NVIDIA**. + +### Trovare tutto +- **Cerca un comando (`Ctrl+K`, o 🔍 Cerca in alto)**: scrivi quello che vuoi fare — "rallenta", "sottotitoli", "togli lo sfondo", "tendina cuore", "neon" — e lo trovi, col suo tasto accanto. Dentro ci sono **tutti i comandi, le pagine, le funzioni AI, le 50 transizioni, gli effetti a tempo e sulla clip, i titoli, le 49 animazioni, i countdown e le guide**. Capisce anche senza accenti e coi sinonimi ("slow motion" → velocità). `Invio` lo fa davvero: la transizione va sul taglio, il titolo al cursore, l'effetto sulla clip scelta (e se manca la clip te lo dice). +- **Come si fa (`F1`)**: 22 guide brevi — il primo montaggio in un minuto, tagliare, spostare, transizioni, effetti, titoli, movimento, velocità, audio, sottotitoli, lingue, sfondo, tracking, DaProdMontage, colore, export, progetti, LIVE, più timeline, "se un'AI non parte" — ognuna coi passi e il pulsante **Fallo adesso**. I tasti restano in Aiuto → Tasti. +- **La pagina iniziale** ha "Impara a usarlo" (il primo montaggio, la guida, la ricerca, il Centro AI); il pannello Proprietà vuoto ha i pulsanti Cerca e Come si fa. +- **I comandi sono tutti in un registro solo**: Salva, Importa, Esporta, le pagine, la vista e l'aiuto hanno ora lo stesso nome nei menu, nella ricerca e nella finestra dei tasti. +- **Vista → Copie leggere**: scegli quando farle (da sole, sempre, mai) e **svuotale**; **Vista → Svuota la memoria delle misure**. Esistevano dentro il programma ma non c'era modo di arrivarci. + +### Sistemato +- **La testata non esce più dallo schermo**: sui portatili (1366, 1440, 1536 pixel) il tasto **Esporta** finiva fuori dalla finestra. Ora la testata si stringe a gradini e tutto resta in vista, da 920 a 2200 pixel. + +### Per chi ha curiosità +- `src/media/libreriaAI.ts` (libreria locale, dispositivi, tipi con ripiego), `erroriAI.ts`, `diagnosiAI.ts` + `provaAI.worker.ts`; `scripts/ai-locale.mjs` prende transformers.js e ONNX Runtime dal registro npm (impronta sha512 controllata) prima di ogni build; `src/ui/cerca.ts` + `fontiCerca.ts`, `guida.ts`, `centroAI.ts`. +- Prove nuove in `test/prove-aiuto.mjs`. **Nota onesta**: i modelli veri (Whisper, NLLB, BEN2, MODNet, SAM) e il motore NVIDIA non si possono scaricare dal luogo dove si prova; le prove usano librerie e motori finti con lo stesso protocollo. Il **motore AI vero** (la libreria e ONNX Runtime) invece è provato davvero: parte dai file dell'app e fa i conti giusti. + ## [1.2.1] — 2026-10-07 · Le rifiniture: più sicuro, più preciso, niente sorprese 🔧 Una versione di pulizia: nessuno strumento nuovo, ma tante piccole cose che prima potevano andare storte. diff --git a/CLAUDE.md b/CLAUDE.md index a9784c2..5e0df54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,9 +123,27 @@ Editor video "vecchio stile, moderno dentro" (ispirato a EDIUS e alle centraline non passano. Nel browser `potaDeposito` (progetti.ts) tiene in IndexedDB solo i file del montaggio aperto e dei recenti. Copia/incolla porta i blocchetti FX della clip (`padreDi`). In `fotogrammi.ts` un flusso buttato a metà play lascia il suo ultimo fotogramma in `ponti` finché il nuovo non ne ha uno (niente nero). Prove: `test/prove-rifiniture.mjs`; nessun tasto deve stare su due comandi (lo controlla la prova). +- **Trovare e capire (1.3.0)**: **Ctrl+K** = `src/ui/cerca.ts` (`apriCerca`, `risultati`/`punteggio` con sinonimi, recenti in localStorage + `dpv-cerca-recenti`): dentro ci sono **tutti i comandi registrati** in `azioni.ts` più le fonti iscritte con `fonteCerca` (`src/ui/fontiCerca.ts`: + pagine, sezioni del Finale, guide, transizioni, effetti, titoli, animazioni, countdown; `manca()` = perché adesso non si può). Il fuoco va + all'input **subito** (se no le lettere finiscono al banco come tasti). **F1** = `src/ui/guida.ts` (`ARGOMENTI`, `finestraGuida(id)`, "Fallo adesso" + con `vaiPagina/vaiFinale/vaiSezione` + `evidenzia` di `dom.ts`). **Centro AI** = `src/ui/centroAI.ts` (`FUNZIONI_AI`, carte `.ai-carta[data-ai]`, + "Controlla l'AI" = `diagnosi()` di `src/media/diagnosiAI.ts`, impostazioni: scheda video sì/no, libera lo spazio, togli il motore). I comandi di + `app.ts` (file, pagine, vista, aiuto) ora passano da `registra` (= `reg` di `azioni.ts`): **un tasto, un comando** (lo controlla la prova); con la + pagina iniziale aperta passano solo `SOPRA_HOME` in `tastiera.ts`. Eventi: `dpv:cerca`, `dpv:guida`, `dpv:tasti`, `dpv:centro-ai`, `dpv:finale`, + `dpv:sezione` (→ `Ispettore.apriSezione`), `dpv:home-nascondi`. Testata: `.testata > *` non si stringe, le scritte spariscono a scalini + (1840/1720/1620/1520/1420/1250 px) così **Esporta resta sempre nello schermo** (la prova la misura da 1024 a 1920). +- **L'AI che non si ferma (1.3.0)**: `src/media/libreriaAI.ts` è la base di tutti i worker AI (voce, traduci, ritaglio, provaAI): la libreria + **viaggia dentro l'app** in `public/ai/` (→ `dist/ai/`, la scrive `scripts/ai-locale.mjs` in `prebuild` da registry.npmjs.org col controllo + sha512, è in `.gitignore`; se manca si va sulla CDN), `wasmPaths` puntano lì (asyncify). `caricaConRipieghi` prova **WebGPU poi WASM** e per + ognuno **più pesi** (q8/fp16/fp32: a MODNet e BiRefNet manca q8); se la scheda si rompe a metà lavoro si ricarica su WASM e si rifà. Scheda video + spegnibile (`dpv-ai-scheda`). Un worker morto si butta (`onerror` → `null`). **Errori**: `src/media/erroriAI.ts` (`spiegaErroreAI`: regole + regex → frase italiana con cosa fare; le frasi già italiane passano). Nemotron che sbaglia → Whisper da solo (`ripiego` in `sottotitoliAI`), e + NeMo riprova con `--device cpu` (`eseguiConRipiego`). Nelle prove la libreria finta si serve anche su `**/ai/transformers.min.js`. + Prove: `test/prove-aiuto.mjs`. - **Cursore che si aggancia** (`agganciaCursore` in `src/ui/timeline.ts`): sul righello si aggancia sempre (calamita `N`), Alt lo lascia libero. -- **Motore NVIDIA** (`src-tauri/src/motori.rs` + `src/media/nemo.ts`): NeMo-Speech.cpp v0.1.0 (Apache-2.0), programma a parte che l'app - scarica dalla release di NVIDIA (`nemo-speech-0.1.0-{windows,macos,linux}-{x86_64,aarch64}-{cpu,cuda,vulkan,metal}`, `.sha256` a fianco) in +- **Motore NVIDIA** (`src-tauri/src/motori.rs` + `src/media/nemo.ts`): NeMo-Speech.cpp **0.2.0** (Apache-2.0; la 0.1.0 non aveva gli archivi), programma a parte che l'app + scarica dalla release di NVIDIA (`nemo-speech--{windows,macos,linux}-{x86_64,aarch64}-{cpu,cuda,vulkan,metal}`, `.sha256` a fianco) in `app_local_data_dir/motori/`: **CUDA se `nvidia-smi` risponde, Metal su Apple Silicon, se no CPU**. Il lato Rust fa solo I/O a polling (come `aggiorna.rs`): `motore_stato/installa/scarico/lancia/lavoro/ferma/elenca/peso_modelli`; i modelli li scarica lui (`pull`, cartella `NEMO_SPEECH_MODEL_DIR` nostra). Il JS (`MotoreNemo`) scrive i WAV a 16 kHz (`wav.ts`), lancia `transcribe --format srt @@ -144,7 +162,7 @@ Editor video "vecchio stile, moderno dentro" (ispirato a EDIUS e alle centraline - TRASPARENZA (tasto B, era ELASTICO) = la linea gialla dell'opacità sui video; IN/OUT si vedono solo se ci sono (Alt+X o ✕). - Si parte con **2 tracce video e 2 audio**. **Alt+Shift+trascina** = la clip e tutto quello dopo, su tutte le tracce. **V** = timeline stretta (la colonna di destra scende fino in fondo). -- **Sottotitoli AI**: Whisper via transformers.js in `src/media/voce.worker.ts` (libreria dalla CDN, modello da +- **Sottotitoli AI**: Whisper via transformers.js in `src/media/voce.worker.ts` (libreria dentro l'app, CDN di ripiego; modello da Hugging Face, entrambi al primo uso); `src/media/voce.ts` prende l'audio a 16 kHz e fa le righe. Nelle prove si usa un trascrittore finto (`impostaTrascrittore`): dal container non si raggiungono né la CDN né Hugging Face. - **Aggiornamenti**: `src/ui/aggiornamenti.ts` (API delle release di GitHub, novità dal CHANGELOG che viaggia diff --git a/README.md b/README.md index e13fdd5..2d4a51e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ ed elimini. Gira su **Windows, Mac e Android** e, in versione prova, **nel brows ## ▶ Come si monta +> **Non sai dove sta una cosa?** Premi **`Ctrl+K`** (o 🔍 **Cerca** in alto) e scrivi quello che vuoi fare: *rallenta*, +> *sottotitoli*, *togli lo sfondo*, *dissolvenza*, *titolo neon*… e la fai da lì, col suo tasto accanto. **`F1`** apre +> **Come si fa**: una guida corta per ogni lavoro, col pulsante **Fallo adesso** che ti porta proprio al punto giusto. +> Tutte le funzioni AI (sottotitoli, traduzione, voce, sfondo, tracking, montaggio automatico, colore) stanno insieme nel +> **Centro AI** (pulsante ✨ **AI** in alto o menu **AI**), con **Controlla l'AI** che prova sul tuo computer se tutto è a posto. + 0. All'apertura trovi la **pagina iniziale**: i progetti recenti (con la miniatura), Nuovo, Apri e DaProdMontage. Torni quando vuoi dal tasto **Progetti** in alto. 1. **Importa** i video (pulsante *Importa*, `Ctrl+I`, o trascinali nel contenitore). Il primo video decide il formato del progetto. Nel contenitore a sinistra c'è l'albero: **Video, Musica, Immagini** e **le tue cartelle** (*Nuova cartella*, trascina i file dentro); importando da una cartella il file entra lì, da Musica la finestra mostra solo gli audio. 2. Nel **contenitore** passa il mouse su un video: scorre avanti e indietro col puntatore. **Trascinalo** nella timeline, o premi **+** per metterlo al cursore (e il cursore va alla fine: +, +, + e hai la scaletta). Se lì è occupato va su una traccia libera: **niente viene coperto**. @@ -50,10 +56,10 @@ ed elimini. Gira su **Windows, Mac e Android** e, in versione prova, **nel brows 13. **`P`** fa un'**istantanea** del fotogramma nel contenitore, da allungare quanto vuoi (**`Shift+P`**: fermo immagine al cursore). 14. **Sull'immagine del monitor**: clic su quello che vedi e lo scegli; trascina per spostarlo, tira un angolo per ingrandirlo, il pallino in alto lo gira. tira un lato per stirare (Maiusc sugli angoli, Ctrl dal centro, **✂** per ritagliare) e Ctrl+clic sceglie quella sotto. **↝ Movimento** gli dà una posizione d'inizio e una di fine e tutte le **tappe** che vuoi (**+**, o fermo fra due tappe muovi) e ci passa dentro piano piano; **🎯** segue un oggetto (tracking) e titoli, immagini ed effetti lo inseguono; nelle proprietà ci sono i movimenti pronti (entra da sinistra, sale dal basso, si avvicina…). Gli effetti come **bolla**, **vortice** e **zoom** hanno un mirino: mettilo dove vuoi, anche in movimento. Più **transizioni sullo stesso taglio si sommano**. 15. **File → Salva il pacchetto .daprod**: il progetto **con dentro tutti i suoi file** (uno zip vero, i file restano identici). Lo apri su un altro computer e ritrovi tutto. Il **.dpv** invece è il progetto leggero: tiene il montaggio, i file restano dove sono. -17. **`F8`** apre **DaProdMontage**, la quarta pagina: butta dentro **foto e video alla rinfusa** (anche solo foto), scegli **cosa festeggi** (28 stili: matrimonio, battesimo, comunione, cresima, laurea, compleanno, Natale, viaggio, reel verticale…), quanto deve durare e, se vuoi, il brano. **✨ CREA IL MONTAGGIO** e il programma ordina le foto (per data di scatto), le tiene il tempo giusto, le muove, mette transizioni, titoli e quello che cade sopra, appoggia i tagli ai battiti. Lo vedi **in anteprima nel monitor in alto**: se non ti piace **🎲 Rigenera** (ogni volta cambia, anche con le stesse scelte), se ti piace **✅ Importa nella timeline** e lo ritocchi. -18. **Togliere lo sfondo**: sulla clip, **Proprietà → Sfondo** (o il tasto **SFONDO** sul monitor). **Colore** (green screen con fino a 3 colori, contagocce, via il riflesso), **Luce** o **AI** (soggetto, persona, oggetti coi clic). **Generatori → Animazioni**: 49 animazioni personalizzabili (sottopancia, testi, grafici, social, fondi, cerimonie). -19. **La barra delle attività** (in basso a destra) mostra sempre cosa fa il programma dietro le quinte — riaprire i file, forme d'onda, colore, copie leggere, conversioni, sottotitoli e voce con l'AI — con la percentuale e il tempo che manca. **Un clic** apre il pannello con tutte le righe (passati, mancano, in coda, finite da poco): **✕** ferma una riga, **⏸ Pausa** ferma i lavori in coda. I lavori pesanti vanno uno alla volta e aspettano mentre il montaggio suona; riaprendo un progetto con tante clip si riaprono prima i file che stanno in timeline, e le misure già fatte (forme d'onda, colore, locandine) si ricordano. -16. A **schermo pieno** le tracce crescono da sole; **`Ctrl+Shift`+rotella** le alza e le abbassa. Nell'app **`Ctrl +`** e **`Ctrl −`** ingrandiscono tutta l'interfaccia. +16. **`F8`** apre **DaProdMontage**, la quarta pagina: butta dentro **foto e video alla rinfusa** (anche solo foto), scegli **cosa festeggi** (28 stili: matrimonio, battesimo, comunione, cresima, laurea, compleanno, Natale, viaggio, reel verticale…), quanto deve durare e, se vuoi, il brano. **✨ CREA IL MONTAGGIO** e il programma ordina le foto (per data di scatto), le tiene il tempo giusto, le muove, mette transizioni, titoli e quello che cade sopra, appoggia i tagli ai battiti. Lo vedi **in anteprima nel monitor in alto**: se non ti piace **🎲 Rigenera** (ogni volta cambia, anche con le stesse scelte), se ti piace **✅ Importa nella timeline** e lo ritocchi. +17. **Togliere lo sfondo**: sulla clip, **Proprietà → Sfondo** (o il tasto **SFONDO** sul monitor). **Colore** (green screen con fino a 3 colori, contagocce, via il riflesso), **Luce** o **AI** (soggetto, persona, oggetti coi clic). **Generatori → Animazioni**: 49 animazioni personalizzabili (sottopancia, testi, grafici, social, fondi, cerimonie). +18. **La barra delle attività** (in basso a destra) mostra sempre cosa fa il programma dietro le quinte — riaprire i file, forme d'onda, colore, copie leggere, conversioni, sottotitoli e voce con l'AI — con la percentuale e il tempo che manca. **Un clic** apre il pannello con tutte le righe (passati, mancano, in coda, finite da poco): **✕** ferma una riga, **⏸ Pausa** ferma i lavori in coda. I lavori pesanti vanno uno alla volta e aspettano mentre il montaggio suona; riaprendo un progetto con tante clip si riaprono prima i file che stanno in timeline, e le misure già fatte (forme d'onda, colore, locandine) si ricordano. +19. A **schermo pieno** le tracce crescono da sole; **`Ctrl+Shift`+rotella** le alza e le abbassa. Nell'app **`Ctrl +`** e **`Ctrl −`** ingrandiscono tutta l'interfaccia. ### 🎛 La pulsantiera @@ -151,7 +157,7 @@ Se il sito non si accende da solo, una volta sola: *Settings → Pages → Deplo | Togliere lo sfondo | chiave a colori nel piano del colore (YUV) dello shader; ritaglio AI con [transformers.js](https://github.com/huggingface/transformers.js) in un Worker: **BEN2** e **BiRefNet** (MIT), **MODNet** (Apache-2.0), **SAM 2.1** / SlimSAM (Apache-2.0); le maschere si mescolano nel tempo e stanno in una cache sul computer | | Animazioni | funzioni pure del tempo disegnate su **Canvas 2D** (`src/render/anim/`), ispirate al catalogo di [HyperFrames](https://github.com/heygen-com/hyperframes) (Apache-2.0); caratteri **@fontsource** (OFL) | | DaProdMontage | `src/core/montage.ts` (piano: ordine, doppioni, tempo esatto, battiti, Ken Burns) + 28 stili in `montagePreset.ts`; data EXIF, qualità e ritmo del brano calcolati sul posto, senza AI | -| Sottotitoli AI | **Nemotron 3.5** di NVIDIA con [NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp) (programma a parte che l'app scarica da sola: CUDA, Metal o CPU; modelli GGUF che scarica lui) oppure **Whisper** (tiny, base o small) con [transformers.js](https://huggingface.co/docs/transformers.js) in un worker, su WebGPU se c'è (se no WASM): la libreria arriva dalla CDN e il modello da Hugging Face **una volta sola**, poi restano in cache. L'audio della presa diretta si prende a 16 kHz, si taglia nei silenzi a pezzi di un minuto e **non esce mai dal computer** | +| AI (sottotitoli, traduzione, voce, sfondo) | **Nemotron 3.5** di NVIDIA con [NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp) 0.2.0 (programma a parte che l'app scarica da sola: CUDA, Metal o CPU, e se la scheda non ce la fa riprova sul processore; modelli GGUF che scarica lui) oppure **Whisper** (tiny, base o small), **NLLB-200** per tradurre e i modelli per lo sfondo con [transformers.js](https://huggingface.co/docs/transformers.js) in un worker. **La libreria e ONNX Runtime viaggiano dentro l'app** (`public/ai/`, li prepara `scripts/ai-locale.mjs` prima della build, con il controllo dell'impronta): da Internet arrivano solo i modelli, da Hugging Face, **una volta sola**. Su WebGPU se c'è e funziona davvero, se no WASM; ogni modello prova più pesi (q8, fp16, fp32) prima di arrendersi, e gli errori si spiegano in italiano (`src/media/erroriAI.ts`). L'audio della presa diretta si prende a 16 kHz, si taglia nei silenzi a pezzi di un minuto e **non esce mai dal computer** | | App | **Tauri 2 / Rust**: `src-tauri/src/lib.rs` legge i media a pezzi, scrive l'export in streaming (anche `content://` su Android), salva progetti e autosalvataggio; `aggiorna.rs` scarica e apre la versione nuova | ```bash @@ -162,9 +168,10 @@ npm run tauri dev # l'app desktop in sviluppo (serve Rust) npm run tauri build # l'app desktop di rilascio ``` -### 🙏 Crediti e licenze (1.1.4) +### 🙏 Crediti e licenze - **Animazioni**: il catalogo si ispira a [HyperFrames](https://github.com/heygen-com/hyperframes) di HeyGen (Apache-2.0); i disegni sono riscritti da zero per il nostro motore, nessun codice è copiato. I caratteri vengono da [Fontsource](https://fontsource.org) (SIL Open Font License). +- **AI nell'app (1.3.0)**: [transformers.js](https://github.com/huggingface/transformers.js) 4.3.1 (Apache-2.0) e [ONNX Runtime Web](https://github.com/microsoft/onnxruntime) (MIT) sono copiati dentro l'app con le loro licenze (`ai/LICENSE-*.txt`). - **Togliere lo sfondo**: i modelli non stanno nell'app, si scaricano al primo uso da Hugging Face e restano sul computer: [BEN2](https://huggingface.co/PramaLLC/BEN2) e [BiRefNet](https://github.com/ZhengPeng7/BiRefNet) (MIT), [MODNet](https://github.com/ZHKKKe/MODNet) (Apache-2.0), [SAM 2.1](https://github.com/facebookresearch/sam2) e [SlimSAM](https://github.com/czg1225/SlimSAM) (Apache-2.0), tutti via [transformers.js](https://github.com/huggingface/transformers.js) (Apache-2.0). Per la chiave a colori e il ritaglio ho letto come li fa [OpenReel](https://github.com/Augani/openreel-video) per confrontare le idee; il codice è nostro. ### ✅ Controlli automatici @@ -172,7 +179,7 @@ npm run tauri build # l'app desktop di rilascio ```bash npm run build npx playwright install chromium -node test/prove.mjs # oltre 300 prove (più `node test/prove-nuove.mjs [sfondo|animazioni|montage]` da sole): timecode, 1 taglia (e sceglie il pezzo corto), 2 elimina (e passa alla +node test/prove.mjs # oltre 300 prove (più `node test/prove-nuove.mjs [sfondo|animazioni|montage|aiuto]` da sole): timecode, 1 taglia (e sceglie il pezzo corto), 2 elimina (e passa alla # dopo), S separa/unisce, Q e W, tracce accese, rotella, niente viene coperto, volume # trascinato, FX sulle clip (si attaccano ai bordi, seguono la clip, suoni accesi/spenti # e nel mixaggio), Alt+Shift, maniglie delle dissolvenze, navigatore, pagina Finale @@ -191,5 +198,6 @@ node test/foto.mjs # foto del banco (computer, Finale, contenitore, timeline - `src/media/` Mediabunny: contenitore, fotogrammi (flusso e ricerca), proxy automatici, banco audio. - `src/render/` il piano di ogni fotogramma e il mixer WebGL2 (effetti a tempo, colore automatico e colore finale, logo e sottotitoli), titolatrice e countdown, le anteprime del contenitore coi fotogrammi del cursore (`provino.ts`). - `src/ui/` timeline, monitor, pulsantiera, contenitore, proprietà, pagina Finale, pagina LIVE (`live.ts`), pagina DaProdMontage (`montage.ts`), togliere lo sfondo (`sfondo.ts`), mixer e VU, strumenti, finestre. +- Per ritrovare le cose: `src/ui/cerca.ts` (Ctrl+K) con quello che si trova in `fontiCerca.ts`, `src/ui/guida.ts` (F1, Come si fa), `src/ui/centroAI.ts` (Centro AI) e, sotto, `src/media/libreriaAI.ts` (la libreria AI dentro l'app e i ripieghi), `erroriAI.ts` (gli errori spiegati), `diagnosiAI.ts` (Controlla l'AI). - `src/azioni.ts` tutti i comandi con i loro tasti · `src/effetti.ts` gli effetti al volo · `src/export/` export ed EDL · `src/demo.ts` il montaggio dimostrativo. - `src-tauri/` l'app Rust, con `gen/android` per l'APK · `test/` le prove. diff --git a/package-lock.json b/package-lock.json index c24eef6..ece9d0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.2.1", + "version": "1.3.0", "license": "MIT", "dependencies": { "@fontsource/archivo-black": "^5.3.0", diff --git a/package.json b/package.json index 892786b..b207b85 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.2.1", + "version": "1.3.0", "description": "DaProd Video: il montaggio video vecchio stile, moderno dentro. Timeline, due monitor, pulsantiera e VU a lancetta.", "author": "DaProd", "license": "MIT", @@ -9,6 +9,7 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "node scripts/ai-locale.mjs", "build": "vite build", "typecheck": "tsc --noEmit", "preview": "vite preview --port 4173", diff --git a/scripts/ai-locale.mjs b/scripts/ai-locale.mjs new file mode 100644 index 0000000..f4c65e6 --- /dev/null +++ b/scripts/ai-locale.mjs @@ -0,0 +1,68 @@ +// La libreria AI dentro l'app. Le funzioni AI (sottotitoli con Whisper, traduzione, togliere lo sfondo) usano +// transformers.js e il motore ONNX Runtime: prima si scaricavano dalla CDN ogni volta, e se la CDN non rispondeva (rete +// aziendale, CDN lenta, versione sparita) l'AI non partiva. Adesso, prima di ogni build (npm run build lo chiama da solo), +// questo script prende i file giusti dal registro npm, controlla l'impronta (sha512) e li mette in public/ai/: Vite li +// copia in dist/ai/ e i worker li caricano da lì. Se qualcosa va storto non si ferma la build: l'app userà la CDN. +// node scripts/ai-locale.mjs (non rifà niente se i file della versione giusta ci sono già) +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DEST = path.join(ROOT, 'public', 'ai'); +/** la versione di transformers.js che usano i worker (deve essere la stessa di CDN_LIBRERIA in src/media/libreriaAI.ts) */ +export const TRANSFORMERS = '4.3.1'; +const REGISTRO = 'https://registry.npmjs.org'; + +/** i file di un pacchetto npm (tgz) come mappa nome → contenuto, controllando l'impronta pubblicata */ +async function pacchetto(nome, versione) { + const meta = await (await fetch(`${REGISTRO}/${nome.replace('/', '%2F')}/${versione}`)).json(); + const r = await fetch(meta.dist.tarball); + if (!r.ok) throw new Error(`${nome}@${versione}: ${r.status}`); + const tgz = Buffer.from(await r.arrayBuffer()); + const [algo, atteso] = meta.dist.integrity.split('-'); + const vero = crypto.createHash(algo).update(tgz).digest('base64'); + if (vero !== atteso) throw new Error(`${nome}@${versione}: l'impronta non corrisponde`); + // tar senza programmi esterni: blocchi da 512 byte, nome (con prefisso) e grandezza in ottale + const tar = zlib.gunzipSync(tgz); + const out = new Map(); + for (let o = 0; o + 512 <= tar.length;) { + const testa = tar.subarray(o, o + 512); + if (testa.every((b) => b === 0)) break; + const str = (a, b) => testa.subarray(a, b).toString('utf8').replace(/\0.*$/s, ''); + const n = str(0, 100), prefisso = str(345, 500), dim = parseInt(str(124, 136).trim() || '0', 8), tipo = String.fromCharCode(testa[156] || 48); + const file = (prefisso ? prefisso + '/' : '') + n; + if (tipo === '0' || tipo === '\0') out.set(file.replace(/^package\//, ''), tar.subarray(o + 512, o + 512 + dim)); + o += 512 + Math.ceil(dim / 512) * 512; + } + return { meta, file: out }; +} + +async function main() { + const segno = path.join(DEST, 'versione.json'); + try { + const v = JSON.parse(fs.readFileSync(segno, 'utf8')); + if (v.transformers === TRANSFORMERS && fs.existsSync(path.join(DEST, 'ort-wasm-simd-threaded.asyncify.wasm'))) { console.log(`ai-locale: transformers.js ${TRANSFORMERS} già pronto`); return; } + } catch { /* da fare */ } + const tr = await pacchetto('@huggingface/transformers', TRANSFORMERS); + const ortVer = tr.meta.dependencies['onnxruntime-web']; + const ort = await pacchetto('onnxruntime-web', ortVer); + fs.mkdirSync(DEST, { recursive: true }); + const copia = (p, da, a) => { const b = p.file.get(da); if (!b) throw new Error('manca ' + da); fs.writeFileSync(path.join(DEST, a ?? path.basename(da)), b); }; + copia(tr, 'dist/transformers.min.js'); + copia(tr, 'LICENSE', 'LICENSE-transformers.txt'); + // il motore: la versione "asyncify" è quella che transformers.js 4 usa sia col processore sia con la scheda video + copia(ort, 'dist/ort-wasm-simd-threaded.asyncify.mjs'); + copia(ort, 'dist/ort-wasm-simd-threaded.asyncify.wasm'); + // ONNX Runtime non mette il file della licenza nel pacchetto: è MIT (Microsoft), lo si scrive qui + fs.writeFileSync(path.join(DEST, 'LICENSE-onnxruntime.txt'), `onnxruntime-web ${ortVer} · MIT License · Copyright (c) Microsoft Corporation\nhttps://github.com/microsoft/onnxruntime/blob/main/LICENSE\n`); + fs.writeFileSync(segno, JSON.stringify({ transformers: TRANSFORMERS, ort: ortVer }, null, 2)); + console.log(`ai-locale: transformers.js ${TRANSFORMERS} e onnxruntime-web ${ortVer} in public/ai/`); +} + +main().catch((e) => { + // niente rete o registro irraggiungibile: la build va avanti, l'app userà la CDN come prima + console.warn('ai-locale: non riesco a preparare la libreria AI locale (' + (e?.message ?? e) + '): l\'app userà la CDN'); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b8bec3e..3bdb1b7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -624,7 +624,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "daprod-video" -version = "1.2.1" +version = "1.3.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9c8ba7e..9476fa2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.2.1" +version = "1.3.0" description = "DaProd Video: il montaggio vecchio stile, moderno dentro" authors = ["DaProd"] license = "MIT" diff --git a/src-tauri/src/motori.rs b/src-tauri/src/motori.rs index ac81f9c..b1ba0c0 100644 --- a/src-tauri/src/motori.rs +++ b/src-tauri/src/motori.rs @@ -13,7 +13,9 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex, OnceLock}; -const VERSIONE: &str = "0.1.0"; +/// La versione del motore. La 0.1.0 non aveva i programmi pronti da scaricare (nessun archivio nella release): l'app +/// chiedeva un file che non c'era e i sottotitoli NVIDIA finivano in "lo scarico non è riuscito". Dalla 0.2.0 ci sono. +const VERSIONE: &str = "0.2.0"; const RELEASE: &str = "https://github.com/NVIDIA/NeMo-Speech.cpp/releases/download/"; fn senza_finestra(c: &mut Command) -> &mut Command { @@ -53,7 +55,7 @@ pub fn nome_archivio(backend: &str) -> Option { ("windows", "x86_64", "cpu" | "cuda" | "vulkan") => true, ("linux", "x86_64", "cpu" | "cuda" | "vulkan") => true, ("linux", "aarch64", "cpu") => true, - ("macos", "aarch64", "metal") => true, + ("macos", "aarch64", "metal" | "cpu") => true, ("macos", "x86_64", "cpu") => true, _ => false, }; diff --git a/src/azioni.ts b/src/azioni.ts index c038b44..dbf089f 100644 --- a/src/azioni.ts +++ b/src/azioni.ts @@ -26,6 +26,9 @@ export interface Azione { export const azioni = new Map(); const reg = (a: Azione) => azioni.set(a.id, a); +/** per chi registra comandi da fuori (app.ts: salva, importa, esporta, le pagine…): così stanno tutti in un posto, coi + * loro tasti, nella ricerca (Ctrl+K), nella guida dei tasti e nei menu */ +export const registra = reg; export const esegui = (id: string) => azioni.get(id)?.fn(); /** stato dei modi della centralina */ diff --git a/src/main.ts b/src/main.ts index 0d4525d..6fcdaa1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -114,4 +114,6 @@ import * as RI from './media/ritmo'; import * as AT from './media/attivita'; import * as CH from './media/cache'; import * as SM from './core/stima'; -(window as unknown as Record).__dpvTest = { M, TC, P, esporta, creaEdl, motore, guadagnoClip, pianoVideo, Z, statoDecoder, ripresaDiProva, ripresaVfrDiProva, branoVbrDiProva, NZ, TD, PR, importaFile, apriFile, PK, proxyStato: (id: string) => mediaRT(id)?.proxyStato, mediaRT, B, S, SU, V, AG, LV, SQ, SOT, FE, G, mixaggio, PV, TR, TK, ST, Compositore, NM, DP, WV, LAV, SF, MK, RT, CP, USF, AN, RA, fontAnim, MT, MP, EX, QL, RI, AT, CH, SM, ui: () => banco }; +import * as DG from './media/diagnosiAI'; +import * as EA from './media/erroriAI'; +(window as unknown as Record).__dpvTest = { M, TC, P, esporta, creaEdl, motore, guadagnoClip, pianoVideo, Z, statoDecoder, ripresaDiProva, ripresaVfrDiProva, branoVbrDiProva, NZ, TD, PR, importaFile, apriFile, PK, proxyStato: (id: string) => mediaRT(id)?.proxyStato, mediaRT, B, S, SU, V, AG, LV, SQ, SOT, FE, G, mixaggio, PV, TR, TK, ST, Compositore, NM, DP, WV, LAV, SF, MK, RT, CP, USF, AN, RA, fontAnim, MT, MP, EX, QL, RI, AT, CH, SM, DG, EA, ui: () => banco }; diff --git a/src/media/diagnosiAI.ts b/src/media/diagnosiAI.ts new file mode 100644 index 0000000..c0e5479 --- /dev/null +++ b/src/media/diagnosiAI.ts @@ -0,0 +1,87 @@ +// La diagnosi delle funzioni AI (Centro AI → "Controlla l'AI"): risponde alle domande che prima restavano senza +// risposta quando un'AI "dava errore": la libreria parte? da dove (app o CDN)? c'è la scheda video? Hugging Face si +// raggiunge (per scaricare i modelli la prima volta)? quali modelli sono già sul computer? il motore NVIDIA c'è? +import { baseLocaleAI } from './libreriaAI'; +import { spiegaErroreAI } from './erroriAI'; +import { motoreNemo, type StatoMotore } from './nemo'; + +export interface ProvaLibreria { ok: boolean; dove: string; ms: number; msg: string } + +/** la libreria e il suo motore partono? (un conto piccolo in un worker, niente modelli) */ +export function provaLibreria(scadenza = 60000): Promise { + return new Promise((ok) => { + let w: Worker; + try { w = new Worker(new URL('./provaAI.worker.ts', import.meta.url), { type: 'module' }); } catch (e) { ok({ ok: false, dove: '', ms: 0, msg: spiegaErroreAI(e) }); return; } + const timer = setTimeout(() => { w.terminate(); ok({ ok: false, dove: '', ms: scadenza, msg: 'la prova non ha risposto in tempo' }); }, scadenza); + w.onmessage = (e) => { clearTimeout(timer); w.terminate(); const r = e.data as ProvaLibreria; ok({ ...r, msg: r.ok ? '' : spiegaErroreAI(r.msg) }); }; + w.onerror = (e) => { clearTimeout(timer); w.terminate(); ok({ ok: false, dove: '', ms: 0, msg: spiegaErroreAI(e.message || 'il worker di prova non parte') }); }; + w.postMessage({ base: baseLocaleAI() }); + }); +} + +/** Hugging Face risponde? (serve solo per scaricare un modello la prima volta) */ +export async function provaInternet(scadenza = 7000): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), scadenza); + try { + const r = await fetch('https://huggingface.co/onnx-community/whisper-tiny/resolve/main/config.json', { method: 'HEAD', signal: ctrl.signal, cache: 'no-store' }); + return r.ok || r.status === 302 || r.status === 307; + } catch { return false; } finally { clearTimeout(timer); } +} + +/** la scheda video vista dall'AI (WebGPU): il nome, o null se non c'è */ +export async function schedaVideo(): Promise { + const gpu = (navigator as unknown as { gpu?: { requestAdapter: () => Promise<{ info?: { vendor?: string; description?: string; architecture?: string } } | null> } }).gpu; + if (!gpu) return null; + try { + const a = await gpu.requestAdapter(); + if (!a) return null; + const i = a.info ?? {}; + return [i.vendor, i.description || i.architecture].filter(Boolean).join(' ') || 'presente'; + } catch { return null; } +} + +const CACHE = 'transformers-cache'; + +/** i modelli già scaricati (dalla cache della libreria): indirizzo → byte (circa) */ +export async function modelliScaricati(): Promise> { + const out = new Map(); + try { + if (!('caches' in self)) return out; + const c = await caches.open(CACHE); + for (const req of await c.keys()) { + const m = /huggingface\.co\/([^/]+\/[^/]+)\/resolve\//.exec(req.url); + if (!m) continue; + const r = await c.match(req); + const n = Number(r?.headers.get('content-length') ?? 0); + out.set(m[1], (out.get(m[1]) ?? 0) + n); + } + } catch { /* niente cache */ } + return out; +} + +/** toglie i modelli scaricati (si riscaricano quando servono) */ +export async function svuotaModelli(): Promise { + try { return 'caches' in self ? await caches.delete(CACHE) : false; } catch { return false; } +} + +/** lo stato del motore NVIDIA (solo nell'app) */ +export async function statoNvidia(): Promise { + const m = motoreNemo(); + if (!m) return null; + return m.stato().catch(() => null); +} + +export interface Diagnosi { + libreria: ProvaLibreria; + internet: boolean; + scheda: string | null; + modelli: Map; + nvidia: StatoMotore | null; +} + +/** tutto insieme (le prove vanno in parallelo) */ +export async function diagnosi(): Promise { + const [libreria, internet, scheda, modelli, nvidia] = await Promise.all([provaLibreria(), provaInternet(), schedaVideo(), modelliScaricati(), statoNvidia()]); + return { libreria, internet, scheda, modelli, nvidia }; +} diff --git a/src/media/erroriAI.ts b/src/media/erroriAI.ts new file mode 100644 index 0000000..82f662a --- /dev/null +++ b/src/media/erroriAI.ts @@ -0,0 +1,40 @@ +// Gli errori delle funzioni AI detti in parole: dietro c'è una libreria (transformers.js, ONNX Runtime) o un programma +// (il motore NVIDIA) che parla inglese tecnico ("Failed to fetch", "Could not locate file", "Aborted()"...). Chi monta +// deve capire cosa è successo e cosa fare: qui il messaggio grezzo diventa una frase con la soluzione. +// È puro (niente interfaccia): lo usano i sottotitoli, la traduzione, il ritaglio dello sfondo e il Centro AI. + +/** il messaggio di un errore qualunque */ +export const testoErrore = (e: unknown) => (typeof e === 'string' ? e : e instanceof Error ? e.message : String(e ?? '')); + +const REGOLE: [RegExp, string][] = [ + [/failed to fetch dynamically imported module|importing a module script failed|error loading dynamically imported module/i, + 'La libreria AI non si carica: controlla la connessione a internet e riprova'], + [/could not locate file|404|not found.*(onnx|json|model)|unauthorized|401|403/i, + 'Il modello non si trova su Hugging Face (forse è stato spostato o rinominato): prova un altro modello dalle opzioni'], + [/failed to fetch|networkerror|network error|load failed|err_internet|err_name_not_resolved|err_connection|net::|timed? ?out/i, + 'Non riesco a scaricare il modello: serve internet la prima volta (poi resta sul computer e va anche senza rete)'], + [/quota|storage.*full|disk.*full|no space/i, + 'Lo spazio per tenere i modelli è pieno: libera un po\' di spazio sul disco e riprova'], + [/out of memory|oom|array buffer allocation failed|memory access out of bounds|cannot allocate|bad_alloc|aborted\(\)|maximum call stack/i, + 'La memoria non basta per questo modello: scegli quello più leggero (opzioni) o chiudi gli altri programmi'], + [/webgpu|gpudevice|device (was )?lost|gpu.*(lost|error|fail)/i, + 'La scheda video si è fermata durante il lavoro: riprova (il programma userà il processore)'], + [/no available backend|backend.*(not|fail)|wasm.*(fail|abort|compile)|webassembly/i, + 'Il motore AI non si avvia su questo sistema: aggiorna il programma o il sistema e riprova'], +]; + +/** il messaggio grezzo di un errore AI → una frase che spiega cosa è successo e cosa fare */ +export function spiegaErroreAI(e: unknown): string { + const t = testoErrore(e).trim(); + if (!t) return 'L\'AI si è fermata senza dire perché: riprova'; + // già spiegato (le frasi nostre cominciano in italiano e non hanno bisogno di altro) + if (/^(la |il |lo |non |l'|serve |questa |questo |nel )/i.test(t)) return t; + for (const [re, frase] of REGOLE) if (re.test(t)) return frase; + return t.length > 160 ? t.slice(0, 157) + '…' : t; +} + +/** l'errore va riprovato sul processore? (la scheda video non ce l'ha fatta) */ +export const erroreDellaScheda = (e: unknown) => /webgpu|gpudevice|device (was )?lost|gpubuffer|gpu.*(lost|error|fail)|createbuffer|shader|jsep/i.test(testoErrore(e)); + +/** il file del modello non c'è (con un altro "tipo" o un altro indirizzo può andare) */ +export const erroreFileMancante = (e: unknown) => /could not locate file|404|not found/i.test(testoErrore(e)); diff --git a/src/media/libreriaAI.ts b/src/media/libreriaAI.ts new file mode 100644 index 0000000..399fa9e --- /dev/null +++ b/src/media/libreriaAI.ts @@ -0,0 +1,130 @@ +// La libreria AI comune ai worker (sottotitoli con Whisper, traduzione, togliere lo sfondo). Qui stanno le regole che +// prima ogni worker si faceva da solo, e che erano la causa di quasi tutti gli errori: +// · la libreria (transformers.js) e il suo motore (ONNX Runtime) si caricano dai file dell'app (dist/ai/, preparati da +// scripts/ai-locale.mjs); solo se lì non ci sono si va sulla CDN, come prima; +// · il "tipo" del modello (q8, fp16, fp32…) si dice sempre, e se un tipo non c'è nel repository (il file manca: 404) si +// prova il successivo. Prima non si diceva: col processore la libreria cercava la versione q8, che per alcuni modelli +// (MODNet, BiRefNet) non esiste, e il ritaglio finiva in "Could not locate file"; +// · scheda video (WebGPU) prima e processore (WASM) dopo, sia quando si carica sia quando la scheda si ferma a metà +// lavoro (prima un errore della scheda durante la trascrizione fermava tutto). +// Il file è usato dai worker (che lo includono nel loro pacchetto) e, per baseLocaleAI e statoLibreria, dalla pagina. +import { erroreDellaScheda, erroreFileMancante, testoErrore } from './erroriAI'; + +/** la stessa versione di scripts/ai-locale.mjs (la CDN serve solo se i file locali mancano) */ +export const VERSIONE_LIBRERIA = '4.3.1'; +export const CDN_LIBRERIA = `https://cdn.jsdelivr.net/npm/@huggingface/transformers@${VERSIONE_LIBRERIA}/dist/transformers.min.js`; + +/** dove stanno i file locali della libreria, visto dalla pagina (app/index.html → ../ai/): si passa ai worker */ +export function baseLocaleAI(): string { + try { return new URL('../ai/', document.baseURI).href; } catch { return ''; } +} + +/** la scheda video per l'AI si può spegnere (Centro AI): su alcuni computer WebGPU c'è ma sbaglia. Solo nella pagina. */ +export function usaSchedaAI(): boolean { + try { return localStorage.getItem('dpv-ai-scheda') !== 'no'; } catch { return true; } +} +export function impostaSchedaAI(on: boolean) { + try { if (on) localStorage.removeItem('dpv-ai-scheda'); else localStorage.setItem('dpv-ai-scheda', 'no'); } catch { /* niente */ } +} + +export type Dispositivo = 'webgpu' | 'wasm'; +export type Tipo = string | Record; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface LibreriaAI { + env: any; + pipeline: (compito: string, modello: string, opz: Record) => Promise; + RawImage: any; + AutoProcessor: any; + SamModel: any; + Sam2Model?: any; + [k: string]: any; +} + +let promessa: Promise<{ T: LibreriaAI; dove: 'locale' | 'cdn' }> | null = null; + +const safariVecchio = () => { + if (typeof navigator === 'undefined') return false; + const ua = navigator.userAgent; + const safari = (navigator.vendor || '').includes('Apple') && !/CriOS|FxiOS|EdgiOS|Chrome|Android/.test(ua); + const v = /Version\/(\d+)/.exec(ua); + return safari && !!v && Number(v[1]) < 26; +}; + +/** carica la libreria una volta sola: prima dai file dell'app, se no dalla CDN */ +export function libreria(base?: string): Promise<{ T: LibreriaAI; dove: 'locale' | 'cdn' }> { + promessa ??= (async () => { + let T: LibreriaAI | null = null; + let dove: 'locale' | 'cdn' = 'cdn'; + if (base) { + try { T = (await import(/* @vite-ignore */ base + 'transformers.min.js')) as LibreriaAI; dove = 'locale'; } catch { T = null; } + } + if (!T) T = (await import(/* @vite-ignore */ CDN_LIBRERIA)) as LibreriaAI; + T.env.allowLocalModels = false; + // il motore (wasm) dai file dell'app: niente CDN nemmeno per lui. Safari prima della 26 senza scheda vuole un'altra + // versione del motore: lì si lascia scegliere alla libreria (dalla CDN). + const wasm = T.env.backends?.onnx?.wasm; + const gpu = typeof navigator !== 'undefined' && 'gpu' in navigator; + if (dove === 'locale' && wasm && !(safariVecchio() && !gpu)) { + wasm.wasmPaths = { mjs: base + 'ort-wasm-simd-threaded.asyncify.mjs', wasm: base + 'ort-wasm-simd-threaded.asyncify.wasm' }; + } + return { T, dove }; + })(); + // se non si carica, la prossima volta si riprova (magari la rete è tornata) + promessa.catch(() => { promessa = null; }); + return promessa; +} + +/** scheda video prima (se c'è e non è stata spenta), poi il processore */ +export async function dispositivi(conScheda = true): Promise { + if (!conScheda) return ['wasm']; + const gpu = (navigator as unknown as { gpu?: { requestAdapter: () => Promise } }).gpu; + const adattatore = gpu ? await gpu.requestAdapter().catch(() => null) : null; + return adattatore ? ['webgpu', 'wasm'] : ['wasm']; +} + +/** quanto si è scaricato: un messaggio ogni 2%, non a ogni pezzetto */ +export function progressoScarico(manda: (m: unknown) => void) { + const visti = new Map(); + return (x: { status: string; file?: string; loaded?: number; total?: number }) => { + if (x.status !== 'progress' || !x.file || !x.total) return; + const k = Math.floor(((x.loaded ?? 0) / x.total) * 50); + if (visti.get(x.file) === k) return; + visti.set(x.file, k); + manda({ tipo: 'scarico', file: x.file, loaded: x.loaded ?? 0, total: x.total }); + }; +} + +export interface Caricato { v: V; repo: string; device: Dispositivo; dtype: Tipo } + +/** + * Prova a caricare un modello finché una combinazione va: per ogni indirizzo, per ogni dispositivo, per ogni tipo. + * Un file che manca (404) fa passare al tipo dopo; un errore della scheda fa passare al processore. Se non va + * niente, si rilancia l'errore più utile (quello che non è un semplice "file mancante", se c'è). + */ +export async function caricaConRipieghi( + repo: string[], devices: Dispositivo[], tipi: (d: Dispositivo) => Tipo[], + fn: (repo: string, device: Dispositivo, dtype: Tipo) => Promise, +): Promise> { + let utile: unknown = null, ultimo: unknown = new Error('nessun modello da provare'); + for (const r of repo) { + for (const device of devices) { + for (const dtype of tipi(device)) { + try { + return { v: await fn(r, device, dtype), repo: r, device, dtype }; + } catch (e) { + ultimo = e; + if (!erroreFileMancante(e)) utile ??= e; + // la scheda video non ce la fa: inutile provare gli altri tipi su di lei + if (device === 'webgpu' && erroreDellaScheda(e)) break; + } + } + } + } + throw utile ?? ultimo; +} + +/** un nome leggibile del tipo (per dire cosa si è caricato) */ +export const nomeTipo = (t: Tipo) => (typeof t === 'string' ? t : Object.values(t).join('+')); + +export { erroreDellaScheda, testoErrore }; diff --git a/src/media/nemo.ts b/src/media/nemo.ts index dbd6305..082de80 100644 --- a/src/media/nemo.ts +++ b/src/media/nemo.ts @@ -95,6 +95,9 @@ function spiega(righe: string[]): string { const utili = righe.filter((r) => !/^\s*\[?(info|debug)/i.test(r)); const l = (utili.length ? utili : righe).slice(-3).join(' · '); if (/curl/i.test(l) && /not found|non trovato|recognized/i.test(l)) return 'manca il programma curl nel sistema (serve per scaricare i modelli)'; + if (/zh|mandarin|chinese/i.test(l) && /unsupported|not supported|unknown|invalid/i.test(l)) return 'la voce in cinese non c\'è nella versione del motore NVIDIA che si scarica (NVIDIA la include solo se compilata apposta)'; + if (/out of memory|cudaMalloc|failed to allocate|bad_alloc/i.test(l)) return 'la memoria non basta (della scheda video o del computer): chiudi altri programmi e riprova'; + if (/could not resolve host|couldn't connect|connection|timed out|SSL/i.test(l)) return 'non riesco a scaricare il modello: controlla la connessione a internet (serve solo la prima volta)'; return l || 'il motore si è fermato senza dire perché'; } @@ -148,6 +151,20 @@ async function esegui(args: string[], o: OpzEsegui = {}): Promise { } } +/** + * Esegue un comando del motore e, se la versione per la scheda video (CUDA, Vulkan, Metal) si ferma con un errore, + * lo rifà una volta sul processore (`--device cpu`): più lento, ma il lavoro arriva in fondo. + */ +async function eseguiConRipiego(args: string[], o: OpzEsegui = {}, cambio?: () => void): Promise { + try { + await esegui(args, o); + } catch (e) { + if (o.segnale?.aborted || messaggio(e) === 'fermato' || args.includes('--device')) throw e; + cambio?.(); + await esegui([...args, '--device', 'cpu'], o); + } +} + const mbTesto = (b: number) => (b / MB).toFixed(b > 100 * MB ? 0 : 1); const messaggio = (e: unknown) => (typeof e === 'string' ? e : e instanceof Error ? e.message : String(e)); @@ -217,14 +234,15 @@ const motoreApp: MotoreNemo = { await scrivi(`${dIn}/${nome}.wav`, codificaWav(p.audio, 16000)); } const n = pezzi.length; - await esegui(['transcribe', dIn, '--model', 'nemotron-3.5', '--language', codiceLingua(o.lingua), '--format', 'srt', '--output-dir', dOut, '--concurrency', '1', '--force'], { + let sulProcessore = false; + await eseguiConRipiego(['transcribe', dIn, '--model', 'nemotron-3.5', '--language', codiceLingua(o.lingua), '--format', 'srt', '--output-dir', dOut, '--concurrency', '1', '--force'], { segnale, intervallo: 500, sonda: async () => { const f = await invoke<[string, number][]>('motore_elenca', { path: dOut }).catch(() => [] as [string, number][]); const fatti = f.filter(([nome]) => nome.endsWith('.srt')).length; - avanza(Math.min(0.98, fatti / n), `Ascolto e scrivo con Nemotron: ${Math.min(fatti, n)} di ${n} pezzi`); + avanza(Math.min(0.98, fatti / n), `Ascolto e scrivo con Nemotron${sulProcessore ? ' (sul processore)' : ''}: ${Math.min(fatti, n)} di ${n} pezzi`); }, - }); + }, () => { sulProcessore = true; avanza(0, 'La scheda video non ce l\'ha fatta: riprovo sul processore…'); }); const out: Pezzo[] = []; for (const [i, nome] of nomi.entries()) { let testo = ''; @@ -243,13 +261,15 @@ const motoreApp: MotoreNemo = { const lavoro = 'tts' + Date.now().toString(36); const dir = await invoke('motore_cartella_lavoro', { nome: lavoro }); const out = new Map(); + // passati una volta al processore, le frasi dopo restano lì (non si riprova la scheda a ogni frase) + let dispositivo: string[] = []; try { for (const [i, v] of voci.entries()) { if (segnale?.aborted) throw new Error('fermato'); avanza(i / voci.length, `Faccio parlare la voce: frase ${i + 1} di ${voci.length}`); const t = `${dir}/t${i}.txt`, w = `${dir}/t${i}.wav`; await scrivi(t, new TextEncoder().encode(v.testo)); - await esegui(['synthesize', '-i', t, '-o', w, '--language', codiceLingua(o.lingua), '--speaker', String(o.voce), '--format', 'wav', '--force'], { segnale, intervallo: 250 }); + await eseguiConRipiego(['synthesize', '-i', t, '-o', w, '--language', codiceLingua(o.lingua), '--speaker', String(o.voce), '--format', 'wav', '--force', ...dispositivo], { segnale, intervallo: 250 }, () => { dispositivo = ['--device', 'cpu']; }); const { audio, sr } = leggiWav(await leggi(w)); out.set(v.id, { audio, sr }); } diff --git a/src/media/provaAI.worker.ts b/src/media/provaAI.worker.ts new file mode 100644 index 0000000..59e67aa --- /dev/null +++ b/src/media/provaAI.worker.ts @@ -0,0 +1,20 @@ +// La prova del motore AI (il pulsante "Controlla l'AI" del Centro AI): si carica la libreria come fanno i worker veri +// e le si fa fare un conto piccolo (una moltiplicazione di matrici, che passa dal motore ONNX Runtime in wasm). Così si +// sa subito se l'AI può partire su questo computer, senza scaricare nessun modello. +import { libreria, testoErrore } from './libreriaAI'; + +self.onmessage = async (e: MessageEvent) => { + const { base } = e.data as { base?: string }; + const t0 = performance.now(); + try { + const { T, dove } = await libreria(base); + const a = new T.Tensor('float32', new Float32Array([1, 2, 3, 4]), [2, 2]); + const b = new T.Tensor('float32', new Float32Array([0, 1, 1, 0]), [2, 2]); + const c = await T.matmul(a, b); + const dati = Array.from(c.data as Float32Array); + const giusto = dati.join(',') === '2,1,4,3'; + (self as unknown as Worker).postMessage({ ok: giusto, dove, ms: performance.now() - t0, msg: giusto ? '' : 'il conto di prova è sbagliato: ' + dati.join(',') }); + } catch (err) { + (self as unknown as Worker).postMessage({ ok: false, dove: '', ms: performance.now() - t0, msg: testoErrore(err) }); + } +}; diff --git a/src/media/ritaglio.ts b/src/media/ritaglio.ts index 089ec25..13400cf 100644 --- a/src/media/ritaglio.ts +++ b/src/media/ritaglio.ts @@ -15,6 +15,8 @@ import { firmaRitaglio, istantiCampioni, levigaMaschere, puoRitagliare, QUALITA_ import { mediaRT } from './libreria'; import { misuraPer, pixelAl, type Pixel } from './campiona'; import { impostaMaschere, potaMaschere, type Maschere } from './maschere'; +import { spiegaErroreAI } from './erroriAI'; +import { baseLocaleAI, usaSchedaAI } from './libreriaAI'; export interface ModelloRitaglio { id: string; @@ -64,7 +66,8 @@ function lavoratore(): Worker { let a = 0, b = 0; for (const [x, y] of scaricati.values()) { a += x; b += y; } caricamento.stato(`Scarico il modello: ${(a / 1048576).toFixed(0)} di ${(b / 1048576).toFixed(0)} MB (solo la prima volta)`, b ? a / b : 0); - } else if (m.tipo === 'pronto') { caricamento?.ok((m.repo ? m.repo + ' · ' : '') + (m.device ?? '')); caricamento = null; } + } else if (m.tipo === 'avviso') { caricamento?.stato(m.msg ?? '', 0); } + else if (m.tipo === 'pronto') { caricamento?.ok((m.repo ? m.repo + ' · ' : '') + (m.device ?? '')); caricamento = null; } else if (m.tipo === 'maschera') { attese.get(m.id!)?.ok(m.dati!); attese.delete(m.id!); } else if (m.tipo === 'errore') { const err = new Error(m.msg || 'errore del modello'); @@ -86,7 +89,7 @@ const conIlWorker: Segmentatore = { carica: (m, stato) => new Promise((ok, no) => { scaricati.clear(); caricamento = { ok, no, stato }; - lavoratore().postMessage({ tipo: 'carica', famiglia: m.famiglia, repo: m.repo }); + lavoratore().postMessage({ tipo: 'carica', famiglia: m.famiglia, repo: m.repo, base: baseLocaleAI(), scheda: usaSchedaAI() }); }), maschera: (px, o) => new Promise((ok, no) => { const id = ++seq; @@ -169,7 +172,7 @@ export async function elaboraRitaglio(p: Project, c: Clip, o: OpzioniElabora = { stato(`Preparo il modello ${mod.nome}…`, 0); let dispositivo = ''; try { dispositivo = await segmentatore.carica(mod, (fase, k) => stato(fase, k * 0.1)); } catch (e) { - return { ok: false, motivo: 'Non riesco a caricare il modello: ' + (e instanceof Error ? e.message : String(e)) }; + return { ok: false, motivo: 'Non riesco a caricare il modello: ' + spiegaErroreAI(e) }; } if (ferma()) return { ok: false, motivo: 'Fermato' }; const q = QUALITA_RITAGLIO[Math.max(0, Math.min(QUALITA_RITAGLIO.length - 1, r.qualita))]; @@ -231,7 +234,7 @@ export async function elaboraRitaglio(p: Project, c: Clip, o: OpzioniElabora = { } } } catch (e) { - return { ok: false, motivo: 'Il modello si è fermato: ' + (e instanceof Error ? e.message : String(e)) }; + return { ok: false, motivo: 'Il modello si è fermato: ' + spiegaErroreAI(e) }; } if (ferma()) return { ok: false, motivo: 'Fermato' }; const completi = dati.filter(Boolean); diff --git a/src/media/ritaglio.worker.ts b/src/media/ritaglio.worker.ts index dd5fe8d..a0093a2 100644 --- a/src/media/ritaglio.worker.ts +++ b/src/media/ritaglio.worker.ts @@ -1,25 +1,14 @@ // Il worker del ritaglio: i modelli che separano il soggetto dallo sfondo girano qui, lontano dall'interfaccia. -// La libreria (transformers.js) arriva dalla CDN la prima volta che serve, il modello da Hugging Face una volta sola -// (poi sta nella cache del browser/app). Le immagini non escono mai dal computer. +// La libreria sta dentro l'app (src/media/libreriaAI.ts, con la CDN di riserva), il modello arriva da Hugging Face una +// volta sola (poi sta nella cache del browser/app). Le immagini non escono mai dal computer. // · famiglia "sfondo": la pipeline "background-removal" (BEN2, BiRefNet, MODNet…): un'immagine → la maschera; // · famiglia "sam": Segment Anything (SAM 2.1, SlimSAM…): un'immagine + dei clic o un riquadro → la maschera dell'oggetto. -// Ogni modello ha più indirizzi di riserva: si prova il primo, se non c'è si passa al successivo. - -// (l'export vuoto fa di questo file un modulo: le sue variabili non si mescolano con quelle degli altri worker) -export {}; - -const LIBRERIA = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/dist/transformers.min.js'; +// Ogni modello ha più indirizzi di riserva e più "tipi" (fp16, fp32, q8): si prova finché uno va. Il tipo si dice +// sempre: prima no, e col processore la libreria cercava una versione (q8) che per MODNet e BiRefNet non c'è. +import { caricaConRipieghi, dispositivi, erroreDellaScheda, libreria, nomeTipo, progressoScarico, testoErrore, type Dispositivo, type LibreriaAI, type Tipo } from './libreriaAI'; interface Immagine { data: Uint8ClampedArray | Uint8Array; width: number; height: number; channels: number } interface Tensore { data: ArrayLike; dims: number[] } -interface Libreria { - env: { allowLocalModels: boolean }; - RawImage: new (data: Uint8ClampedArray, w: number, h: number, canali: number) => unknown; - pipeline: (compito: string, modello: string, opz: Record) => Promise<(img: unknown) => Promise>; - AutoProcessor: { from_pretrained: (m: string, o?: Record) => Promise }; - SamModel: { from_pretrained: (m: string, o?: Record) => Promise }; - Sam2Model?: { from_pretrained: (m: string, o?: Record) => Promise }; -} type Elaboratore = ((img: unknown, o: Record) => Promise>) & { post_process_masks: (m: unknown, a: unknown, b: unknown) => Promise; }; @@ -27,51 +16,43 @@ type ModelloSam = (inputs: Record) => Promise<{ pred_masks: unk interface Msg { tipo: string; id?: number; famiglia?: string; repo?: string[]; w?: number; h?: number; rgba?: Uint8ClampedArray; - punti?: { x: number; y: number; dentro: boolean }[]; riquadro?: [number, number, number, number]; + punti?: { x: number; y: number; dentro: boolean }[]; riquadro?: [number, number, number, number]; base?: string; scheda?: boolean; } -let T: Libreria | null = null; +let T: LibreriaAI | null = null; let sfondo: ((img: unknown) => Promise) | null = null; let sam: { model: ModelloSam; processor: Elaboratore } | null = null; let caricato = ''; +let ultimaRichiesta: { famiglia: string; repo: string[] } | null = null; +let dispositivo: Dispositivo = 'wasm'; +let base = ''; const manda = (m: unknown, trasferibili: Transferable[] = []) => (self as unknown as Worker).postMessage(m, trasferibili); -async function carica(famiglia: string, repo: string[]) { +/** i tipi da provare: sulla scheda mezza precisione (leggera e veloce), sul processore prima il quantizzato, poi il pieno */ +const tipi = (d: Dispositivo): Tipo[] => (d === 'webgpu' ? ['fp16', 'fp32'] : ['q8', 'fp16', 'fp32']); + +async function carica(famiglia: string, repo: string[], conScheda = true) { const chiave = famiglia + ':' + repo.join(','); - if (caricato === chiave && (sfondo || sam)) { manda({ tipo: 'pronto', device: 'già pronto' }); return; } - T ??= (await import(/* @vite-ignore */ LIBRERIA)) as Libreria; - T.env.allowLocalModels = false; - const visti = new Map(); - const progress_callback = (x: { status: string; file?: string; loaded?: number; total?: number }) => { - if (x.status !== 'progress' || !x.file || !x.total) return; - const k = Math.floor(((x.loaded ?? 0) / x.total) * 50); - if (visti.get(x.file) === k) return; - visti.set(x.file, k); - manda({ tipo: 'scarico', file: x.file, loaded: x.loaded ?? 0, total: x.total }); - }; - const gpu = (navigator as unknown as { gpu?: { requestAdapter: () => Promise } }).gpu; - const conGpu = gpu ? await gpu.requestAdapter().catch(() => null) : null; - const dispositivi = conGpu ? ['webgpu', 'wasm'] : ['wasm']; + if (caricato === chiave && (sfondo || sam) && (conScheda || dispositivo === 'wasm')) { manda({ tipo: 'pronto', device: dispositivo }); return; } + ultimaRichiesta = { famiglia, repo }; + const lib = await libreria(base); + T = lib.T; + const progress_callback = progressoScarico(manda); sfondo = null; sam = null; - let ultimo: unknown = new Error('nessun modello'); - for (const r of repo) { - for (const device of dispositivi) { - try { - if (famiglia === 'sam') { - const processor = await T.AutoProcessor.from_pretrained(r, { progress_callback }); - const Classe = /sam2|edgetam/i.test(r) && T.Sam2Model ? T.Sam2Model : T.SamModel; - const model = await Classe.from_pretrained(r, { device, progress_callback }); - sam = { model, processor }; - } else { - sfondo = await T.pipeline('background-removal', r, { progress_callback, device }); - } - caricato = chiave; - manda({ tipo: 'pronto', device, repo: r }); - return; - } catch (e) { ultimo = e; } + const ok = await caricaConRipieghi(repo, await dispositivi(conScheda), tipi, async (r, device, dtype) => { + if (famiglia === 'sam') { + const processor = await T!.AutoProcessor.from_pretrained(r, { progress_callback }); + const Classe = /sam2|edgetam/i.test(r) && T!.Sam2Model ? T!.Sam2Model : T!.SamModel; + const model = await Classe.from_pretrained(r, { device, dtype, progress_callback }); + return { sam: { model, processor } as { model: ModelloSam; processor: Elaboratore } }; } - } - throw ultimo; + return { sfondo: await T!.pipeline('background-removal', r, { progress_callback, device, dtype }) as (img: unknown) => Promise }; + }); + sfondo = ok.v.sfondo ?? null; + sam = ok.v.sam ?? null; + caricato = chiave; + dispositivo = ok.device; + manda({ tipo: 'pronto', device: `${ok.device === 'webgpu' ? 'scheda video' : 'processore'} · ${nomeTipo(ok.dtype)}`, repo: ok.repo, libreria: lib.dove }); } const senzaAlfa = (rgba: Uint8ClampedArray) => { @@ -112,11 +93,24 @@ async function maschera(m: Msg): Promise { let best = 0; for (let i = 1; i < Math.min(k, punteggi.length); i++) if (punteggi[i] > punteggi[best]) best = i; const o = new Uint8Array(W * H); - const base = best * W * H; - for (let i = 0; i < o.length; i++) o[i] = t.data[base + i] ? 255 : 0; + const base0 = best * W * H; + for (let i = 0; i < o.length; i++) o[i] = t.data[base0 + i] ? 255 : 0; return W === w && H === h ? o : ridimensiona(o, W, H, w, h); } +/** la maschera, e se la scheda video si ferma a metà si ricarica il modello sul processore e si rifà */ +async function mascheraSicura(m: Msg): Promise { + try { + return await maschera(m); + } catch (e) { + if (dispositivo !== 'webgpu' || !erroreDellaScheda(e) || !ultimaRichiesta) throw e; + manda({ tipo: 'avviso', msg: 'La scheda video si è fermata: continuo col processore (più lento)' }); + caricato = ''; + await carica(ultimaRichiesta.famiglia, ultimaRichiesta.repo, false); + return await maschera(m); + } +} + function ridimensiona(m: Uint8Array, w: number, h: number, W: number, H: number): Uint8Array { const o = new Uint8Array(W * H); for (let y = 0; y < H; y++) { @@ -129,12 +123,13 @@ function ridimensiona(m: Uint8Array, w: number, h: number, W: number, H: number) self.onmessage = async (e: MessageEvent) => { const m = e.data as Msg; try { - if (m.tipo === 'carica') await carica(m.famiglia!, m.repo!); + if (m.base !== undefined) base = m.base; + if (m.tipo === 'carica') await carica(m.famiglia!, m.repo!, m.scheda !== false); else if (m.tipo === 'maschera') { - const dati = await maschera(m); + const dati = await mascheraSicura(m); manda({ tipo: 'maschera', id: m.id, dati }, [dati.buffer]); } } catch (err) { - manda({ tipo: 'errore', id: m.id, msg: err instanceof Error ? err.message : String(err) }); + manda({ tipo: 'errore', id: m.id, msg: testoErrore(err) }); } }; diff --git a/src/media/traduci.ts b/src/media/traduci.ts index e39eeab..ea4632b 100644 --- a/src/media/traduci.ts +++ b/src/media/traduci.ts @@ -3,6 +3,11 @@ // · "Traduci i sottotitoli": le righe restano ai loro tempi, cambia solo il testo; // · la voce AI: se la voce parla una lingua diversa da quella dei sottotitoli, i testi si traducono prima di leggerli // (altrimenti una voce inglese leggerebbe parole italiane con l'accento sbagliato). +import { spiegaErroreAI, testoErrore } from './erroriAI'; +import { baseLocaleAI } from './libreriaAI'; + +/** il modello della traduzione (e quello di riserva), come li prova il worker */ +export const MODELLI_TRADUZIONE = ['Xenova/nllb-200-distilled-600M', 'onnx-community/nllb-200-distilled-600M-ONNX']; /** le lingue che si traducono: codice nostro, codice di NLLB, nome */ export const LINGUE_TRADUZIONE: [string, string, string][] = [ @@ -56,6 +61,9 @@ function lavoratore(): Worker { caricamento?.no(err); caricamento = null; for (const x of attese.values()) x.no(err); attese.clear(); + // un worker fermato non risponde più: la prossima volta se ne fa uno nuovo (prima la traduzione restava appesa) + worker?.terminate(); + worker = null; }; return worker; } @@ -64,7 +72,7 @@ const nllb: Traduttore = { carica: (stato) => new Promise((ok, no) => { scaricati.clear(); caricamento = { ok, no, stato }; - lavoratore().postMessage({ tipo: 'carica' }); + lavoratore().postMessage({ tipo: 'carica', base: baseLocaleAI() }); }), traduci: (testi, da, a) => new Promise((ok, no) => { const id = ++seq; @@ -96,7 +104,10 @@ export async function traduciTesti(testi: string[], da: string, a: string, stato if (codiceNllb(da) === codiceNllb(a)) return testi.slice(); if (!sappiamoTradurre(da, a)) throw new Error('Questa coppia di lingue non la so tradurre'); stato('Preparo la traduzione…', 0); - await traduttore.carica((fase, x) => stato(fase, x * 0.3)); + try { await traduttore.carica((fase, x) => stato(fase, x * 0.3)); } catch (e) { + if (segnale?.aborted || testoErrore(e) === 'fermato') throw e; + throw new Error(spiegaErroreAI(e)); + } fermo(); const out = testi.slice(); const dafare = testi.map((t, i) => [t, i] as const).filter(([t]) => t.trim()); diff --git a/src/media/traduci.worker.ts b/src/media/traduci.worker.ts index af9c234..1056401 100644 --- a/src/media/traduci.worker.ts +++ b/src/media/traduci.worker.ts @@ -1,50 +1,34 @@ // Il worker della traduzione: NLLB-200 (Meta, nella versione di Hugging Face per il browser) traduce i testi fra -// duecento lingue, qui lontano dall'interfaccia. La libreria (transformers.js) arriva dalla CDN solo la prima volta -// che serve; il modello si scarica da Hugging Face una volta e resta nella cache. Il testo non esce mai dal computer. -export {}; +// duecento lingue, qui lontano dall'interfaccia. La libreria sta dentro l'app (src/media/libreriaAI.ts, con la CDN di +// riserva); il modello si scarica da Hugging Face una volta e resta nella cache. Il testo non esce mai dal computer. +import { caricaConRipieghi, libreria, nomeTipo, progressoScarico, testoErrore } from './libreriaAI'; -const LIBRERIA = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/dist/transformers.min.js'; -/** il modello, e i ripieghi se uno non si trova (dal più preciso al più leggero) */ -const MODELLI = ['Xenova/nllb-200-distilled-600M', 'onnx-community/nllb-200-distilled-600M-ONNX']; +import { MODELLI_TRADUZIONE as MODELLI } from './traduci'; type Traduzione = { translation_text: string }[]; type Pipeline = (testi: string[], opz: Record) => Promise; -interface Libreria { - env: { allowLocalModels: boolean }; - pipeline: (compito: string, modello: string, opz: Record) => Promise; -} let tr: Pipeline | null = null; let caricato = ''; +let base = ''; const manda = (m: unknown) => (self as unknown as Worker).postMessage(m); async function carica() { if (tr) { manda({ tipo: 'pronto', modello: caricato }); return; } - const T = (await import(/* @vite-ignore */ LIBRERIA)) as Libreria; - T.env.allowLocalModels = false; - const visti = new Map(); - const progress_callback = (x: { status: string; file?: string; loaded?: number; total?: number }) => { - if (x.status !== 'progress' || !x.file || !x.total) return; - const k = Math.floor(((x.loaded ?? 0) / x.total) * 50); - if (visti.get(x.file) === k) return; - visti.set(x.file, k); - manda({ tipo: 'scarico', file: x.file, loaded: x.loaded ?? 0, total: x.total }); - }; - let ultimo: unknown = null; - for (const m of MODELLI) { - try { - tr = await T.pipeline('translation', m, { progress_callback, dtype: 'q8' }); - caricato = m; - manda({ tipo: 'pronto', modello: m }); - return; - } catch (e) { ultimo = e; } - } - throw new Error('Non riesco a caricare il modello di traduzione: ' + (ultimo instanceof Error ? ultimo.message : String(ultimo))); + const { T, dove } = await libreria(base); + const progress_callback = progressoScarico(manda); + // la traduzione sta sul processore: i modelli a codificatore e decodificatore sulla scheda video sbagliano spesso + const ok = await caricaConRipieghi(MODELLI, ['wasm'], () => ['q8', 'fp32'], + (r, device, dtype) => T.pipeline('translation', r, { progress_callback, device, dtype }) as Promise); + tr = ok.v; + caricato = ok.repo; + manda({ tipo: 'pronto', modello: ok.repo, dettaglio: `${nomeTipo(ok.dtype)} · libreria ${dove}` }); } self.onmessage = async (e: MessageEvent) => { - const m = e.data as { tipo: string; id?: number; testi?: string[]; da?: string; a?: string }; + const m = e.data as { tipo: string; id?: number; testi?: string[]; da?: string; a?: string; base?: string }; try { + if (m.base !== undefined) base = m.base; if (m.tipo === 'carica') await carica(); else if (m.tipo === 'traduci') { if (!tr) throw new Error('modello non caricato'); @@ -52,6 +36,6 @@ self.onmessage = async (e: MessageEvent) => { manda({ tipo: 'tradotto', id: m.id, testi: out.map((x) => x.translation_text) }); } } catch (err) { - manda({ tipo: 'errore', id: m.id, msg: err instanceof Error ? err.message : String(err) }); + manda({ tipo: 'errore', id: m.id, msg: testoErrore(err) }); } }; diff --git a/src/media/voce.ts b/src/media/voce.ts index 24f3ac5..ab86d25 100644 --- a/src/media/voce.ts +++ b/src/media/voce.ts @@ -9,6 +9,8 @@ import { end, projectEnd, uid } from '../core/progetto'; import { f2s, fps } from '../core/timecode'; import { mixaggio } from './audio'; import { assicuraMotore, motoreNemo } from './nemo'; +import { spiegaErroreAI } from './erroriAI'; +import { baseLocaleAI, usaSchedaAI } from './libreriaAI'; export interface Modello { id: string; nome: string; info: string; mb: number } @@ -55,7 +57,8 @@ function lavoratore(): Worker { let a = 0, b = 0; for (const [x, y] of scaricati.values()) { a += x; b += y; } caricamento.stato(`Scarico il modello: ${(a / 1048576).toFixed(0)} di ${(b / 1048576).toFixed(0)} MB (solo la prima volta)`, b ? a / b : 0); - } else if (m.tipo === 'pronto') { caricamento?.ok(m.device ?? ''); caricamento = null; } + } else if (m.tipo === 'avviso') { caricamento?.stato(m.msg ?? '', 0); avvisoVoce?.(m.msg ?? ''); } + else if (m.tipo === 'pronto') { caricamento?.ok(m.device ?? ''); caricamento = null; } else if (m.tipo === 'testo') { attese.get(m.id!)?.ok(m.pezzi ?? []); attese.delete(m.id!); } else if (m.tipo === 'errore') { const err = new Error(m.msg || 'errore della voce'); @@ -68,15 +71,22 @@ function lavoratore(): Worker { caricamento?.no(err); caricamento = null; for (const x of attese.values()) x.no(err); attese.clear(); + // un worker che si è fermato (memoria finita…) non risponde più: si butta, la prossima volta se ne fa uno nuovo + // (prima si riusava quello morto e la funzione restava appesa per sempre) + worker?.terminate(); + worker = null; }; return worker; } +/** chi vuole sapere i cambi in corsa (la scheda video si è fermata, si continua col processore) */ +let avvisoVoce: ((msg: string) => void) | null = null; + const whisper: Trascrittore = { carica: (modello, stato) => new Promise((ok, no) => { scaricati.clear(); caricamento = { ok, no, stato }; - lavoratore().postMessage({ tipo: 'carica', modello }); + lavoratore().postMessage({ tipo: 'carica', modello, base: baseLocaleAI(), scheda: usaSchedaAI() }); }), trascrivi: (audio, lingua, traduci) => new Promise((ok, no) => { const id = ++seq; @@ -196,20 +206,37 @@ export async function sottotitoliAI(p: Project, o: OpzioniVoce, stato: (fase: st for (let i = 0; i < audio.length; i += 64) picco = Math.max(picco, Math.abs(audio[i])); if (picco < 0.003) throw new Error('muto'); const tagli = puntiDiTaglio(audio); - if (o.motore === 'nemotron') { + // nel browser il motore NVIDIA non c'è: si va con Whisper, e lo si dice + let ripiego = o.motore === 'nemotron' && !motoreNemo() ? 'c\'è solo nell\'app per Windows e Mac' : ''; + if (o.motore === 'nemotron' && motoreNemo()) { // Nemotron 3.5 di NVIDIA, col motore dell'app: prima si prepara (motore e modello, solo la prima volta), poi si ascolta - const nem = motoreNemo(); - if (!nem) throw new Error('Nemotron serve l\'app per Windows o Mac: nel browser usa Whisper'); - await assicuraMotore(nem, ['asr'], (k, t) => stato(t, 0.1 + k * 0.4), segnale); - fermo(); - const daAscoltare = tagli.slice(0, -1).map((a, k) => ({ da: a, audio: audio.slice(Math.floor(a * SR), Math.floor(tagli[k + 1] * SR)) })); - const pezzi = await nem.trascrivi(daAscoltare, { lingua: o.lingua }, (k, t) => stato(t, 0.5 + k * 0.5), segnale); - fermo(); - stato('Fatto', 1); - return righeDaPezzi(pezzi, p); + const nem = motoreNemo()!; + try { + await assicuraMotore(nem, ['asr'], (k, t) => stato(t, 0.1 + k * 0.4), segnale); + fermo(); + const daAscoltare = tagli.slice(0, -1).map((a, k) => ({ da: a, audio: audio.slice(Math.floor(a * SR), Math.floor(tagli[k + 1] * SR)) })); + const pezzi = await nem.trascrivi(daAscoltare, { lingua: o.lingua }, (k, t) => stato(t, 0.5 + k * 0.5), segnale); + fermo(); + stato('Fatto', 1); + return righeDaPezzi(pezzi, p); + } catch (e) { + // il motore NVIDIA non è partito (niente internet per scaricarlo, scheda o sistema che non lo regge…): invece di + // fermarsi si passa a Whisper, che gira dentro l'app. Chi ha premuto "Ferma" invece si ferma davvero. + const msg = e instanceof Error ? e.message : String(e); + if (msg === 'fermato' || segnale?.aborted) throw e; + ripiego = msg; + stato(`Il motore NVIDIA non è partito (${msg}): passo a Whisper…`, 0.1); + } + } + stato(ripiego ? `Il motore NVIDIA ${motoreNemo() ? 'non è partito' : 'c\'è solo nell\'app'}: carico Whisper…` : 'Carico il modello…', 0.1); + let dove: string; + try { + dove = await trascrittore.carica(o.modello || MODELLI[1].id, (fase, x) => stato(fase, 0.1 + x * 0.3)); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg === 'fermato' || segnale?.aborted) throw e; + throw new Error(spiegaErroreAI(msg) + (ripiego ? ` (e il motore NVIDIA: ${ripiego})` : '')); } - stato('Carico il modello…', 0.1); - const dove = await trascrittore.carica(o.modello, (fase, x) => stato(fase, 0.1 + x * 0.3)); fermo(); const pezzi: Pezzo[] = []; for (let k = 0; k < tagli.length - 1; k++) { @@ -217,7 +244,12 @@ export async function sottotitoliAI(p: Project, o: OpzioniVoce, stato: (fase: st const a = tagli[k], b = tagli[k + 1]; stato(`Ascolto e scrivo${dove === 'webgpu' ? ' (con la scheda video)' : ''}: ${Math.round(a)} di ${Math.round(tagli[tagli.length - 1])} secondi`, 0.4 + (0.6 * k) / (tagli.length - 1)); const pezzo = audio.slice(Math.floor(a * SR), Math.floor(b * SR)); - const testo = await trascrittore.trascrivi(pezzo, o.lingua, o.traduci && o.lingua === 'it'); + let testo: Pezzo[]; + try { testo = await trascrittore.trascrivi(pezzo, o.lingua, o.traduci && o.lingua === 'it'); } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg === 'fermato' || segnale?.aborted) throw e; + throw new Error(spiegaErroreAI(msg)); + } for (const x of testo) pezzi.push({ da: x.da + a, a: x.a === null ? null : x.a + a, testo: x.testo }); } fermo(); diff --git a/src/media/voce.worker.ts b/src/media/voce.worker.ts index da7cf3f..c299837 100644 --- a/src/media/voce.worker.ts +++ b/src/media/voce.worker.ts @@ -1,68 +1,68 @@ // Il worker della voce: Whisper (il riconoscimento del parlato di OpenAI, nella versione di Hugging Face per il -// browser) gira qui, lontano dall'interfaccia. La libreria (transformers.js) arriva dalla CDN solo la prima volta che -// serve; il modello si scarica da Hugging Face una volta e resta nella cache del browser/app. L'audio non esce mai -// dal computer: tutto il lavoro si fa qui. - -const LIBRERIA = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/dist/transformers.min.js'; +// browser) gira qui, lontano dall'interfaccia. La libreria (transformers.js) sta dentro l'app (src/media/libreriaAI.ts, +// con la CDN di riserva); il modello si scarica da Hugging Face una volta e resta nella cache del browser/app. L'audio +// non esce mai dal computer: tutto il lavoro si fa qui. +import { caricaConRipieghi, dispositivi, erroreDellaScheda, libreria, nomeTipo, progressoScarico, testoErrore, type Dispositivo, type Tipo } from './libreriaAI'; type Pipeline = (audio: Float32Array, opz: Record) => Promise<{ text: string; chunks?: { timestamp: [number, number | null]; text: string }[] }>; -interface Libreria { - env: { allowLocalModels: boolean }; - pipeline: (compito: string, modello: string, opz: Record) => Promise; -} let asr: Pipeline | null = null; let caricato = ''; +let dispositivo: Dispositivo = 'wasm'; +let base = ''; const manda = (m: unknown) => (self as unknown as Worker).postMessage(m); -async function carica(modello: string) { - if (asr && caricato === modello) { manda({ tipo: 'pronto' }); return; } - const T = (await import(/* @vite-ignore */ LIBRERIA)) as Libreria; - T.env.allowLocalModels = false; - // quanto si è scaricato: un messaggio ogni tanto, non a ogni pezzetto - const visti = new Map(); - const progress_callback = (x: { status: string; file?: string; loaded?: number; total?: number }) => { - if (x.status !== 'progress' || !x.file || !x.total) return; - const k = Math.floor(((x.loaded ?? 0) / x.total) * 50); - if (visti.get(x.file) === k) return; - visti.set(x.file, k); - manda({ tipo: 'scarico', file: x.file, loaded: x.loaded ?? 0, total: x.total }); +/** i tipi del modello da provare: sulla scheda il codificatore preciso e il decodificatore leggero, sul processore q8 */ +const tipi = (d: Dispositivo): Tipo[] => (d === 'webgpu' + ? [{ encoder_model: 'fp32', decoder_model_merged: 'q4' }, 'fp32'] + : ['q8', 'fp32']); + +async function carica(modello: string, conScheda = true) { + if (asr && caricato === modello && (conScheda || dispositivo === 'wasm')) { manda({ tipo: 'pronto', device: dispositivo }); return; } + const { T, dove } = await libreria(base); + const progress_callback = progressoScarico(manda); + const ok = await caricaConRipieghi([modello], await dispositivi(conScheda), tipi, + (r, device, dtype) => T.pipeline('automatic-speech-recognition', r, { progress_callback, device, dtype }) as Promise); + asr = ok.v; + caricato = modello; + dispositivo = ok.device; + manda({ tipo: 'pronto', device: ok.device, dettaglio: `${ok.device === 'webgpu' ? 'scheda video' : 'processore'} · ${nomeTipo(ok.dtype)} · libreria ${dove}` }); +} + +const LINGUE: Record = { it: 'italian', en: 'english', es: 'spanish', fr: 'french', de: 'german', pt: 'portuguese' }; + +async function trascrivi(audio: Float32Array, lingua?: string, traduci?: boolean) { + if (!asr) throw new Error('modello non caricato'); + const opz = { + // "auto": Whisper riconosce da solo la lingua + ...(lingua && lingua !== 'auto' ? { language: LINGUE[lingua] ?? 'italian' } : {}), + task: traduci ? 'translate' : 'transcribe', + chunk_length_s: 30, stride_length_s: 5, return_timestamps: true, }; - const gpu = (navigator as unknown as { gpu?: { requestAdapter: () => Promise } }).gpu; - const conGpu = gpu ? await gpu.requestAdapter().catch(() => null) : null; - let device = conGpu ? 'webgpu' : 'wasm'; try { - asr = await T.pipeline('automatic-speech-recognition', modello, { - progress_callback, device, dtype: conGpu ? { encoder_model: 'fp32', decoder_model_merged: 'q4' } : 'q8', - }); + return await asr(audio, opz); } catch (e) { - if (!conGpu) throw e; - // la scheda video non ce la fa: si va col processore - device = 'wasm'; - asr = await T.pipeline('automatic-speech-recognition', modello, { progress_callback, device, dtype: 'q8' }); + // la scheda video si è fermata a metà: si ricarica il modello sul processore e si rifà lo stesso pezzo + if (dispositivo !== 'webgpu' || !erroreDellaScheda(e)) throw e; + manda({ tipo: 'avviso', msg: 'La scheda video si è fermata: continuo col processore (più lento)' }); + asr = null; + await carica(caricato, false); + return await asr!(audio, opz); } - caricato = modello; - manda({ tipo: 'pronto', device }); } self.onmessage = async (e: MessageEvent) => { - const m = e.data as { tipo: string; id?: number; modello?: string; audio?: Float32Array; lingua?: string; traduci?: boolean }; + const m = e.data as { tipo: string; id?: number; modello?: string; audio?: Float32Array; lingua?: string; traduci?: boolean; base?: string; scheda?: boolean }; try { - if (m.tipo === 'carica') await carica(m.modello!); + if (m.base !== undefined) base = m.base; + if (m.tipo === 'carica') await carica(m.modello!, m.scheda !== false); else if (m.tipo === 'trascrivi') { - if (!asr) throw new Error('modello non caricato'); - const lingue: Record = { it: 'italian', en: 'english', es: 'spanish', fr: 'french', de: 'german', pt: 'portuguese' }; - const out = await asr(m.audio!, { - // "auto": Whisper riconosce da solo la lingua - ...(m.lingua && m.lingua !== 'auto' ? { language: lingue[m.lingua] ?? 'italian' } : {}), - task: m.traduci ? 'translate' : 'transcribe', - chunk_length_s: 30, stride_length_s: 5, return_timestamps: true, - }); + const out = await trascrivi(m.audio!, m.lingua, m.traduci); const pezzi = (out.chunks?.length ? out.chunks : [{ timestamp: [0, null] as [number, number | null], text: out.text }]) .map((c) => ({ da: c.timestamp[0], a: c.timestamp[1], testo: c.text })); manda({ tipo: 'testo', id: m.id, pezzi }); } } catch (err) { - manda({ tipo: 'errore', id: m.id, msg: err instanceof Error ? err.message : String(err) }); + manda({ tipo: 'errore', id: m.id, msg: testoErrore(err) }); } }; diff --git a/src/stile/editor.css b/src/stile/editor.css index b5b2cd2..259d1bd 100644 --- a/src/stile/editor.css +++ b/src/stile/editor.css @@ -1143,3 +1143,101 @@ body.pip-live { margin: 0; background: #111016; overflow: hidden; } .home-nulla { padding: 40px 12px; text-align: center; color: var(--testo3); border: 2px dashed #000; border-radius: 10px; } .home-nulla b { color: var(--testo2); font: 800 15px var(--ui); } @media (max-width: 800px) { .home-corpo { grid-template-columns: 1fr; } } + +/* ——— 1.3.0: cerca (Ctrl+K), guida "Come si fa", Centro AI ——— */ +.btn-cerca, .btn-ai { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 8px; border: 1px solid #000; background: linear-gradient(180deg, #2e2c37, #23212b); color: var(--testo2); font: 800 11.5px var(--ui); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); white-space: nowrap; } +.btn-cerca { min-width: 150px; } +.btn-cerca span { flex: 1; text-align: left; } +.btn-cerca kbd { font-size: 10px; color: var(--testo3); } +.btn-cerca:hover, .btn-ai:hover { color: var(--oro); } +.btn-ai { color: #ffd0fb; border-color: #4a1546; background: linear-gradient(180deg, #3a1d3d, #26142b); } +.btn-ai:hover { color: #fff; box-shadow: 0 0 12px rgba(255, 61, 242, 0.35); } +/* la testata a gradini: prima vanno via le scritte di contorno, per ultime le etichette delle pagine; Esporta resta + sempre in vista (prima, su uno schermo da portatile, la testata traboccava e il tasto Esporta finiva fuori) */ +.btn-cerca kbd { display: none; } +@media (min-width: 2000px) { .btn-cerca kbd { display: inline; } } +.testata > * { flex-shrink: 0; } +.testata-destra { min-width: 0; flex-shrink: 1; } +@media (max-width: 1840px) { .badge.edizione { display: none; } } +@media (max-width: 1720px) { .badge.formato { display: none; } .btn-progetti span { display: none; } .nome-progetto { max-width: 170px; } } +@media (max-width: 1620px) { .voce-menu { padding: 6px 7px; } .pagina-btn { padding: 5px 10px; letter-spacing: 1px; } .pagine { margin-left: 4px; } .testata { gap: 7px; } } +@media (max-width: 1520px) { .btn-cerca { min-width: 0; } .btn-cerca span { display: none; } .scritta i { display: none; } } +@media (max-width: 1420px) { .marchio .scritta { display: none; } .nome-progetto { display: none; } } +@media (max-width: 1250px) { .pagina-btn span, .btn-ai span { display: none; } .voce-menu { padding: 6px 5px; } } + +.cerca-velo { position: fixed; inset: 0; z-index: 950; background: rgba(5, 4, 10, 0.55); backdrop-filter: blur(3px); display: flex; justify-content: center; align-items: flex-start; padding-top: 11vh; animation: appare 0.12s; } +.cerca { width: min(700px, calc(100vw - 24px)); max-height: 72vh; display: flex; flex-direction: column; background: linear-gradient(180deg, #221f2b, #17161d); border: 1px solid #000; border-radius: 14px; box-shadow: var(--ombra), inset 0 0 0 1px var(--bordo-forte), 0 0 40px rgba(255, 213, 74, 0.08); overflow: hidden; animation: sale 0.15s; } +.cerca-riga { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid #000; box-shadow: 0 1px 0 var(--bordo); } +.cerca-lente { font-size: 18px; } +.cerca-input { flex: 1; background: transparent; border: 0; outline: none; font: 600 17px var(--ui); color: var(--testo); } +.cerca-input::placeholder { color: var(--testo3); } +.cerca-intesta { padding: 6px 14px 2px; font: 800 10.5px var(--ui); letter-spacing: 1px; text-transform: uppercase; color: var(--testo3); } +.cerca-lista { overflow: auto; padding: 4px 6px 8px; } +.cerca-voce { display: grid; grid-template-columns: 118px 1fr auto; gap: 10px; align-items: center; padding: 7px 9px; border-radius: 8px; cursor: pointer; } +.cerca-voce.scelta { background: linear-gradient(90deg, rgba(255, 213, 74, 0.16), rgba(255, 213, 74, 0.04)); box-shadow: inset 2px 0 0 var(--oro); } +.cerca-voce.manca .cerca-testo b { color: var(--testo2); } +.cerca-testo b { display: block; font-size: 14px; color: var(--testo); font-weight: 700; } +.cerca-testo small { display: block; color: var(--testo3); font-size: 11.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cerca-cat { font: 800 10px var(--ui); letter-spacing: 0.6px; text-transform: uppercase; color: var(--testo3); padding: 2px 6px; border-radius: 10px; background: #1f1d26; border: 1px solid #000; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cerca-cat[data-cat='AI'] { color: #ffd0fb; background: #2b1530; } +.cerca-cat[data-cat='Guida'] { color: var(--verde); } +.cerca-cat[data-cat='Pagine'] { color: var(--cia); } +.cerca-cat[data-cat='Transizioni'], .cerca-cat[data-cat='Effetti a tempo'] { color: var(--oro); } +.cerca-piede { display: flex; gap: 14px; align-items: center; padding: 8px 14px; border-top: 1px solid #000; color: var(--testo3); font-size: 11.5px; } +.cerca-piede .spazio { flex: 1; } +.cerca-piede kbd { margin-right: 2px; } +@media (max-width: 899px) { .cerca-voce { grid-template-columns: 1fr auto; } .cerca-cat { display: none; } .cerca-velo { padding-top: 4vh; } } + +.dialogo.guida { width: min(940px, calc(100vw - 24px)); } +.guida-dentro { display: grid; grid-template-columns: 270px 1fr; gap: 16px; min-height: 380px; } +.guida-elenco { display: flex; flex-direction: column; gap: 1px; max-height: 62vh; overflow: auto; padding-right: 6px; border-right: 1px solid #000; box-shadow: 1px 0 0 var(--bordo); } +.guida-elenco h5 { margin: 10px 0 3px; font: 800 10.5px var(--ui); letter-spacing: 1px; text-transform: uppercase; color: var(--oro); } +.guida-elenco h5:first-child { margin-top: 0; } +.guida-voce { display: flex; gap: 8px; align-items: center; text-align: left; padding: 6px 8px; border-radius: 7px; border: 0; background: transparent; color: var(--testo2); font-weight: 700; } +.guida-voce:hover { background: #24222c; color: var(--testo); } +.guida-voce.attiva { background: linear-gradient(90deg, rgba(255, 213, 74, 0.18), transparent); color: var(--oro); } +.guida-ic { width: 22px; text-align: center; flex: none; } +.guida-corpo h3 { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; font: 800 17px var(--ui); color: var(--testo); } +.guida-riassunto { margin: 0 0 12px; color: var(--testo2); font-weight: 500; } +.guida-passi { margin: 0 0 16px; padding-left: 22px; display: flex; flex-direction: column; gap: 9px; font: 500 14px/1.45 var(--ui); color: var(--testo); } +.guida-passi li::marker { color: var(--oro); font-weight: 800; } +.guida-passi b { color: var(--oro); font-weight: 700; } +.guida-vai { font-size: 13px; } +@media (max-width: 899px) { .guida-dentro { grid-template-columns: 1fr; } .guida-elenco { max-height: 30vh; border-right: 0; box-shadow: none; } } + +.dialogo.centro-ai { width: min(1040px, calc(100vw - 24px)); } +.ai-intro { margin: 0 0 12px; color: var(--testo2); font-weight: 500; } +.ai-griglia { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 10px; } +.ai-carta { display: flex; flex-direction: column; gap: 6px; padding: 11px 12px; border-radius: 11px; border: 1px solid #000; background: linear-gradient(180deg, #24212c, #1b1a21); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); } +.ai-carta header { display: flex; align-items: center; gap: 8px; } +.ai-carta header b { flex: 1; font-size: 14.5px; color: var(--testo); } +.ai-ic { font-size: 19px; } +.ai-dove { font: 800 9.5px var(--ui); letter-spacing: 0.5px; text-transform: uppercase; padding: 2px 6px; border-radius: 10px; background: #1a1920; border: 1px solid #000; color: var(--testo3); white-space: nowrap; } +.ai-dove.app { color: var(--ambra); } +.ai-dove.tutto { color: var(--cia); } +.ai-carta p { margin: 0; color: var(--testo2); font-weight: 500; font-size: 12.5px; } +.ai-come { margin: 0; padding-left: 18px; color: var(--testo); font: 500 12px/1.4 var(--ui); } +.ai-stato { font-size: 11.5px; color: var(--testo3); font-weight: 600; min-height: 15px; } +.ai-ok { color: var(--verde); } +.ai-manca { color: var(--ambra) !important; font-size: 12px !important; } +.ai-usa { margin-top: auto; align-self: flex-start; } +.ai-sezione { margin-top: 14px; padding-top: 10px; border-top: 1px solid #000; box-shadow: inset 0 1px 0 var(--bordo); } +.ai-sezione h4 { margin: 0 0 8px; font: 800 11px var(--ui); letter-spacing: 1px; text-transform: uppercase; color: var(--oro); } +.ai-riga, .ai-imp { display: flex; gap: 10px; align-items: center; margin: 4px 0; flex-wrap: wrap; } +.ai-diagnosi { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } +.ai-diag { display: grid; grid-template-columns: 22px 150px 1fr; gap: 6px; align-items: baseline; font-weight: 500; } +.ai-diag b { color: var(--testo); } +.ai-diag-seg { font-weight: 800; text-align: center; } +.ai-diag.ok .ai-diag-seg { color: var(--verde); } +.ai-diag.no .ai-diag-seg { color: var(--rosso); } +.ai-diag.no span:last-child { color: #ffb0bd; } +.ai-diag.forse .ai-diag-seg { color: var(--testo3); } + +.home-impara { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } +.home-impara .btn { justify-content: flex-start; } + +/* il controllo indicato dalla guida o dal Centro AI lampeggia un attimo */ +.evidenziato { animation: evidenzia 0.85s ease-in-out 3; position: relative; z-index: 2; } +@keyframes evidenzia { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 213, 74, 0); } 50% { box-shadow: 0 0 0 4px rgba(255, 213, 74, 0.85), 0 0 22px rgba(255, 213, 74, 0.6); } } +.isp-aiuti { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } +.isp-aiuti .btn { display: inline-flex; gap: 6px; align-items: center; } diff --git a/src/ui/app.ts b/src/ui/app.ts index 1478239..20a0058 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -28,6 +28,13 @@ import { statoDecoder } from '../media/fotogrammi'; import { CentroAttivita } from './attivita'; import { impostaUsoMedia, risvegliaProxy } from '../media/libreria'; import { banco } from '../media/audio'; +import { registra } from '../azioni'; +import { apriCerca } from './cerca'; +import './fontiCerca'; +import { finestraGuida } from './guida'; +import { finestraCentroAI, FUNZIONI_AI } from './centroAI'; +import { modoProxy, impostaModoProxy, svuotaProxy } from '../media/proxy'; +import { svuotaCache } from '../media/cache'; export function avvia(radice: HTMLElement) { const tl = new Timeline(); @@ -66,6 +73,16 @@ export function avvia(radice: HTMLElement) { mostraLato('clip'); }); document.addEventListener('dpv:ispettore', () => { pagina('montaggio'); mostraLato('clip'); apriFoglio('lato'); }); + // "porta dove si fa" (guida, ricerca, Centro AI): una sezione delle proprietà della clip scelta + document.addEventListener('dpv:sezione', (e) => { + const id = (e as CustomEvent).detail as string; + pagina('montaggio'); + if (radice.classList.contains('senza-lato')) { radice.classList.remove('senza-lato'); salvaBanco(); setTimeout(() => monitor.adatta(), 50); } + mostraLato('clip'); + apriFoglio('lato'); + if (!store.sel.size) { avviso('Scegli prima una clip nella timeline: qui compariranno le sue impostazioni', 'info', 3200); return; } + setTimeout(() => { if (!isp.apriSezione(id)) avviso('Per la clip scelta questa impostazione non c\'è: scegli una clip video', 'info', 3000); }, 60); + }); // ——— le quattro pagine: Montaggio, Finale, LIVE e DaProdMontage ——— type Pagina = 'montaggio' | 'finale' | 'live' | 'montage'; @@ -130,6 +147,14 @@ export function avvia(radice: HTMLElement) { { nome: 'Animazioni', sotto: GRUPPI_ANIM.map((g) => ({ nome: g.nome, sotto: animazioniDi(g.id).map((a) => ({ nome: a.nome, fn: () => { inserisciGeneratore('anim', undefined, undefined, { anim: a.id }); avviso(`${a.nome} al cursore`, 'ok', 1200); } })) })) }, voce('genAnimazione'), ]], + ['AI', () => [ + { nome: 'Centro AI (tutte le funzioni, come si usano)…', fn: () => finestraCentroAI() }, + { sep: true }, + ...FUNZIONI_AI.map((f) => ({ nome: `${f.icona} ${f.nome}`, fn: f.apri })), + { sep: true }, + { nome: '🔍 Controlla l\'AI (se qualcosa non va)', fn: () => finestraCentroAI(true) }, + { nome: 'Se un\'AI non parte (guida)', fn: () => finestraGuida('problemi-ai') }, + ]], ['Vista', () => [ { nome: 'Pagina Montaggio', spunta: radice.dataset.pagina === 'montaggio', tasto: 'F9', fn: () => pagina('montaggio') }, { nome: 'Pagina Finale (colore, audio, esporta)', spunta: radice.dataset.pagina === 'finale', tasto: 'F9', fn: () => pagina('finale') }, @@ -154,9 +179,22 @@ export function avvia(radice: HTMLElement) { { sep: true }, { nome: 'Strumenti di misura', fn: () => { pagina('montaggio'); mostraLato('scopi'); } }, { nome: 'Mixer', fn: () => { pagina('montaggio'); mostraLato('mixer'); } }, + { sep: true }, + { nome: 'Copie leggere per il monitor (proxy)', sotto: [ + { nome: 'Da sole, per le riprese pesanti (consigliato)', spunta: modoProxy() === 'auto', fn: () => { impostaModoProxy('auto'); avviso('Copie leggere: da sole per le riprese pesanti in timeline', 'info'); } }, + { nome: 'Sempre (anche per i file leggeri)', spunta: modoProxy() === 'sempre', fn: () => { impostaModoProxy('sempre'); avviso('Copie leggere: sempre', 'info'); } }, + { nome: 'Mai (il monitor legge gli originali)', spunta: modoProxy() === 'mai', fn: () => { impostaModoProxy('mai'); avviso('Copie leggere spente: il monitor legge gli originali', 'info'); } }, + { sep: true }, + { nome: 'Svuota le copie leggere (si rifanno quando servono)', fn: async () => { await svuotaProxy(); avviso('Copie leggere tolte dal disco: si rifanno da sole quando servono', 'ok', 2600); } }, + ] }, + { nome: 'Svuota la memoria delle misure (forme d\'onda, colore, locandine)', fn: async () => { await svuotaCache(); avviso('Memoria delle misure svuotata: si rifanno alla prossima apertura dei file', 'ok', 2600); } }, ]], ['Aiuto', () => [ - { nome: 'Tasti della centralina', tasto: 'F1', fn: () => finestraTasti() }, + { nome: 'Come si fa (la guida)', tasto: 'F1', fn: () => finestraGuida() }, + { nome: 'Cerca un comando…', tasto: 'Ctrl+K', fn: () => apriCerca() }, + { nome: 'Tasti della centralina', fn: () => finestraTasti() }, + { nome: 'Centro AI…', fn: () => finestraCentroAI() }, + { sep: true }, { nome: `Novità della ${VERSIONE}`, fn: () => finestraNovita() }, ...(isTauri ? [{ nome: 'Aggiornamenti…', fn: () => void finestraAggiornamenti() }] : []), { nome: 'Scarica le app (Windows, Mac, Android)', fn: () => apriLink('https://github.com/cammo22/DaProdVideo/releases/latest') }, @@ -202,6 +240,8 @@ export function avvia(radice: HTMLElement) { h('span', { class: 'moneta' }, 'D'), h('span', { class: 'scritta' }, 'Da', h('b', null, 'Prod'), h('i', null, ' VIDEO'))), h('button', { class: 'btn-progetti', title: 'I tuoi progetti: nuovo, apri, recenti', on: { click: () => home.mostra() } }, icona('apri', 15), h('span', null, 'Progetti')), barraMenu, + h('button', { class: 'btn-cerca', title: 'Cerca un comando, un effetto, una transizione, una funzione AI o una guida (Ctrl+K)', on: { click: () => apriCerca() } }, '🔍', h('span', null, 'Cerca'), h('kbd', null, 'Ctrl K')), + h('button', { class: 'btn-ai', title: 'Centro AI: tutte le funzioni intelligenti, come si usano e se sono pronte', on: { click: () => finestraCentroAI() } }, '✨', h('span', null, 'AI')), h('div', { class: 'pagine' }, tastoPagina('montaggio', 'MONTAGGIO', 'montaggio', 'Il banco di montaggio (F9)'), tastoPagina('finale', 'FINALE', 'finale', 'Colore e audio su tutto il montaggio, poi esporta (F9)'), @@ -323,32 +363,53 @@ export function avvia(radice: HTMLElement) { // ——— tastiera, trascinamenti, audio ——— installaTastiera(); if (isTauri && grandezzaUI() !== 1) void impostaGrandezzaUI(grandezzaUI()); - const extra: Record void> = { - 'ctrl+s': () => salva(), 'ctrl+shift+s': () => salva(true), 'ctrl+o': () => apri(), 'ctrl+i': () => importaDialogo(), - 'ctrl+m': () => finestraEsporta(), 'ctrl+n': () => nuovo(), f1: () => finestraTasti(), '+': () => tl.zoom(1.5), '-': () => tl.zoom(1 / 1.5), - '\\': () => tl.adattaTutto(), v: () => vistaStretta(), g: () => { modi.zoneSicure = !modi.zoneSicure; monitor.disegnaSopra(); }, - f11: () => void schermoIntero(), f9: () => pagina(radice.dataset.pagina === 'finale' ? 'montaggio' : 'finale'), - f10: () => pagina(radice.dataset.pagina === 'live' ? 'montaggio' : 'live'), - f8: () => pagina(radice.dataset.pagina === 'montage' ? 'montaggio' : 'montage'), - // la grandezza dell'interfaccia nell'app, un gradino alla volta (Ctrl 0 torna al 100%); nel browser fa il browser - ...(isTauri ? { - 'ctrl++': () => void impostaGrandezzaUI(GRANDEZZE_UI.find((k) => k > grandezzaUI() + 0.01) ?? grandezzaUI()), - 'ctrl+=': () => void impostaGrandezzaUI(GRANDEZZE_UI.find((k) => k > grandezzaUI() + 0.01) ?? grandezzaUI()), - 'ctrl+-': () => void impostaGrandezzaUI([...GRANDEZZE_UI].reverse().find((k) => k < grandezzaUI() - 0.01) ?? grandezzaUI()), - 'ctrl+0': () => void impostaGrandezzaUI(1), - } : {}), - }; + // i comandi del banco (file, pagine, vista, aiuto): stanno nel registro di src/azioni.ts come tutti gli altri, così + // hanno un tasto solo, compaiono nella ricerca (Ctrl+K) e nella finestra dei tasti + const cambiaPagina = (pg: Pagina) => pagina(radice.dataset.pagina === pg ? 'montaggio' : pg); + for (const a of [ + { id: 'importa', nome: 'Importa video, audio, immagini…', gruppo: 'File', tasti: ['Ctrl+I'], fn: () => void importaDialogo() }, + { id: 'salva', nome: 'Salva il progetto', gruppo: 'File', tasti: ['Ctrl+S'], fn: () => void salva() }, + { id: 'salvaCome', nome: 'Salva come…', gruppo: 'File', tasti: ['Ctrl+Shift+S'], fn: () => void salva(true) }, + { id: 'apri', nome: 'Apri un progetto…', gruppo: 'File', tasti: ['Ctrl+O'], fn: () => { home.nascondi(); void apri(); } }, + { id: 'nuovo', nome: 'Nuovo progetto', gruppo: 'File', tasti: ['Ctrl+N'], fn: () => void nuovo() }, + { id: 'progetti', nome: 'Pagina iniziale (i progetti recenti)', gruppo: 'File', fn: () => home.mostra() }, + { id: 'pacchetto', nome: 'Salva il pacchetto .daprod (con tutti i file)…', gruppo: 'File', info: 'Il progetto e tutti i suoi file in un file solo, da aprire identico su un altro computer.', fn: () => void salvaPacchetto() }, + { id: 'ricollega', nome: 'Ricollega i file mancanti…', gruppo: 'File', fn: () => void ricollega() }, + { id: 'esporta', nome: 'Esporta il video (master)…', gruppo: 'File', tasti: ['Ctrl+M'], info: 'MP4, MOV, WebM o WAV; tutto il montaggio o solo fra attacco e stacco.', fn: () => finestraEsporta() }, + { id: 'esportaEdl', nome: 'Esporta la EDL (CMX3600)…', gruppo: 'File', fn: () => esportaEdl() }, + { id: 'esportaFotogramma', nome: 'Esporta il fotogramma in PNG', gruppo: 'File', fn: () => esportaFotogramma() }, + { id: 'impostazioniProgetto', nome: 'Impostazioni del progetto (misura, fotogrammi)…', gruppo: 'File', fn: () => finestraProgetto() }, + { id: 'demo', nome: 'Montaggio dimostrativo', gruppo: 'File', info: 'Un piccolo montaggio di prova, per vedere come funziona.', fn: () => montaggioDimostrativo() }, + { id: 'paginaFinale', nome: 'Pagina Finale ↔ Montaggio', gruppo: 'Pagine', tasti: ['F9'], fn: () => cambiaPagina('finale') }, + { id: 'paginaLive', nome: 'Pagina LIVE (registra lo schermo)', gruppo: 'Pagine', tasti: ['F10'], fn: () => cambiaPagina('live') }, + { id: 'paginaMontage', nome: 'DaProdMontage (montaggio automatico)', gruppo: 'Pagine', tasti: ['F8'], fn: () => cambiaPagina('montage') }, + { id: 'vistaStretta', nome: 'Timeline stretta ↔ larga (proprietà fino in fondo)', gruppo: 'Vista', tasti: ['V'], fn: () => vistaStretta() }, + { id: 'zoneSicure', nome: 'Zone di sicurezza sul monitor', gruppo: 'Vista', tasti: ['G'], fn: () => { modi.zoneSicure = !modi.zoneSicure; monitor.disegnaSopra(); } }, + { id: 'zoomPiu', nome: 'Zoom avanti sulla timeline', gruppo: 'Vista', tasti: ['+'], fn: () => tl.zoom(1.5) }, + { id: 'zoomMeno', nome: 'Zoom indietro sulla timeline', gruppo: 'Vista', tasti: ['-'], fn: () => tl.zoom(1 / 1.5) }, + { id: 'adatta', nome: 'Tutto il montaggio nella finestra', gruppo: 'Vista', tasti: ['\\'], fn: () => tl.adattaTutto() }, + { id: 'schermoIntero', nome: 'Finestra a schermo intero', gruppo: 'Vista', tasti: ['F11'], fn: () => void schermoIntero() }, + { id: 'guida', nome: 'Come si fa (la guida)', gruppo: 'Aiuto', tasti: ['F1'], info: 'I lavori principali in pochi passi, col pulsante che porta dove si fa.', fn: () => finestraGuida() }, + { id: 'cerca', nome: 'Cerca un comando', gruppo: 'Aiuto', tasti: ['Ctrl+K'], info: 'Scrivi cosa vuoi fare: comandi, effetti, transizioni, titoli, AI e guide.', fn: () => apriCerca() }, + { id: 'tasti', nome: 'Tutti i tasti della centralina', gruppo: 'Aiuto', fn: () => finestraTasti() }, + { id: 'centroAI', nome: 'Centro AI (tutte le funzioni intelligenti)', gruppo: 'AI', info: 'Sottotitoli, traduzione, voce, sfondo, segui un oggetto, montaggio automatico: cosa fanno e come si usano.', fn: () => finestraCentroAI() }, + { id: 'controllaAI', nome: 'Controlla l\'AI (se qualcosa non va)', gruppo: 'AI', fn: () => finestraCentroAI(true) }, + ]) registra(a); + // la grandezza dell'interfaccia nell'app, un gradino alla volta (Ctrl 0 torna al 100%); nel browser fa il browser + const zoomApp: Record void> = isTauri ? { + 'ctrl++': () => void impostaGrandezzaUI(GRANDEZZE_UI.find((k) => k > grandezzaUI() + 0.01) ?? grandezzaUI()), + 'ctrl+=': () => void impostaGrandezzaUI(GRANDEZZE_UI.find((k) => k > grandezzaUI() + 0.01) ?? grandezzaUI()), + 'ctrl+-': () => void impostaGrandezzaUI([...GRANDEZZE_UI].reverse().find((k) => k < grandezzaUI() - 0.01) ?? grandezzaUI()), + 'ctrl+0': () => void impostaGrandezzaUI(1), + } : {}; addEventListener('keydown', (e) => { const t = e.target as HTMLElement; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return; if (document.querySelector('.velo')) return; - const k = [(e.ctrlKey || e.metaKey) ? 'ctrl' : '', e.shiftKey && e.key.length > 1 || (e.shiftKey && /^[a-z]$/i.test(e.key)) ? 'shift' : '', e.key.toLowerCase()].filter(Boolean).join('+'); - const fn = extra[k] as (() => void) | undefined; + const k = [(e.ctrlKey || e.metaKey) ? 'ctrl' : '', e.key.toLowerCase()].filter(Boolean).join('+'); + const fn = zoomApp[k] as (() => void) | undefined; if (!fn) return; - // sopra la pagina iniziale valgono solo Apri e lo schermo intero - if (home.visibile && k !== 'ctrl+o' && k !== 'f11') return; e.preventDefault(); - if (k === 'ctrl+o') home.nascondi(); fn(); }); const sveglia = () => banco.sveglia(); @@ -366,6 +427,11 @@ export function avvia(radice: HTMLElement) { } document.addEventListener('dpv:demo', () => montaggioDimostrativo()); + document.addEventListener('dpv:cerca', () => apriCerca()); + document.addEventListener('dpv:guida', (e) => finestraGuida((e as CustomEvent).detail as string | undefined)); + document.addEventListener('dpv:tasti', () => finestraTasti()); + document.addEventListener('dpv:centro-ai', () => finestraCentroAI()); + document.addEventListener('dpv:finale', (e) => { pagina('finale'); finale.mostra((e as CustomEvent).detail as Parameters[0]); }); document.addEventListener('dpv:sottotitoli', () => { pagina('finale'); finale.mostra('sottotitoli'); }); document.addEventListener('dpv:pagina', (e) => pagina((e as CustomEvent).detail as Pagina)); document.addEventListener('dpv:adatta', () => tl.adattaTutto()); diff --git a/src/ui/centroAI.ts b/src/ui/centroAI.ts new file mode 100644 index 0000000..4e1b1ee --- /dev/null +++ b/src/ui/centroAI.ts @@ -0,0 +1,202 @@ +// Il Centro AI: tutte le funzioni intelligenti in un posto solo (menu AI, pulsante ✨ AI in alto, Ctrl+K). Prima erano +// sparse (sottotitoli e voce nel Finale, lo sfondo nelle proprietà della clip, il montaggio automatico in una pagina a +// parte) e non si capiva cosa serviva per usarle. Qui per ognuna: cosa fa, come si usa in tre passi, cosa serve, se il +// modello è già sul computer, e il pulsante che porta dritto al punto giusto. Sotto, "Controlla l'AI" (la diagnosi) e +// le impostazioni: la scheda video sì o no, lo spazio occupato dai modelli, il motore NVIDIA. +import { store } from '../core/store'; +import { isVideoClip } from '../core/progetto'; +import * as M from '../core/montaggio'; +import { sottotitoliDi } from '../core/sottotitoli'; +import { MODELLI } from '../media/voce'; +import { MODELLI_RITAGLIO } from '../media/ritaglio'; +import { MODELLI_TRADUZIONE } from '../media/traduci'; +import { disinstallaMotore, motoreNemo } from '../media/nemo'; +import { diagnosi, modelliScaricati, statoNvidia, svuotaModelli, type Diagnosi } from '../media/diagnosiAI'; +import { impostaSchedaAI, usaSchedaAI } from '../media/libreriaAI'; +import { conferma, dialogo, evidenzia, h } from './dom'; +import { vaiFinale, vaiPagina, vaiSezione } from './guida'; +import { fonteCerca } from './cerca'; + +export interface FunzioneAI { + id: string; + nome: string; + icona: string; + cosaFa: string; + come: string[]; + /** dove gira: 'tutto' = app e browser, 'app' = solo nell'app (motore NVIDIA) */ + dove: 'tutto' | 'app' | 'locale'; + /** i modelli che usa (indirizzi su Hugging Face) e quanto pesano circa */ + modelli?: { repo: string; nome: string; mb?: number }[]; + /** se adesso non si può usare, perché (e cosa fare) */ + manca: () => string | null; + /** porta al punto giusto del programma */ + apri: () => void; +} + +const haAudio = () => store.doc.clips.some((c) => c.kind === 'media' && store.doc.tracks.find((t) => t.id === c.track)?.kind === 'audio'); +const haSottotitoli = () => sottotitoliDi(store.doc).righe.some((r) => r.testo.trim()); + +/** la clip su cui lavorare: quella scelta (video o immagine), o quella più in alto sotto il cursore (e la si sceglie) */ +export function clipVideoDaUsare(scegli = true) { + const p = store.doc; + const scelta = p.clips.find((c) => store.sel.has(c.id) && c.kind === 'media' && isVideoClip(c, p)); + if (scelta) return scelta; + const sotto = M.topClipAt(p, Math.round(store.head), 'video'); + const c = sotto && sotto.kind === 'media' ? sotto : undefined; + if (c && scegli) store.select([c.id]); + return c; +} + +export const FUNZIONI_AI: FunzioneAI[] = [ + { + id: 'sottotitoli', nome: 'Sottotitoli automatici', icona: '💬', dove: 'tutto', + cosaFa: 'Ascolta i dialoghi del montaggio e scrive i sottotitoli con i loro tempi.', + come: ['Metti in timeline i video con l\'audio', 'Scegli la lingua e premi "Scrivi i sottotitoli"', 'Correggi le righe e, se vuoi, esporta il .srt'], + modelli: [...MODELLI.map((m) => ({ repo: m.id, nome: `Whisper ${m.nome}`, mb: m.mb }))], + manca: () => (haAudio() ? null : 'Metti in timeline un video con l\'audio dei dialoghi'), + apri: () => { vaiFinale('sottotitoli'); evidenzia('[data-ai="sottotitoli"]'); }, + }, + { + id: 'traduci', nome: 'Traduci i sottotitoli', icona: '🌍', dove: 'tutto', + cosaFa: 'Traduce le righe in un\'altra lingua (nove lingue, anche cinese, giapponese, arabo); i tempi restano.', + come: ['Prima servono i sottotitoli', 'Scegli la lingua d\'arrivo', 'Premi "Traduci i sottotitoli" (Ctrl+Z torna indietro)'], + modelli: [{ repo: MODELLI_TRADUZIONE[0], nome: 'NLLB-200 di Meta', mb: 600 }], + manca: () => (haSottotitoli() ? null : 'Prima servono i sottotitoli (scritti a mano o dall\'AI)'), + apri: () => { vaiFinale('lingue'); evidenzia('[data-ai="traduci"]'); }, + }, + { + id: 'voce', nome: 'Voce AI (doppiaggio)', icona: '🗣', dove: 'app', + cosaFa: 'Una voce sintetica legge i sottotitoli; ogni frase diventa una clip al suo posto.', + come: ['Servono i sottotitoli', 'Scegli la voce e la lingua', 'Premi "Fai parlare i sottotitoli"'], + manca: () => (!motoreNemo() ? 'La voce AI c\'è solo nell\'app per Windows e Mac (usa il motore NVIDIA)' : haSottotitoli() ? null : 'Prima servono i sottotitoli'), + apri: () => { vaiFinale('lingue'); evidenzia('[data-ai="voce"]'); }, + }, + { + id: 'sfondo', nome: 'Togli lo sfondo', icona: '🪄', dove: 'tutto', + cosaFa: 'Stacca la persona o il soggetto dallo sfondo, anche senza green screen; o un oggetto coi clic.', + come: ['Scegli la clip (o metti il cursore sopra)', 'Proprietà → Togli lo sfondo → AI: Persona, Soggetto o Oggetti', 'Premi "Togli lo sfondo" e controlla col tasto SFONDO del monitor'], + modelli: MODELLI_RITAGLIO.map((m) => ({ repo: m.repo[0], nome: m.nome })), + manca: () => (clipVideoDaUsare(false) ? null : 'Scegli una clip video o un\'immagine nella timeline (o mettici sopra il cursore)'), + apri: () => { clipVideoDaUsare(true); vaiSezione('sfondo'); }, + }, + { + id: 'segui', nome: 'Segui un oggetto', icona: '🎯', dove: 'locale', + cosaFa: 'Segue qualcosa nella ripresa: un titolo, un\'immagine o un effetto gli stanno dietro. Stabilizza la ripresa.', + come: ['Scegli la ripresa', 'Proprietà → Segui un oggetto → 🎯 e clicca l\'oggetto sul monitor', 'Sulle altre clip scegli "segue"'], + manca: () => (clipVideoDaUsare(false) ? null : 'Scegli una ripresa nella timeline (o mettici sopra il cursore)'), + apri: () => { clipVideoDaUsare(true); vaiSezione('segui'); }, + }, + { + id: 'montage', nome: 'Montaggio automatico', icona: '✨', dove: 'locale', + cosaFa: 'Da foto e video alla rinfusa un montaggio a tempo di musica, in 28 stili (matrimonio, viaggio, reel…).', + come: ['Apri DaProdMontage (F8) e aggiungi i file', 'Scegli lo stile, la durata e il brano', 'Crea, rigenera finché ti piace, importa'], + manca: () => null, + apri: () => vaiPagina('montage'), + }, + { + id: 'colore', nome: 'Colore automatico', icona: '🎨', dove: 'locale', + cosaFa: 'Sistema livelli, bianco e luce di ogni ripresa da solo, perché tutte stiano bene insieme.', + come: ['F9 apre il Finale → Colore', 'Lascia acceso "Colore automatico"', 'Scegli un look se vuoi un tono'], + manca: () => null, + apri: () => vaiFinale('colore'), + }, +]; + +const mb = (b: number) => (b >= 1 << 30 ? (b / (1 << 30)).toFixed(1).replace('.', ',') + ' GB' : Math.max(1, Math.round(b / (1 << 20))) + ' MB'); +const DOVE: Record = { tutto: 'app e browser', app: 'solo nell\'app', locale: 'sul computer, senza modelli' }; + +/** la finestra del Centro AI */ +export function finestraCentroAI(subito = false) { + const d = dialogo('Centro AI · tutte le funzioni intelligenti', { largo: true }); + d.el.classList.add('centro-ai'); + const stati = new Map(); + const carte = FUNZIONI_AI.map((f) => { + const stato = h('div', { class: 'ai-stato' }, f.modelli ? 'controllo i modelli…' : ''); + stati.set(f.id, stato); + const motivo = f.manca(); + return h('article', { class: 'ai-carta', 'data-ai': f.id }, + h('header', null, h('span', { class: 'ai-ic' }, f.icona), h('b', null, f.nome), h('span', { class: 'ai-dove ' + f.dove }, DOVE[f.dove])), + h('p', null, f.cosaFa), + h('ol', { class: 'ai-come' }, f.come.map((c) => h('li', null, c))), + stato, + motivo ? h('p', { class: 'ai-manca' }, '⚠ ', motivo) : null, + h('button', { class: 'btn primario ai-usa', on: { click: () => { d.chiudi(); f.apri(); } } }, motivo ? 'Mostrami dove ▶' : 'Usa ▶')); + }); + + // ——— lo stato dei modelli (dalla cache) e del motore NVIDIA + void (async () => { + const [scaricati, nv] = await Promise.all([modelliScaricati(), statoNvidia()]); + for (const f of FUNZIONI_AI) { + const el = stati.get(f.id)!; + if (!f.modelli) { el.textContent = f.dove === 'app' ? '' : 'Pronto: non serve scaricare niente'; continue; } + const ci = f.modelli.filter((m) => scaricati.has(m.repo)); + const righe: (string | HTMLElement)[] = []; + if (ci.length) righe.push(h('span', { class: 'ai-ok' }, '✓ sul computer: ', ci.map((m) => `${m.nome} (${mb(scaricati.get(m.repo)!)})`).join(', '))); + else { + const piccolo = f.modelli.filter((m) => m.mb).sort((a, b) => (a.mb ?? 0) - (b.mb ?? 0))[0]; + righe.push(h('span', null, '⬇ il modello si scarica la prima volta' + (piccolo ? ` (da ${piccolo.mb} MB)` : '') + ', poi resta sul computer')); + } + if (f.id === 'sottotitoli' && nv) righe.push(h('span', { class: nv.installato ? 'ai-ok' : '' }, nv.installato ? ` · motore NVIDIA pronto (${nv.backend})` : ' · nell\'app il motore NVIDIA si scarica la prima volta')); + el.replaceChildren(...righe); + } + const v = stati.get('voce'); + if (v) v.replaceChildren(nv ? h('span', { class: nv.installato ? 'ai-ok' : '' }, nv.installato ? `✓ motore NVIDIA pronto (${nv.backend})` : '⬇ il motore NVIDIA e la voce si scaricano la prima volta') : h('span', null, 'nel browser non c\'è')); + })(); + + // ——— la diagnosi + const esito = h('div', { class: 'ai-diagnosi' }); + const controlla = h('button', { class: 'btn ai-controlla', on: { click: () => void fai() } }, '🔍 Controlla l\'AI') as HTMLButtonElement; + const fai = async () => { + controlla.disabled = true; + esito.replaceChildren(h('p', { class: 'nota' }, 'Controllo: avvio il motore AI, guardo la scheda video, provo a raggiungere Hugging Face…')); + try { disegnaDiagnosi(esito, await diagnosi()); } finally { controlla.disabled = false; } + }; + + // ——— le impostazioni + const scheda = h('input', { type: 'checkbox', checked: usaSchedaAI() }) as HTMLInputElement; + scheda.addEventListener('change', () => impostaSchedaAI(scheda.checked)); + const spazio = h('span', { class: 'nota' }, '…'); + const aggSpazio = async () => { const m = await modelliScaricati(); const tot = [...m.values()].reduce((s, x) => s + x, 0); spazio.textContent = m.size ? `${m.size} modelli sul computer · ${mb(tot)}` : 'Nessun modello scaricato'; }; + void aggSpazio(); + const svuota = h('button', { class: 'btn-mini', on: { click: async () => { + if (!(await conferma('Libera lo spazio dei modelli', 'I modelli AI scaricati si tolgono dal computer. Si riscaricano da soli la prossima volta che servono (serve internet).', 'Toglili', 'Annulla'))) return; + await svuotaModelli(); await aggSpazio(); + } } }, 'Libera lo spazio'); + const nvidia = motoreNemo() ? h('div', { class: 'ai-imp' }, + h('span', null, 'Motore NVIDIA (sottotitoli e voce nell\'app)'), + h('button', { class: 'btn-mini', on: { click: async () => { + if (!(await conferma('Togli il motore NVIDIA', 'Il motore e i suoi modelli si tolgono dal computer. Si riscaricano la prossima volta che servono.', 'Toglilo', 'Annulla'))) return; + await disinstallaMotore(true).catch(() => {}); + await aggSpazio(); + } } }, 'Togli motore e modelli')) : null; + + d.corpo.append( + h('p', { class: 'ai-intro' }, 'Tutte le funzioni AI di DaProd Video. Girano sul tuo computer: le immagini, l\'audio e i testi non vanno da nessuna parte. Ogni modello si scarica una volta sola (serve internet la prima volta), poi va anche senza rete.'), + h('div', { class: 'ai-griglia' }, carte), + h('section', { class: 'ai-sezione' }, h('h4', null, 'Se qualcosa non va'), h('div', { class: 'ai-riga' }, controlla, h('span', { class: 'nota' }, 'Prova il motore AI, la scheda video e la connessione per i modelli, e ti dice cosa fare.')), esito), + h('section', { class: 'ai-sezione' }, h('h4', null, 'Impostazioni'), + h('label', { class: 'ai-imp spunta-riga', title: 'Su alcuni computer la scheda video (WebGPU) c\'è ma sbaglia: spenta, l\'AI usa il processore (più lento ma sicuro)' }, scheda, ' Usa la scheda video (WebGPU) quando c\'è'), + h('div', { class: 'ai-imp' }, spazio, svuota), + nvidia)); + d.piede.append(h('button', { class: 'btn', on: { click: () => { d.chiudi(); document.dispatchEvent(new CustomEvent('dpv:guida', { detail: 'problemi-ai' })); } } }, '❓ Se un\'AI non parte'), h('span', { class: 'spazio' }), h('button', { class: 'btn primario', on: { click: d.chiudi } }, 'Chiudi')); + if (subito) void fai(); + return d; +} + +function disegnaDiagnosi(el: HTMLElement, g: Diagnosi) { + const riga = (ok: boolean | null, titolo: string, testo: string) => h('div', { class: 'ai-diag ' + (ok === null ? 'forse' : ok ? 'ok' : 'no') }, h('span', { class: 'ai-diag-seg' }, ok === null ? '•' : ok ? '✓' : '✕'), h('b', null, titolo), h('span', null, testo)); + const tot = [...g.modelli.values()].reduce((s, x) => s + x, 0); + el.replaceChildren( + riga(g.libreria.ok, 'Motore AI', g.libreria.ok + ? `parte (${g.libreria.dove === 'locale' ? 'dai file dell\'app' : 'dalla CDN'}, ${(g.libreria.ms / 1000).toFixed(1).replace('.', ',')} s)` + : `non parte: ${g.libreria.msg}`), + riga(g.scheda ? true : null, 'Scheda video', g.scheda ? `${g.scheda}: l'AI la usa quando può${usaSchedaAI() ? '' : ' (adesso è spenta nelle impostazioni)'}` : 'non disponibile per l\'AI: si usa il processore (va bene, solo più lento)'), + riga(g.internet, 'Hugging Face', g.internet ? 'si raggiunge: i modelli si possono scaricare' : 'non si raggiunge: i modelli già scaricati vanno, per gli altri serve internet (o una rete che non blocchi huggingface.co)'), + riga(g.modelli.size > 0 ? true : null, 'Modelli sul computer', g.modelli.size ? `${[...g.modelli.keys()].map((k) => k.split('/')[1]).join(', ')} · ${mb(tot)}` : 'nessuno ancora: ognuno si scarica la prima volta che serve'), + g.nvidia ? riga(g.nvidia.installato ? true : null, 'Motore NVIDIA', g.nvidia.installato ? `installato (${g.nvidia.backend}${g.nvidia.nvidia ? ', scheda NVIDIA trovata' : ''})` : `non ancora: si scarica la prima volta (versione ${g.nvidia.consigliato === 'cuda' ? 'per la scheda NVIDIA' : g.nvidia.consigliato === 'metal' ? 'per il Mac' : 'per il processore'})`) : riga(null, 'Motore NVIDIA', 'solo nell\'app per Windows e Mac: qui si usano Whisper e gli altri modelli'), + g.libreria.ok ? h('p', { class: 'nota' }, 'Tutto quello che serve c\'è. Se una funzione dà ancora errore, il messaggio ora spiega cosa fare; scrivilo a chi ti aiuta insieme a queste righe.') : h('p', { class: 'nota' }, 'Il motore AI non parte: aggiorna DaProd Video all\'ultima versione o prova un altro browser (Chrome, Edge).'), + ); +} + +// le funzioni AI si trovano anche con la ricerca dei comandi (Ctrl+K) +fonteCerca(() => FUNZIONI_AI.map((f) => ({ id: 'ai:' + f.id, titolo: f.nome, sotto: f.cosaFa, cat: 'AI' as const, parole: 'ai intelligenza artificiale ' + f.come.join(' '), fn: f.apri }))); diff --git a/src/ui/cerca.ts b/src/ui/cerca.ts new file mode 100644 index 0000000..6da1360 --- /dev/null +++ b/src/ui/cerca.ts @@ -0,0 +1,166 @@ +// La ricerca dei comandi (Ctrl+K, o il pulsante 🔍 in alto): si scrive quello che si vuole fare ("rallenta", +// "sottotitoli", "togli lo sfondo", "neon", "dissolvenza") e lo si trova, con il suo tasto accanto. Dentro ci sono tutti +// i comandi registrati in src/azioni.ts (anche quelli di app.ts) e le "fonti" che si iscrivono qui: pagine, funzioni AI, +// transizioni, effetti, titoli, animazioni, generatori e le guide. Le ultime cose fatte stanno in cima. +import { azioni } from '../azioni'; +import { avviso, h } from './dom'; + +export type CatCerca = 'Comandi' | 'Pagine' | 'AI' | 'Guida' | 'Transizioni' | 'Effetti a tempo' | 'Effetti sulla clip' | 'Titoli' | 'Animazioni' | 'Generatori'; + +export interface VoceCerca { + id: string; + titolo: string; + /** a cosa serve, o dove sta */ + sotto?: string; + cat: CatCerca; + tasto?: string; + /** parole in più per trovarla (sinonimi, inglese) */ + parole?: string; + fn: () => void; + /** se non si può fare adesso, il perché ("scegli prima una clip") */ + manca?: () => string | null; +} + +const fonti: (() => VoceCerca[])[] = []; +/** chi ha qualcosa da far trovare si iscrive qui (la lista si rifà ogni volta che si apre la ricerca) */ +export const fonteCerca = (f: () => VoceCerca[]) => { fonti.push(f); }; + +/** sinonimi: chi cerca con altre parole (o in inglese) trova lo stesso */ +const SINONIMI: Record = { + taglia: 'split cut dividi', elimina: 'cancella togli delete rimuovi', annulla: 'undo indietro', ripeti: 'redo', + velocita: 'rallenta accelera slow motion veloce speed ralenti', sottotitoli: 'srt caption trascrivi testo parlato', + sfondo: 'green screen chroma scontorna ritaglia background', titolo: 'testo scritta text title', + dissolvenza: 'fade mix incrociata', esporta: 'export render salva video mp4', importa: 'import aggiungi carica file', + volume: 'audio livello gain', colore: 'color grading look', transizione: 'transition passaggio', + effetto: 'effect filtro fx', registra: 'record schermo screen', salva: 'save', apri: 'open', nuovo: 'new', +}; + +export const normalizza = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); +const parole = (s: string) => normalizza(s).split(/[^a-z0-9+]+/).filter(Boolean); + +/** quanto una voce risponde alla ricerca (0 = per niente): il titolo conta di più, ogni parola cercata deve esserci */ +export function punteggio(v: VoceCerca, q: string): number { + const qq = normalizza(q).trim(); + if (!qq) return 1; + const tit = normalizza(v.titolo); + const pt = parole(v.titolo); + const altro = normalizza([v.sotto, v.parole, v.cat, v.tasto].filter(Boolean).join(' ')); + const pa = altro.split(/[^a-z0-9+]+/).filter(Boolean); + const extra = pt.concat(pa).map((w) => SINONIMI[w] ?? '').join(' '); + const pe = extra.split(' ').filter(Boolean); + let s = 0; + for (const tk of qq.split(/\s+/)) { + if (pt.some((w) => w.startsWith(tk))) s += 6; + else if (tit.includes(tk)) s += 4; + else if (pa.some((w) => w.startsWith(tk))) s += 2; + else if (tk.length >= 3 && altro.includes(tk)) s += 1; + else if (pe.some((w) => w.startsWith(tk))) s += 1.5; + else return 0; + } + if (tit === qq) s += 10; + else if (tit.startsWith(qq)) s += 5; + if (v.cat === 'Comandi' || v.cat === 'AI' || v.cat === 'Pagine') s += 0.5; + return s; +} + +const tastoBello = (t?: string) => t?.replace('ArrowLeft', '←').replace('ArrowRight', '→').replace('ArrowUp', '↑').replace('ArrowDown', '↓').replace('Space', 'Spazio'); + +/** tutte le voci: i comandi registrati e quelle delle fonti (senza doppioni di id) */ +export function tutteLeVoci(): VoceCerca[] { + const out = new Map(); + for (const a of azioni.values()) { + out.set('az:' + a.id, { id: 'az:' + a.id, titolo: a.nome, sotto: a.info ?? a.gruppo, cat: 'Comandi', tasto: tastoBello(a.tasti?.[0]), parole: a.gruppo, fn: a.fn }); + } + for (const f of fonti) for (const v of f()) if (!out.has(v.id)) out.set(v.id, v); + return [...out.values()]; +} + +const CHIAVE = 'dpv-cerca-recenti'; +function recenti(): string[] { try { const l = JSON.parse(localStorage.getItem(CHIAVE) ?? '[]'); return Array.isArray(l) ? l : []; } catch { return []; } } +function ricorda(id: string) { try { localStorage.setItem(CHIAVE, JSON.stringify([id, ...recenti().filter((x) => x !== id)].slice(0, 8))); } catch { /* niente */ } } + +/** le cose che si consigliano a chi non ha ancora scritto niente */ +const CONSIGLIATI = ['az:importa', 'guida:inizio', 'ai:sottotitoli', 'ai:sfondo', 'az:velocita', 'az:genTitolo', 'az:esporta', 'az:centroAI']; + +/** i risultati per una ricerca (i recenti contano un po' di più) */ +export function risultati(q: string, voci = tutteLeVoci(), max = 60): VoceCerca[] { + const rec = recenti(); + if (!q.trim()) { + const per = new Map(voci.map((v) => [v.id, v])); + const ids = [...rec, ...CONSIGLIATI.filter((x) => !rec.includes(x))]; + return ids.map((id) => per.get(id)).filter((v): v is VoceCerca => !!v).slice(0, 12); + } + return voci.map((v) => ({ v, s: punteggio(v, q) + (rec.includes(v.id) ? 1.5 : 0) })) + .filter((x) => x.s > 0.4) + .sort((a, b) => b.s - a.s || a.v.titolo.localeCompare(b.v.titolo)) + .slice(0, max).map((x) => x.v); +} + +let aperta: HTMLElement | null = null; + +/** apre la ricerca (se è già aperta, la chiude) */ +export function apriCerca(iniziale = '') { + if (aperta) { chiudi(); return; } + const voci = tutteLeVoci(); + const input = h('input', { class: 'cerca-input', type: 'search', placeholder: 'Cosa vuoi fare? (es. sottotitoli, rallenta, togli lo sfondo, dissolvenza, titolo neon)', 'aria-label': 'Cerca un comando', spellcheck: 'false', autocomplete: 'off' }) as HTMLInputElement; + const lista = h('div', { class: 'cerca-lista', role: 'listbox' }); + const testa = h('div', { class: 'cerca-intesta' }); + let trovati: VoceCerca[] = []; + let scelto = 0; + const fai = (v: VoceCerca) => { + const motivo = v.manca?.(); + chiudi(); + ricorda(v.id); + if (motivo) { avviso(motivo, 'info', 2600); return; } + // dopo che la ricerca si è chiusa (il fuoco torna al banco); la pagina iniziale, se c'è, si toglie di mezzo + document.dispatchEvent(new CustomEvent('dpv:home-nascondi')); + setTimeout(() => v.fn(), 0); + }; + const disegna = () => { + trovati = risultati(input.value, voci); + scelto = Math.min(scelto, Math.max(0, trovati.length - 1)); + testa.textContent = input.value.trim() ? (trovati.length ? `${trovati.length} risultati` : 'Niente: prova con altre parole') : 'Ultimi usati e consigliati'; + lista.replaceChildren(...trovati.map((v, i) => { + const motivo = v.manca?.() ?? null; + const el = h('div', { class: 'cerca-voce' + (i === scelto ? ' scelta' : '') + (motivo ? ' manca' : ''), role: 'option', 'data-id': v.id, on: { + click: () => fai(v), + pointermove: () => { if (scelto !== i) { scelto = i; segna(); } }, + } }, + h('span', { class: 'cerca-cat', 'data-cat': v.cat }, v.cat), + h('span', { class: 'cerca-testo' }, h('b', null, v.titolo), v.sotto || motivo ? h('small', null, motivo ? `${v.sotto ? v.sotto + ' · ' : ''}⚠ ${motivo}` : v.sotto!) : null), + v.tasto ? h('kbd', null, v.tasto) : null); + return el; + })); + }; + const segna = () => { + lista.querySelectorAll('.cerca-voce').forEach((el, i) => el.classList.toggle('scelta', i === scelto)); + (lista.children[scelto] as HTMLElement | undefined)?.scrollIntoView({ block: 'nearest' }); + }; + input.addEventListener('input', () => { scelto = 0; disegna(); }); + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); scelto = Math.min(trovati.length - 1, scelto + 1); segna(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); scelto = Math.max(0, scelto - 1); segna(); } + else if (e.key === 'Enter') { e.preventDefault(); const v = trovati[scelto]; if (v) fai(v); } + else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); chiudi(); } + // Ctrl+K con la ricerca aperta la chiude + else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); chiudi(); } + }); + aperta = h('div', { class: 'cerca-velo', on: { pointerdown: (e: PointerEvent) => { if (e.target === aperta) chiudi(); } } }, + h('div', { class: 'cerca', role: 'dialog', 'aria-label': 'Cerca un comando' }, + h('div', { class: 'cerca-riga' }, h('span', { class: 'cerca-lente' }, '🔍'), input), + testa, lista, + h('div', { class: 'cerca-piede' }, h('span', null, h('kbd', null, '↑'), h('kbd', null, '↓'), ' scegli'), h('span', null, h('kbd', null, 'Invio'), ' fai'), h('span', null, h('kbd', null, 'Esc'), ' chiudi'), h('span', { class: 'spazio' }), h('span', null, 'Trovi comandi, effetti, transizioni, titoli, AI e guide')))); + document.body.appendChild(aperta); + input.value = iniziale; + disegna(); + // il fuoco subito (se arrivasse dopo, le prime lettere scritte finirebbero al banco come tasti di montaggio) + input.focus(); + setTimeout(() => { if (document.activeElement !== input) input.focus(); }, 0); +} + +function chiudi() { + aperta?.remove(); + aperta = null; +} + +export const cercaAperta = () => !!aperta; diff --git a/src/ui/dom.ts b/src/ui/dom.ts index fa5c833..e793eda 100644 --- a/src/ui/dom.ts +++ b/src/ui/dom.ts @@ -251,3 +251,24 @@ export function trascina(e: PointerEvent, muovi: (ev: PointerEvent, dx: number, } export const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v)); + +/** + * Porta lo sguardo su un controllo: aspetta che compaia (fino a 2 s: la pagina o il pannello si stanno aprendo), lo fa + * scorrere in vista e lo fa lampeggiare un attimo. Serve alla guida, alla ricerca e al Centro AI ("Fallo adesso"). + */ +export function evidenzia(selettore: string, dentro: ParentNode = document) { + const t0 = performance.now(); + const prova = () => { + const el = dentro.querySelector(selettore) as HTMLElement | null; + if (!el || !el.offsetParent) { + if (performance.now() - t0 < 2000) setTimeout(prova, 80); + return; + } + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + el.classList.remove('evidenziato'); + void el.offsetWidth; + el.classList.add('evidenziato'); + setTimeout(() => el.classList.remove('evidenziato'), 2600); + }; + prova(); +} diff --git a/src/ui/finale.ts b/src/ui/finale.ts index e6923db..7185736 100644 --- a/src/ui/finale.ts +++ b/src/ui/finale.ts @@ -11,6 +11,7 @@ import { MODELLI, fermaVoce, sottotitoliAI, type OpzioniVoce } from '../media/vo import { LINGUE_MOTORE, LINGUE_PARLATE, LINGUE_VOCE, VOCI_MAGPIE, motoreNemo } from '../media/nemo'; import { LINGUE_TRADUZIONE, fermaTraduzione, sappiamoTradurre, traduciTesti } from '../media/traduci'; import { doppia, linguaVoce, posaVoce } from '../media/doppiaggio'; +import { spiegaErroreAI } from '../media/erroriAI'; import { codificaWav } from '../media/wav'; import { salvaMediaSulDisco, salvaTesto } from '../platform'; import { importaDialogo, importaFile } from '../progetti'; @@ -281,7 +282,7 @@ export class Finale { return h('div', { class: 'fin-scelte' }, h('span', null, nome), h('div', { class: 'isp-chips' }, bott)); }; const lavoro = new BarraLavoro('Sottotitoli con l\'AI', 'ai', () => { this.lavoroAI?.abort(); fermaVoce(); }); - const vai = h('button', { class: 'btn primario ai-vai', on: { click: () => void this.scriviConAI(lavoro, vai, ferma) } }, '✨ Scrivi i sottotitoli con l\'AI'); + const vai = h('button', { class: 'btn primario ai-vai', 'data-ai': 'sottotitoli', on: { click: () => void this.scriviConAI(lavoro, vai, ferma) } }, '✨ Scrivi i sottotitoli con l\'AI'); const ferma = h('button', { class: 'btn-mini', style: 'display:none', on: { click: () => { this.lavoroAI?.abort(); fermaVoce(); } } }, '■ Ferma'); const nemotron = () => this.motoreAI() === 'nemotron'; const motore = scelta('Ascolta con', [ @@ -357,8 +358,9 @@ export class Finale { if (msg === 'fermato') lavoro.ferma('Fermato'); else if (msg === 'muto') { lavoro.ferma('L\'audio dei dialoghi è muto'); avviso('L\'audio dei dialoghi è muto: niente da scrivere', 'info', 2600); } else { - lavoro.ferma('Non ci sono riuscito: ' + msg); - avviso(o.motore === 'nemotron' ? 'L\'AI non è partita: ' + msg : 'L\'AI non è partita: serve internet la prima volta (per scaricare il modello). ' + msg, 'errore', 5000); + const chiaro = spiegaErroreAI(msg); + lavoro.ferma('Non ci sono riuscito: ' + chiaro); + avviso('I sottotitoli con l\'AI non sono partiti: ' + chiaro, 'errore', 6000); } } finally { this.lavoroAI = null; @@ -384,7 +386,7 @@ export class Finale { }; const lavoro = new BarraLavoro('Voce AI dai sottotitoli', 'ai', () => this.lavoroVoce?.abort()); const ferma = h('button', { class: 'btn-mini', style: 'display:none', on: { click: () => this.lavoroVoce?.abort() } }, '■ Ferma'); - const vai = h('button', { class: 'btn primario ai-vai', title: 'Legge i sottotitoli con la voce scelta e mette il risultato su una traccia audio nuova', on: { click: () => void this.faiParlare(lavoro, vai, ferma) } }, '🗣 Fai parlare i sottotitoli'); + const vai = h('button', { class: 'btn primario ai-vai', 'data-ai': 'voce', title: 'Legge i sottotitoli con la voce scelta e mette il risultato su una traccia audio nuova', on: { click: () => void this.faiParlare(lavoro, vai, ferma) } }, '🗣 Fai parlare i sottotitoli'); const silenzia = h('input', { type: 'checkbox' }) as HTMLInputElement; silenzia.addEventListener('change', () => { this.voce.silenzia = silenzia.checked; salva(); }); sinc.push(() => { silenzia.checked = this.voce.silenzia; }); @@ -448,8 +450,9 @@ export class Finale { else if (msg === 'lingua') lavoro.ferma('Questa lingua la voce non la sa'); else if (msg === 'traduzione') lavoro.ferma('Questa coppia di lingue non la so tradurre'); else { - lavoro.ferma('Non ci sono riuscito: ' + msg); - avviso('La voce AI non è partita: ' + msg, 'errore', 5000); + const chiaro = spiegaErroreAI(msg); + lavoro.ferma('Non ci sono riuscito: ' + chiaro); + avviso('La voce AI non è partita: ' + chiaro, 'errore', 6000); } } finally { this.lavoroVoce = null; @@ -619,7 +622,7 @@ export class Finale { aTrad.addEventListener('change', () => { this.trad.a = aTrad.value; try { localStorage.setItem('dpv-trad', JSON.stringify(this.trad)); } catch { /* niente */ } this.campi.forEach((c) => c.aggiorna()); }); const lavoro = new BarraLavoro('Traduzione dei sottotitoli', 'ai', () => { this.lavoroTrad?.abort(); fermaTraduzione(); }); const ferma = h('button', { class: 'btn-mini', style: 'display:none', on: { click: () => { this.lavoroTrad?.abort(); fermaTraduzione(); } } }, '■ Ferma'); - const vai = h('button', { class: 'btn primario ai-vai', on: { click: () => void this.traduciSottotitoli(lavoro, vai, ferma) } }, '🌍 Traduci i sottotitoli'); + const vai = h('button', { class: 'btn primario ai-vai', 'data-ai': 'traduci', on: { click: () => void this.traduciSottotitoli(lavoro, vai, ferma) } }, '🌍 Traduci i sottotitoli'); const nota = h('p', { class: 'nota' }); this.campi.push({ aggiorna: () => { const s = sottotitoliDi(store.doc); @@ -666,7 +669,7 @@ export class Finale { } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (msg === 'fermato') lavoro.ferma('Fermato'); - else { lavoro.ferma('Non ci sono riuscito: ' + msg); avviso('La traduzione non è partita: ' + msg, 'errore', 5000); } + else { const chiaro = spiegaErroreAI(msg); lavoro.ferma('Non ci sono riuscito: ' + chiaro); avviso('La traduzione non è partita: ' + chiaro, 'errore', 6000); } } finally { this.lavoroTrad = null; ferma.style.display = 'none'; diff --git a/src/ui/fontiCerca.ts b/src/ui/fontiCerca.ts new file mode 100644 index 0000000..f75db45 --- /dev/null +++ b/src/ui/fontiCerca.ts @@ -0,0 +1,61 @@ +// Cosa si trova con la ricerca dei comandi (Ctrl+K), oltre ai comandi di src/azioni.ts e alle funzioni del Centro AI: +// le pagine e le sezioni del Finale, le guide, e i cataloghi (transizioni, effetti a tempo, effetti sulla clip, titoli, +// animazioni, countdown). Ogni voce fa la cosa vera: la transizione va sul taglio più vicino, l'effetto sulla clip +// scelta, il titolo al cursore. +import { store } from '../core/store'; +import { inserisciGeneratore, mettiBlocco } from '../azioni'; +import { EFFETTI_TEMPO } from '../core/blocchi'; +import { TENDINE, EFFETTI as TRANSIZIONI_DVE } from '../render/transizioni'; +import { EFFETTI as EFFETTI_CLIP, adatte, alternaEffetto } from '../effetti'; +import { PRESET_TITOLI } from '../core/generatori'; +import { ANIMAZIONI, GRUPPI_ANIM } from '../core/animazioni'; +import { STILI_CONTO } from '../render/grafica'; +import { avviso } from './dom'; +import { fonteCerca, type VoceCerca } from './cerca'; +import { ARGOMENTI, finestraGuida, vaiFinale, vaiPagina } from './guida'; + +const scelte = () => store.doc.clips.filter((c) => store.sel.has(c.id)); + +fonteCerca(() => [ + { id: 'pg:montaggio', titolo: 'Pagina Montaggio', sotto: 'contenitore, monitor, timeline', cat: 'Pagine', tasto: 'F9', parole: 'banco editing', fn: () => vaiPagina('montaggio') }, + { id: 'pg:finale', titolo: 'Pagina Finale', sotto: 'colore, audio, sottotitoli, logo, voce e lingue, esporta', cat: 'Pagine', tasto: 'F9', fn: () => vaiPagina('finale') }, + { id: 'pg:live', titolo: 'Pagina LIVE (registra lo schermo)', sotto: 'schermo, microfono e webcam', cat: 'Pagine', tasto: 'F10', parole: 'registra record screen tutorial', fn: () => vaiPagina('live') }, + { id: 'pg:montage', titolo: 'DaProdMontage (montaggio automatico)', sotto: 'foto e video alla rinfusa → un montaggio a tempo', cat: 'Pagine', tasto: 'F8', parole: 'automatico slideshow foto matrimonio', fn: () => vaiPagina('montage') }, + ...([ + ['colore', 'Colore di tutto il montaggio', 'colore automatico, look, luce, contrasto', 'look grading correzione'], + ['audio', 'Audio finale e limitatore', 'volume finale, livella, limitatore', 'volume livella normalizza'], + ['sottotitoli', 'Sottotitoli', 'scrivili a mano o con l\'AI, importa ed esporta .srt', 'srt caption'], + ['logo', 'Logo sempre in vista', 'il tuo logo in un angolo', 'watermark marchio'], + ['apertura', 'Apertura e chiusura', 'titoli, clip, dal nero e al nero all\'inizio e alla fine', 'intro outro'], + ['lingue', 'Voce e lingue', 'traduci i sottotitoli e falli parlare', 'traduci voce doppiaggio'], + ['esporta', 'Esporta (pagina Finale)', 'master, EDL, fotogramma', 'render mp4'], + ] as const).map(([s, t, sotto, parole]): VoceCerca => ({ id: 'fin:' + s, titolo: t, sotto: 'Finale · ' + sotto, cat: 'Pagine', parole, fn: () => vaiFinale(s) })), +]); + +// le guide "Come si fa" +fonteCerca(() => ARGOMENTI.map((a): VoceCerca => ({ id: 'guida:' + a.id, titolo: 'Come si fa: ' + a.titolo, sotto: a.riassunto, cat: 'Guida', parole: a.parole, fn: () => finestraGuida(a.id) }))); + +// le transizioni: sul taglio più vicino al cursore (come i tasti 5, 6, 7) +fonteCerca(() => [ + { id: 'tr:mix', titolo: 'Dissolvenza incrociata', sotto: 'sul taglio più vicino al cursore', cat: 'Transizioni' as const, tasto: '5', parole: 'fade mix', fn: () => { mettiBlocco('transizione', 'mix'); } }, + { id: 'tr:dip', titolo: 'Passaggio al nero', sotto: 'sul taglio più vicino al cursore', cat: 'Transizioni' as const, tasto: '7', parole: 'dip nero', fn: () => { mettiBlocco('transizione', 'dip'); } }, + ...TENDINE.map((m): VoceCerca => ({ id: 'tr:wipe:' + m.p, titolo: 'Tendina ' + m.nome.replace(/^\d+ · /, ''), sotto: m.info, cat: 'Transizioni', parole: 'tendina wipe smpte ' + m.p, fn: () => { mettiBlocco('transizione', 'wipe:' + m.p); } })), + ...TRANSIZIONI_DVE.map((m): VoceCerca => ({ id: 'tr:dve:' + m.p, titolo: m.nome, sotto: m.info, cat: 'Transizioni', parole: 'dve digitale', fn: () => { mettiBlocco('transizione', 'dve:' + m.p); } })), +]); + +// gli effetti a tempo: al cursore (sul bordo della clip se il cursore è lì vicino) +fonteCerca(() => EFFETTI_TEMPO.map((e): VoceCerca => ({ id: 'fx:' + e.id, titolo: e.nome, sotto: e.info + ' · al cursore', cat: 'Effetti a tempo', parole: 'effetto fx ' + e.gruppo, fn: () => { mettiBlocco('effetto', e.id); } }))); + +// gli effetti sulla clip (accendi/spegni sulla clip scelta) +fonteCerca(() => EFFETTI_CLIP.map((e): VoceCerca => ({ + id: 'fxc:' + e.id, titolo: e.nome, sotto: e.info + ' · sulla clip scelta', cat: 'Effetti sulla clip', parole: 'effetto filtro ' + e.per, + manca: () => (adatte(e, scelte()).length ? null : `Scegli prima una clip ${e.per === 'video' ? 'video' : 'audio'} nella timeline`), + fn: () => { const ids = adatte(e, scelte()).map((c) => c.id); const n = alternaEffetto(e.id, ids); if (n) avviso(`${e.nome}: fatto su ${n} clip`, 'tasto', 1400); }, +}))); + +// i titoli pronti, le animazioni e i countdown: al cursore +fonteCerca(() => [ + ...PRESET_TITOLI.map((t): VoceCerca => ({ id: 'tit:' + t.id, titolo: 'Titolo ' + t.nome, sotto: 'al cursore', cat: 'Titoli', parole: 'titolo testo scritta ' + t.gruppo, fn: () => { inserisciGeneratore('title', undefined, undefined, { titolo: t.id }); } })), + ...ANIMAZIONI.map((a): VoceCerca => ({ id: 'anim:' + a.id, titolo: a.nome, sotto: `animazione · ${GRUPPI_ANIM.find((g) => g.id === a.gruppo)?.nome ?? ''} · al cursore`, cat: 'Animazioni', parole: 'animazione sottopancia ' + a.gruppo, fn: () => { inserisciGeneratore('anim', undefined, undefined, { anim: a.id }); } })), + ...STILI_CONTO.map((s): VoceCerca => ({ id: 'conto:' + s.id, titolo: 'Countdown ' + s.nome, sotto: `${s.secondi}…1 al cursore`, cat: 'Generatori', parole: 'conto alla rovescia countdown numeri', fn: () => { inserisciGeneratore('countdown', undefined, undefined, { conto: { stile: s.id, secondi: s.secondi } }); } })), +]); diff --git a/src/ui/guida.ts b/src/ui/guida.ts new file mode 100644 index 0000000..5c02b02 --- /dev/null +++ b/src/ui/guida.ts @@ -0,0 +1,295 @@ +// La guida "Come si fa": i lavori principali spiegati in pochi passi, ognuno col suo pulsante "Fallo adesso" che porta +// dritto al punto giusto del programma (la pagina, la sezione delle proprietà, il comando). Si apre con F1, dal menu +// Aiuto, dalla pagina iniziale, e ogni argomento compare anche nella ricerca dei comandi (Ctrl+K). +// I passi usano due segni: **grassetto** e `tasto`. +import { esegui } from '../azioni'; +import { dialogo, h } from './dom'; + +export interface Argomento { + id: string; + titolo: string; + icona: string; + /** una riga: a cosa serve */ + riassunto: string; + /** parole in più per la ricerca */ + parole: string; + passi: string[]; + /** il pulsante "Fallo adesso" (porta al posto giusto) */ + vai?: { nome: string; fn: () => void }; + gruppo: 'Primi passi' | 'Montaggio' | 'Effetti e titoli' | 'Audio' | 'AI' | 'Finale ed export' | 'Altro'; +} + +const evento = (nome: string, detail?: unknown) => document.dispatchEvent(new CustomEvent(nome, { detail })); +/** apre una pagina (montaggio, finale, live, montage) */ +export const vaiPagina = (p: string) => evento('dpv:pagina', p); +/** apre una sezione del Finale */ +export const vaiFinale = (s: string) => evento('dpv:finale', s); +/** apre (ed evidenzia) una sezione del pannello Proprietà della clip scelta */ +export const vaiSezione = (s: string) => evento('dpv:sezione', s); + +export const ARGOMENTI: Argomento[] = [ + { + id: 'inizio', gruppo: 'Primi passi', icona: '🚀', titolo: 'Il primo montaggio in un minuto', + riassunto: 'importa, metti in timeline, taglia, esporta', parole: 'inizio primi passi tutorial comincia nuovo importa', + passi: [ + '**Importa** i tuoi video: pulsante *Importa* in alto, `Ctrl+I`, o trascinali dentro la finestra.', + '**Trascina** un file dal contenitore (a sinistra) alla timeline (in basso). Niente viene mai coperto: se lì è occupato va su un\'altra traccia.', + 'Premi `Spazio` per guardarlo. La **rotella** va avanti e indietro di un fotogramma, col suono.', + 'Sul punto giusto premi `1`: **taglio**. Il pezzo più corto è già scelto: `2` e sparisce.', + 'Quando ti piace, **Esporta** (pulsante giallo in alto, `Ctrl+M`).', + ], + vai: { nome: 'Importa i file', fn: () => esegui('importa') }, + }, + { + id: 'cerca', gruppo: 'Primi passi', icona: '🔍', titolo: 'Trovare qualunque comando', + riassunto: 'Ctrl+K: scrivi cosa vuoi fare', parole: 'cerca comandi palette trova dove sta come si fa', + passi: [ + 'Premi `Ctrl+K` (o il pulsante **🔍 Cerca** in alto) e scrivi quello che vuoi fare: "sottotitoli", "rallenta", "sfondo", "dissolvenza", "titolo"…', + 'Le frecce scelgono, `Invio` lo fa. Trovi i comandi, gli effetti, le transizioni, i titoli, le animazioni, le funzioni AI e queste guide.', + 'Accanto a ogni comando c\'è il suo tasto: la prossima volta puoi premere quello.', + ], + vai: { nome: 'Apri la ricerca', fn: () => evento('dpv:cerca') }, + }, + { + id: 'tagliare', gruppo: 'Montaggio', icona: '✂', titolo: 'Tagliare e togliere i pezzi', + riassunto: '1 taglia, 2 elimina, Q/W via lo scarto', parole: 'taglia elimina cancella scarto ripple buco split', + passi: [ + '`1` taglia sotto il cursore (solo le tracce accese, se ne hai accese: clic sul nome della traccia).', + '`2` elimina la clip scelta lasciando il buco; `3` la elimina e **chiude il buco**.', + '`Q` toglie tutto quello che sta **a sinistra** del cursore nella clip, `W` quello **a destra**.', + '`R` accende il **ripple**: eliminando e accorciando, il resto scorre indietro da solo.', + '`Ctrl+Z` annulla (fino a 200 passi).', + ], + }, + { + id: 'spostare', gruppo: 'Montaggio', icona: '↔', titolo: 'Spostare, allungare e accorciare', + riassunto: 'trascina, tira i bordi, roll e slip', parole: 'sposta trim bordo allunga accorcia roll slip traccia', + passi: [ + '**Trascina** la clip: si ferma contro le vicine, non copre niente. Anche su un\'altra traccia.', + 'Tira i **bordi** per accorciarla o allungarla; con `Ctrl` il resto scorre (trim ripple).', + '`Shift` sul taglio fra due clip sposta il taglio (roll); `Alt` cambia il pezzo di ripresa senza muovere la clip (slip).', + '`Alt+Shift`+trascina sposta la clip **e tutto quello che viene dopo**, su tutte le tracce.', + '`S` separa l\'audio dal video (o unisce più clip in un gruppo).', + ], + }, + { + id: 'transizioni', gruppo: 'Effetti e titoli', icona: '✦', titolo: 'Mettere una transizione', + riassunto: 'dissolvenza, tendina, spinta, cubo…', parole: 'transizione dissolvenza incrociata tendina passaggio nero cubo spinta zoom', + passi: [ + 'Porta il cursore vicino a un taglio e premi `5` (dissolvenza), `6` (tendina) o `7` (passaggio al nero).', + 'Per le altre: contenitore → **Transizioni**, passa il mouse per vederle sui tuoi fotogrammi e **trascinala** sul taglio.', + 'Il blocchetto sta sopra le clip: **tira i bordi** per farla più lunga (più lenta). Due transizioni sullo stesso taglio si sommano.', + 'Clic sul blocchetto: a destra la durata, il verso, la forza e il **suono** (spento di partenza: l\'altoparlante lo accende).', + ], + vai: { nome: 'Dissolvenza sul taglio più vicino', fn: () => esegui('dissolvenza') }, + }, + { + id: 'effetti', gruppo: 'Effetti e titoli', icona: '⚡', titolo: 'Effetti: sulla clip o a tempo', + riassunto: 'Vivace, Cinema, B/N… oppure lampo, scossa, zoom', parole: 'effetto colore look vivace cinema bianco nero lampo scossa glitch zoom', + passi: [ + '**Sulla clip** (valgono per tutta la clip): sceglila e nelle **Proprietà → Effetti al volo** accendi Vivace, Caldo, Cinema, Pellicola, B/N… Si sommano.', + '**A tempo** (un momento preciso): contenitore → **Effetti**, trascina lampo, scossa, zoom colpo, glitch, bagliore… sopra la clip.', + 'Il blocchetto si mette da solo all\'inizio, alla fine o sul taglio; tiralo dai bordi per la durata.', + 'Bolla, vortice e zoom hanno un **mirino** sul monitor: mettilo dove vuoi.', + ], + vai: { nome: 'Apri gli effetti della clip', fn: () => vaiSezione('fxv') }, + }, + { + id: 'titoli', gruppo: 'Effetti e titoli', icona: '🅣', titolo: 'Scrivere un titolo o un sottopancia', + riassunto: 'titoli pronti e 49 animazioni', parole: 'titolo testo scritta sottopancia nome lower third animazione', + passi: [ + '`T` mette un titolo al cursore. Scrivi il testo nelle **Proprietà → Titolatrice** (font, colore, ombra, entrata e uscita).', + 'Pronti: contenitore → **Titoli** (cinema, neon, macchina da scrivere, rullo dei crediti…).', + 'Animati: contenitore → **Animazioni** (sottopancia, social, grafici, cerimonie): trascinali e cambia nomi e colori a destra.', + 'Sul monitor puoi **spostarlo e ingrandirlo** col mouse.', + ], + vai: { nome: 'Titolo al cursore', fn: () => esegui('genTitolo') }, + }, + { + id: 'movimento', gruppo: 'Effetti e titoli', icona: '↝', titolo: 'Muovere un\'immagine (Ken Burns, zoom, PiP)', + riassunto: 'sposta e ingrandisci sul monitor, movimento con le tappe', parole: 'movimento ken burns zoom posizione sposta ingrandisci pip riquadro angoli ombra', + passi: [ + 'Clic sull\'immagine del **monitor**: scegli la clip. Trascina per spostarla, tira un angolo per ingrandirla, il pallino la gira.', + '**↝ Movimento** (sul monitor): una posizione all\'inizio e una alla fine, e la clip ci passa dentro piano. **+** aggiunge tappe.', + 'Nelle **Proprietà → Avanzate** ci sono i movimenti pronti, gli **angoli tondi** e l\'**ombra** (per il riquadro picture-in-picture).', + ], + vai: { nome: 'Apri posizione e movimento', fn: () => vaiSezione('avanzate') }, + }, + { + id: 'velocita', gruppo: 'Montaggio', icona: '⏩', titolo: 'Accelerare o rallentare', + riassunto: 'Alt+E: velocità, voce naturale, movimento fluido', parole: 'velocità rallenta accelera slow motion ralenti veloce', + passi: [ + 'Scegli la clip e premi `Alt+E` (o tasto destro → Velocità).', + 'Scegli ×2, ×0,5… o scrivi il valore. La voce resta naturale (a meno che tu voglia l\'effetto nastro).', + 'Rallentando molto, **Movimento fluido** ricostruisce i fotogrammi che mancano.', + ], + vai: { nome: 'Velocità della clip scelta', fn: () => esegui('velocita') }, + }, + { + id: 'audio', gruppo: 'Audio', icona: '🔊', titolo: 'Volume, musica e dissolvenze audio', + riassunto: 'linea gialla, quadratini, livella, mixer', parole: 'volume audio musica dissolvenza fade in out abbassa livella mixer', + passi: [ + 'La **linea gialla** sulle clip audio è il volume: trascinala, doppio clic per un punto, tasto destro → *Abbassa qui*.', + 'I **quadratini in alto** agli angoli della clip sono le dissolvenze: tirali verso l\'interno, poi piega la linea per la forma.', + 'Finale → **Audio**: *Livella* porta tutte le clip a un volume comodo; il limitatore evita che distorca.', + 'Il **Mixer** (pannello a destra) ha volume, panorama, muto e solo per ogni traccia.', + ], + vai: { nome: 'Apri l\'audio finale', fn: () => vaiFinale('audio') }, + }, + { + id: 'sottotitoli', gruppo: 'AI', icona: '💬', titolo: 'Sottotitoli scritti dall\'AI', + riassunto: 'ascolta i dialoghi e scrive le righe coi tempi', parole: 'sottotitoli srt trascrizione whisper nemotron parlato dialoghi ai', + passi: [ + 'Metti in timeline i video **con l\'audio** dei dialoghi.', + 'Finale → **Sottotitoli** → scegli in che lingua si parla e premi **✨ Scrivi i sottotitoli con l\'AI**.', + 'La prima volta il modello si scarica (serve internet), poi resta sul computer. Nell\'app usa il motore NVIDIA, e se non parte passa da solo a Whisper.', + 'Correggi le righe nella lista; nella timeline stanno nella riga **SOTT**. Esporta il .srt dal Finale.', + ], + vai: { nome: 'Apri i sottotitoli', fn: () => vaiFinale('sottotitoli') }, + }, + { + id: 'lingue', gruppo: 'AI', icona: '🌍', titolo: 'Tradurre i sottotitoli e farli parlare', + riassunto: 'traduzione e voce AI (doppiaggio)', parole: 'traduci traduzione lingua inglese voce doppiaggio parla tts magpie', + passi: [ + 'Servono i sottotitoli (scritti a mano o dall\'AI).', + 'Finale → **Voce e lingue** → *Traduci i sottotitoli*: scegli la lingua, le righe restano ai loro tempi.', + '*Fai parlare i sottotitoli*: una voce AI legge le righe (solo nell\'app, col motore NVIDIA). Ogni frase diventa una clip nella traccia "Voce AI".', + ], + vai: { nome: 'Apri voce e lingue', fn: () => vaiFinale('lingue') }, + }, + { + id: 'sfondo', gruppo: 'AI', icona: '🪄', titolo: 'Togliere lo sfondo (green screen o AI)', + riassunto: 'colore, contagocce, o l\'AI senza fondale', parole: 'sfondo green screen chroma key scontorna ritaglia persona soggetto oggetto ai', + passi: [ + 'Scegli la clip nella timeline.', + '**Proprietà → Togli lo sfondo**: *Colore* per il green screen (contagocce, fino a 3 colori), oppure *AI* (Persona, Soggetto, Oggetti coi clic) senza fondale.', + 'Con l\'AI premi **Togli lo sfondo**: la prima volta il modello si scarica, poi lavora sul tuo computer.', + 'Il tasto **SFONDO** sul monitor mostra la maschera o gli scacchi per controllare.', + ], + vai: { nome: 'Apri "Togli lo sfondo"', fn: () => vaiSezione('sfondo') }, + }, + { + id: 'segui', gruppo: 'AI', icona: '🎯', titolo: 'Seguire un oggetto (tracking) e stabilizzare', + riassunto: 'un titolo o un effetto che insegue qualcosa', parole: 'tracking segui insegui oggetto stabilizza mirino', + passi: [ + 'Scegli la ripresa e nelle **Proprietà → Segui un oggetto** premi 🎯, poi clicca l\'oggetto sul monitor.', + 'Il programma lo segue in tutta la clip. Un titolo, un\'immagine o un effetto possono **seguirlo** (scegli "segue" nelle loro proprietà).', + '**Stabilizza** tiene ferma la ripresa sull\'oggetto.', + ], + vai: { nome: 'Apri "Segui un oggetto"', fn: () => vaiSezione('segui') }, + }, + { + id: 'montage', gruppo: 'AI', icona: '✨', titolo: 'Il montaggio automatico (DaProdMontage)', + riassunto: 'foto e video alla rinfusa → un montaggio a tempo di musica', parole: 'montage automatico foto slideshow matrimonio compleanno viaggio reel musica battiti', + passi: [ + 'Premi `F8` (o MONTAGE in alto) e butta dentro foto e video, anche disordinati.', + 'Scegli cosa festeggi (28 stili), quanto deve durare e il brano.', + '**✨ Crea il montaggio**: lo vedi nel monitor. **🎲 Rigenera** per un\'altra versione, **✅ Importa** per tenerlo e ritoccarlo.', + ], + vai: { nome: 'Apri DaProdMontage', fn: () => vaiPagina('montage') }, + }, + { + id: 'colore', gruppo: 'Finale ed export', icona: '🎨', titolo: 'Il colore di tutto il montaggio', + riassunto: 'colore automatico, look, prima/dopo', parole: 'colore look correzione luce contrasto saturazione temperatura automatico grading', + passi: [ + '`F9` apre la pagina **Finale** → **Colore**.', + 'Il **colore automatico** sistema livelli e bianco di ogni ripresa; i **look** (Cinema, Caldo, Vintage…) danno il tono a tutto.', + 'Luce, contrasto, saturazione, temperatura, vignetta e grana valgono per tutto il montaggio. Il monitor ha il **prima | dopo**.', + ], + vai: { nome: 'Apri il colore finale', fn: () => vaiFinale('colore') }, + }, + { + id: 'esporta', gruppo: 'Finale ed export', icona: '📤', titolo: 'Esportare il video', + riassunto: 'MP4, MOV, WebM, WAV, EDL, fotogramma', parole: 'esporta salva video mp4 render master youtube instagram', + passi: [ + '`Ctrl+M` (o **Esporta** in alto): nome, formato (MP4 va ovunque), dimensione e qualità.', + 'Con attacco e stacco segnati (`I`, `O`) puoi esportare solo quel pezzo.', + 'La finestra resta aperta mentre lavora: **Annulla** ferma. Alla fine **Fatto**.', + ], + vai: { nome: 'Esporta adesso', fn: () => esegui('esporta') }, + }, + { + id: 'progetti', gruppo: 'Finale ed export', icona: '💾', titolo: 'Salvare e portare il progetto altrove', + riassunto: '.dpv leggero o pacchetto .daprod con tutti i file', parole: 'salva progetto apri recenti pacchetto daprod dpv sposta altro computer', + passi: [ + '`Ctrl+S` salva il progetto (.dpv): leggero, i file restano dove sono. Ogni 15 secondi c\'è anche l\'autosalvataggio.', + 'File → **Salva il pacchetto .daprod**: il progetto con dentro tutti i suoi file, da aprire identico su un altro computer.', + 'Il tasto **Progetti** in alto mostra i recenti con la miniatura.', + ], + vai: { nome: 'Salva', fn: () => esegui('salva') }, + }, + { + id: 'live', gruppo: 'Altro', icona: '⏺', titolo: 'Registrare lo schermo (LIVE)', + riassunto: 'schermo, microfono e webcam a bolla', parole: 'registra schermo live tutorial webcam microfono presentazione', + passi: [ + '`F10` apre **LIVE**: scegli schermo, microfono e (se vuoi) la webcam.', + '`R` registra (col 3-2-1), `Spazio` pausa, `M` mette un segno, `F` ferma.', + 'La registrazione finisce nel contenitore e in fondo alla timeline, con la webcam a bolla sopra.', + ], + vai: { nome: 'Apri LIVE', fn: () => vaiPagina('live') }, + }, + { + id: 'timeline', gruppo: 'Altro', icona: '🗂', titolo: 'Più timeline nello stesso progetto', + riassunto: 'versione lunga, trailer, verticale…', parole: 'timeline sequenza scheda copia versione trailer verticale', + passi: [ + 'Sopra la timeline ci sono le **schede**: il **+** fa una timeline nuova o una copia di quella aperta.', + 'Doppio clic su una scheda per rinominarla. I file del contenitore valgono per tutte.', + ], + }, + { + id: 'problemi-ai', gruppo: 'AI', icona: '🩺', titolo: 'Se un\'AI non parte', + riassunto: 'Controlla l\'AI e le soluzioni più comuni', parole: 'errore ai non funziona problema modello scarica internet scheda video memoria diagnostica', + passi: [ + 'Apri il **Centro AI** (menu AI) e premi **🔍 Controlla l\'AI**: ti dice se il motore parte, se Hugging Face si raggiunge e quali modelli hai già.', + 'La prima volta ogni modello si **scarica**: serve internet. Dopo va anche senza rete.', + 'Se la scheda video dà problemi, nel Centro AI spegni **Usa la scheda video**: si lavora col processore (più lento, ma sicuro).', + 'Memoria che non basta: scegli il modello più leggero nelle opzioni.', + ], + vai: { nome: 'Apri il Centro AI', fn: () => evento('dpv:centro-ai') }, + }, +]; + +const GRUPPI: Argomento['gruppo'][] = ['Primi passi', 'Montaggio', 'Effetti e titoli', 'Audio', 'AI', 'Finale ed export', 'Altro']; + +/** **grassetto** e `tasto` dentro un passo */ +export function testoRicco(t: string): (string | HTMLElement)[] { + const out: (string | HTMLElement)[] = []; + const re = /\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*/g; + let i = 0, m: RegExpExecArray | null; + while ((m = re.exec(t))) { + if (m.index > i) out.push(t.slice(i, m.index)); + out.push(m[1] ? h('b', null, m[1]) : m[2] ? h('kbd', null, m[2]) : h('i', null, m[3])); + i = m.index + m[0].length; + } + if (i < t.length) out.push(t.slice(i)); + return out; +} + +/** la finestra della guida, aperta su un argomento (o sull'elenco) */ +export function finestraGuida(id?: string) { + const d = dialogo('Come si fa · la guida', { largo: true }); + d.el.classList.add('guida'); + const elenco = h('nav', { class: 'guida-elenco' }); + const corpo = h('div', { class: 'guida-corpo' }); + const mostra = (a: Argomento) => { + elenco.querySelectorAll('.guida-voce').forEach((b) => b.classList.toggle('attiva', (b as HTMLElement).dataset.id === a.id)); + corpo.replaceChildren( + h('h3', null, h('span', { class: 'guida-ic' }, a.icona), a.titolo), + h('p', { class: 'guida-riassunto' }, a.riassunto), + h('ol', { class: 'guida-passi' }, a.passi.map((p) => h('li', null, ...testoRicco(p)))), + ); + if (a.vai) corpo.append(h('button', { class: 'btn primario guida-vai', 'data-id': a.id, on: { click: () => { d.chiudi(); evento('dpv:home-nascondi'); a.vai!.fn(); } } }, '▶ ', a.vai.nome)); + }; + for (const g of GRUPPI) { + const qui = ARGOMENTI.filter((a) => a.gruppo === g); + if (!qui.length) continue; + elenco.append(h('h5', null, g), ...qui.map((a) => h('button', { class: 'guida-voce', 'data-id': a.id, on: { click: () => mostra(a) } }, h('span', { class: 'guida-ic' }, a.icona), a.titolo))); + } + d.corpo.append(h('div', { class: 'guida-dentro' }, elenco, corpo)); + mostra(ARGOMENTI.find((a) => a.id === id) ?? ARGOMENTI[0]); + d.piede.append( + h('button', { class: 'btn', on: { click: () => { d.chiudi(); evento('dpv:tasti'); } } }, '⌨ Tutti i tasti'), + h('button', { class: 'btn', on: { click: () => { d.chiudi(); evento('dpv:cerca'); } } }, '🔍 Cerca un comando (Ctrl+K)'), + h('span', { class: 'spazio' }), + h('button', { class: 'btn primario', on: { click: d.chiudi } }, 'Chiudi')); +} diff --git a/src/ui/home.ts b/src/ui/home.ts index c64b67a..63a3506 100644 --- a/src/ui/home.ts +++ b/src/ui/home.ts @@ -16,6 +16,7 @@ const quando = (t: number) => { const g = Math.floor(d / 86_400_000); return g === 1 ? 'ieri' : g < 30 ? `${g} giorni fa` : new Date(t).toLocaleDateString('it-IT'); }; +const evento = (nome: string, detail?: unknown) => document.dispatchEvent(new CustomEvent(nome, { detail })); const durataUmana = (s: number) => { const m = Math.floor(s / 60), r = Math.round(s % 60); return m ? `${m}:${String(r).padStart(2, '0')}` : `${r} s`; }; export class Home { @@ -33,7 +34,9 @@ export class Home { h('div', null, h('b', null, 'DaProd Video'), h('small', null, 'i tuoi progetti')), h('button', { class: 'btn-icona home-chiudi', title: 'Torna al montaggio (Esc)', on: { click: () => this.nascondi() } }, icona('x', 18))), this.corpo)); - document.addEventListener('keydown', (e) => { if (this.visibile && e.key === 'Escape') { e.stopPropagation(); this.nascondi(); } }, true); + document.addEventListener('keydown', (e) => { if (this.visibile && e.key === 'Escape' && !document.querySelector('.cerca-velo, .velo')) { e.stopPropagation(); this.nascondi(); } }, true); + // chi porta da un'altra parte (la ricerca, la guida) chiude la pagina iniziale + document.addEventListener('dpv:home-nascondi', () => { if (this.visibile) this.nascondi(); }); } mostra() { @@ -70,7 +73,14 @@ export class Home { h('span', null, `${p.name || 'Montaggio senza nome'} · ${p.clips.filter((c) => c.kind !== 'fx').length} clip`), h('button', { class: 'btn primario', on: { click: () => this.nascondi() } }, '▶ Continua')) : null, h('h3', null, 'Ricomincia'), - chipFormati, nuovoB, apriB, mont, demo); + chipFormati, nuovoB, apriB, mont, demo, + // per chi comincia: la guida, la ricerca dei comandi e il Centro AI (prima non c'era un posto da cui partire) + h('h3', null, 'Impara a usarlo'), + h('div', { class: 'home-impara' }, + h('button', { class: 'btn', on: { click: () => evento('dpv:guida', 'inizio') } }, '🚀 Il primo montaggio in un minuto'), + h('button', { class: 'btn', on: { click: () => evento('dpv:guida') } }, '📖 Come si fa (tutte le guide, F1)'), + h('button', { class: 'btn', on: { click: () => evento('dpv:cerca') } }, '🔍 Cerca un comando (Ctrl+K)'), + h('button', { class: 'btn', on: { click: () => evento('dpv:centro-ai') } }, '✨ Centro AI: sottotitoli, sfondo, voce…'))); // 2 · i recenti const lista = recenti(); diff --git a/src/ui/ispettore.ts b/src/ui/ispettore.ts index 041c748..37d0fcd 100644 --- a/src/ui/ispettore.ts +++ b/src/ui/ispettore.ts @@ -5,7 +5,7 @@ import { store } from '../core/store'; import type { Clip, TitleSpec } from '../core/tipi'; import { clipById, end, isVideoClip, mediaOf, trackOf, TF0, FX0 } from '../core/progetto'; import { frameToTc, fps } from '../core/timecode'; -import { avviso, h } from './dom'; +import { avviso, h, evidenzia } from './dom'; import * as M from '../core/montaggio'; import { esegui, modi, mettiBlocco } from '../azioni'; import { DIREZIONALI, EFFETTI, TENDINE } from '../render/transizioni'; @@ -78,7 +78,11 @@ export class Ispettore { h('li', null, h('kbd', null, 'S'), ' separa o unisce i gruppi di clip'), h('li', null, h('kbd', null, 'Q'), ' / ', h('kbd', null, 'W'), ' via lo scarto a sinistra / a destra'), h('li', null, h('kbd', null, 'rotella'), ' un fotogramma alla volta, col suono'), - h('li', null, h('kbd', null, 'Ctrl'), '+', h('kbd', null, 'rotella'), ' zoom della timeline')))); + h('li', null, h('kbd', null, 'Ctrl'), '+', h('kbd', null, 'rotella'), ' zoom della timeline')), + // da qui si arriva a tutto il resto: la ricerca dei comandi e la guida + h('div', { class: 'isp-aiuti' }, + h('button', { class: 'btn', on: { click: () => document.dispatchEvent(new CustomEvent('dpv:cerca')) } }, '🔍 Cerca un comando ', h('kbd', null, 'Ctrl K')), + h('button', { class: 'btn', on: { click: () => document.dispatchEvent(new CustomEvent('dpv:guida')) } }, '📖 Come si fa ', h('kbd', null, 'F1'))))); return; } const blocchi = cs.filter((c) => c.kind === 'fx'); @@ -554,6 +558,21 @@ export class Ispettore { return el; } + /** + * Apre una sezione da fuori (la guida, la ricerca, il Centro AI: "porta dove si fa") e la mette in vista. Ritorna + * false se per la clip scelta quella sezione non c'è (per esempio "Togli lo sfondo" su una clip audio). + */ + apriSezione(id: string): boolean { + this.aperti.add(id); + this.firma = ''; + this.costruisci(); + const el = this.corpo.querySelector(`.isp-gruppo[data-g="${id}"]`) as HTMLDetailsElement | null; + if (!el) return false; + el.open = true; + evidenzia(`.isp-gruppo[data-g="${id}"] > summary`, this.corpo); + return true; + } + // ——— mattoncini ——— private gruppo(id: string, titolo: string, righe: (HTMLElement | null)[]): HTMLElement { const grip = h('span', { class: 'isp-grip', title: 'Trascina per spostare la sezione (l\'ordine si ricorda)' }, '⠿'); diff --git a/src/ui/tastiera.ts b/src/ui/tastiera.ts index 47f39e8..3311208 100644 --- a/src/ui/tastiera.ts +++ b/src/ui/tastiera.ts @@ -24,6 +24,9 @@ export function nomeTasto(e: KeyboardEvent): string { return [...mods, k].join('+'); } +/** i comandi che vanno anche con la pagina iniziale aperta */ +const SOPRA_HOME = new Set(['apri', 'schermoIntero', 'cerca', 'guida']); + let tcInserimento: ((cifra: string) => void) | null = null; /** il monitor attivo registra qui chi riceve le cifre del tastierino */ export function suTastierino(fn: (cifra: string) => void) { tcInserimento = fn; } @@ -37,8 +40,8 @@ export function installaTastiera() { return; } if (document.querySelector('.velo')) return; // dialogo aperto - if (document.documentElement.classList.contains('home-aperta')) return; // la pagina iniziale copre il banco - if (/^Numpad\d$/.test(e.code) && !e.ctrlKey && !e.altKey) { + const home = document.documentElement.classList.contains('home-aperta'); // la pagina iniziale copre il banco + if (/^Numpad\d$/.test(e.code) && !e.ctrlKey && !e.altKey && !home) { e.preventDefault(); tcInserimento?.(e.code.slice(6)); return; @@ -47,6 +50,8 @@ export function installaTastiera() { const nome = nomeTasto(e).toLowerCase(); const id = mappa!.get(nome); if (!id) return; + // sopra la pagina iniziale valgono solo i comandi che non toccano il montaggio nascosto + if (home && !SOPRA_HOME.has(id)) return; // [ e ] solo se il tasto scritto è davvero una parentesi (sulla tastiera italiana quei tasti sono è e +) if ((nome === '[' || nome === ']') && e.key !== '[' && e.key !== ']') return; e.preventDefault(); diff --git a/test/prove-aiuto.mjs b/test/prove-aiuto.mjs new file mode 100644 index 0000000..98faefc --- /dev/null +++ b/test/prove-aiuto.mjs @@ -0,0 +1,240 @@ +// Le prove della 1.3.0: trovare le cose (la ricerca dei comandi, la guida "Come si fa", il Centro AI) e le AI che non +// si fermano più per niente (libreria dentro l'app, tipi del modello provati in ordine, ripiego da NVIDIA a Whisper, +// errori spiegati in parole). +// Girano dentro prove.mjs (con le prove nuove), oppure da sole: `node test/prove-nuove.mjs aiuto`. + +/** una pagina pulita (contesto nuovo) */ +async function pagina(paginaDi) { + const contesto = await paginaDi.context().browser().newContext({ viewport: { width: 1600, height: 950 } }); + const page = await contesto.newPage(); + const errori = []; + page.on('pageerror', (e) => errori.push(e.message)); + // la prova di Hugging Face fallisce dove la rete lo blocca (qui nel container): non è un errore del programma + page.on('console', (m) => { if (m.type() === 'error' && !/WebGPU|Failed to load resource/i.test(m.text())) errori.push(m.text()); }); + await page.goto(paginaDi.url()); + await page.waitForSelector('.pulsantiera'); + await page.waitForTimeout(1000); + return { page, contesto, errori }; +} + +/** un progetto con un'immagine e una clip colorata attaccate su V1 e un tono su A1 */ +const progetto = (page) => page.evaluate(async () => { + const { P, importaFile } = window.__dpvTest; + const s = window.__dpv; + const c = new OffscreenCanvas(320, 180); const x = c.getContext('2d'); x.fillStyle = '#aa2222'; x.fillRect(0, 0, 320, 180); + const f = new File([await c.convertToBlob({ type: 'image/png' })], 'Rosso.png', { type: 'image/png' }); + const p = P.newProject({ w: 1280, h: 720, rate: { num: 25, den: 1 }, drop: false }); + s.load(p); + const [m] = await importaFile([{ name: f.name, file: f }], { chiediFormato: false }); + s.edit('Prova', (pp) => { + const v1 = pp.tracks.filter((t) => t.kind === 'video')[1].id, a1 = pp.tracks.find((t) => t.kind === 'audio').id; + pp.clips.push(P.newClip('media', v1, 0, 50, { name: 'Rosso', media: m.id })); + pp.clips.push(P.newClip('color', v1, 50, 50, { name: 'Blu', gen: { color: '#2233aa' } })); + pp.clips.push(P.newClip('tone', a1, 0, 100, { name: 'Tono', gen: { freq: 440, level: -12 } })); + }); + s.select([]); +}); + +/** una transformers.js finta per i worker di voce e traduzione: il tipo "q8" non c'è (come per alcuni modelli veri) */ +const LIB = ` +export const env = { allowLocalModels: true }; +self.__provati = []; +export async function pipeline(task, repo, opz) { + const dtype = typeof opz.dtype === 'string' ? opz.dtype : JSON.stringify(opz.dtype); + self.__provati.push(task + ':' + dtype); + if (dtype === 'q8') throw new Error('Could not locate file: "https://huggingface.co/' + repo + '/resolve/main/onnx/model_quantized.onnx".'); + opz.progress_callback?.({ status: 'progress', file: 'model.onnx', loaded: 10, total: 10 }); + if (task === 'automatic-speech-recognition') return async (audio) => ({ text: 'ciao', chunks: [{ timestamp: [0.2, 1.4], text: 'Ciao a tutti (' + self.__provati.join(' ') + ')' }] }); + if (task === 'translation') return async (testi, o) => testi.map((t) => ({ translation_text: '[' + o.tgt_lang + '] ' + t })); + throw new Error('compito inatteso ' + task); +} +`; + +export async function proveAiuto({ page: paginaDi, prova }) { + // ——— 1 · trovare le cose ——— + { + const { page, contesto, errori } = await pagina(paginaDi); + try { + console.log('▶ Aiuto: la ricerca dei comandi (Ctrl+K)'); + await progetto(page); + await page.mouse.click(5, 5); + await page.keyboard.press('Control+k'); + await page.waitForSelector('.cerca-velo'); + prova('Ctrl+K apre la ricerca, col cursore già nel campo', await page.evaluate(() => document.activeElement?.classList.contains('cerca-input'))); + const primi = () => page.$$eval('.cerca-voce b', (b) => b.slice(0, 5).map((x) => x.textContent)); + prova('a ricerca vuota propone i consigliati', (await page.$$eval('.cerca-voce', (x) => x.length)) >= 5); + await page.keyboard.type('velocita'); + await page.waitForTimeout(120); + prova('"velocita" (senza accento) trova "Velocità della clip"', (await primi()).some((t) => /Velocità della clip/.test(t)), JSON.stringify(await primi())); + await page.fill('.cerca-input', 'sfondo'); + await page.waitForTimeout(120); + prova('"sfondo" trova "Togli lo sfondo" fra le funzioni AI e la sua guida', (await primi()).includes('Togli lo sfondo') && (await page.$$eval('.cerca-voce b', (b) => b.map((x) => x.textContent))).some((t) => /Come si fa: Togliere lo sfondo/.test(t)), JSON.stringify(await primi())); + await page.fill('.cerca-input', 'slow motion'); + await page.waitForTimeout(120); + prova('anche coi sinonimi: "slow motion" → velocità', (await primi()).some((t) => /Velocità/.test(t)), JSON.stringify(await primi())); + // una transizione dalla ricerca: va sul taglio più vicino al cursore + await page.keyboard.press('Escape'); + await page.waitForTimeout(100); + prova('Esc chiude la ricerca', !(await page.$('.cerca-velo'))); + await page.evaluate(() => window.__dpv.setHead(48)); + await page.keyboard.press('Control+k'); + await page.waitForSelector('.cerca-velo'); + await page.keyboard.type('tendina cuore'); + await page.waitForTimeout(120); + await page.keyboard.press('Enter'); + await page.waitForTimeout(250); + const tr = await page.evaluate(() => window.__dpv.doc.clips.filter((c) => c.kind === 'fx').map((c) => ({ id: c.fxb.id, start: c.start, len: c.len }))); + prova('Invio su "Tendina Cuore" mette la transizione sul taglio (centrata sul fotogramma 50)', tr.length === 1 && tr[0].id === 'wipe:121' && tr[0].start < 50 && tr[0].start + tr[0].len > 50, JSON.stringify(tr)); + // un effetto sulla clip senza clip scelta: dice cosa fare + await page.evaluate(() => window.__dpv.select([])); + await page.keyboard.press('Control+k'); + await page.keyboard.type('vivace'); + await page.waitForTimeout(150); + const avviso = await page.$$eval('.cerca-voce small', (s) => s.map((x) => x.textContent).find((t) => /Scegli prima una clip/.test(t)) ?? ''); + prova('un effetto sulla clip, senza clip scelta, dice "scegli prima una clip"', /Scegli prima una clip video/.test(avviso), avviso); + await page.keyboard.press('Escape'); + + console.log('▶ Aiuto: la guida "Come si fa" (F1)'); + await page.keyboard.press('F1'); + await page.waitForSelector('.dialogo.guida'); + const guida = await page.evaluate(() => ({ voci: document.querySelectorAll('.guida-voce').length, passi: document.querySelectorAll('.guida-passi li').length, titolo: document.querySelector('.guida-corpo h3')?.textContent })); + prova('F1 apre la guida: almeno 20 argomenti, il primo con i suoi passi', guida.voci >= 20 && guida.passi >= 4 && /primo montaggio/.test(guida.titolo ?? ''), JSON.stringify(guida)); + await page.click('.guida-voce[data-id="sottotitoli"]'); + await page.click('.guida-vai'); + await page.waitForTimeout(600); + const dove = await page.evaluate(() => ({ pagina: document.getElementById('app').dataset.pagina, sezione: document.querySelector('.fin-voce.attiva')?.dataset.s, bottone: !!document.querySelector('[data-ai="sottotitoli"]')?.offsetParent })); + prova('"Fallo adesso" sui sottotitoli porta al Finale → Sottotitoli, col pulsante dell\'AI in vista', dove.pagina === 'finale' && dove.sezione === 'sottotitoli' && dove.bottone, JSON.stringify(dove)); + await page.keyboard.press('F9'); + await page.waitForTimeout(300); + + console.log('▶ Aiuto: il Centro AI'); + prova('c\'è il menu AI in alto e il pulsante ✨ AI', (await page.$$eval('.voce-menu', (b) => b.map((x) => x.textContent))).includes('AI') && !!(await page.$('.btn-ai'))); + await page.click('.btn-ai'); + await page.waitForSelector('.dialogo.centro-ai'); + const carte = await page.$$eval('.ai-carta', (c) => c.map((x) => x.dataset.ai)); + prova('il Centro AI ha una scheda per ogni funzione (sottotitoli, traduci, voce, sfondo, segui, montaggio, colore)', ['sottotitoli', 'traduci', 'voce', 'sfondo', 'segui', 'montage', 'colore'].every((k) => carte.includes(k)), JSON.stringify(carte)); + const voce = await page.$eval('.ai-carta[data-ai="voce"] .ai-manca', (x) => x.textContent).catch(() => ''); + prova('nel browser la voce AI dice che serve l\'app', /solo nell'app/.test(voce), voce); + await page.click('.ai-controlla'); + await page.waitForFunction(() => document.querySelectorAll('.ai-diag').length >= 4, null, { timeout: 90000 }); + const diag = await page.$$eval('.ai-diag', (d) => d.map((x) => ({ ok: x.className, t: x.textContent }))); + const motore = diag.find((x) => /Motore AI/.test(x.t)); + prova('"Controlla l\'AI": il motore AI parte davvero, dai file dell\'app (niente CDN)', !!motore && /\bok\b/.test(motore.ok) && /dai file dell'app/.test(motore.t), JSON.stringify(diag)); + prova('la diagnosi dice di Hugging Face, della scheda video e dei modelli sul computer', diag.some((x) => /Hugging Face/.test(x.t)) && diag.some((x) => /Scheda video/.test(x.t)) && diag.some((x) => /Modelli sul computer/.test(x.t))); + // "Usa" sullo sfondo con il cursore su una clip: la sceglie e apre la sua sezione + await page.evaluate(() => { window.__dpv.select([]); window.__dpv.setHead(20); }); + await page.click('.ai-carta[data-ai="sfondo"] .ai-usa'); + await page.waitForTimeout(500); + const sez = await page.evaluate(() => ({ scelta: [...window.__dpv.sel].map((id) => window.__dpv.doc.clips.find((c) => c.id === id)?.name), aperta: !!document.querySelector('.isp-gruppo[data-g="sfondo"][open]') })); + prova('"Usa" su "Togli lo sfondo": sceglie la clip sotto il cursore e apre la sua sezione', sez.scelta[0] === 'Rosso' && sez.aperta, JSON.stringify(sez)); + + console.log('▶ Aiuto: tasti e menu riordinati'); + const reg = await page.evaluate(() => { const a = window.__dpvTest.Z.azioni; return ['importa', 'salva', 'esporta', 'guida', 'cerca', 'centroAI', 'paginaFinale'].filter((id) => a.has(id)); }); + prova('salva, importa, esporta, guida, cerca, Centro AI stanno nel registro dei comandi (coi loro tasti)', reg.length === 7, JSON.stringify(reg)); + const doppi = await page.evaluate(() => { + const visti = new Map(); + for (const a of window.__dpvTest.Z.azioni.values()) for (const t of a.tasti ?? []) { const k = t.toLowerCase(); visti.set(k, [...(visti.get(k) ?? []), a.id]); } + return [...visti].filter(([, ids]) => ids.length > 1).map(([k, ids]) => k + ': ' + ids.join(', ')); + }); + prova('nemmeno con i comandi nuovi un tasto è preso da due comandi', doppi.length === 0, doppi.join(' · ')); + const vista = await page.evaluate(async () => { + document.querySelectorAll('.voce-menu').forEach((b) => { if (b.textContent === 'Vista') b.click(); }); + await new Promise((r) => setTimeout(r, 100)); + const t = document.querySelector('.tendina')?.textContent ?? ''; + document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + return t; + }); + prova('Vista ha le copie leggere (quando farle, svuotale) e la memoria delle misure', /Copie leggere/.test(vista) && /memoria delle misure/.test(vista), vista.slice(0, 200)); + + console.log('▶ Aiuto: la testata sta dentro lo schermo'); + const larghezze = []; + for (const w of [1920, 1600, 1440, 1366, 1280, 1024]) { + await page.setViewportSize({ width: w, height: 900 }); + await page.waitForTimeout(80); + larghezze.push(await page.evaluate((w) => { const t = document.querySelector('.testata'); const e = document.querySelector('.testata-destra .btn.primario')?.getBoundingClientRect(); return { w, trabocca: t.scrollWidth > t.clientWidth + 1, esporta: !!e && e.right <= w && e.width > 0 }; }, w)); + } + await page.setViewportSize({ width: 1600, height: 950 }); + prova('a 1920, 1600, 1440, 1366, 1280 e 1024 pixel la testata non trabocca e Esporta si vede (prima a 1366 usciva)', larghezze.every((x) => !x.trabocca && x.esporta), JSON.stringify(larghezze)); + + console.log('▶ Aiuto: gli errori dell\'AI detti in parole'); + const frasi = await page.evaluate(() => { + const { EA } = window.__dpvTest; + return { + rete: EA.spiegaErroreAI('TypeError: Failed to fetch'), + manca: EA.spiegaErroreAI('Could not locate file: "https://huggingface.co/Xenova/modnet/resolve/main/onnx/model_quantized.onnx".'), + memoria: EA.spiegaErroreAI('RangeError: Array buffer allocation failed'), + scheda: EA.spiegaErroreAI('GPUDevice was lost: device lost'), + nostra: EA.spiegaErroreAI('Non ho sentito parole'), + }; + }); + prova('"Failed to fetch" → serve internet la prima volta', /serve internet la prima volta/.test(frasi.rete), frasi.rete); + prova('"Could not locate file" → il modello non si trova', /non si trova su Hugging Face/.test(frasi.manca), frasi.manca); + prova('memoria finita → scegli il modello più leggero', /memoria non basta/.test(frasi.memoria), frasi.memoria); + prova('scheda video persa → si userà il processore', /processore/.test(frasi.scheda), frasi.scheda); + prova('le frasi già in italiano restano come sono', frasi.nostra === 'Non ho sentito parole', frasi.nostra); + prova('nessun errore nelle prove della ricerca, della guida e del Centro AI', errori.length === 0, errori.slice(0, 5).join(' | ')); + } finally { + await contesto.close(); + } + } + + // ——— 2 · le AI che non si fermano ——— + { + const { page, contesto, errori } = await pagina(paginaDi); + try { + console.log('▶ AI: se il motore NVIDIA non parte, i sottotitoli passano a Whisper'); + await progetto(page); + const ripiego = await page.evaluate(async () => { + const { V, NM } = window.__dpvTest; + const fasi = []; + NM.impostaMotoreNemo({ + stato: async () => ({ os: 'windows', arch: 'x86_64', cartella: '', installato: false, backend: '', consigliato: 'cpu', nvidia: false, modelli: 0 }), + installa: async () => { throw new Error('lo scarico non è riuscito (exit status: 22): serve internet'); }, + modello: async () => {}, trascrivi: async () => [], sintetizza: async () => new Map(), + }); + V.impostaTrascrittore({ carica: async () => 'wasm', trascrivi: async () => [{ da: 0.1, a: 1.5, testo: 'Buongiorno a tutti' }] }); + try { + const righe = await V.sottotitoliAI(window.__dpv.doc, { lingua: 'it', traduci: false, modello: 'onnx-community/whisper-base', motore: 'nemotron' }, (f) => fasi.push(f)); + return { righe: righe.map((r) => r.testo), passato: fasi.some((f) => /passo a Whisper/.test(f)), fasi: fasi.slice(0, 6) }; + } catch (e) { return { errore: e.message, fasi }; } finally { NM.impostaMotoreNemo(null); V.impostaTrascrittore(null); } + }); + prova('motore NVIDIA rotto: le righe arrivano lo stesso (da Whisper) e la barra lo dice', ripiego.righe?.[0] === 'Buongiorno a tutti' && ripiego.passato, JSON.stringify(ripiego)); + // nel browser il motore NVIDIA non c'è proprio: stessa strada, e la barra dice che è solo nell'app + const browser = await page.evaluate(async () => { + const { V } = window.__dpvTest; + const fasi = []; + V.impostaTrascrittore({ carica: async () => 'wasm', trascrivi: async () => [{ da: 0.1, a: 1.5, testo: 'Buongiorno a tutti' }] }); + try { + const righe = await V.sottotitoliAI(window.__dpv.doc, { lingua: 'it', traduci: false, modello: 'onnx-community/whisper-base', motore: 'nemotron' }, (f) => fasi.push(f)); + return { righe: righe.map((r) => r.testo), avvisa: fasi.some((f) => /solo nell'app: carico Whisper/.test(f)) }; + } catch (e) { return { errore: e.message, fasi }; } finally { V.impostaTrascrittore(null); } + }); + prova('nel browser, chiedendo Nemotron: le righe arrivano da Whisper e la barra dice che il motore NVIDIA è solo nell\'app', browser.righe?.[0] === 'Buongiorno a tutti' && browser.avvisa, JSON.stringify(browser)); + + console.log('▶ AI: i worker veri, con una libreria finta che non ha il tipo "q8"'); + const finta = (r) => r.fulfill({ status: 200, contentType: 'text/javascript', headers: { 'access-control-allow-origin': '*' }, body: LIB }); + await page.context().route('**/ai/transformers.min.js', finta); + await page.context().route('https://cdn.jsdelivr.net/npm/@huggingface/transformers@*/dist/transformers.min.js', finta); + const whisper = await page.evaluate(async () => { + const { V } = window.__dpvTest; + V.impostaTrascrittore(null); + try { + const righe = await V.sottotitoliAI(window.__dpv.doc, { lingua: 'it', traduci: false, modello: 'onnx-community/whisper-tiny', motore: 'whisper' }, () => {}); + return { righe: righe.map((r) => r.testo).join(' ') }; + } catch (e) { return { errore: e.message }; } + }); + prova('Whisper: il tipo q8 manca, il worker prova fp32 e scrive le righe', /Ciao a tutti/.test(whisper.righe ?? '') && /automatic-speech-recognition:q8/.test(whisper.righe ?? '') && /automatic-speech-recognition:fp32/.test(whisper.righe ?? ''), JSON.stringify(whisper)); + const trad = await page.evaluate(async () => { + const { TD } = window.__dpvTest; + TD.impostaTraduttore(null); + try { return { testi: await TD.traduciTesti(['Buongiorno', '', 'Grazie'], 'it', 'en', () => {}) }; } catch (e) { return { errore: e.message }; } + }); + prova('Traduzione: stessa cosa (q8 manca → fp32), le righe vuote restano vuote', trad.testi?.[0] === '[eng_Latn] Buongiorno' && trad.testi?.[1] === '' && trad.testi?.[2] === '[eng_Latn] Grazie', JSON.stringify(trad)); + await page.context().unroute('**/ai/transformers.min.js'); + await page.context().unroute('https://cdn.jsdelivr.net/npm/@huggingface/transformers@*/dist/transformers.min.js'); + prova('nessun errore nelle prove delle AI', errori.length === 0, errori.slice(0, 5).join(' | ')); + } finally { + await contesto.close(); + } + } +} diff --git a/test/prove-nuove.mjs b/test/prove-nuove.mjs index 5740591..93b7a06 100644 --- a/test/prove-nuove.mjs +++ b/test/prove-nuove.mjs @@ -12,13 +12,14 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); import { leggi, vicino, apriGruppi } from './aiuti.mjs'; export { leggi, vicino }; -export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage', 'pannello', 'seguito', 'attivita', 'rifiniture']) { +export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage', 'pannello', 'seguito', 'attivita', 'rifiniture', 'aiuto']) { const { page, prova, OUT } = ctx; if (quali.includes('sfondo')) await proveSfondo({ page, prova, OUT }); if (quali.includes('animazioni')) { const m = await import('./prove-animazioni.mjs').catch(() => null); if (m) await m.proveAnimazioni({ page, prova, OUT }); } if (quali.includes('seguito')) { const m = await import('./prove-seguito.mjs').catch((e) => { console.log(' ✗ prove-seguito:', e.message); return null; }); if (m) await m.proveSeguito({ page, prova, OUT }); } if (quali.includes('pannello')) { const m = await import('./prove-pannello.mjs').catch((e) => { console.log(' ✗ prove-pannello:', e.message); return null; }); if (m) await m.provePannello({ page, prova, OUT }); } if (quali.includes('attivita')) { const m = await import('./prove-attivita.mjs').catch((e) => { console.log(' ✗ prove-attivita:', e.message); return null; }); if (m) await m.proveAttivita({ page, prova, OUT }); } + if (quali.includes('aiuto')) { const m = await import('./prove-aiuto.mjs').catch((e) => { console.log(' ✗ prove-aiuto:', e.message); return null; }); if (m) await m.proveAiuto({ page, prova, OUT }); } if (quali.includes('rifiniture')) { const m = await import('./prove-rifiniture.mjs').catch((e) => { console.log(' ✗ prove-rifiniture:', e.message); return null; }); if (m) await m.proveRifiniture({ page, prova, OUT }); } if (quali.includes('montage')) { const m = await import('./prove-montage.mjs').catch(() => null); if (m) await m.proveMontage({ page, prova, OUT }); } } @@ -372,7 +373,10 @@ export class SamModel { static async from_pretrained(repo, opz) { return async () => ({ pred_masks: 'x', iou_scores: { dims: [1, 1, 3], data: [0.1, 0.95, 0.4] } }); } } `; - await page.context().route('https://cdn.jsdelivr.net/npm/@huggingface/transformers@*/dist/transformers.min.js', (r) => r.fulfill({ status: 200, contentType: 'text/javascript', headers: { 'access-control-allow-origin': '*' }, body: LIB })); + // la libreria ora sta dentro l'app (dist/ai/), con la CDN di riserva: si sostituiscono tutte e due + const finta = (r) => r.fulfill({ status: 200, contentType: 'text/javascript', headers: { 'access-control-allow-origin': '*' }, body: LIB }); + await page.context().route('https://cdn.jsdelivr.net/npm/@huggingface/transformers@*/dist/transformers.min.js', finta); + await page.context().route('**/ai/transformers.min.js', finta); const vero = await page.evaluate(async () => { const { RT, MK } = window.__dpvTest; RT.impostaSegmentatore(null); @@ -400,8 +404,9 @@ export class SamModel { static async from_pretrained(repo, opz) { }); prova('il worker: BEN2 (pipeline background-removal) dà la maschera, con l\'immagine data in RGB', vero.sfondo.ok && vero.sfondo.angolo === 0 && vero.sfondo.centro === 255 && vero.sfondo.w === 640 && /BEN2/i.test(vero.sfondo.disp), JSON.stringify(vero.sfondo)); prova('il worker: SAM 2.1 non c\'è, si ripiega su SlimSAM; il clic diventa la maschera dell\'oggetto', vero.sam.ok && vero.sam.sulPunto === 255 && vero.sam.lontano === 0 && /slimsam/i.test(vero.sam.disp), JSON.stringify(vero.sam)); - prova('il worker: se nessun indirizzo risponde, l\'errore è chiaro', !vero.manca.ok && /Non riesco a caricare il modello/.test(vero.manca.motivo) && /404/.test(vero.manca.motivo), JSON.stringify(vero.manca)); + prova('il worker: se nessun indirizzo risponde, l\'errore è chiaro (detto in parole: il modello non si trova)', !vero.manca.ok && /Non riesco a caricare il modello/.test(vero.manca.motivo) && /non si trova su Hugging Face/.test(vero.manca.motivo), JSON.stringify(vero.manca)); await page.context().unroute('https://cdn.jsdelivr.net/npm/@huggingface/transformers@*/dist/transformers.min.js'); + await page.context().unroute('**/ai/transformers.min.js'); } // —————————————————————————————————————————————————————————————————————— diff --git a/test/prove.mjs b/test/prove.mjs index 29b4033..aca8696 100644 --- a/test/prove.mjs +++ b/test/prove.mjs @@ -25,7 +25,9 @@ const exe = process.env.CHROME || (fs.existsSync('/opt/pw-browsers/chromium') ? const browser = await chromium.launch({ executablePath: exe, args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'] }); const page = await browser.newPage({ viewport: { width: 1600, height: 950 }, acceptDownloads: true }); const errori = []; -page.on('pageerror', (e) => errori.push(e.message + ' @ ' + String(e.stack ?? '').split('\n').slice(1, 5).join(' | '))); +/** l'errore con le prime righe dello stack (per capire da dove viene, anche dalle pagine aperte a parte) */ +const conStack = (e) => e.message + ' @ ' + String(e.stack ?? '').split('\n').slice(1, 5).join(' | '); +page.on('pageerror', (e) => errori.push(conStack(e))); page.on('console', (m) => { if (m.type() === 'error') errori.push(m.text()); }); const doc = () => page.evaluate(() => window.__dpv.doc); @@ -1055,7 +1057,7 @@ try { console.log('▶ Pacchetto .daprod: salva con tutti i file e riapri identico'); { const pg = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - pg.on('pageerror', (e) => errori.push(e.message)); + pg.on('pageerror', (e) => errori.push(conStack(e))); await pg.goto(srv.url + '/app/'); await pg.waitForSelector('.pulsantiera'); await pg.click('text=Prova con il montaggio dimostrativo'); @@ -1096,7 +1098,7 @@ try { console.log('▶ LIVE con webcam, conto alla rovescia, segni e stile presentazione'); { const pg = await browser.newPage({ viewport: { width: 1600, height: 950 } }); - pg.on('pageerror', (e) => errori.push(e.message)); + pg.on('pageerror', (e) => errori.push(conStack(e))); await pg.goto(srv.url + '/app/'); await pg.waitForSelector('.pulsantiera'); await pg.evaluate(() => { @@ -1175,7 +1177,7 @@ try { console.log('▶ 1.1.3: motore NVIDIA (finto nelle prove), sottotitoli con Nemotron, voce AI, barra col tempo'); { const pa = await browser.newPage({ viewport: { width: 1600, height: 950 } }); - pa.on('pageerror', (e) => errori.push(e.message)); + pa.on('pageerror', (e) => errori.push(conStack(e))); pa.on('console', (m) => { if (m.type() === 'error') errori.push(m.text()); }); await pa.goto(srv.url + '/app/'); await pa.waitForSelector('.pulsantiera'); @@ -1253,16 +1255,22 @@ try { const doc = structuredClone(window.__dpv.doc); const righe = await V.sottotitoliAI(doc, { lingua: 'auto', traduci: false, modello: 'x', motore: 'nemotron' }, (t, k) => prog.push(k)); NM.impostaMotoreNemo(null); - // senza motore (nel browser) Nemotron dice che serve l'app + // senza motore (nel browser) Nemotron passa a Whisper e dice che il motore NVIDIA c'è solo nell'app let errore = ''; - try { await V.sottotitoliAI(doc, { lingua: 'it', traduci: false, modello: 'x', motore: 'nemotron' }, () => {}); } catch (e) { errore = e.message; } + const fasiBrowser = []; + V.impostaTrascrittore({ carica: async () => 'wasm', trascrivi: async () => [{ da: 0.2, a: 1.4, testo: 'Ciao da Whisper' }] }); + try { + const r = await V.sottotitoliAI(doc, { lingua: 'it', traduci: false, modello: 'x', motore: 'nemotron' }, (t) => fasiBrowser.push(t)); + if (!r.some((x) => x.testo === 'Ciao da Whisper')) errore = 'Whisper non ha scritto'; + else errore = fasiBrowser.find((t) => /solo nell'app/.test(t)) ?? 'nessun avviso'; + } catch (e) { errore = 'errore: ' + e.message; } finally { V.impostaTrascrittore(null); } let cresce = true; for (let i = 1; i < prog.length; i++) if (prog[i] < prog[i - 1] - 1e-9) cresce = false; return { chiamate, righe: righe.map((x) => x.testo), secondi: window.__secondi, fine: window.__dpvTest.P.projectEnd(window.__dpv.doc) / 25, cresce, ultimo: prog[prog.length - 1], errore }; }); prova('Nemotron: prima installa il motore, poi il modello, poi ascolta (lingua automatica)', sn.chiamate[0] === 'installa' && sn.chiamate[1] === 'modello:asr' && /^trascrivi:\d+:auto$/.test(sn.chiamate[2]), JSON.stringify(sn.chiamate)); prova('Nemotron ascolta tutto il montaggio (audio a 16 kHz) e le frasi diventano righe', Math.abs(sn.secondi - sn.fine) < 0.6 && sn.righe.length >= 2 && sn.righe[0] === 'Buonasera Napoli' && !sn.righe.some((t) => /musica/i.test(t)), JSON.stringify(sn)); prova('la barra dei sottotitoli non torna mai indietro e arriva a 100%', sn.cresce && sn.ultimo === 1, JSON.stringify({ c: sn.cresce, u: sn.ultimo })); - prova('nel browser (senza motore) Nemotron avvisa che serve l\'app', /app/.test(sn.errore), sn.errore); + prova('nel browser (senza motore) Nemotron passa a Whisper e avvisa che il motore NVIDIA è solo nell\'app', /solo nell'app/.test(sn.errore) && !/^errore/.test(sn.errore), sn.errore); // la voce AI dalla pagina Finale, col motore finto await pa.evaluate(() => { @@ -1330,7 +1338,7 @@ try { console.log('▶ LIVE: voce e audio del computer separati'); { const pg = await browser.newPage({ viewport: { width: 1600, height: 950 } }); - pg.on('pageerror', (e) => errori.push(e.message)); + pg.on('pageerror', (e) => errori.push(conStack(e))); await pg.goto(srv.url + '/app/'); await pg.waitForSelector('.pulsantiera'); await pg.evaluate(() => { @@ -1388,7 +1396,7 @@ try { console.log('▶ 1.1.2: effetti che si sommano davvero, tappe di mezzo, stira e ritaglia, tracking, effetti/transizioni/titoli nuovi'); { const page = await browser.newPage({ viewport: { width: 1600, height: 950 } }); - page.on('pageerror', (e) => errori.push(e.message)); + page.on('pageerror', (e) => errori.push(conStack(e))); await page.goto(srv.url + '/app/'); await page.waitForSelector('.pulsantiera'); await page.waitForTimeout(1000); @@ -1620,7 +1628,7 @@ try { console.log('▶ 1.1.3: velocità delle clip, movimento fluido, cursore che si aggancia ai tagli'); { const pv = await browser.newPage({ viewport: { width: 1600, height: 950 } }); - pv.on('pageerror', (e) => errori.push(e.message)); + pv.on('pageerror', (e) => errori.push(conStack(e))); pv.on('console', (m) => { if (m.type() === 'error') errori.push(m.text()); }); await pv.goto(srv.url + '/app/'); await pv.waitForSelector('.pulsantiera'); @@ -1821,7 +1829,7 @@ try { console.log('▶ LIVE con più finestre'); const pg = await browser.newPage({ viewport: { width: 1600, height: 950 } }); - pg.on('pageerror', (e) => errori.push(e.message)); + pg.on('pageerror', (e) => errori.push(conStack(e))); await pg.goto(srv.url + '/app/'); await pg.waitForSelector('.pulsantiera'); await pg.evaluate(() => { @@ -1894,7 +1902,7 @@ try { console.log('▶ Riproduzione: ripresa coi fotogrammi chiave radi (e il suo proxy)'); { const pg = await browser.newPage({ viewport: { width: 1400, height: 900 } }); - pg.on('pageerror', (e) => errori.push(e.message)); + pg.on('pageerror', (e) => errori.push(conStack(e))); await pg.goto(srv.url + '/app/'); await pg.waitForSelector('.pulsantiera'); await pg.waitForTimeout(800);