From 297ae82b47aa38807ffbbb00ea4672d05ff3b544 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:01:28 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Pagina=20iniziale,=20scelta=20multipla=20ne?= =?UTF-8?q?l=20contenitore,=20voce=20in=20altre=20lingue=20(anche=20cinese?= =?UTF-8?q?),=20file=20a=20velocit=C3=A0=20costante;=20versione=201.1.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pagina iniziale con progetti recenti (miniatura), Nuovo, Apri, DaProdMontage e Continua - contenitore: Ctrl/Maiusc+clic, trascinare più file (uno dopo l'altro), bordo verde per quelli in timeline, aggancio dopo una clip (anche nel fantasma) - Montage: monitor più piccolo, video che scorrono e lente per guardarli, "Intere con lo sfondo" / "Riempi il quadro", griglia sistemata senza classi sul banco - voce: cinese, traduzione automatica (NLLB) se la lingua della voce è diversa dai sottotitoli, "Traduci i sottotitoli", una clip di voce per frase scelta dalla riga dei sottotitoli, Finale "Voce e lingue" più semplice - import: video VFR → frame rate costante, brani VBR → bitrate costante (l'originale non si tocca) - prove: test/prove-seguito.mjs; la prova del Finale aggiornata - 1.1.6: CHANGELOG, README, CLAUDE.md, versioni Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014EwuMzmAMyXoJ9EFxHhdGk --- CHANGELOG.md | 30 ++++ CLAUDE.md | 6 +- README.md | 1 + package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src/core/montage.ts | 12 +- src/core/tipi.ts | 6 +- src/demo.ts | 47 +++++- src/main.ts | 7 +- src/media/doppiaggio.ts | 65 ++++++-- src/media/nemo.ts | 10 +- src/media/normalizza.ts | 151 ++++++++++++++++++ src/media/traduci.ts | 113 ++++++++++++++ src/media/traduci.worker.ts | 57 +++++++ src/progetti.ts | 84 +++++++++- src/stile/editor.css | 58 ++++++- src/ui/app.ts | 10 +- src/ui/contenitore.ts | 66 +++++++- src/ui/finale.ts | 117 ++++++++++---- src/ui/home.ts | 85 ++++++++++ src/ui/montage.ts | 87 +++++++++-- src/ui/timeline.ts | 106 ++++++++++--- test/foto.mjs | 10 ++ test/prove-nuove.mjs | 3 +- test/prove-seguito.mjs | 300 ++++++++++++++++++++++++++++++++++++ test/prove.mjs | 12 +- 28 files changed, 1342 insertions(+), 111 deletions(-) create mode 100644 src/media/normalizza.ts create mode 100644 src/media/traduci.ts create mode 100644 src/media/traduci.worker.ts create mode 100644 src/ui/home.ts create mode 100644 test/prove-seguito.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ffdb6..c7a0b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ 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.1.6] — 2026-10-02 · Pagina iniziale, scelta multipla nel contenitore, voce in altre lingue (anche cinese), file a velocità costante 🗂 + +### Pagina iniziale: i tuoi progetti +- All'apertura (o dal tasto **Progetti** in alto, o File → Pagina iniziale) vedi **i progetti recenti con la miniatura**, **Nuovo progetto** (HD, 24p/30p, verticale, quadrato, 4K), **Apri un progetto…**, **DaProdMontage** e **Continua** il montaggio in corso. Un clic su un recente lo riapre; la ✕ lo toglie dall'elenco (il file resta dov'è). Esc chiude. Quello che salvi o apri finisce da solo nell'elenco (nel browser il programma tiene anche una copia per riaprirlo). + +### Contenitore +- **Scelta multipla**: **Ctrl+clic** aggiunge o toglie un file, **Maiusc+clic** prende un tratto, clic nel vuoto toglie la scelta. **Trascinando** uno dei file scelti li porti tutti nella timeline, **uno dopo l'altro** (anche in una cartella, o col tasto destro: in coda al cursore). +- I file **già in timeline hanno un bordo verde** (con il numero di volte che ci sono); quelli scelti, un bordo oro. +- **Aggancio dopo una clip**: lasciando un file sulla seconda metà di una clip si mette **subito dopo di lei**; più in generale il file si aggancia a inizi e fini delle clip con una calamita più larga (14 px), e **il fantasma che vedi mentre trascini è già nel punto giusto**, non solo quando lasci. + +### DaProdMontage +- **Il monitor è più piccolo** (poco più di un quarto dello schermo): più posto alle scelte e ai file. +- **I video si guardano prima di sceglierli**: passandoci sopra con il mouse scorrono (con il tempo), e la **lente 🔍** li apre nel monitor in alto con il play. +- **Foto e video di un formato diverso dal progetto**: scegli **Intere, con lo sfondo** (nessun pezzo tagliato) oppure **Riempi il quadro** (a tutto quadro, tagliando i bordi). Non vengono mai stirati: nelle prove un cerchio resta un cerchio in 16:9, 9:16 e 1:1. +- Sistemato: con nessun pannello aperto o chiuso (nessuna classe sul banco) la griglia della pagina Montage non scattava. + +### Voce e lingue (Finale) +- **Il cinese** fra le lingue della voce (Mandarino, `zh-CN`). +- **Se la voce parla un'altra lingua dei sottotitoli, i testi si traducono da soli prima di essere letti** (prima una voce inglese leggeva il testo italiano con l'accento sbagliato). I sottotitoli restano come sono: puoi avere sottotitoli in una lingua e voce in un'altra. +- **Traduci i sottotitoli**: scegli la lingua e le righe si traducono ai loro tempi (Ctrl+Z per tornare). Il traduttore è **NLLB-200 di Meta** (nove lingue: italiano, inglese, spagnolo, francese, tedesco, portoghese, cinese, giapponese, arabo); gira sul tuo computer e si scarica la prima volta (il modello è grande: circa 600 MB). +- **La voce AI è una clip per frase**: ogni frase sta al suo posto nella traccia "Voce AI" e si sposta, si abbassa, si taglia o si toglie come ogni altra clip. **Un clic su una riga dei sottotitoli sceglie la sua voce**, e il pannello a destra mostra volume e il resto. +- **Finale più semplice**: la pagina si chiama **Voce e lingue** e ha due passi (1 · Sottotitoli: in che lingua sono e traduci; 2 · Voce: scegli la voce e la lingua, e un tasto). Le scelte che quasi nessuno cambia (chi ascolta, il modello) stanno in un riquadro chiuso nei Sottotitoli. + +### File a velocità costante (importazione) +- Un **video a frame rate variabile (VFR)**, come quelli dei telefoni, si rifà da solo a **frame rate costante** all'importazione (se no in montaggio audio e video slittano); un **brano a bitrate variabile (VBR: MP3, Opus, Vorbis)** si rifà a **bitrate costante**. Si converte solo se serve davvero, l'**originale non si tocca** (la copia va in Video/DaProd Video nell'app, in memoria nel browser) e il programma te lo dice. Per spegnere: `localStorage dpv-normalizza = no`. + +### Per chi ha curiosità +- Dentro: `src/ui/home.ts` + `registraRecente`/`apriRecente` in `src/progetti.ts`; `src/media/traduci.ts` (+ worker); `src/media/normalizza.ts`; `componiVoceConPosti` e `posaVoce` con le clip per frase in `src/media/doppiaggio.ts`; `Sottotitolo.voce`. +- Prove nuove in `test/prove-seguito.mjs`. **Nota onesta**: i modelli di traduzione (NLLB) e la voce cinese di Magpie non sono stati provati con i modelli veri (dal luogo dove si prova non si raggiungono Hugging Face né i modelli NVIDIA): le prove usano un traduttore e un motore finti con lo stesso protocollo. Il cinese per la voce dipende dalla versione del motore di NVIDIA installata. + ## [1.1.5] — 2026-10-01 · DaProdMontage con l'anteprima in alto, ogni montaggio diverso, proprietà riordinabili 🎲 ### DaProdMontage: prima lo guardi, poi lo importi diff --git a/CLAUDE.md b/CLAUDE.md index f4f9da2..8082a26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,8 +94,12 @@ Editor video "vecchio stile, moderno dentro" (ispirato a EDIUS e alle centraline - **Togliere lo sfondo (1.1.4)**: `fx.keyColori` (fino a 3 colori, `coloriChiave`) nello shader (`src/render/compositore.ts`: chiave nel piano YUV, despill, choke, feather, pulizia, `vistaChiave` = maschera/scacchi). **AI**: `c.ritaglio` (`Ritaglio` in `tipi.ts`: modo `soggetto|persona|oggetti`, modello, qualità, punti coi clic), `src/media/ritaglio.ts` (catalogo modelli e coordinamento) + `ritaglio.worker.ts` (transformers.js dalla CDN: pipeline `background-removal` per BEN2/BiRefNet/MODNet, `SamModel`/`Sam2Model` con `input_points` per gli oggetti; elenco di modelli di ripiego) → `src/media/maschere.ts` (maschere R8 per "firma", cache IndexedDB, `potaMaschere`, interpolazione fra due maschere nel compositore) e `src/media/campiona.ts` (`pixelAl`). `src/core/sfondo.ts` = parti pure (campioni, firma, levigatura). UI: `src/ui/sfondo.ts` + `sfondoGruppo` in `ispettore.ts` + tasto SFONDO nel monitor; il contagocce è la modalità `SceltaImmagine` di `posiziona.ts`. **Nelle prove** il segmentatore è finto e la libreria transformers è finta (`context.route`): i modelli veri non sono mai stati provati dal container. - **Animazioni (1.1.4)**: clip `kind: 'title'` con `gen.anim` (`AnimSpec`, catalogo `ANIMAZIONI` in `src/core/animazioni.ts`, 6 gruppi). Ogni animazione è una **funzione pura del tempo** (`DISEGNA[id]` in `src/render/anim/*.ts`, coordinate virtuali alte 1080, easing alla GSAP, caso con seme) disegnata su Canvas 2D da `disegnaAnimazione` (`src/render/animazioni.ts`), uguale nel monitor e nell'export (`esporta.ts` aspetta i font: `caricaFontAnimazioni` in `src/render/font.ts`, @fontsource). Pannello proprietà: `animatrice` in `ispettore.ts`; libreria nel contenitore (ramo *Animazioni*, `anteprimaAnimazione`); `g:anim:` in `generatori.ts`; un "fondo" va sempre sulla traccia video più in basso. `riquadroClip` le tratta a tutto quadro. - **DaProdMontage (1.1.4, pagina F8)**: `src/ui/montage.ts` (la pagina: file, stile, scelte; `crea()` legge EXIF, misura qualità/pezzo migliore, cerca i battiti, poi `store.edit` → `costruisciSequenza`) su `src/core/montage.ts` (**puro**: `pianifica` = ordine, `senzaDoppioni` per firma 8×8, `ripartisci` con bisezione per far sommare le durate al tempo chiesto, `inFotogrammi` somma esatta, `sulBattito`, ripetizioni se le foto sono poche; `costruisciSequenza` = una sequenza nuova con sfondo sfumato, foto a scheda o a tutto quadro con `moto` (Ken Burns via `tf/tfFine`), transizioni/effetti come blocchi, sovrapposizioni e titoli come clip `title` con animazioni, musica e audio dei video) e i 28 stili in `src/core/montagePreset.ts` (`PRESET_MONTAGE`). Aiuti: `src/media/exif.ts` (DateTimeOriginal), `qualita.ts` (Laplaciano, luce, firma), `ritmo.ts` (battiti da onset-autocorrelazione). Funziona anche con **sole foto**. Prove: `test/prove-montage.mjs`. - **Anteprima (1.1.5)**: `crea(seme?)` costruisce la sequenza con `store.edit('DaProdMontage · variante XXXXX', …)` e la mostra nel **monitor vero** (la pagina ha il suo posto in griglia: `monitor` sopra, `.montage` sotto, timeline nascosta); `this.anteprima` ricorda etichetta e seme. **Rigenera** = `togliAnteprima()` (un `doUndo`, solo se in cima all'annulla c'è ancora la *sua* etichetta, se no vuol dire che è stata toccata e si lascia) + nuova `crea()`; **Importa** = azzera `anteprima` e apre il Montaggio; **Scarta** = annulla. Così le timeline non si accumulano e Ctrl+Z funziona (il listener su `doc` rimette i bottoni a "Crea"). **Il caso**: `pianifica` usa `casoSeme(o.seme)` per durate (`variata`), movimenti (`scegliMoto`: mai due uguali di fila, `Voce.forza/verso`), transizioni (una su 4 dalla lista `FANTASIA` del ritmo), sovrapposizioni ed effetti (spostati e a volte saltati), foto lasciate fuori (rumore sul punteggio); stesso seme = stesso montaggio. L'analisi (qualità, battiti) si fa una volta per file (`analisi`, `ritmi`). **Attenzione CSS**: le regole `lato-lungo` e `senza-bin/senza-lato` valgono anche per le pagine nuove se non le escludi (`:not([data-pagina='montage'])`, `[class]` per la specificità). + **Anteprima (1.1.5)**: `crea(seme?)` costruisce la sequenza con `store.edit('DaProdMontage · variante XXXXX', …)` e la mostra nel **monitor vero** (la pagina ha il suo posto in griglia: `monitor` sopra, `.montage` sotto, timeline nascosta); `this.anteprima` ricorda etichetta e seme. **Rigenera** = `togliAnteprima()` (un `doUndo`, solo se in cima all'annulla c'è ancora la *sua* etichetta, se no vuol dire che è stata toccata e si lascia) + nuova `crea()`; **Importa** = azzera `anteprima` e apre il Montaggio; **Scarta** = annulla. Così le timeline non si accumulano e Ctrl+Z funziona (il listener su `doc` rimette i bottoni a "Crea"). **Il caso**: `pianifica` usa `casoSeme(o.seme)` per durate (`variata`), movimenti (`scegliMoto`: mai due uguali di fila, `Voce.forza/verso`), transizioni (una su 4 dalla lista `FANTASIA` del ritmo), sovrapposizioni ed effetti (spostati e a volte saltati), foto lasciate fuori (rumore sul punteggio); stesso seme = stesso montaggio. L'analisi (qualità, battiti) si fa una volta per file (`analisi`, `ritmi`). **Attenzione CSS**: le regole `lato-lungo` e `senza-bin/senza-lato` valgono anche per le pagine nuove se non le escludi (`:not([data-pagina='montage'])`, `:not(.nessuna)` per la specificità (non `[class]`: senza classi non scatta)). - **Proprietà (1.1.5)**: le sezioni (`Ispettore.gruppo(id, …)` in `src/ui/ispettore.ts`) partono **tutte chiuse** (`aperti` in memoria) e si **riordinano trascinando la maniglia** `⠿` (eventi pointer sul `document`, non HTML5 drag: in Tauri/Windows il drag&drop di sistema lo intercetta). L'ordine sta in localStorage `dpv-isp-ordine` per **tipo di elemento** (`clip:immagine|video|audio|titolo|animazione|generatore`, `blocco:effetto|transizione`); `conOrdine` riordina solo i *posti* delle sezioni (il resto del pannello resta dov'è), le sezioni nuove o nascoste vanno in fondo / restano dove stavano (`salvaOrdineDa`). Le prove che lavorano dentro le sezioni le aprono con `apriGruppi` (`test/aiuti.mjs`). Prove: `test/prove-pannello.mjs`. +- **Pagina iniziale e progetti recenti (1.1.6)**: `src/ui/home.ts` (`Home`, overlay a tutto schermo, evento `dpv:home`, tasto **Progetti** in testata, File → Pagina iniziale). Compare all'avvio **tranne se `navigator.webdriver`** (le prove automatiche partono col banco libero: per vederla nelle prove, `dpv:home`). I recenti stanno in localStorage `dpv-recenti` (`registraRecente` in `src/progetti.ts`, chiamata da `salva` e `apriFile`: nome, percorso, clip, durata, miniatura dal poster); nel browser si tiene anche una copia JSON in IndexedDB (`rec:`) per riaprirla senza file. +- **Contenitore, scelta multipla (1.1.6)**: `scelti` + `cliccaCarta` (Ctrl/Maiusc) in `src/ui/contenitore.ts`; il dato del trascinamento è `m:id1|id2|…` (timeline: `mediaDaDato`, posa uno dopo l'altro con un solo `store.edit`). **Aggancio**: `fDrop` in `timeline.ts` (14 px, fine delle clip della traccia preferita, seconda metà di una clip → subito dopo) usato **sia** dal fantasma (`anteprimaDrop`) **sia** da `rilascia`. Classe `.in-timeline` (bordo verde) sulle carte con `usi > 0`. +- **Voce e lingue (1.1.6)**: `src/media/traduci.ts` (`traduciTesti`, NLLB-200 600M in `traduci.worker.ts` da transformers.js/CDN; `LINGUE_TRADUZIONE`; `impostaTraduttore` finto per le prove). `doppia` traduce da sola i testi se `lingua` ≠ `sottotitoliDi(p).lingua` (solo per la voce) e ritorna `posti` (`componiVoceConPosti`); `posaVoce(p, mediaId, dur, silenzia, posti)` fa **una clip per frase** dallo stesso WAV (`srcIn = da`) e mette `Sottotitolo.voce` = id della clip; in timeline un clic sulla riga SOTT sceglie quella clip. Il cinese è in `LINGUE_VOCE`/`LINGUE_PARLATE` (`nemo.ts`, `zh-CN`) ma **non** in `LINGUE_MOTORE` (riconoscimento). Finale: sezione `lingue` = "Voce e lingue" (`paginaLingue`, `traduciSottotitoli`, `pannelloVoce`). +- **File a velocità costante (1.1.6)**: `src/media/normalizza.ts` (`esamina` → `Verdetto`, `converti`) chiamato da `importaFile` prima di `importa`: video VFR (`computeFrameRateMetrics`, max/min > 1,25) → CFR con `Conversion({video:{frameRate}})`; audio-solo MP3/Opus/Vorbis con pacchetti molto variabili → AAC (o Opus) a bitrate costante. Output su disco nell'app (`registrazione_percorso` + `export_*`), in memoria nel browser; l'originale non si tocca. Spento con localStorage `dpv-normalizza = no`. - **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 diff --git a/README.md b/README.md index 9a30ff5..388f562 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ ed elimini. Gira su **Windows, Mac e Android** e, in versione prova, **nel brows ## ▶ Come si monta +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**. 3. Vuoi scegliere il pezzo? **Doppio clic**: si apre nel monitor come **sorgente**. Segna **attacco** (`I`) e **stacco** (`O`) e premi **`,`** (inserisci) o **`.`** (sovrascrivi). **Tab** torna al montaggio. diff --git a/package-lock.json b/package-lock.json index 9cf6a62..7bada5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.1.5", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.1.5", + "version": "1.1.6", "license": "MIT", "dependencies": { "@fontsource/archivo-black": "^5.3.0", diff --git a/package.json b/package.json index e3da928..3b40bbb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.1.5", + "version": "1.1.6", "description": "DaProd Video: il montaggio video vecchio stile, moderno dentro. Timeline, due monitor, pulsantiera e VU a lancetta.", "author": "DaProd", "license": "MIT", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f73d213..4ef2cf3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -624,7 +624,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "daprod-video" -version = "1.1.5" +version = "1.1.6" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eeb148b..edbe777 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.1.5" +version = "1.1.6" description = "DaProd Video: il montaggio vecchio stile, moderno dentro" authors = ["DaProd"] license = "MIT" diff --git a/src/core/montage.ts b/src/core/montage.ts index df0b926..2fb8352 100644 --- a/src/core/montage.ts +++ b/src/core/montage.ts @@ -51,6 +51,8 @@ export interface OpzMontage { musica?: { media: string; durata: number; battiti?: number[]; nome?: string }; /** scarta foto sfocate e doppie quando sono troppe */ scarta: boolean; + /** foto e video di un formato diverso dal progetto: "intero" (scheda con ombra su uno sfondo sfumato) o "riempi" (a tutto quadro, taglia i bordi) */ + adatta?: 'intero' | 'riempi'; } /** una cosa sulla linea principale: quale file, quando comincia, quanto dura, da dove si prende (video) e come si muove */ @@ -327,11 +329,11 @@ export const fmtDur = (s: number) => { const m = Math.floor(s / 60), r = Math.ro // ——— Ken Burns ——— /** dove sta e quanto è grande la foto: a tutto quadro se ha quasi le stesse proporzioni, se no intera, un po' più piccola, con l'ombra */ -export function inquadra(p: Pick, m: Pick): { scala: number; scheda: boolean } { +export function inquadra(p: Pick, m: Pick, riempi = false): { scala: number; scheda: boolean } { const w = m.rotation % 180 ? m.height : m.width, h = m.rotation % 180 ? m.width : m.height; if (!w || !h) return { scala: 1, scheda: false }; const ra = (w / h) / (p.w / p.h); - if (ra >= 0.8 && ra <= 1.25) { + if (riempi || (ra >= 0.8 && ra <= 1.25)) { const kf = Math.min(p.w / w, p.h / h), kc = Math.max(p.w / w, p.h / h); return { scala: kc / kf, scheda: false }; } @@ -339,8 +341,8 @@ export function inquadra(p: Pick, m: Pick, m: Pick, id: Moto, forza: number, indice = 0): { tf: Transform; tfFine?: Transform } { - const { scala, scheda } = inquadra(p, m); +export function moto(p: Pick, m: Pick, id: Moto, forza: number, indice = 0, riempi = false): { tf: Transform; tfFine?: Transform } { + const { scala, scheda } = inquadra(p, m, riempi); const base: Transform = { ...TF0, scale: scala }; if (scheda) { base.ombra = 0.75; base.angoli = 0.035; } if (id === 'fermo') return { tf: base }; @@ -404,7 +406,7 @@ export function costruisciSequenza(p: Project, piano: PianoMontage, o: OpzMontag piano.voci.forEach((v, i) => { const m = mediaDi(v.entrata.media); const len = durF[i]; - const mt = m ? moto(p, m, v.moto, pr.forzaMoto * v.forza, v.verso) : { tf: { ...TF0 } }; + const mt = m ? moto(p, m, v.moto, pr.forzaMoto * v.forza, v.verso, o.adatta === 'riempi') : { tf: { ...TF0 } }; const link = v.entrata.tipo === 'video' && v.entrata.audio && o.audioVideo > -50 ? uid('l') : undefined; const c = newClip('media', tFoto.id, a, len, { media: v.entrata.media, name: v.entrata.nome, srcIn: v.srcIn, tf: mt.tf, tfFine: mt.tfFine, link, fx: { ...FX0, effetti: pr.look.length ? [...pr.look] : undefined } }); if (i === 0) c.fadeIn = Math.min(Math.round(r * 0.8), Math.floor(len / 2)); diff --git a/src/core/tipi.ts b/src/core/tipi.ts index d4632b1..cd69287 100644 --- a/src/core/tipi.ts +++ b/src/core/tipi.ts @@ -391,7 +391,11 @@ export interface Logo { } /** una riga dei sottotitoli: da e a in fotogrammi della timeline */ -export interface Sottotitolo { id: string; da: number; a: number; testo: string } +export interface Sottotitolo { + id: string; da: number; a: number; testo: string; + /** la clip audio con la voce AI di questa riga (si sceglie dalla riga e si gestisce come ogni altra clip) */ + voce?: string; +} export interface Sottotitoli { righe: Sottotitolo[]; diff --git a/src/demo.ts b/src/demo.ts index 6831eb8..5996f36 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -95,7 +95,7 @@ async function melodia(note: number[]): Promise { } /** crea un file video di prova (MP4 se il sistema codifica H.264/AAC, altrimenti WebM) */ -async function creaClip(sc: typeof scene[number], o: { dur?: number; gop?: number } = {}): Promise { +async function creaClip(sc: typeof scene[number], o: { dur?: number; gop?: number; vfr?: boolean } = {}): Promise { const DUR = o.dur ?? DUR0; const v = await getFirstEncodableVideoCodec(['avc', 'vp9', 'vp8', 'av1'], { width: W, height: H }); if (!v) throw new Error('nessun codificatore video'); @@ -110,9 +110,21 @@ async function creaClip(sc: typeof scene[number], o: { dur?: number; gop?: numbe if (a) { as = new AudioBufferSource({ codec: a, bitrate: new Quality('medium') }); out.addAudioTrack(as); } await out.start(); if (as) for (let t = 0; t < DUR; t += DUR0) await as.add(await melodia(sc.nota)); - for (let i = 0; i < DUR * FPS; i++) { - sc.disegna(ctx, i / FPS); - await vs.add(i / FPS, 1 / FPS); + if (o.vfr) { + // frame rate variabile (come i telefoni): i fotogrammi arrivano a distanze diverse, da 1 a 4 passi + const passi = [1, 1, 3, 1, 2, 1, 4, 1, 1, 2]; + let k = 0; + for (let t = 0; t < DUR * FPS; k++) { + const p = passi[k % passi.length]; + sc.disegna(ctx, t / FPS); + await vs.add(t / FPS, p / FPS); + t += p; + } + } else { + for (let i = 0; i < DUR * FPS; i++) { + sc.disegna(ctx, i / FPS); + await vs.add(i / FPS, 1 / FPS); + } } await out.finalize(); const nome = mp4 ? sc.nome : sc.nome.replace('.mp4', '.webm'); @@ -120,6 +132,33 @@ async function creaClip(sc: typeof scene[number], o: { dur?: number; gop?: numbe } /** una ripresa lunga con i fotogrammi chiave radi (come i video dei telefoni e dei generatori): per le prove */ +/** una ripresa a frame rate variabile (VFR), come quelle dei telefoni: per le prove */ +export const ripresaVfrDiProva = (dur: number) => creaClip({ ...scene[2], nome: `Telefono ${dur}s.mp4` }, { dur, gop: 25, vfr: true }); + +/** un brano a bitrate variabile (Opus VBR, con pause: i pacchetti cambiano molto di grandezza): per le prove */ +export async function branoVbrDiProva(dur: number): Promise { + const out = new Output({ format: new WebMOutputFormat(), target: new BufferTarget() }); + const as = new AudioBufferSource({ codec: 'opus', bitrate: new Quality({ quality: 'high', preferBitrate: true, bitrateMode: 'variable' }) }); + out.addAudioTrack(as); + await out.start(); + const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.round(48000 * dur), sampleRate: 48000 }); + const g = ctx.createGain(); + g.connect(ctx.destination); + // note piene e pause: Opus a bitrate variabile usa pochissimi byte per le pause e tanti per le note + for (let i = 0; i < dur * 2; i++) { + if (i % 3 === 2) continue; + const o = ctx.createOscillator(), e = ctx.createGain(); + o.type = i % 2 ? 'sawtooth' : 'triangle'; + o.frequency.value = 180 + (i % 5) * 90; + e.gain.setValueAtTime(0, i * 0.5); e.gain.linearRampToValueAtTime(0.5, i * 0.5 + 0.02); e.gain.linearRampToValueAtTime(0, i * 0.5 + 0.45); + o.connect(e).connect(g); + o.start(i * 0.5); o.stop(i * 0.5 + 0.5); + } + await as.add(await ctx.startRendering()); + await out.finalize(); + return new File([(out.target as BufferTarget).buffer!], `Brano VBR ${dur}s.webm`, { type: 'audio/webm', lastModified: Date.now() }); +} + export const ripresaDiProva = (dur: number, gop: number) => creaClip({ ...scene[2], nome: `Lunga ${dur}s.mp4` }, { dur, gop }); export async function montaggioDimostrativo() { diff --git a/src/main.ts b/src/main.ts index 7557a73..e296881 100644 --- a/src/main.ts +++ b/src/main.ts @@ -80,7 +80,10 @@ import * as G from './render/grafica'; import { pianoVideo } from './render/piano'; import * as Z from './azioni'; import { statoDecoder } from './media/fotogrammi'; -import { ripresaDiProva } from './demo'; +import { ripresaDiProva, ripresaVfrDiProva, branoVbrDiProva } from './demo'; +import * as NZ from './media/normalizza'; +import * as PR from './progetti'; +import * as TD from './media/traduci'; import { apriFile, importaFile } from './progetti'; import * as PK from './pacchetto'; import { mediaRT } from './media/libreria'; @@ -108,4 +111,4 @@ import * as MP from './core/montagePreset'; import * as EX from './media/exif'; import * as QL from './media/qualita'; import * as RI from './media/ritmo'; -(window as unknown as Record).__dpvTest = { M, TC, P, esporta, creaEdl, motore, guadagnoClip, pianoVideo, Z, statoDecoder, ripresaDiProva, 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, ui: () => banco }; +(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, ui: () => banco }; diff --git a/src/media/doppiaggio.ts b/src/media/doppiaggio.ts index ea191da..b620a3a 100644 --- a/src/media/doppiaggio.ts +++ b/src/media/doppiaggio.ts @@ -3,25 +3,28 @@ // lingua se i sottotitoli sono tradotti. Ogni frase va al suo posto: se la voce è più lunga dello spazio che ha, si // accelera un po' (col tono giusto, src/media/stira.ts); poi l'audio intero diventa un file e va su una traccia sua. import type { Clip, Project, Sottotitolo } from '../core/tipi'; -import { end, newTrack, nextTrackName, uid } from '../core/progetto'; +import { end, newClip, newTrack, nextTrackName, uid } from '../core/progetto'; import { f2s, fps } from '../core/timecode'; import { sottotitoliDi } from '../core/sottotitoli'; import * as M from '../core/montaggio'; import { assicuraMotore, LINGUE_VOCE, motoreNemo, type Parlato } from './nemo'; import { stiraTutto } from './stira'; +import { codiceNllb, sappiamoTradurre, traduciTesti } from './traduci'; import { ricampiona } from './wav'; export interface OpzioniDoppiaggio { /** quale voce di Magpie (0-4) */ voce: number; - /** la lingua in cui si parla: it, en, es, fr, de */ + /** la lingua in cui si parla: it, en, es, fr, de, zh */ lingua: string; + /** se la lingua della voce è diversa da quella dei sottotitoli, si traducono prima di leggerli (di partenza sì) */ + traduci?: boolean; /** l'audio originale delle riprese va in silenzio */ silenzia: boolean; } /** una frase da dire, coi tempi in secondi dall'inizio del montaggio */ -export interface Enunciato { id: string; da: number; a: number; testo: string } +export interface Enunciato { id: string; da: number; a: number; testo: string; /** le righe dei sottotitoli che ci sono dentro */ righe?: string[] } /** l'audio finale esce a 48 kHz */ export const SR_VOCE = 48000; @@ -43,7 +46,8 @@ export function enunciatiDaRighe(righe: Sottotitolo[], p: Project): Enunciato[] if (u && da - u.a <= 0.5 && !FINE_FRASE.test(u.testo) && u.testo.length + testo.length < 170) { u.testo += ' ' + testo; u.a = Math.max(u.a, a); - } else out.push({ id: uid('f'), da, a, testo }); + u.righe!.push(x.id); + } else out.push({ id: uid('f'), da, a, testo, righe: [x.id] }); } return out; } @@ -59,8 +63,17 @@ export function clipDialogo(p: Project): Clip[] { /** mette le frasi al loro posto in un audio solo. Ogni frase: a livello, e accelerata se non ci sta prima della successiva. */ export function componiVoce(enunc: Enunciato[], parlati: Map, o: { velocitaMax?: number } = {}): Float32Array { + return componiVoceConPosti(enunc, parlati, o).audio; +} + +/** dove sta ogni frase nell'audio composto (secondi dall'inizio del montaggio) */ +export interface PostoVoce { id: string; da: number; len: number; testo: string; righe: string[] } + +/** come componiVoce, e dice anche dove sta ogni frase: così ognuna può diventare una clip sua */ +export function componiVoceConPosti(enunc: Enunciato[], parlati: Map, o: { velocitaMax?: number } = {}): { audio: Float32Array; posti: PostoVoce[] } { const vMax = o.velocitaMax ?? 1.6; const pezzi: { at: number; audio: Float32Array }[] = []; + const posti: PostoVoce[] = []; let tot = 0; enunc.forEach((e, i) => { const pr = parlati.get(e.id); @@ -87,11 +100,12 @@ export function componiVoce(enunc: Enunciato[], parlati: Map, o } const at = Math.max(0, Math.round(e.da * SR_VOCE)); pezzi.push({ at, audio: a }); + posti.push({ id: e.id, da: at / SR_VOCE, len: a.length / SR_VOCE, testo: e.testo, righe: e.righe ?? [] }); tot = Math.max(tot, at + a.length); }); const out = new Float32Array(tot); for (const x of pezzi) for (let k = 0; k < x.audio.length; k++) out[x.at + k] += x.audio[k]; - return out; + return { audio: out, posti }; } /** la lingua della voce: quella indicata se Magpie la sa, altrimenti quella dei sottotitoli, altrimenti null */ @@ -104,35 +118,60 @@ export function linguaVoce(p: Project, scelta?: string): string | null { * Fa dire i sottotitoli a una voce. stato(testo, 0..1) racconta cosa sta facendo; il segnale ferma tutto. * Ritorna l'audio (mono, 48 kHz) e quante frasi ha detto. */ -export async function doppia(p: Project, o: OpzioniDoppiaggio, stato: (testo: string, k: number) => void, segnale?: AbortSignal): Promise<{ audio: Float32Array; sr: number; frasi: number }> { +export async function doppia(p: Project, o: OpzioniDoppiaggio, stato: (testo: string, k: number) => void, segnale?: AbortSignal): Promise<{ audio: Float32Array; sr: number; frasi: number; posti: PostoVoce[]; tradotto: boolean }> { const fermo = () => { if (segnale?.aborted) throw new Error('fermato'); }; const nem = motoreNemo(); if (!nem) throw new Error('la voce AI serve l\'app per Windows o Mac'); const enunc = enunciatiDaRighe(sottotitoliDi(p).righe, p); if (!enunc.length) throw new Error('senza-righe'); if (!LINGUE_VOCE.includes(o.lingua)) throw new Error('lingua'); - await assicuraMotore(nem, ['tts'], (k, t) => stato(t, k * 0.3), segnale); + // sottotitoli in una lingua e voce in un'altra: prima si traduce (solo per la voce, i sottotitoli restano), se no la voce + // leggerebbe parole di una lingua con l'accento dell'altra + const lingS = sottotitoliDi(p).lingua; + const serve = o.traduci !== false && !!lingS && lingS !== o.lingua && codiceNllb(lingS) !== codiceNllb(o.lingua); + if (serve && !sappiamoTradurre(lingS, o.lingua)) throw new Error('traduzione'); + let testi = enunc.map((e) => e.testo); + if (serve) { + testi = await traduciTesti(testi, lingS, o.lingua, (t, k) => stato(t, k * 0.3), segnale); + fermo(); + } + await assicuraMotore(nem, ['tts'], (k, t) => stato(t, (serve ? 0.3 : 0) + k * 0.2), segnale); fermo(); - const parlati = await nem.sintetizza(enunc.map((e) => ({ id: e.id, testo: e.testo })), { lingua: o.lingua, voce: o.voce }, (k, t) => stato(t, 0.3 + k * 0.62), segnale); + const base = serve ? 0.5 : 0.2; + const parlati = await nem.sintetizza(enunc.map((e, i) => ({ id: e.id, testo: testi[i] })), { lingua: o.lingua, voce: o.voce }, (k, t) => stato(t, base + k * (0.92 - base)), segnale); fermo(); stato('Metto le frasi al loro posto…', 0.94); - const audio = componiVoce(enunc, parlati); + const { audio, posti } = componiVoceConPosti(enunc, parlati); if (!audio.length) throw new Error('muto'); stato('Fatto', 1); - return { audio, sr: SR_VOCE, frasi: enunc.length }; + return { audio, sr: SR_VOCE, frasi: enunc.length, posti, tradotto: serve }; } /** * Mette la voce nel montaggio: su una traccia audio nuova ("Voce AI"), dall'inizio. Se `silenzia`, le voci originali * vanno a zero: la traccia si silenzia se contiene solo dialoghi, altrimenti si abbassano quelle clip. */ -export function posaVoce(p: Project, mediaId: string, duratasec: number, silenzia: boolean): string[] { +export function posaVoce(p: Project, mediaId: string, duratasec: number, silenzia: boolean, posti?: PostoVoce[]): string[] { const tr = newTrack('audio', nextTrackName(p, 'audio')); tr.name = 'Voce AI'; const dialoghi = silenzia ? clipDialogo(p) : []; p.tracks.push(tr); - const nuovi = M.placeSource(p, { mediaId, srcIn: 0, srcOut: duratasec }, 0, null, { video: null, audio: [tr.id] }, 'libero'); - for (const c of p.clips) if (nuovi.includes(c.id)) c.name = 'Voce AI'; + let nuovi: string[]; + if (posti?.length) { + // una clip per frase (dallo stesso file): ognuna si sceglie, si sposta, si abbassa o si toglie come ogni altra clip; + // e la riga dei sottotitoli sa qual è la sua + const r = fps(p.rate); + nuovi = []; + for (const x of posti) { + const c = newClip('media', tr.id, Math.max(0, Math.round(x.da * r)), Math.max(2, Math.round(x.len * r)), { media: mediaId, name: 'Voce: ' + (x.testo.length > 28 ? x.testo.slice(0, 27) + '…' : x.testo), srcIn: x.da, fadeIn: 1, fadeOut: 2 }); + p.clips.push(c); + nuovi.push(c.id); + for (const idRiga of x.righe) { const riga = p.sottotitoli?.righe.find((z) => z.id === idRiga); if (riga) riga.voce = c.id; } + } + } else { + nuovi = M.placeSource(p, { mediaId, srcIn: 0, srcOut: duratasec }, 0, null, { video: null, audio: [tr.id] }, 'libero'); + for (const c of p.clips) if (nuovi.includes(c.id)) c.name = 'Voce AI'; + } if (silenzia) { const usati = new Set(dialoghi.map((c) => c.track)); for (const t of p.tracks) { diff --git a/src/media/nemo.ts b/src/media/nemo.ts index d065a36..dbd6305 100644 --- a/src/media/nemo.ts +++ b/src/media/nemo.ts @@ -50,9 +50,13 @@ export const LINGUE_MOTORE: [string, string, string][] = [ ['fr', 'fr-FR', 'Francese'], ['de', 'de-DE', 'Tedesco'], ]; -export const codiceLingua = (l: string) => LINGUE_MOTORE.find((x) => x[0] === l)?.[1] ?? (l.includes('-') ? l : 'en-US'); -/** le lingue in cui Magpie sa parlare (fra quelle che offriamo) */ -export const LINGUE_VOCE = ['it', 'en', 'es', 'fr', 'de']; +/** le lingue in cui Magpie sa parlare (fra quelle che offriamo): c'è anche il cinese (mandarino), che il riconoscimento qui non offre */ +export const LINGUE_VOCE = ['it', 'en', 'es', 'fr', 'de', 'zh']; +/** nome e codice del motore delle lingue della voce */ +export const LINGUE_PARLATE: [string, string, string][] = [ + ['it', 'it-IT', 'Italiano'], ['en', 'en-US', 'Inglese'], ['es', 'es-ES', 'Spagnolo'], ['fr', 'fr-FR', 'Francese'], ['de', 'de-DE', 'Tedesco'], ['zh', 'zh-CN', 'Cinese'], +]; +export const codiceLingua = (l: string) => LINGUE_MOTORE.find((x) => x[0] === l)?.[1] ?? LINGUE_PARLATE.find((x) => x[0] === l)?.[1] ?? (l.includes('-') ? l : 'en-US'); // ——— i sottotitoli .srt che escono dal motore ——— diff --git a/src/media/normalizza.ts b/src/media/normalizza.ts new file mode 100644 index 0000000..f9f0caf --- /dev/null +++ b/src/media/normalizza.ts @@ -0,0 +1,151 @@ +// File "a velocità variabile" → a velocità costante, da soli, quando entrano nel progetto. +// · video con frame rate variabile (VFR: i telefoni e le registrazioni dello schermo): in montaggio fanno slittare +// audio e video e saltare i tagli; si rifanno a frame rate costante (CFR); +// · audio con bitrate variabile (VBR: MP3, Opus, Vorbis): la durata e i punti dove si salta non sono precisi, e la +// forma d'onda si sposta; si rifanno a bitrate costante (CBR). +// Si converte solo se serve davvero; l'originale non si tocca (la copia convertita va nella cartella Video/DaProd Video +// nell'app, in memoria nel browser). Con "dpv-normalizza" = "no" nella memoria del programma non si converte niente. +import { + ALL_FORMATS, BlobSource, BufferTarget, Conversion, EncodedPacketSink, Input, Mp4OutputFormat, Output, Quality, StreamSource, + StreamTarget, WebMOutputFormat, getFirstEncodableAudioCodec, getFirstEncodableVideoCodec, type StreamTargetChunk, +} from 'mediabunny'; +import { invoke, isTauri, type FileScelto } from '../platform'; + +export interface Verdetto { + /** video a frame rate variabile */ + vfr: boolean; + /** il frame rate costante da usare (quello più frequente) */ + fps: number; + /** audio (senza video) a bitrate variabile */ + vbr: boolean; + /** in parole, per dirlo a chi importa */ + motivo: string; +} + +export const normalizzaAttivo = () => { try { return localStorage.getItem('dpv-normalizza') !== 'no'; } catch { return true; } }; + +const FPS_COMUNI = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60]; +/** il frame rate comune più vicino (entro il 2%), se no due decimali */ +export function fpsPulito(x: number): number { + const v = FPS_COMUNI.find((c) => Math.abs(c - x) / c < 0.02); + return v ?? Math.round(x * 100) / 100; +} + +/** quanto varia la grandezza dei pacchetti: 0 = tutti uguali (bitrate costante) */ +export function variazione(grandezze: number[]): { cv: number; rapporto: number } { + if (grandezze.length < 8) return { cv: 0, rapporto: 1 }; + const m = grandezze.reduce((a, b) => a + b, 0) / grandezze.length; + const sd = Math.sqrt(grandezze.reduce((a, b) => a + (b - m) ** 2, 0) / grandezze.length); + const min = Math.min(...grandezze), max = Math.max(...grandezze); + return { cv: m ? sd / m : 0, rapporto: min > 0 ? max / min : 99 }; +} + +function sorgenteDi(sel: FileScelto) { + if (sel.file) return new BlobSource(sel.file, { maxCacheSize: 32 * 1024 * 1024 }); + const path = sel.path!; + return new StreamSource({ + getSize: () => invoke('media_dimensione', { path }), + read: async (start: number, end: number) => new Uint8Array(await invoke('media_leggi', { path, start, end })), + maxCacheSize: 32 * 1024 * 1024, + prefetchProfile: 'fileSystem', + }); +} + +/** guarda il file (pochi pacchetti) e dice se va rifatto a velocità costante; null se va bene com'è */ +export async function esamina(sel: FileScelto): Promise { + if (!sel.file && !(sel.path && isTauri)) return null; + const input = new Input({ source: sorgenteDi(sel), formats: ALL_FORMATS }); + try { + const v = await input.getPrimaryVideoTrack(); + const a = await input.getPrimaryAudioTrack(); + if (v) { + const m = await v.computeFrameRateMetrics({ targetPacketCount: 400 }).catch(() => null); + if (m && !m.frameRateIsConstant && m.probedPacketCount >= 30 && m.maxFrameRate / Math.max(1e-6, m.minFrameRate) > 1.25) { + return { vfr: true, fps: fpsPulito(m.medianFrameRate || m.averageFrameRate), vbr: false, motivo: 'frame rate variabile → costante' }; + } + return null; + } + if (a) { + const codec = String((await a.getCodec()) ?? ''); + if (!/^(mp3|opus|vorbis)$/.test(codec)) return null; + const sink = new EncodedPacketSink(a); + const grandezze: number[] = []; + for await (const p of sink.packets(undefined, undefined, { metadataOnly: true })) { + grandezze.push(p.byteLength); + if (grandezze.length >= 400) break; + } + const { cv, rapporto } = variazione(grandezze); + // l'MP3 a bitrate costante cambia di un byte (il "padding"): cv sotto 0,01. Opus e Vorbis sono variabili di natura. + if (cv > 0.08 && rapporto > 1.25) return { vfr: false, fps: 0, vbr: true, motivo: 'bitrate variabile → costante' }; + } + return null; + } catch { + return null; + } finally { + input.dispose(); + } +} + +/** una scrittura che accetta pezzi in qualunque posizione: su disco nell'app, in memoria nel browser */ +async function destinazione(nome: string): Promise<{ target: BufferTarget | StreamTarget; fine: () => Promise<{ path?: string; blob?: Blob }>; annulla: () => Promise }> { + if (isTauri) { + const path = await invoke('registrazione_percorso', { name: nome }); + const id = await invoke('export_apri', { path }); + const ws = new WritableStream({ + write: async (chunk) => { await invoke('export_scrivi', chunk.data, { headers: { 'x-id': String(id), 'x-pos': String(chunk.position) } }); }, + }); + return { + target: new StreamTarget(ws, { chunked: true, chunkSize: 4 * 1024 * 1024 }), + fine: async () => { await invoke('export_chiudi', { id }); return { path }; }, + annulla: async () => { await invoke('export_chiudi', { id }).catch(() => {}); }, + }; + } + const t = new BufferTarget(); + return { target: t, fine: async () => ({ blob: new Blob([t.buffer!]) }), annulla: async () => {} }; +} + +let aacPronto: Promise | null = null; +/** il codificatore AAC (nel browser di tutti i sistemi non c'è sempre): arriva solo quando serve */ +const assicuraAac = () => aacPronto ??= import('@mediabunny/aac-encoder').then((m) => { m.registerAacEncoder(); }).catch(() => {}); + +/** rifà il file a velocità costante; ritorna il file nuovo (stesso nome, estensione del nuovo contenitore) */ +export async function converti(sel: FileScelto, v: Verdetto, avanza: (k: number) => void, segnale?: AbortSignal): Promise { + const input = new Input({ source: sorgenteDi(sel), formats: ALL_FORMATS }); + const base = sel.name.replace(/\.[^.]+$/, ''); + await assicuraAac(); + try { + // quali codec si possono scrivere qui: H.264 + AAC in MP4 se si può, se no VP9 + Opus in WebM + let mp4: boolean, vc: Awaited> = null; + if (v.vfr) { + vc = await getFirstEncodableVideoCodec(['avc', 'vp9', 'vp8', 'av1']); + if (!vc) throw new Error('nessun codificatore video'); + mp4 = vc === 'avc'; + } else { + const ac = await getFirstEncodableAudioCodec(['aac', 'opus']); + mp4 = ac === 'aac'; + } + const ac = await getFirstEncodableAudioCodec(mp4 ? ['aac', 'opus'] : ['opus', 'vorbis']); + const estensione = v.vfr ? (mp4 ? 'mp4' : 'webm') : (mp4 ? 'm4a' : 'webm'); + const nome = `${base}.${estensione}`; + const dest = await destinazione(nome); + const out = new Output({ format: mp4 ? new Mp4OutputFormat({ fastStart: isTauri ? false : 'in-memory' }) : new WebMOutputFormat(), target: dest.target }); + try { + const conv = await Conversion.init({ + input, output: out, + ...(v.vfr ? { video: { frameRate: v.fps, ...(vc ? { codec: vc } : {}), bitrate: new Quality('high') } } : { video: { discard: true } }), + // l'audio a bitrate costante (192 kbit/s); se è già adatto resta com'è + audio: { ...(ac ? { codec: ac } : {}), bitrate: new Quality({ quality: 'high', preferBitrate: true, bitrateMode: 'constant' }), ...(v.vbr ? { forceTranscode: true } : {}) }, + }); + if (!conv.isValid) throw new Error('questo file non si riesce a convertire'); + conv.onProgress = (k: number) => { avanza(k); if (segnale?.aborted) void conv.cancel(); }; + await conv.execute(); + if (segnale?.aborted) throw new Error('fermato'); + } catch (e) { await dest.annulla(); throw e; } + const r = await dest.fine(); + if (r.path) return { name: nome, path: r.path }; + const file = new File([r.blob!], nome, { type: estensione === 'mp4' ? 'video/mp4' : estensione === 'm4a' ? 'audio/mp4' : 'video/webm', lastModified: Date.now() }); + return { name: nome, file }; + } finally { + input.dispose(); + } +} diff --git a/src/media/traduci.ts b/src/media/traduci.ts new file mode 100644 index 0000000..e39eeab --- /dev/null +++ b/src/media/traduci.ts @@ -0,0 +1,113 @@ +// La traduzione dei testi (i sottotitoli, e quello che la voce deve dire in un'altra lingua): NLLB-200 di Meta, +// dentro un worker (src/media/traduci.worker.ts). Il modello si scarica una volta sola. Serve a due cose: +// · "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). + +/** le lingue che si traducono: codice nostro, codice di NLLB, nome */ +export const LINGUE_TRADUZIONE: [string, string, string][] = [ + ['it', 'ita_Latn', 'Italiano'], + ['en', 'eng_Latn', 'Inglese'], + ['es', 'spa_Latn', 'Spagnolo'], + ['fr', 'fra_Latn', 'Francese'], + ['de', 'deu_Latn', 'Tedesco'], + ['pt', 'por_Latn', 'Portoghese'], + ['zh', 'zho_Hans', 'Cinese'], + ['ja', 'jpn_Jpan', 'Giapponese'], + ['ar', 'arb_Arab', 'Arabo'], +]; + +/** il codice di NLLB (il napoletano si traduce come l'italiano) */ +export const codiceNllb = (l: string) => LINGUE_TRADUZIONE.find((x) => x[0] === (l === 'nap' ? 'it' : l))?.[1] ?? null; +export const sappiamoTradurre = (da: string, a: string) => !!codiceNllb(da) && !!codiceNllb(a); + +/** chi traduce un gruppo di testi: di solito il worker; le prove ne mettono uno finto */ +export interface Traduttore { + carica(stato: (fase: string, prog: number) => void): Promise; + traduci(testi: string[], da: string, a: string): Promise; +} + +let worker: Worker | null = null; +let seq = 0; +const attese = new Map void; no: (e: Error) => void }>(); +let caricamento: { ok: () => void; no: (e: Error) => void; stato: (fase: string, prog: number) => void } | null = null; +const scaricati = new Map(); + +function lavoratore(): Worker { + if (worker) return worker; + worker = new Worker(new URL('./traduci.worker.ts', import.meta.url), { type: 'module' }); + worker.onmessage = (e: MessageEvent) => { + const m = e.data as { tipo: string; id?: number; testi?: string[]; msg?: string; file?: string; loaded?: number; total?: number }; + if (m.tipo === 'scarico' && caricamento) { + scaricati.set(m.file!, [m.loaded!, m.total!]); + let a = 0, b = 0; + for (const [x, y] of scaricati.values()) { a += x; b += y; } + caricamento.stato(`Scarico il modello di traduzione: ${(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(); caricamento = null; } + else if (m.tipo === 'tradotto') { attese.get(m.id!)?.ok(m.testi ?? []); attese.delete(m.id!); } + else if (m.tipo === 'errore') { + const err = new Error(m.msg || 'errore della traduzione'); + if (m.id !== undefined && attese.has(m.id)) { attese.get(m.id)!.no(err); attese.delete(m.id); } + else { caricamento?.no(err); caricamento = null; } + } + }; + worker.onerror = (e) => { + const err = new Error(e.message || 'il worker della traduzione si è fermato'); + caricamento?.no(err); caricamento = null; + for (const x of attese.values()) x.no(err); + attese.clear(); + }; + return worker; +} + +const nllb: Traduttore = { + carica: (stato) => new Promise((ok, no) => { + scaricati.clear(); + caricamento = { ok, no, stato }; + lavoratore().postMessage({ tipo: 'carica' }); + }), + traduci: (testi, da, a) => new Promise((ok, no) => { + const id = ++seq; + attese.set(id, { ok, no }); + lavoratore().postMessage({ tipo: 'traduci', id, testi, da: codiceNllb(da), a: codiceNllb(a) }); + }), +}; + +let traduttore: Traduttore = nllb; +/** per le prove: un traduttore finto (null = torna NLLB) */ +export const impostaTraduttore = (t: Traduttore | null) => { traduttore = t ?? nllb; }; + +/** ferma la traduzione in corso (il worker si chiude, il modello si ricarica la prossima volta) */ +export function fermaTraduzione() { + worker?.terminate(); + worker = null; + const err = new Error('fermato'); + caricamento?.no(err); caricamento = null; + for (const x of attese.values()) x.no(err); + attese.clear(); +} + +/** + * Traduce i testi dalla lingua `da` alla lingua `a`, a gruppi (così si vede avanzare la barra). Le righe vuote restano + * vuote; se le lingue sono la stessa cosa il testo torna com'è. + */ +export async function traduciTesti(testi: string[], da: string, a: string, stato: (fase: string, prog: number) => void, segnale?: AbortSignal): Promise { + const fermo = () => { if (segnale?.aborted) throw new Error('fermato'); }; + 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)); + fermo(); + const out = testi.slice(); + const dafare = testi.map((t, i) => [t, i] as const).filter(([t]) => t.trim()); + const GRUPPO = 6; + for (let k = 0; k < dafare.length; k += GRUPPO) { + fermo(); + const gruppo = dafare.slice(k, k + GRUPPO); + stato(`Traduco: ${Math.min(dafare.length, k + gruppo.length)} di ${dafare.length}`, 0.3 + (0.7 * k) / Math.max(1, dafare.length)); + const r = await traduttore.traduci(gruppo.map(([t]) => t), da, a); + gruppo.forEach(([, i], j) => { out[i] = (r[j] ?? '').trim() || testi[i]; }); + } + stato('Fatto', 1); + return out; +} diff --git a/src/media/traduci.worker.ts b/src/media/traduci.worker.ts new file mode 100644 index 0000000..af9c234 --- /dev/null +++ b/src/media/traduci.worker.ts @@ -0,0 +1,57 @@ +// 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 {}; + +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']; + +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 = ''; +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))); +} + +self.onmessage = async (e: MessageEvent) => { + const m = e.data as { tipo: string; id?: number; testi?: string[]; da?: string; a?: string }; + try { + if (m.tipo === 'carica') await carica(); + else if (m.tipo === 'traduci') { + if (!tr) throw new Error('modello non caricato'); + const out = await tr(m.testi!, { src_lang: m.da, tgt_lang: m.a, max_new_tokens: 256 }); + 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) }); + } +}; diff --git a/src/progetti.ts b/src/progetti.ts index e40e4bc..50da99a 100644 --- a/src/progetti.ts +++ b/src/progetti.ts @@ -8,6 +8,7 @@ import { ALTEZZA, MASTER0, newProject } from './core/progetto'; import { migraBlocchi } from './core/blocchi'; import { apri as apriMedia, chiudi as chiudiMedia, importa, mediaRT } from './media/libreria'; import { dimenticaMedia } from './media/fotogrammi'; +import { converti, esamina, normalizzaAttivo } from './media/normalizza'; import { dialogoApri, ESTENSIONI_DI, invoke, isTauri, nomeDaPercorso, salvaTesto, scegliFileBrowser, scegliMedia, type FileScelto, type TipoMedia } from './platform'; import { leggiPacchetto, pianoPacchetto, scriviPacchetto, type Sorgente } from './pacchetto'; import { avviso, conferma, dialogo, h } from './ui/dom'; @@ -54,14 +55,28 @@ export async function importaFile(lista: (FileScelto & { maniglia?: Maniglia })[ let i = 0; for (const f of lista) { barra.testo(`Importo ${++i}/${lista.length}: ${f.name}`); - const r = await importa(f); + // frame rate o bitrate variabili: il file si rifà a velocità costante (se no in montaggio audio e video slittano) + let sel: FileScelto & { maniglia?: Maniglia } = f; + if (normalizzaAttivo() && !/\.(jpe?g|png|webp|gif|bmp|avif)$/i.test(f.name)) { + try { + const v = await esamina(f); + if (v) { + const nome = f.name; + barra.testo(`Converto ${nome}: ${v.motivo}…`); + const conv = await converti(f, v, (k) => barra.testo(`Converto ${nome} (${v.motivo}): ${Math.round(k * 100)}%`)); + sel = conv; + avviso(`🔧 ${nome}: ${v.motivo} (l'originale non è stato toccato)`, 'info', 3600); + } + } catch { sel = f; /* se la conversione non riesce si importa il file com'è */ } + } + const r = await importa(sel); if ('errore' in r) { errori.push(`${r.nome}: ${r.errore}`); continue; } if (opzioni.cartella) r.item.cartella = opzioni.cartella; nuovi.push(r.item); - if (f.maniglia) void idb('maniglie', 'put', r.item.id, f.maniglia); + if (f.maniglia && sel === f) void idb('maniglie', 'put', r.item.id, f.maniglia); // nel browser, senza "maniglia" (Firefox, Safari, file trascinati, istantanee) i file piccoli si tengono // dentro il browser: così il progetto li ritrova alla prossima apertura senza ricollegarli - else if (!isTauri && f.file && f.file.size <= FILE_LOCALE_MAX) void idb('file', 'put', r.item.id, f.file); + else if (!isTauri && sel.file && sel.file.size <= FILE_LOCALE_MAX) void idb('file', 'put', r.item.id, sel.file); } barra.chiudi(); if (nuovi.length) { @@ -126,6 +141,67 @@ export async function importaDaDrop(dt: DataTransfer, cartella?: string) { return importaFile(lista, { cartella }); } +// ——— i progetti recenti (la pagina iniziale) ——— +export interface Recente { chiave: string; nome: string; path?: string; data: number; clip: number; durata: number; formato: string; miniatura?: string } +const RECENTI_MAX = 12; + +export function recenti(): Recente[] { + try { const l = JSON.parse(localStorage.getItem('dpv-recenti') ?? '[]') as Recente[]; return Array.isArray(l) ? l : []; } catch { return []; } +} +function scriviRecenti(l: Recente[]) { try { localStorage.setItem('dpv-recenti', JSON.stringify(l.slice(0, RECENTI_MAX))); } catch { /* spazio pieno: pazienza */ } } + +export function dimenticaRecente(chiave: string) { + scriviRecenti(recenti().filter((x) => x.chiave !== chiave)); + void idb('kv', 'delete', 'rec:' + chiave).catch(() => {}); +} + +/** una miniatura piccola dal primo video o dalla prima immagine del montaggio */ +function miniaturaDi(p: Project): string | undefined { + try { + const clip = p.clips.filter((c) => c.kind === 'media' && c.media).sort((a, b) => a.start - b.start).find((c) => { const rt = mediaRT(c.media!); return !!rt?.poster; }); + const src = clip ? (mediaRT(clip.media!)?.poster as (CanvasImageSource & { width: number; height: number }) | undefined) : undefined; + if (!src || !src.width) return undefined; + const cv = document.createElement('canvas'); + cv.width = 192; cv.height = 108; + const x = cv.getContext('2d')!; + x.fillStyle = '#111'; x.fillRect(0, 0, 192, 108); + const k = Math.min(192 / src.width, 108 / src.height); + x.drawImage(src, (192 - src.width * k) / 2, (108 - src.height * k) / 2, src.width * k, src.height * k); + return cv.toDataURL('image/jpeg', 0.7); + } catch { return undefined; } +} + +/** ricorda un progetto salvato o aperto: in cima all'elenco. Nel browser tiene anche una copia (per riaprirlo dalla pagina iniziale). */ +export function registraRecente(p: Project, path?: string, testo?: string) { + const chiave = path ?? 'b:' + (p.name || 'montaggio'); + const r = fps0(p); + const voce: Recente = { + chiave, nome: p.name || 'Montaggio senza nome', path, data: Date.now(), clip: p.clips.filter((c) => c.kind !== 'fx').length, + durata: Math.max(0, ...p.clips.map((c) => c.start + c.len)) / r, formato: `${p.w}×${p.h} · ${Math.round(r * 100) / 100} fps`, miniatura: miniaturaDi(p), + }; + scriviRecenti([voce, ...recenti().filter((x) => x.chiave !== chiave)]); + if (!isTauri && testo) void idb('kv', 'put', 'rec:' + chiave, testo).catch(() => {}); +} +const fps0 = (p: Project) => p.rate.num / p.rate.den; + +/** apre un progetto della lista: dal percorso (app) o dalla copia tenuta nel browser */ +export async function apriRecente(r: Recente): Promise { + if (store.dirty && store.doc.clips.length && !(await conferma('Apri progetto', 'Il montaggio attuale ha modifiche non salvate. Aprire un altro progetto?', 'Apri', 'Annulla'))) return false; + if (r.path && isTauri) { + try { await invoke('progetto_leggi', { path: r.path }); } catch { avviso('Questo progetto non c\'è più dove l\'avevi salvato', 'errore', 4000); dimenticaRecente(r.chiave); return false; } + await apriFile({ path: r.path }); + return true; + } + const testo = await idb('kv', 'get', 'rec:' + r.chiave); + let p: Project | null = null; + try { p = testo ? valida(JSON.parse(testo)) : null; } catch { p = null; } + if (!p) { avviso('Di questo progetto non ho più la copia: aprilo dal file (Apri progetto…)', 'info', 4500); return false; } + percorsoProgetto = null; + await carica(p); + avviso(`📂 ${p.name}`, 'ok'); + return true; +} + // ——— salvataggio ——— function serializza(): string { const p = store.doc; @@ -140,6 +216,7 @@ export async function salva(come = false) { if (!r) return; if (isTauri) percorsoProgetto = r; store.dirty = false; + registraRecente(store.doc, isTauri ? r : undefined, isTauri ? undefined : serializza()); avviso(`💾 Salvato: ${nomeDaPercorso(r)}`, 'ok'); store.emit('status'); } @@ -194,6 +271,7 @@ export async function apriFile(s: Sorgente) { if (!p) { avviso('Questo file non è un progetto DaProd Video', 'errore'); return; } percorsoProgetto = s.path ?? null; await carica(p); + registraRecente(p, s.path, s.path ? undefined : JSON.stringify(p)); avviso(`📂 ${p.name}`, 'ok'); } diff --git a/src/stile/editor.css b/src/stile/editor.css index 4896ee1..4371a32 100644 --- a/src/stile/editor.css +++ b/src/stile/editor.css @@ -97,9 +97,9 @@ button svg { pointer-events: none; } #app .live { grid-area: live; display: none; } #app[data-pagina='live'] .live { display: flex; } /* la pagina DaProdMontage: tre colonne (file, stile, scelte), la timeline resta sotto per vedere il risultato */ -#app[data-pagina='montage'][class] { +#app[data-pagina='montage']:not(.nessuna) { grid-template-columns: minmax(0, 1fr); - grid-template-rows: 44px minmax(210px, 42vh) minmax(0, 1fr) 22px; + grid-template-rows: 44px minmax(150px, 28vh) minmax(0, 1fr) 22px; grid-template-areas: 'testa' 'monitor' 'montage' 'stato'; } /* l'anteprima è il monitor vero, in alto a tutta larghezza; la timeline e le colonne a lato aspettano */ @@ -242,6 +242,10 @@ button svg { pointer-events: none; } .carta { position: relative; background: #1d1c24; border: 1px solid #000; border-radius: 8px; padding: 4px; cursor: grab; display: flex; flex-direction: column; gap: 3px; min-width: 0; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); touch-action: none; transition: border-color 0.12s, background 0.12s; } .carta:hover { background: #26242f; border-color: rgba(255, 213, 74, 0.4); } .carta.nel-monitor { box-shadow: 0 0 0 1.5px var(--cia), 0 0 12px rgba(53, 232, 255, 0.2); } +.carta.in-timeline { border-color: rgba(93, 211, 158, 0.55); box-shadow: inset 0 0 0 1px rgba(93, 211, 158, 0.28), 0 0 9px rgba(93, 211, 158, 0.14); } +.carta.in-timeline .carta-usi { background: #2ea36f; color: #04170e; } +.carta.scelta { border-color: var(--oro); background: #2a2718; box-shadow: 0 0 0 1.5px var(--oro), 0 0 12px rgba(255, 213, 74, 0.25); } +.bin-scelti { margin-left: auto; padding: 2px 9px; border-radius: 12px; background: var(--oro); color: #1a1206; font: 800 10.5px var(--ui); } .carta.offline .carta-img { background: repeating-linear-gradient(135deg, #2a1119 0 8px, #1d1c24 8px 16px); } .carta.gen, .carta.tr, .carta.fx { cursor: pointer; } .carta-img { position: relative; aspect-ratio: 16 / 9; border-radius: 5px; overflow: hidden; background: #000; } @@ -1019,11 +1023,18 @@ body.pip-live { margin: 0; background: #111016; overflow: hidden; } .mt-num { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--oro); color: #1a1206; font: 900 12px var(--ui); } .mt-barra { display: flex; flex-wrap: wrap; gap: 6px; } .mt-conta { color: var(--testo3); font: 700 11.5px var(--ui); } -.mt-file { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 6px; align-content: start; } +.mt-file { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 6px; align-content: start; } .mt-thumb { position: relative; padding: 0; border: 2px solid #000; border-radius: 6px; overflow: hidden; background: #000; opacity: 0.45; cursor: pointer; line-height: 0; } .mt-thumb canvas { width: 100%; height: auto; display: block; } .mt-thumb.scelta { opacity: 1; border-color: var(--oro); } .mt-tipo { position: absolute; left: 3px; bottom: 3px; font: 800 9px var(--ui); background: rgba(0, 0, 0, 0.7); color: #fff; padding: 1px 4px; border-radius: 3px; line-height: 1.3; } +.mt-linea { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--oro); display: none; pointer-events: none; } +.mt-tempo { position: absolute; left: 0; right: 0; bottom: 0; padding: 1px 4px; background: rgba(0, 0, 0, 0.7); color: #fff; font: 700 9.5px var(--ui); display: none; text-align: right; } +.mt-thumb.scorre .mt-linea, .mt-thumb.scorre .mt-tempo { display: block; } +.mt-thumb.scorre .mt-tipo { display: none; } +.mt-guarda { position: absolute; right: 3px; bottom: 3px; width: 20px; height: 20px; display: grid; place-items: center; border-radius: 50%; background: rgba(0, 0, 0, 0.72); font-size: 11px; font-style: normal; line-height: 1; cursor: pointer; opacity: 0; transition: opacity 0.12s; } +.mt-thumb:hover .mt-guarda { opacity: 1; } +.mt-guarda:hover { background: var(--oro); } .mt-spunta { position: absolute; right: 3px; top: 3px; width: 16px; height: 16px; display: none; place-items: center; border-radius: 50%; background: var(--oro); color: #1a1206; font: 900 11px var(--ui); line-height: 1; } .mt-thumb.scelta .mt-spunta { display: grid; } .mt-vuoto { grid-column: 1 / -1; text-align: center; color: var(--testo3); padding: 24px 8px; border: 2px dashed #000; border-radius: 8px; } @@ -1045,3 +1056,44 @@ body.pip-live { margin: 0; background: #111016; overflow: hidden; } .mt-riepilogo b { color: var(--testo); font-size: 13px; } .mt-lavoro { min-height: 0; } @media (max-width: 900px) { .mt-dentro { grid-template-columns: 1fr; overflow: auto; } .mt-col { overflow: visible; } } + +/* Finale: le opzioni che quasi nessuno cambia stanno in un riquadro chiuso */ +.fin-avanzate { border: 1px solid #000; border-radius: 8px; background: #17161d; padding: 0 8px; margin: 4px 0; } +.fin-avanzate > summary { padding: 6px 2px; font: 700 11px var(--ui); color: var(--testo3); cursor: pointer; list-style: none; } +.fin-avanzate > summary::before { content: '▸ '; } +.fin-avanzate[open] > summary::before { content: '▾ '; } +.fin-avanzate > :not(summary) { margin-bottom: 8px; } + +/* ——— la pagina iniziale (i progetti) ——— */ +.btn-progetti { 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); } +.btn-progetti:hover { color: var(--oro); } +.stretto .btn-progetti span { display: none; } +.home { position: fixed; inset: 0; z-index: 400; background: rgba(8, 7, 12, 0.94); backdrop-filter: blur(6px); display: grid; place-items: center; overflow: auto; } +.home-dentro { width: min(1180px, 96vw); max-height: 94vh; display: flex; flex-direction: column; gap: 14px; padding: 16px; } +.home-testa { display: flex; align-items: center; gap: 12px; } +.home-testa .moneta { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 50%; border: 3px solid var(--oro); font: 900 20px var(--marchio); color: var(--oro); } +.home-testa b { font: 900 22px var(--marchio); letter-spacing: 1px; color: var(--testo); display: block; } +.home-testa small { color: var(--testo3); font: 700 12px var(--ui); } +.home-chiudi { margin-left: auto; } +.home-corpo { display: grid; grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); gap: 16px; min-height: 0; } +.home-col { display: flex; flex-direction: column; gap: 10px; padding: 14px; border-radius: 12px; background: #1b1a21; box-shadow: inset 0 0 0 1px #000; min-height: 0; } +.home-col h3 { margin: 0; font: 800 13px var(--ui); letter-spacing: 1.5px; text-transform: uppercase; color: var(--oro); } +.home-grande { padding: 12px 14px; font-size: 14px; text-align: left; } +.home-montage { background: linear-gradient(180deg, #ff8ff8, #d42ccc) !important; color: #fff !important; border-color: #5b0f58 !important; } +.home-continua { display: flex; flex-direction: column; gap: 5px; padding: 10px 12px; border-radius: 10px; background: #26301f; box-shadow: inset 0 0 0 1px rgba(93, 211, 158, 0.4); } +.home-continua b { font: 800 12px var(--ui); letter-spacing: 1px; text-transform: uppercase; color: #5dd39e; } +.home-continua span { color: var(--testo2); font: 700 12px var(--ui); } +.home-recenti { overflow: auto; } +.home-griglia { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; align-content: start; } +.home-carta { position: relative; border-radius: 10px; overflow: hidden; background: #23212b; box-shadow: inset 0 0 0 1px #000; cursor: pointer; display: flex; flex-direction: column; transition: transform 0.12s, box-shadow 0.12s; } +.home-carta:hover { transform: translateY(-2px); box-shadow: 0 0 0 2px var(--oro), 0 8px 20px rgba(0, 0, 0, 0.5); } +.home-carta img, .home-vuota { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; background: #111; } +.home-vuota { display: grid; place-items: center; color: var(--testo3); } +.home-info { padding: 7px 9px 9px; display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.home-info b { font: 800 13px var(--ui); color: var(--testo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.home-info small { color: var(--testo3); font: 600 10.5px var(--ui); } +.home-togli { position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, 0.7); opacity: 0; transition: opacity 0.12s; display: grid; place-items: center; } +.home-carta:hover .home-togli { opacity: 1; } +.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; } } diff --git a/src/ui/app.ts b/src/ui/app.ts index 2abaa1c..25829c5 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -10,6 +10,7 @@ import { PannelloMonitor } from './monitor'; import { Finale } from './finale'; import { Live } from './live'; import { Montage } from './montage'; +import { Home } from './home'; import { Contenitore } from './contenitore'; import { Pulsantiera } from './pulsantiera'; import { Ispettore } from './ispettore'; @@ -82,6 +83,8 @@ export function avvia(radice: HTMLElement) { if (pg === 'finale' && radice.classList.contains('stretto')) radice.dataset.foglio = 'finale'; setTimeout(() => { monitor.adatta(); tl.adattaTutto(); }, 80); }; + const home = new Home(() => pagina('montage')); + document.addEventListener('dpv:home', () => home.mostra()); const tastoPagina = (pg: Pagina, nome: string, ic: string, title: string) => h('button', { class: 'pagina-btn' + (pg === 'montaggio' ? ' attiva' : ''), 'data-p': pg, title, on: { click: () => pagina(pg) } }, icona(ic, 16), h('span', null, nome)); // ——— menu ——— @@ -91,6 +94,8 @@ export function avvia(radice: HTMLElement) { }; const menus: [string, () => VoceMenu[]][] = [ ['File', () => [ + { nome: 'Pagina iniziale (progetti)', fn: () => home.mostra() }, + { sep: true }, { nome: 'Nuovo progetto', tasto: 'Ctrl+N', sotto: FORMATI.map((f) => ({ nome: f.nome, fn: () => nuovo(f) })) }, { nome: 'Apri progetto…', tasto: 'Ctrl+O', fn: () => apri() }, { nome: 'Salva', tasto: 'Ctrl+S', fn: () => salva() }, @@ -195,6 +200,7 @@ export function avvia(radice: HTMLElement) { const testa = h('header', { class: 'testata' }, h('button', { class: 'marchio', title: 'DaProd Video', on: { click: () => finestraInfo() } }, 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('div', { class: 'pagine' }, tastoPagina('montaggio', 'MONTAGGIO', 'montaggio', 'Il banco di montaggio (F9)'), @@ -240,7 +246,7 @@ export function avvia(radice: HTMLElement) { // col pannello chiuso resta una linguetta sul bordo destro per riaprirlo const linguetta = h('button', { class: 'linguetta-lato', title: 'Riapri il pannello delle proprietà', on: { click: () => { radice.classList.remove('senza-lato'); salvaBanco(); setTimeout(() => monitor.adatta(), 50); } } }, '‹ Proprietà'); radice.dataset.pagina = 'montaggio'; - radice.append(testa, bin.el, bordoBin, monitor.el, bordoLato, pannelloLato, finale.el, live.el, montage.el, puls.el, divisore, tl.el, statoBar, barraTel, linguetta); + radice.append(testa, bin.el, bordoBin, monitor.el, bordoLato, pannelloLato, finale.el, live.el, montage.el, home.el, puls.el, divisore, tl.el, statoBar, barraTel, linguetta); const VARI = ['--alto', '--bin', '--lato', '--fin', '--tlfin']; const salvaBanco = () => { @@ -371,6 +377,8 @@ export function avvia(radice: HTMLElement) { void daFile.then(async (f) => { if (f) { await apriFile({ path: f }); setTimeout(() => tl.adattaTutto(), 200); return; } if (await riprendi()) setTimeout(() => tl.adattaTutto(), 200); + // all'apertura si parte dai progetti (non nelle prove automatiche: lì il banco deve essere subito libero) + if (!navigator.webdriver) home.mostra(); }); // sul Mac, doppio clic su un progetto con l'app già aperta if (isTauri) void import('@tauri-apps/api/event').then(({ listen }) => listen('apri-file', (e) => { void invoke('file_di_avvio'); void apriFile({ path: e.payload }); })).catch(() => {}); diff --git a/src/ui/contenitore.ts b/src/ui/contenitore.ts index 2422239..03b1937 100644 --- a/src/ui/contenitore.ts +++ b/src/ui/contenitore.ts @@ -96,12 +96,17 @@ export class Contenitore { private scorre: { m: MediaItem; cv: HTMLCanvasElement; t: number; linea: HTMLElement } | null = null; private carteEffetti: { el: HTMLElement; e: Effetto }[] = []; + /** i file scelti nel contenitore (Ctrl+clic ne aggiunge, Maiusc+clic prende un tratto) */ + private scelti = new Set(); + private ultimoScelto: string | null = null; + constructor() { const cerca = h('input', { class: 'bin-cerca', type: 'search', placeholder: 'Cerca…' }) as HTMLInputElement; cerca.addEventListener('input', () => { this.filtro = cerca.value.toLowerCase(); this.firma = ''; this.disegnaDestra(); }); this.albero = h('nav', { class: 'bin-albero' }); this.barra = h('div', { class: 'bin-barra' }); this.corpo = h('div', { class: 'bin-pagina' }); + this.corpo.addEventListener('click', (e) => { if (e.target === this.corpo || (e.target as HTMLElement).classList.contains('bin-griglia')) { this.scelti.clear(); this.ultimoScelto = null; this.mostraScelti(); } }); const grand = h('button', { class: 'btn-icona piccolo', title: 'Schede piccole, medie o grandi', on: { click: () => { const i = GRANDEZZE.findIndex((g) => g.id === this.grandezza); this.grandezza = GRANDEZZE[(i + 1) % GRANDEZZE.length].id; scrivi('dpv-bin-grandezza', this.grandezza); this.applicaGrandezza(); } }, @@ -132,7 +137,7 @@ export class Contenitore { const v = this.voceSotto(x, y); if (!v || !dato.startsWith('m:')) return; const n = v.dataset.c!; - this.sposta(dato.slice(2), n.startsWith('dir:') ? n.slice(4) : undefined); + for (const id of dato.slice(2).split('|')) this.sposta(id, n.startsWith('dir:') ? n.slice(4) : undefined); }, esci: () => this.albero.querySelectorAll('.sopra').forEach((e) => e.classList.remove('sopra')), }); @@ -464,13 +469,20 @@ export class Contenitore { } const apri = () => { motore.caricaPlayer(m.id); motore.setMonitor('player'); }; const el = h('div', { - class: 'carta' + (ok ? '' : ' offline'), 'data-id': m.id, title: `${m.name}\n${m.container} · ${m.vcodec || ''} ${m.acodec || ''}\nDoppio clic: apri nel monitor · trascina: nella timeline o in una cartella · +: al cursore`, + class: 'carta' + (ok ? '' : ' offline') + (usi ? ' in-timeline' : '') + (this.scelti.has(m.id) ? ' scelta' : ''), 'data-id': m.id, title: `${m.name}\n${m.container} · ${m.vcodec || ''} ${m.acodec || ''}\nDoppio clic: apri nel monitor · trascina: nella timeline o in una cartella · +: al cursore`, on: { dblclick: apri, - click: () => { if (matchMedia('(pointer: coarse)').matches) apri(); }, + click: (e: MouseEvent) => { if (matchMedia('(pointer: coarse)').matches) apri(); else this.cliccaCarta(m.id, e); }, contextmenu: (e: MouseEvent) => { e.preventDefault(); + if (!this.scelti.has(m.id)) { this.scelti = new Set([m.id]); this.ultimoScelto = m.id; this.mostraScelti(); } menuContesto(e.clientX, e.clientY, [ + ...(this.scelti.size > 1 ? [ + { nome: `Metti i ${this.scelti.size} file scelti in coda al cursore`, fn: () => { motore.setMonitor('recorder'); for (const id of this.sceltiInOrdine()) { const mm = store.doc.media.find((z) => z.id === id); if (mm) mettiAlCursore(mm); } } }, + { nome: 'Sposta i file scelti in una cartella', sotto: this.menuCartelleMolti() }, + { nome: 'Togli i file scelti dal contenitore', fn: () => { for (const id of this.sceltiInOrdine()) togliMedia(id); this.scelti.clear(); } }, + { sep: true }, + ] as VoceMenu[] : []), { nome: 'Apri nel monitor (attacco e stacco)', fn: apri }, { nome: 'Metti al cursore', fn: () => mettiAlCursore(m) }, { nome: 'Metti in coda al montaggio', fn: () => { motore.setMonitor('recorder'); store.setHead(projectEnd(store.doc)); mettiAlCursore(m); } }, @@ -489,10 +501,56 @@ export class Contenitore { h('div', { class: 'carta-info' }, ok ? (m.type === 'audio' ? `${m.acodec.toUpperCase()} · ${m.sampleRate / 1000} kHz` : `${m.width}×${m.height}${m.fps ? ' · ' + Math.round(m.fps * 100) / 100 + ' fps' : ''}${m.hasAudio && m.type === 'video' ? ' · audio' : ''}`) : (rt?.stato === 'caricamento' ? 'apro…' : 'OFFLINE · da ricollegare'))); - trascinabile(el, () => 'm:' + m.id, () => (m.type === 'audio' ? '🎵 ' : m.type === 'image' ? '🖼 ' : '🎞 ') + m.name); + // trascinando un file scelto si portano dietro tutti i file scelti; trascinandone uno non scelto, resta solo quello + trascinabile(el, () => this.datoTrascina(m.id), () => (this.scelti.size > 1 && this.scelti.has(m.id) ? `🎞 ${this.scelti.size} file` : (m.type === 'audio' ? '🎵 ' : m.type === 'image' ? '🖼 ' : '🎞 ') + m.name)); return el; } + /** i file scelti, nell'ordine in cui si vedono */ + private sceltiInOrdine(): string[] { + const visti = [...this.corpo.querySelectorAll('.carta[data-id]')].map((e) => e.dataset.id!); + const dentro = visti.filter((id) => this.scelti.has(id)); + const fuori = [...this.scelti].filter((id) => !visti.includes(id) && store.doc.media.some((m) => m.id === id)); + return [...dentro, ...fuori]; + } + + private datoTrascina(id: string): string { + if (!this.scelti.has(id)) { this.scelti = new Set([id]); this.ultimoScelto = id; this.mostraScelti(); } + return 'm:' + this.sceltiInOrdine().join('|'); + } + + private cliccaCarta(id: string, e: MouseEvent) { + const visti = [...this.corpo.querySelectorAll('.carta[data-id]')].map((x) => x.dataset.id!); + if (e.shiftKey && this.ultimoScelto && visti.includes(this.ultimoScelto)) { + const a = visti.indexOf(this.ultimoScelto), b = visti.indexOf(id); + const tratto = visti.slice(Math.min(a, b), Math.max(a, b) + 1); + this.scelti = e.ctrlKey || e.metaKey ? new Set([...this.scelti, ...tratto]) : new Set(tratto); + } else if (e.ctrlKey || e.metaKey) { + if (this.scelti.has(id)) this.scelti.delete(id); else this.scelti.add(id); + this.ultimoScelto = id; + } else { + this.scelti = new Set([id]); + this.ultimoScelto = id; + } + this.mostraScelti(); + } + + /** mette (o toglie) il bordo dei file scelti, senza ridisegnare tutta la griglia */ + private mostraScelti() { + for (const e of this.corpo.querySelectorAll('.carta[data-id]')) e.classList.toggle('scelta', this.scelti.has(e.dataset.id!)); + this.barra.querySelector('.bin-scelti')?.remove(); + if (this.scelti.size > 1) this.barra.append(h('span', { class: 'bin-scelti', title: 'Trascinali nella timeline: vanno uno dopo l\'altro' }, `${this.scelti.size} scelti`)); + } + + private menuCartelleMolti(): VoceMenu[] { + const voci: VoceMenu[] = [{ nome: 'Fuori dalle cartelle', fn: () => { for (const id of this.sceltiInOrdine()) this.sposta(id, undefined); } }]; + const scendi = (padre: string | undefined, rientro: string) => { + for (const c of figlieDi(padre)) { voci.push({ nome: rientro + '📁 ' + c.nome, fn: () => { for (const id of this.sceltiInOrdine()) this.sposta(id, c.id); } }); scendi(c.id, rientro + ' '); } + }; + scendi(undefined, ''); + return voci; + } + // ——— la libreria: schede piccole, solo il nome; al passaggio del mouse il provino vero ——— /** la scheda di un pezzo della libreria: anteprima ferma, e al passaggio il provino col fotogramma al cursore */ private cartaLibreria(o: { cls: string; nome: string; titolo: string; anteprima: HTMLElement; provino?: Costruttore; dato?: string; etichetta?: string; clic: () => void; attr?: Record }): HTMLElement { diff --git a/src/ui/finale.ts b/src/ui/finale.ts index b9a2b4d..f478bb0 100644 --- a/src/ui/finale.ts +++ b/src/ui/finale.ts @@ -8,7 +8,8 @@ import * as M from '../core/montaggio'; import { nuovoBlocco, posaBlocco } from '../core/blocchi'; import { LINGUE, SOTTO0, bordoRiga, creaSrt, dividiRiga, leggiSrt, righeDaiDialoghi, sottotitoliDi, unisciRighe } from '../core/sottotitoli'; import { MODELLI, fermaVoce, sottotitoliAI, type OpzioniVoce } from '../media/voce'; -import { LINGUE_MOTORE, LINGUE_VOCE, VOCI_MAGPIE, motoreNemo } from '../media/nemo'; +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 { codificaWav } from '../media/wav'; import { salvaMediaSulDisco, salvaTesto } from '../platform'; @@ -54,7 +55,7 @@ const SEZIONI: { id: Sezione; nome: string; icona: string; info: string }[] = [ { id: 'sottotitoli', nome: 'Sottotitoli', icona: 'sottotitoli', info: 'righe, tempi dai dialoghi, .srt' }, { id: 'logo', nome: 'Logo', icona: 'logo', info: 'il logo sempre in vista' }, { id: 'apertura', nome: 'Apertura', icona: 'apertura', info: 'clip, titoli e nero all\'inizio e alla fine' }, - { id: 'lingue', nome: 'Lingue e AI', icona: 'lingua', info: 'sottotitoli scritti dall\'AI, traduzione in inglese' }, + { id: 'lingue', nome: 'Voce e lingue', icona: 'lingua', info: 'traduci i sottotitoli e falli parlare, anche in un\'altra lingua' }, { id: 'esporta', nome: 'Esporta', icona: 'esporta', info: 'il master, la EDL, il fotogramma' }, ]; @@ -158,7 +159,7 @@ export class Finale { if (z === 'sottotitoli') return pagina(...this.paginaSottotitoli(sez)); if (z === 'logo') return pagina(this.paginaLogo(sez)); if (z === 'apertura') return pagina(...this.paginaApertura(sez)); - if (z === 'lingue') return pagina(this.paginaLingue(sez)); + if (z === 'lingue') return pagina(...this.paginaLingue(sez)); const esporta = h('div', { class: 'fin-esporta' }, this.scelte('Formato', [['mp4', 'MP4'], ['mov', 'MOV'], ['webm', 'WebM'], ['wav', 'Solo audio']], () => this.formato, (v) => { this.formato = v; }), this.scelte('Qualità', [['media', 'Leggera'], ['alta', 'Alta'], ['altissima', 'Master']], () => this.qualita, (v) => { this.qualita = v; }), @@ -312,14 +313,13 @@ export class Finale { ? 'Ascolta la presa diretta del montaggio (non la musica) e scrive le righe coi loro tempi. Il motore e il modello di NVIDIA (Nemotron 3.5) si scaricano una volta sola e poi restano sul computer; l\'audio non esce mai dal computer. Le righe si correggono qui sotto; Ctrl+Z torna a prima.' : 'Ascolta la presa diretta del montaggio (non la musica) e scrive le righe coi loro tempi. Il modello è Whisper (Hugging Face): si scarica una volta sola, poi resta sul computer. L\'audio non esce mai dal computer. Le righe si correggono qui sotto; Ctrl+Z torna a prima.'; }); + // le scelte che quasi nessuno cambia stanno in un riquadro chiuso: motore, modello, inglese diretto + const avanzate = h('details', { class: 'fin-avanzate' }, h('summary', null, 'Opzioni: chi ascolta e il modello'), motore, infoMotore, traduci, modello); const out = [ - motore, - infoMotore, scelta('Si parla in', LINGUE_MOTORE.map(([v, , t]) => [v, t] as [string, string]), () => this.ai.lingua, (v) => { this.ai.lingua = v; }), - traduci, - modello, h('div', { class: 'isp-pulsanti' }, vai, ferma), ...lavoro.elementi, + avanzate, nota, ]; salva(); @@ -389,12 +389,19 @@ export class Finale { silenzia.addEventListener('change', () => { this.voce.silenzia = silenzia.checked; salva(); }); sinc.push(() => { silenzia.checked = this.voce.silenzia; }); const linguaSott = () => sottotitoliDi(store.doc).lingua; - const scelte = scelta('Lingua', [['', 'Quella dei sottotitoli', 'La voce parla nella lingua in cui sono scritti i sottotitoli'], ...LINGUE_MOTORE.filter(([v]) => LINGUE_VOCE.includes(v)).map(([v, , t]) => [v, t] as [string, string])], () => this.voce.lingua, (v) => { this.voce.lingua = v; }); + const scelte = scelta('La voce parla', [['', 'Come i sottotitoli', 'Nella lingua in cui sono scritti i sottotitoli'], ...LINGUE_PARLATE.map(([v, , t]) => [v, t, 'Se i sottotitoli sono in un\'altra lingua, prima si traducono (solo per la voce)'] as [string, string, string])], () => this.voce.lingua, (v) => { this.voce.lingua = v; }); const nota = h('p', { class: 'nota' }); + const nomeLingua = (l: string) => LINGUE.find(([v]) => v === l)?.[1] ?? l; sinc.push(() => { - nota.textContent = motoreNemo() - ? `Legge i sottotitoli (${LINGUE.find(([v]) => v === linguaSott())?.[1] ?? linguaSott()}) con una voce sintetica di NVIDIA (Magpie TTS): così cambi la voce di un parlato, o lo fai dire in un'altra lingua se i sottotitoli sono tradotti. Ogni frase va al suo posto (se è più lunga si accelera un po'). Il motore e il modello si scaricano una volta sola. Prima correggi le righe, poi premi il tasto; Ctrl+Z torna a prima.` - : 'La voce AI usa il motore di NVIDIA, che gira nell\'app per Windows e Mac (sul processore, o meglio sulla scheda NVIDIA). Nel browser non c\'è.'; + const lS = linguaSott(), lV = this.voce.lingua || lS; + const diversa = !!lV && lV !== lS; + nota.textContent = !motoreNemo() + ? 'La voce AI usa il motore di NVIDIA, che gira nell\'app per Windows e Mac (sul processore, o meglio sulla scheda NVIDIA). Nel browser non c\'è.' + : !LINGUE_VOCE.includes(lV) + ? `La voce sa parlare italiano, inglese, spagnolo, francese, tedesco e cinese: scegli in che lingua deve parlare (i sottotitoli sono in ${nomeLingua(lS)}).` + : diversa + ? `I sottotitoli sono in ${nomeLingua(lS)} e la voce parla ${nomeLingua(lV)}: prima li traduco (solo per la voce, i sottotitoli restano come sono), poi li leggo. Ogni frase va al suo posto, e diventa una clip che puoi spostare o abbassare.` + : `La voce legge i sottotitoli (${nomeLingua(lS)}) con una voce sintetica di NVIDIA (Magpie TTS). Ogni frase va al suo posto, e diventa una clip che puoi spostare o abbassare.`; vai.disabled = !motoreNemo() || !!this.lavoroVoce; }); this.aggiornaAI.push(() => sinc.forEach((f) => f())); @@ -415,14 +422,14 @@ export class Finale { const p = store.doc; if (!sottotitoliDi(p).righe.some((x) => x.testo.trim())) { avviso('Prima servono i sottotitoli: scrivili qui, o fatti aiutare dall\'AI', 'info', 3000); return; } const lingua = linguaVoce(p, this.voce.lingua || undefined); - if (!lingua) { avviso('Magpie parla italiano, inglese, spagnolo, francese e tedesco: scegli la lingua', 'info', 3200); return; } + if (!lingua) { avviso('La voce parla italiano, inglese, spagnolo, francese, tedesco e cinese: scegli in che lingua', 'info', 3200); return; } const ctrl = new AbortController(); this.lavoroVoce = ctrl; vai.disabled = true; ferma.style.display = ''; lavoro.avvia('Comincio…'); try { - const r = await doppia(structuredClone(p), { voce: this.voce.voce, lingua, silenzia: this.voce.silenzia }, (t, k) => lavoro.imposta(t, k), ctrl.signal); + const r = await doppia(structuredClone(p), { voce: this.voce.voce, lingua, silenzia: this.voce.silenzia, traduci: true }, (t, k) => lavoro.imposta(t, k), ctrl.signal); lavoro.imposta('Salvo l\'audio…', 0.97); const nome = `Voce AI ${VOCI_MAGPIE[this.voce.voce] ?? ''} ${new Date().toISOString().slice(0, 16).replace(/[T:]/g, '.')}.wav`.replace(' ', ' '); const blob = new Blob([codificaWav(r.audio, r.sr) as BlobPart], { type: 'audio/wav' }); @@ -430,15 +437,16 @@ export class Finale { const file = new File([blob], nome, { type: 'audio/wav' }); const [m] = await importaFile([{ name: nome, path, file: path ? undefined : file }], { chiediFormato: false }); if (!m) throw new Error('non riesco a leggere l\'audio prodotto'); - const ids = store.edit('Voce AI', (pp) => posaVoce(pp, m.id, r.audio.length / r.sr, this.voce.silenzia)); + const ids = store.edit('Voce AI', (pp) => posaVoce(pp, m.id, r.audio.length / r.sr, this.voce.silenzia, r.posti)); store.select(ids); - lavoro.fine(`Fatto: ${r.frasi} frasi, su una traccia nuova "Voce AI"`); - avviso(`🗣 Voce AI pronta: ${r.frasi} frasi su una traccia nuova${this.voce.silenzia ? ' (le voci originali sono in silenzio)' : ''}`, 'ok', 3600); + lavoro.fine(`Fatto: ${r.frasi} frasi${r.tradotto ? ' tradotte' : ''}, ognuna è una clip nella traccia "Voce AI"`); + avviso(`🗣 Voce AI pronta: ${r.frasi} frasi${r.tradotto ? ' (tradotte)' : ''}, una clip per frase${this.voce.silenzia ? ' · le voci originali sono in silenzio' : ''}. Clicca una riga dei sottotitoli per sceglierne la voce`, 'ok', 4800); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (msg === 'fermato') lavoro.ferma('Fermato'); else if (msg === 'senza-righe') lavoro.ferma('Non ci sono righe da leggere'); 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); @@ -596,19 +604,74 @@ export class Finale { avviso(dove === 'inizio' ? 'Il montaggio entra dal nero (e l\'audio sale)' : 'Il montaggio si chiude nel nero (e l\'audio scende)', 'ok'); } - // ——— lingue e AI (predisposto) ——— - private paginaLingue(sez: (t: string, ic: string, ...f: (HTMLElement | null)[]) => HTMLElement): HTMLElement { + // ——— voce e lingue ——— + private trad = (() => { + const base = { a: 'en' }; + try { return { ...base, ...(JSON.parse(localStorage.getItem('dpv-trad') ?? '{}') as Partial) }; } catch { return base; } + })(); + private lavoroTrad: AbortController | null = null; + + /** due passi: 1 i sottotitoli (in che lingua sono, e tradurli), 2 la voce (in che lingua parla: se è un'altra, si traduce da sola) */ + private paginaLingue(sez: (t: string, ic: string, ...f: (HTMLElement | null)[]) => HTMLElement): HTMLElement[] { const lingua = h('select', { class: 'mini-select largo' }, LINGUE.map(([v, t]) => h('option', { value: v }, t))) as HTMLSelectElement; lingua.addEventListener('change', () => this.cambiaSott('Lingua dei sottotitoli', (x) => { x.lingua = lingua.value; })); - this.campi.push({ aggiorna: () => { lingua.value = sottotitoliDi(store.doc).lingua; } }); - const vai = (nome: string, info: string, fn: () => void) => h('button', { class: 'fin-presto pronto', title: info, on: { click: fn } }, h('span', null, nome), h('i', null, 'VAI')); - return sez('Lingue e AI', 'lingua', - h('div', { class: 'fin-scelte' }, h('span', null, 'Lingua dei sottotitoli'), lingua), - vai('✨ Scrivi i sottotitoli con l\'AI', 'Nemotron (NVIDIA, nell\'app) o Whisper ascoltano i dialoghi e scrivono le righe coi tempi', () => { this.ai.traduci = false; this.mostra('sottotitoli'); }), - vai('🌍 Sottotitoli in inglese da un parlato italiano', 'Whisper ascolta l\'italiano e scrive direttamente in inglese', () => { this.ai.lingua = 'it'; this.ai.traduci = true; this.ai.motore = 'whisper'; this.mostra('sottotitoli'); this.aggiornaAI.forEach((f) => f()); }), - h('div', { class: 'fin-sotto' }, h('b', null, '🗣 Voce AI · cambia voce o lingua')), - ...this.pannelloVoce(), - h('p', { class: 'nota' }, 'I sottotitoli li scrive Nemotron 3.5 di NVIDIA (nell\'app) o Whisper di OpenAI (anche nel browser): girano sul tuo computer, l\'audio non va da nessuna parte e i modelli si scaricano una volta sola. La lingua qui sopra finisce nel nome del file .srt e dice alla voce AI in che lingua parlare.')); + const aTrad = h('select', { class: 'mini-select largo' }, LINGUE_TRADUZIONE.map(([v, , t]) => h('option', { value: v }, t))) as HTMLSelectElement; + 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(); + 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 nota = h('p', { class: 'nota' }); + this.campi.push({ aggiorna: () => { + const s = sottotitoliDi(store.doc); + if (document.activeElement !== lingua) lingua.value = s.lingua; + aTrad.value = this.trad.a; + const nome = (l: string) => LINGUE.find(([v]) => v === l)?.[1] ?? l; + const stessa = s.lingua === this.trad.a; + vai.disabled = !!this.lavoroTrad || !s.righe.some((x) => x.testo.trim()) || stessa || !sappiamoTradurre(s.lingua, this.trad.a); + nota.textContent = !s.righe.some((x) => x.testo.trim()) ? 'Prima servono i sottotitoli: scrivili nella pagina Sottotitoli, o fatti aiutare dall\'AI.' + : stessa ? 'I sottotitoli sono già in questa lingua.' + : !sappiamoTradurre(s.lingua, this.trad.a) ? `Da ${nome(s.lingua)} a ${nome(this.trad.a)} non so tradurre.` + : `Le righe restano ai loro tempi, cambia solo il testo (da ${nome(s.lingua)} a ${nome(this.trad.a)}). Si annulla con Ctrl+Z. Il modello di traduzione (NLLB di Meta) si scarica la prima volta e poi resta sul computer.`; + } }); + return [ + sez('1 · Sottotitoli', 'sottotitoli', + h('div', { class: 'fin-scelte' }, h('span', null, 'Sono scritti in'), lingua), + h('div', { class: 'fin-scelte' }, h('span', null, 'Traduci in'), aTrad), + h('div', { class: 'isp-pulsanti' }, vai, ferma), + ...lavoro.elementi, + nota), + sez('2 · Voce', 'altoparlante', ...this.pannelloVoce()), + h('p', { class: 'nota' }, 'Tutto gira sul tuo computer: i testi e l\'audio non vanno da nessuna parte e i modelli si scaricano una volta sola. I sottotitoli e la voce possono essere in lingue diverse: se la voce parla un\'altra lingua, i testi si traducono da soli prima di leggerli.'), + ]; + } + + /** traduce il testo delle righe (restano ai loro tempi) e cambia la lingua dei sottotitoli */ + private async traduciSottotitoli(lavoro: BarraLavoro, vai: HTMLButtonElement, ferma: HTMLElement) { + if (this.lavoroTrad) return; + const s = sottotitoliDi(store.doc); + const da = s.lingua, a = this.trad.a; + const righe = s.righe.map((x) => ({ id: x.id, testo: x.testo })); + if (!righe.some((x) => x.testo.trim())) { avviso('Non ci sono righe da tradurre', 'info'); return; } + const ctrl = new AbortController(); + this.lavoroTrad = ctrl; + vai.disabled = true; + ferma.style.display = ''; + lavoro.avvia('Comincio…'); + try { + const nuovi = await traduciTesti(righe.map((x) => x.testo), da, a, (t, k) => lavoro.imposta(t, k), ctrl.signal); + const per = new Map(righe.map((x, i) => [x.id, nuovi[i]])); + this.cambiaSott('Traduci i sottotitoli', (x) => { for (const r of x.righe) if (per.has(r.id)) r.testo = per.get(r.id)!; x.lingua = a; }); + lavoro.fine(`Fatto: ${righe.length} righe tradotte`); + avviso(`🌍 Sottotitoli tradotti (${LINGUE.find(([v]) => v === a)?.[1] ?? a}). Ctrl+Z per tornare indietro`, 'ok', 3200); + } 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); } + } finally { + this.lavoroTrad = null; + ferma.style.display = 'none'; + this.campi.forEach((c) => c.aggiorna()); + } } /** porta tutte le clip audio a un livello comodo (come l'effetto "Livella" clip per clip) */ diff --git a/src/ui/home.ts b/src/ui/home.ts new file mode 100644 index 0000000..6666d0c --- /dev/null +++ b/src/ui/home.ts @@ -0,0 +1,85 @@ +// La pagina iniziale: i progetti. Da qui si ricomincia, si apre un progetto (uno dei recenti o uno da file), si va +// al montaggio automatico o si torna al montaggio in corso. Compare quando si apre il programma (se non si è +// aperto un file col doppio clic) e quando si preme "Progetti" in alto o File → Pagina iniziale. +import { store } from '../core/store'; +import { FORMATI } from '../core/tipi'; +import { apri, apriRecente, dimenticaRecente, nuovo, recenti, type Recente } from '../progetti'; +import { h, icona } from './dom'; + +const FORMATI_RAPIDI = ['hd25', 'hd30', 'hd24', 'vert25', 'quad25', 'uhd25']; + +const quando = (t: number) => { + const d = Date.now() - t; + if (d < 60_000) return 'adesso'; + if (d < 3_600_000) return `${Math.floor(d / 60_000)} min fa`; + if (d < 86_400_000) return `${Math.floor(d / 3_600_000)} ore fa`; + const g = Math.floor(d / 86_400_000); + return g === 1 ? 'ieri' : g < 30 ? `${g} giorni fa` : new Date(t).toLocaleDateString('it-IT'); +}; +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 { + el: HTMLElement; + private corpo: HTMLElement; + private formato = 'hd25'; + visibile = false; + + constructor(private vaiAMontage: () => void) { + this.corpo = h('div', { class: 'home-corpo' }); + this.el = h('div', { class: 'home', style: 'display:none' }, + h('div', { class: 'home-dentro' }, + h('header', { class: 'home-testa' }, + h('span', { class: 'moneta' }, 'D'), + 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); + } + + mostra() { + this.disegna(); + this.el.style.display = ''; + this.visibile = true; + } + + nascondi() { + this.el.style.display = 'none'; + this.visibile = false; + } + + private disegna() { + const p = store.doc; + const inCorso = p.clips.length || p.media.length; + // 1 · ricominciare + const scelti = FORMATI.filter((f) => FORMATI_RAPIDI.includes(f.id)); + const chipFormati = h('div', { class: 'isp-chips' }, scelti.map((f) => h('button', { + class: 'chip' + (f.id === this.formato ? ' acceso' : ''), 'data-f': f.id, title: f.nome, + on: { click: (e: MouseEvent) => { this.formato = f.id; (e.currentTarget as HTMLElement).parentElement!.querySelectorAll('.chip').forEach((c) => c.classList.toggle('acceso', (c as HTMLElement).dataset.f === f.id)); } }, + }, f.nome.replace(' · ', ' ').replace('Verticale 1080×1920', 'Verticale').replace('Quadrato 1080×1080', 'Quadrato').replace('UHD 4K', '4K')))); + const nuovoB = h('button', { class: 'btn primario home-grande', on: { click: async () => { const f = FORMATI.find((x) => x.id === this.formato) ?? FORMATI[0]; await nuovo(f); if (!(store.dirty && store.doc.clips.length)) this.nascondi(); } } }, '+ Nuovo progetto'); + const apriB = h('button', { class: 'btn home-grande', on: { click: async () => { this.nascondi(); await apri(); } } }, icona('apri', 16), ' Apri un progetto…'); + const mont = h('button', { class: 'btn home-grande home-montage', title: 'Butta dentro le foto e il programma monta da solo', on: { click: () => { this.nascondi(); this.vaiAMontage(); } } }, '✨ DaProdMontage · il montaggio automatico'); + const demo = h('button', { class: 'btn-mini', on: { click: () => { this.nascondi(); document.dispatchEvent(new CustomEvent('dpv:demo')); } } }, 'Prova con il montaggio dimostrativo'); + + const sinistra = h('section', { class: 'home-col' }, + inCorso ? h('div', { class: 'home-continua' }, + h('b', null, 'Montaggio in corso'), + 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); + + // 2 · i recenti + const lista = recenti(); + const carta = (r: Recente) => h('div', { class: 'home-carta', 'data-chiave': r.chiave, title: r.path ?? r.nome, on: { click: async () => { if (await apriRecente(r)) this.nascondi(); } } }, + r.miniatura ? h('img', { src: r.miniatura, alt: '' }) : h('div', { class: 'home-vuota' }, icona('montaggio', 30)), + h('div', { class: 'home-info' }, h('b', null, r.nome), h('small', null, `${r.clip} clip · ${durataUmana(r.durata)} · ${r.formato}`), h('small', null, quando(r.data))), + h('button', { class: 'home-togli btn-icona', title: 'Toglila dall\'elenco (il progetto resta dov\'è)', on: { click: (e: MouseEvent) => { e.stopPropagation(); dimenticaRecente(r.chiave); this.disegna(); } } }, icona('x', 13))); + const destra = h('section', { class: 'home-col home-recenti' }, + h('h3', null, 'Progetti recenti'), + lista.length ? h('div', { class: 'home-griglia' }, lista.map(carta)) + : h('div', { class: 'home-nulla' }, h('b', null, 'Ancora niente'), h('p', null, 'I progetti che salvi o apri compaiono qui, con la loro miniatura, pronti da riaprire con un clic.'))); + this.corpo.replaceChildren(sinistra, destra); + } +} + diff --git a/src/ui/montage.ts b/src/ui/montage.ts index a3faca9..2d19d27 100644 --- a/src/ui/montage.ts +++ b/src/ui/montage.ts @@ -8,6 +8,7 @@ import { costruisciSequenza, fmtDur, pianifica, type EntrataMontage, type Ordine import { disegnaAnimazione } from '../render/animazioni'; import { caricaFontAnimazioni } from '../render/font'; import { mediaRT } from '../media/libreria'; +import { fotogramma, lascia, quandoFotogramma } from '../media/fotogrammi'; import { pixelAl } from '../media/campiona'; import { misuraQualita } from '../media/qualita'; import { dataDiScatto } from '../media/exif'; @@ -28,6 +29,7 @@ interface Scelte { scarta: boolean; audioVideo: number; formato: Formato; + adatta: 'intero' | 'riempi'; testi: TestiMontage; musica: string; } @@ -81,6 +83,8 @@ export class Montage { private bScarta: HTMLButtonElement; private etichetta: HTMLElement; /** quello che si è già misurato (qualità, pezzo migliore, battiti): rigenerare è subito */ + /** il video su cui passa il mouse (anteprima che scorre) */ + private scorrendo: { m: MediaItem; cv: HTMLCanvasElement; t: number } | null = null; private analisi = new Map(); private ritmi = new Map(); /** finisce quando il montaggio è pronto (per le prove) */ @@ -90,7 +94,7 @@ export class Montage { const salvate = leggi(); this.s = { preset: salvate.preset && presetMontage(salvate.preset) ? salvate.preset : 'matrimonio', durata: salvate.durata ?? 120, ordine: salvate.ordine ?? 'data', - titoli: salvate.titoli ?? true, effetti: salvate.effetti ?? true, scarta: salvate.scarta ?? true, audioVideo: salvate.audioVideo ?? -14, formato: salvate.formato ?? 'auto', + titoli: salvate.titoli ?? true, effetti: salvate.effetti ?? true, scarta: salvate.scarta ?? true, audioVideo: salvate.audioVideo ?? -14, formato: salvate.formato ?? 'auto', adatta: salvate.adatta ?? 'intero', testi: salvate.testi ?? { titolo: '', sottotitolo: '', nomi: '', data: '' }, musica: '', }; this.griglia = h('div', { class: 'mt-file' }); @@ -164,7 +168,12 @@ export class Montage { h('div', { class: 'fin-sezione' }, h('h4', null, 'Formato'), h('div', { class: 'isp-chips' }, ([['auto', 'Come lo stile'], ['progetto', 'Come il progetto'], ['16:9', 'Orizzontale'], ['9:16', 'Verticale'], ['1:1', 'Quadrato']] as [Formato, string][]) .map(([f, t]) => chip(t, () => this.s.formato === f, () => { this.s.formato = f; }))), - h('p', { class: 'nota' }, 'Il formato si cambia solo se il progetto è ancora vuoto: se no resta quello che c\'è.')), + h('p', { class: 'nota' }, 'Il formato si cambia solo se il progetto è ancora vuoto: se no resta quello che c\'è.'), + h('h4', null, 'Foto e video di un altro formato'), + h('div', { class: 'isp-chips' }, + chip('Intere, con lo sfondo', () => this.s.adatta === 'intero', () => { this.s.adatta = 'intero'; }, 'Nessun pezzo tagliato: la foto sta intera su uno sfondo sfumato, con l\'ombra'), + chip('Riempi il quadro', () => this.s.adatta === 'riempi', () => { this.s.adatta = 'riempi'; }, 'A tutto quadro, senza bande: i bordi che non entrano si tagliano (mai stirate)')), + h('p', { class: 'nota' }, 'Mai deformate: o intere con lo sfondo, o a tutto quadro con i bordi tagliati.')), this.riepilogo); this.el = h('section', { class: 'montage' }, @@ -172,6 +181,7 @@ export class Montage { h('span', { class: 'live-tastiera' }, 'F8 · poi ritocchi tutto nel Montaggio')), h('div', { class: 'mt-azioni' }, this.bCrea, this.bRigenera, this.bImporta, this.bScarta, this.etichetta, h('div', { class: 'mt-lavoro' }, ...this.lavoro.elementi)), h('div', { class: 'mt-dentro' }, colFile, colFesta, colCome)); + quandoFotogramma(() => { if (this.scorrendo) this.disegnaScorre(); }); store.on('doc', () => { // un annulla (o una modifica) ha tolto l'anteprima di mezzo: i bottoni tornano a "Crea" if (this.anteprima && !this.occupato && !this.anteprimaViva()) this.anteprima = null; @@ -196,6 +206,30 @@ export class Montage { if (si) { this.rinfresca(); void caricaFontAnimazioni().then(() => { if (this.visibile) this.disegnaFeste(); }); } } + private disegnaScorre() { + const s = this.scorrendo; + if (!s) return; + const f = fotogramma('mt', s.m.id, s.t, false); + if (!f) return; + const ctx = s.cv.getContext('2d')!; + const W = s.cv.width, H = s.cv.height; + ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H); + if (f instanceof ImageBitmap) { + const k = Math.min(W / f.width, H / f.height); + ctx.drawImage(f, (W - f.width * k) / 2, (H - f.height * k) / 2, f.width * k, f.height * k); + } else { + try { f.drawWithFit(ctx, { fit: 'contain' }); } catch { /* fotogramma chiuso nel frattempo */ } + } + } + + /** il video nel monitor in alto, con i suoi comandi: si guarda e poi si decide se usarlo */ + private guardaNelMonitor(m: MediaItem) { + this.motoreFerma(); + motore.caricaPlayer(m.id); + motore.setMonitor('player'); + motore.play(1); + } + private rinfresca() { // i file nuovi si scelgono da soli (quelli che l'utente ha già tolto a mano restano tolti) for (const m of this.disponibili()) if (!this.viste.has(m.id)) { this.viste.add(m.id); this.incluse.add(m.id); } @@ -213,15 +247,44 @@ export class Montage { const lista = this.disponibili(); this.carte.clear(); this.griglia.replaceChildren(...lista.map((m) => { - const cv = h('canvas', { width: 96, height: 54 }) as HTMLCanvasElement; - const r = mediaRT(m.id); - const src = r?.poster as (CanvasImageSource & { width: number; height: number }) | undefined; - const x = cv.getContext('2d')!; - x.fillStyle = '#15141a'; x.fillRect(0, 0, 96, 54); - if (src && src.width) { const k = Math.max(96 / src.width, 54 / src.height); x.drawImage(src, (96 - src.width * k) / 2, (54 - src.height * k) / 2, src.width * k, src.height * k); } - const el = h('button', { class: 'mt-thumb' + (this.incluse.has(m.id) ? ' scelta' : ''), title: `${m.name}${m.type === 'video' ? ` · ${fmtDur(m.duration)}` : ''}\nClic: usa / non usare`, 'data-id': m.id, - on: { click: () => { if (this.incluse.has(m.id)) this.incluse.delete(m.id); else this.incluse.add(m.id); el.classList.toggle('scelta', this.incluse.has(m.id)); this.aggiorna(); } } }, - cv, m.type === 'video' ? h('i', { class: 'mt-tipo' }, '▶') : null, h('i', { class: 'mt-spunta' }, '✓')); + const cv = h('canvas', { width: 160, height: 90 }) as HTMLCanvasElement; + const disegnaPoster = () => { + const r = mediaRT(m.id); + const src = r?.poster as (CanvasImageSource & { width: number; height: number }) | undefined; + const x = cv.getContext('2d')!; + x.fillStyle = '#15141a'; x.fillRect(0, 0, 160, 90); + if (src && src.width) { const k = Math.min(160 / src.width, 90 / src.height); x.drawImage(src, (160 - src.width * k) / 2, (90 - src.height * k) / 2, src.width * k, src.height * k); } + }; + disegnaPoster(); + const linea = h('i', { class: 'mt-linea' }); + const tempo = h('i', { class: 'mt-tempo' }); + // un video si guarda prima di sceglierlo: passando col mouse scorre; la lente lo apre nel monitor in alto + const guarda = m.type === 'video' ? h('i', { class: 'mt-guarda', title: 'Guardalo nel monitor in alto (play)', on: { click: (e: MouseEvent) => { e.stopPropagation(); this.guardaNelMonitor(m); }, pointerdown: (e: PointerEvent) => e.stopPropagation() } }, '🔍') : null; + const el = h('button', { class: 'mt-thumb' + (this.incluse.has(m.id) ? ' scelta' : ''), title: `${m.name}${m.type === 'video' ? ` · ${fmtDur(m.duration)}` : ''}\nClic: usa / non usare${m.type === 'video' ? ' · passaci sopra per vederlo scorrere · 🔍 lo guardi nel monitor' : ''}`, 'data-id': m.id, + on: { + click: () => { if (this.incluse.has(m.id)) this.incluse.delete(m.id); else this.incluse.add(m.id); el.classList.toggle('scelta', this.incluse.has(m.id)); this.aggiorna(); }, + dblclick: () => { if (m.type === 'video') this.guardaNelMonitor(m); }, + } }, + cv, linea, tempo, m.type === 'video' ? h('i', { class: 'mt-tipo' }, '▶') : null, guarda, h('i', { class: 'mt-spunta' }, '✓')); + if (m.type === 'video' && mediaRT(m.id)?.stato === 'ok') { + const dur = Math.max(0.1, m.duration - (m.t0 || 0)); + el.addEventListener('pointermove', (e) => { + if (e.pointerType === 'touch') return; + const r = el.getBoundingClientRect(); + const k = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); + linea.style.left = k * 100 + '%'; + el.classList.add('scorre'); + tempo.textContent = fmtDur(k * dur) + ' / ' + fmtDur(dur); + this.scorrendo = { m, cv, t: (m.t0 || 0) + k * Math.max(0, dur - 0.04) }; + this.disegnaScorre(); + }); + el.addEventListener('pointerleave', () => { + el.classList.remove('scorre'); + if (this.scorrendo?.cv === cv) this.scorrendo = null; + lascia('mt', m.id); + disegnaPoster(); + }); + } this.carte.set(m.id, el); return el; })); @@ -279,7 +342,7 @@ export class Montage { } private opzioni(musica?: OpzMontage['musica'], seme = 12345): OpzMontage { - return { preset: this.s.preset, durata: this.s.durata, ordine: this.s.ordine, titoli: this.s.titoli, effetti: this.s.effetti, audioVideo: this.s.audioVideo, testi: this.s.testi, seme, scarta: this.s.scarta, musica }; + return { preset: this.s.preset, durata: this.s.durata, ordine: this.s.ordine, titoli: this.s.titoli, effetti: this.s.effetti, audioVideo: this.s.audioVideo, testi: this.s.testi, seme, scarta: this.s.scarta, adatta: this.s.adatta, musica }; } /** il riassunto sotto le scelte: quante foto, quanto sta ognuna (senza analisi: serve solo a dare un'idea) */ diff --git a/src/ui/timeline.ts b/src/ui/timeline.ts index 7359d3f..a9f196a 100644 --- a/src/ui/timeline.ts +++ b/src/ui/timeline.ts @@ -9,7 +9,7 @@ import { store } from '../core/store'; import { motore } from '../motore'; import * as M from '../core/montaggio'; import { CURVE, curvaFade, nomeCurva, clipById, dbToGain, end, isVideoClip, keyValue, mediaOf, newTrack, newTransition, nextTrackName, projectEnd, srcTimeAt, trackOf, ALTEZZA } from '../core/progetto'; -import type { Clip, Key, Project, Sottotitolo, Track, TrackKind } from '../core/tipi'; +import type { Clip, Key, MediaItem, Project, Sottotitolo, Track, TrackKind } from '../core/tipi'; import { SOTTO0, bordoRiga, dividiRiga, limitiRiga, unisciRighe } from '../core/sottotitoli'; import { ETICHETTE } from '../core/tipi'; import { fps, frameToTc, f2s, tcBase } from '../core/timecode'; @@ -1424,7 +1424,12 @@ export class Timeline { if (!c.sott) { motore.setMonitor('recorder'); store.setHead(Math.max(0, Math.round(c.f))); this.selSott.clear(); this.sporca(); return; } if (e.shiftKey) { if (this.selSott.has(c.sott.id)) this.selSott.delete(c.sott.id); else this.selSott.add(c.sott.id); } else if (!this.selSott.has(c.sott.id)) this.selSott = new Set([c.sott.id]); - if (store.sel.size) store.select([]); + // la riga ha la sua voce AI (una clip audio): si sceglie insieme, così le proprietà sono quelle della voce + // (volume, tracce, dissolvenze…) e si gestisce come ogni altra clip + const riga = store.doc.sottotitoli?.righe.find((z) => z.id === c.sott!.id); + const voce = riga?.voce && store.doc.clips.some((k) => k.id === riga.voce) ? riga.voce : null; + if (voce && !e.shiftKey) { store.select([voce]); document.dispatchEvent(new CustomEvent('dpv:proprieta')); } + else if (store.sel.size) store.select([]); store.begin('Sottotitolo'); this.presa = { tipo: 'sott', id: c.sott.id, lato: c.sott.lato, x0: x, righe0: structuredClone(store.doc.sottotitoli!.righe), mosso: false }; try { cv.setPointerCapture(e.pointerId); } catch { /* ok */ } @@ -2052,20 +2057,24 @@ export class Timeline { const f = Math.max(0, Math.round(this.xF(x))); const riga = this.righe(p).find((r) => y >= r.y && y < r.y + r.h); if (dato.startsWith('m:')) { - const m = p.media.find((z) => z.id === dato.slice(2)); - if (!m) return null; - const len = m.type === 'image' ? Math.round(fps(p.rate) * 5) : Math.round(((m.markOut ?? m.duration) - (m.markIn ?? m.t0 ?? 0)) * fps(p.rate)); - const tg = this.bersagliDrop(riga?.t, m.hasVideo, m.hasAudio); + const lista = this.mediaDaDato(dato); + if (!lista.length) return null; const out: { track: string; f: number; len: number; kind: 'video' | 'audio'; nuova?: boolean }[] = []; // prova a secco: dove finirebbe davvero (una traccia libera, o una nuova) senza toccare il progetto const prova = { ...p, tracks: p.tracks.slice() } as Project; - const dove = (kind: 'video' | 'audio', pref: string, evita?: Set) => { - const id = modi.inserisci ? pref : M.tracciaLibera(prova, kind, pref, f, f + len, undefined, evita); - return p.tracks.some((t) => t.id === id) ? { track: id, nuova: false } : { track: pref, nuova: true }; - }; - if (tg.video) out.push({ ...dove('video', tg.video), f, len, kind: 'video' }); - const usate = new Set(); - for (const a of tg.audio) { const d = dove('audio', a, usate); usate.add(d.track); out.push({ ...d, f, len, kind: 'audio' }); } + let a0 = this.fDrop(x, y, this.lenDiMedia(lista[0]), riga?.t); + for (const m of lista) { + const len = this.lenDiMedia(m); + const tg = this.bersagliDrop(riga?.t, m.hasVideo, m.hasAudio); + const dove = (kind: 'video' | 'audio', pref: string, evita?: Set) => { + const id = modi.inserisci ? pref : M.tracciaLibera(prova, kind, pref, a0, a0 + len, undefined, evita); + return p.tracks.some((t) => t.id === id) ? { track: id, nuova: false } : { track: pref, nuova: true }; + }; + if (tg.video) out.push({ ...dove('video', tg.video), f: a0, len, kind: 'video' }); + const usate = new Set(); + for (const a of tg.audio) { const d = dove('audio', a, usate); usate.add(d.track); out.push({ ...d, f: a0, len, kind: 'audio' }); } + a0 += len; + } return out; } return riga ? [{ track: riga.t.id, f, len: Math.round(fps(p.rate) * 5), kind: riga.t.kind }] : null; @@ -2085,23 +2094,76 @@ export class Timeline { return { video, audio }; } + /** i file di un trascinamento 'm:id1|id2|…' (quelli che esistono ancora) */ + private mediaDaDato(dato: string): MediaItem[] { + const p = store.doc; + return dato.slice(2).split('|').map((id) => p.media.find((z) => z.id === id)).filter((m): m is MediaItem => !!m); + } + + private lenDiMedia(m: MediaItem): number { + const r = fps(store.doc.rate); + return Math.max(1, m.type === 'image' ? Math.round(r * 5) : Math.round(((m.markOut ?? m.duration) - (m.markIn ?? m.t0 ?? 0)) * r)); + } + + /** + * Dove parte un file lasciato in x: l'aggancio è più largo di quello delle clip (14 px) e preferisce la fine di una clip + * della traccia sotto il puntatore: se lo lasci sulla seconda metà di una clip, si mette subito dopo di lei. + * Vale uguale per l'anteprima (il fantasma) e per quando lasci, così quello che vedi è quello che succede. + */ + private fDrop(x: number, y: number, len: number, t?: Track): number { + const p = store.doc; + const f = Math.max(0, Math.round(this.xF(x))); + if (!modi.snap) { this.snapLinea = null; return f; } + const soglia = 14 / this.ppf; + let best = f, bd = soglia; + const prova = (punto: number, d: number, preferito: boolean) => { + const dd = preferito ? d * 0.5 : d; + if (dd < bd) { bd = dd; best = punto; } + }; + // le clip della traccia sotto il puntatore: dopo di lei se sei nella seconda metà + const stessa = t ? p.clips.filter((c) => c.track === t.id && c.kind !== 'fx') : []; + const dentro = stessa.find((c) => f > c.start && f < end(c)); + if (dentro && f >= dentro.start + dentro.len / 2) { this.snapLinea = end(dentro); return end(dentro); } + for (const c of stessa) prova(end(c), Math.abs(end(c) - f), true); + for (const s of M.snapPoints(p, new Set(), [Math.round(store.head)])) { + prova(s, Math.abs(s - f), false); + // anche la fine del file che arriva può appoggiarsi all'inizio di una clip + prova(s - len, Math.abs(s - len - f), false); + } + best = Math.max(0, best); + this.snapLinea = best !== f ? best : null; + return best; + } + private rilascia(dato: string, x: number, y: number) { const p = store.doc; const f = Math.max(0, Math.round(this.xF(x))); const riga = this.righe(p).find((r) => y >= r.y && y < r.y + r.h); if (dato.startsWith('m:')) { - const m = p.media.find((z) => z.id === dato.slice(2)); - if (!m) return; - const srcIn = m.markIn ?? m.t0 ?? 0; - const srcOut = m.markOut ?? (m.type === 'image' ? srcIn + 5 : m.duration); - const tg = this.bersagliDrop(riga?.t, m.hasVideo, m.hasAudio); - let fx = f; - if (modi.snap) fx = this.aggancia(f, new Set()); + const lista = this.mediaDaDato(dato); + if (!lista.length) return; + const fx = this.fDrop(x, y, this.lenDiMedia(lista[0]), riga?.t); this.snapLinea = null; const n0 = store.doc.tracks.length; - const ids = store.edit('Metti nella timeline', (pp) => M.placeSource(pp, { mediaId: m.id, srcIn, srcOut }, fx, null, tg, modi.inserisci ? 'insert' : 'libero')); + // più file: uno dopo l'altro, a partire da dove li lasci + const ids = store.edit(lista.length > 1 ? `Metti ${lista.length} file nella timeline` : 'Metti nella timeline', (pp) => { + const tutti: string[] = []; + let a0 = fx; + for (const m of lista) { + const srcIn = m.markIn ?? m.t0 ?? 0; + const srcOut = m.markOut ?? (m.type === 'image' ? srcIn + 5 : m.duration); + const tg = this.bersagliDrop(riga?.t, m.hasVideo, m.hasAudio); + const nuovi = M.placeSource(pp, { mediaId: m.id, srcIn, srcOut }, a0, null, tg, modi.inserisci ? 'insert' : 'libero'); + tutti.push(...nuovi); + // il prossimo parte dove finisce questo (il più lungo fra video e audio che ha creato) + const fine = Math.max(a0 + 1, ...nuovi.map((id) => { const c = clipById(pp, id); return c ? end(c) : 0; })); + a0 = fine; + } + return tutti; + }); store.select(ids); - avviso(store.doc.tracks.length > n0 ? `${m.name}: lì era occupato, l'ho messa su una traccia nuova` : `${m.name} nella timeline`, 'ok', 1600); + const nome = lista.length > 1 ? `${lista.length} file` : lista[0].name; + avviso(store.doc.tracks.length > n0 ? `${nome}: lì era occupato, ho usato una traccia nuova` : `${nome} nella timeline`, 'ok', 1600); } else if (dato.startsWith('g:')) { const g = leggiGeneratore(dato); inserisciGeneratore(g.kind, f, riga?.t.kind === 'video' ? riga.t.id : undefined, { titolo: g.titolo, conto: g.conto, anim: g.anim }); diff --git a/test/foto.mjs b/test/foto.mjs index 4fc665e..60fe786 100644 --- a/test/foto.mjs +++ b/test/foto.mjs @@ -167,6 +167,16 @@ async function scatta(nome, viewport, extra = {}) { await p.click('.mt-scarta'); await p.waitForTimeout(400); await p.click('.pagina-btn[data-p=montaggio]'); + // Voce e lingue (Finale) e la pagina iniziale + await p.click('.pagina-btn[data-p=finale]'); + await p.click('.fin-voce[data-s=lingue]'); + await p.waitForTimeout(600); + await p.screenshot({ path: path.join(OUT, nome + '-voce-lingue.png') }); + await p.click('.pagina-btn[data-p=montaggio]'); + await p.evaluate(() => document.dispatchEvent(new CustomEvent('dpv:home'))); + await p.waitForTimeout(500); + await p.screenshot({ path: path.join(OUT, nome + '-pagina-iniziale.png') }); + await p.keyboard.press('Escape'); // le novità della versione await p.click('.voce-menu:has-text("Aiuto")'); await p.click('.tendina .voce:has-text("Novità della")'); diff --git a/test/prove-nuove.mjs b/test/prove-nuove.mjs index e35baaf..8fb3217 100644 --- a/test/prove-nuove.mjs +++ b/test/prove-nuove.mjs @@ -12,10 +12,11 @@ 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']) { +export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage', 'pannello', 'seguito']) { 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('montage')) { const m = await import('./prove-montage.mjs').catch(() => null); if (m) await m.proveMontage({ page, prova, OUT }); } } diff --git a/test/prove-seguito.mjs b/test/prove-seguito.mjs new file mode 100644 index 0000000..1845591 --- /dev/null +++ b/test/prove-seguito.mjs @@ -0,0 +1,300 @@ +// Le prove della 1.1.6: contenitore (scelta multipla, bordo, aggancio), Montage (anteprima dei video, come adattare), +// voce e lingue (traduzione, cinese, una clip per frase), file a velocità variabile e pagina iniziale. +import path from 'node:path'; + +/** una pagina pulita (contesto nuovo): le prove di prima hanno riempito il progetto e aperto sezioni */ +async function pagina(paginaDi) { + const contesto = await paginaDi.context().browser().newContext({ viewport: { width: 1600, height: 950 }, acceptDownloads: true }); + const page = await contesto.newPage(); + const errori = []; + page.on('pageerror', (e) => errori.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error') errori.push(m.text()); }); + await page.goto(paginaDi.url()); + await page.waitForSelector('.pulsantiera'); + await page.waitForTimeout(1200); + return { page, contesto, errori }; +} + +const immagini = (page, n) => page.evaluate(async (n) => { + const { importaFile } = window.__dpvTest; + const ids = []; + for (let i = 0; i < n; i++) { + const w = i === 3 ? 600 : 800, h = i === 3 ? 800 : 600; + const c = new OffscreenCanvas(w, h); const x = c.getContext('2d'); + x.fillStyle = `hsl(${i * 70},50%,22%)`; x.fillRect(0, 0, w, h); + x.fillStyle = '#e33'; x.beginPath(); x.arc(w / 2, h / 2, Math.min(w, h) * 0.28, 0, 7); x.fill(); + const nome = `Foto ${String.fromCharCode(65 + i)}.png`; + const [m] = await importaFile([{ name: nome, file: new File([await c.convertToBlob({ type: 'image/png' })], nome, { type: 'image/png' }) }], { chiediFormato: false }); + ids.push(m.id); + } + return ids; +}, n); + +export async function proveSeguito({ page: paginaDi, prova, OUT }) { + const { page, contesto, errori } = await pagina(paginaDi); + + // ——————————————————————————————————————————————————————————— + console.log('▶ Contenitore: scelta multipla, bordo e aggancio'); + const ids = await immagini(page, 4); + await page.waitForTimeout(500); + const carta = (id) => `.carta[data-id="${id}"]`; + const quante = () => page.locator('.carta.scelta').count(); + await page.click(carta(ids[0]), { modifiers: ['Control'] }); + await page.click(carta(ids[1]), { modifiers: ['Control'] }); + prova('Ctrl+clic sceglie più file nel contenitore', (await quante()) === 2); + await page.click(carta(ids[0])); + await page.click(carta(ids[2]), { modifiers: ['Shift'] }); + prova('Maiusc+clic sceglie un tratto (tre file), e un clic semplice ne tiene uno solo', (await quante()) === 3); + await page.click(carta(ids[1]), { modifiers: ['Control'] }); + prova('Ctrl+clic su uno scelto lo toglie', (await quante()) === 2); + await page.click(carta(ids[1]), { modifiers: ['Control'] }); + + // si trascinano tutti e tre sulla timeline, sulla traccia V2, a partire dal fotogramma 100 + const punto = (f, riga, frazione = 0.5) => page.evaluate(([f, riga, frazione]) => { + const tl = window.__dpvTest.ui().tl; + const r = tl.cv.getBoundingClientRect(); + const righe = tl.righe(window.__dpv.doc); + const v = righe.filter((x) => x.t.kind === 'video'); + const rr = v[riga]; + return { x: r.left + tl.fX(f), y: r.top + rr.y + rr.h * frazione, id: rr.t.id }; + }, [f, riga, frazione]); + const trascina = async (daId, a) => { + const b = await page.locator(carta(daId)).boundingBox(); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2); + await page.mouse.down(); + await page.mouse.move(b.x + b.width / 2 + 20, b.y + b.height / 2 + 20, { steps: 4 }); + await page.mouse.move(a.x, a.y, { steps: 10 }); + await page.waitForTimeout(150); + await page.mouse.up(); + await page.waitForTimeout(500); + }; + const dest = await punto(100, 0); + await trascina(ids[0], dest); + const clips = () => page.evaluate(() => window.__dpv.doc.clips.filter((c) => c.kind === 'media').sort((a, b) => a.start - b.start).map((c) => ({ id: c.id, media: c.media, track: c.track, start: c.start, len: c.len }))); + let cl = await clips(); + prova('trascinando un file scelto si portano dietro tutti i file scelti: tre clip, una dopo l\'altra', cl.length === 3 && cl[1].start === cl[0].start + cl[0].len && cl[2].start === cl[1].start + cl[1].len && cl.map((c) => c.media).join() === ids.slice(0, 3).join(), JSON.stringify(cl)); + prova('la prima parte dove le hai lasciate (agganciata a 14 px al massimo)', Math.abs(cl[0].start - 100) <= 3 || cl[0].start === 0, String(cl[0].start)); + await page.waitForTimeout(300); + const stato = await page.evaluate((ids) => ids.map((id) => document.querySelector(`.carta[data-id="${id}"]`)?.classList.contains('in-timeline')), ids); + prova('i file che sono in timeline hanno il bordo colorato, gli altri no', stato.join() === 'true,true,true,false', stato.join()); + + // un file solo, lasciato sulla seconda metà dell'ultima clip: parte subito dopo di lei (non in un altro posto) + await page.evaluate(() => window.__dpvTest.ui().tl.adattaTutto()); + await page.waitForTimeout(300); + await page.click(carta(ids[3])); + const fineUltima = cl[2].start + cl[2].len; + const sulla = await punto(cl[2].start + Math.round(cl[2].len * 0.7), 0); + await trascina(ids[3], sulla); + cl = await clips(); + const nuova = cl.find((c) => c.media === ids[3]); + prova('un file lasciato sulla seconda metà di una clip si aggancia subito dopo di lei, sulla stessa traccia', !!nuova && nuova.start === fineUltima && nuova.track === cl[2].track, JSON.stringify([nuova, fineUltima])); + // lasciato vicino (a pochi pixel) alla fine di una clip, in uno spazio libero: si aggancia + await page.screenshot({ path: path.join(OUT, 'contenitore-scelta.png') }); + + // ——————————————————————————————————————————————————————————— + console.log('▶ Montage: anteprima dei video e come adattare'); + const video = await page.evaluate(async () => { + const { importaFile, ripresaDiProva } = window.__dpvTest; + const f = await ripresaDiProva(5, 25); + const [m] = await importaFile([{ name: f.name, file: f }], { chiediFormato: false }); + return m.id; + }); + await page.keyboard.press('F8'); + await page.waitForTimeout(900); + const th = page.locator(`.mt-thumb[data-id="${video}"]`); + const caselle = await th.boundingBox(); + await page.mouse.move(caselle.x + caselle.width * 0.2, caselle.y + caselle.height / 2); + await page.mouse.move(caselle.x + caselle.width * 0.6, caselle.y + caselle.height / 2, { steps: 5 }); + await page.waitForTimeout(500); + const scorre = await page.evaluate((id) => { const e = document.querySelector(`.mt-thumb[data-id="${id}"]`); return { classe: e.classList.contains('scorre'), tempo: e.querySelector('.mt-tempo')?.textContent, lente: !!e.querySelector('.mt-guarda') }; }, video); + prova('un video nella pagina Montage scorre al passaggio del mouse (con il tempo) e ha la lente', scorre.classe && /\//.test(scorre.tempo ?? '') && scorre.lente, JSON.stringify(scorre)); + // (il play parte e il disegno è lento: il clic si dà dal programma, senza aspettare) + await page.evaluate((id) => document.querySelector(`.mt-thumb[data-id="${id}"] .mt-guarda`).click(), video); + await page.waitForTimeout(1500); + const mon = await page.evaluate(() => ({ player: window.__motore.attivo, play: window.__motore.playing })); + prova('la lente apre il video nel monitor in alto e lo fa partire', mon.player === 'player' && mon.play, JSON.stringify(mon)); + await page.evaluate(() => window.__motore.stop()); + const alt = await page.evaluate(() => { const r = document.querySelector('.monitor').getBoundingClientRect(); return { h: Math.round(r.height), vh: window.innerHeight }; }); + prova('il monitor della pagina Montage è piccolo (meno di un terzo dello schermo)', alt.h < alt.vh * 0.33, JSON.stringify(alt)); + const adatta = await page.evaluate((ids) => { + const { MT } = window.__dpvTest; + const p = { w: 1920, h: 1080 }, ver = { width: 600, height: 800, rotation: 0 }; + const a = MT.inquadra(p, ver, false), b = MT.inquadra(p, ver, true); + // una foto 600×800 in 1920×1080: intera = scheda piccola; riempi = a tutto quadro (cover), mai stirata + const kc = Math.max(1920 / 600, 1080 / 800), kf = Math.min(1920 / 600, 1080 / 800); + return { a, b, atteso: kc / kf }; + }, ids); + prova('"Intere con lo sfondo" lascia la foto verticale come scheda; "Riempi il quadro" la mette a tutto quadro (taglia, non stira)', adatta.a.scheda && !adatta.b.scheda && Math.abs(adatta.b.scala - adatta.atteso) < 1e-6, JSON.stringify(adatta)); + await page.locator('.mt-colcome .chip', { hasText: 'Riempi il quadro' }).click(); + prova('la scelta si ricorda', (await page.evaluate(() => JSON.parse(localStorage.getItem('dpv-montage')).adatta)) === 'riempi'); + await page.keyboard.press('F8'); + await page.waitForTimeout(300); + + // ——————————————————————————————————————————————————————————— + console.log('▶ Voce e lingue: traduzione, cinese, una clip per frase'); + const tr = await page.evaluate(async () => { + const { TD } = window.__dpvTest; + const chiamate = []; + TD.impostaTraduttore({ + carica: async (stato) => { stato('scarico', 0.5); stato('fatto', 1); chiamate.push('carica'); }, + traduci: async (testi, da, a) => { chiamate.push(`${da}>${a}:${testi.length}`); return testi.map((t) => `[${a}] ${t}`); }, + }); + const prog = []; + const out = await TD.traduciTesti(['Buongiorno', '', 'Come stai?'], 'it', 'en', (f, k) => prog.push(k)); + const stessa = await TD.traduciTesti(['ciao'], 'it', 'it', () => {}); + const nap = TD.codiceNllb('nap'); + let errore = ''; + try { await TD.traduciTesti(['x'], 'it', 'xx', () => {}); } catch (e) { errore = e.message; } + TD.impostaTraduttore(null); + return { out, stessa, chiamate, nap, errore, cresce: prog.every((x, i) => !i || x >= prog[i - 1] - 1e-9), ultimo: prog[prog.length - 1], zh: TD.codiceNllb('zh') }; + }); + prova('la traduzione lascia vuote le righe vuote, non traduce se la lingua è la stessa e il napoletano si traduce come l\'italiano', tr.out.join('|') === '[en] Buongiorno||[en] Come stai?' && tr.stessa[0] === 'ciao' && tr.nap === 'ita_Latn' && /xx|coppia/.test(tr.errore) && tr.chiamate.includes('it>en:2'), JSON.stringify(tr)); + prova('la barra della traduzione non torna indietro e arriva a 100%; il cinese è fra le lingue', tr.cresce && tr.ultimo === 1 && tr.zh === 'zho_Hans', JSON.stringify([tr.cresce, tr.ultimo, tr.zh])); + + // la voce in un'altra lingua: i sottotitoli italiani si traducono (solo per la voce) prima di essere letti + const vo = await page.evaluate(async () => { + const { NM, TD, DP, PR } = window.__dpvTest; + const visti = []; + TD.impostaTraduttore({ carica: async () => {}, traduci: async (testi, da, a) => testi.map((t) => `${a}:${t}`) }); + NM.impostaMotoreNemo({ + stato: async () => ({ os: 'linux', arch: 'x86_64', cartella: '/x', installato: true, backend: 'cpu', consigliato: 'cpu', nvidia: false, modelli: 900 * 1048576 }), + installa: async () => {}, modello: async () => {}, trascrivi: async () => [], + sintetizza: async (voci, o) => { + visti.push({ lingua: o.lingua, testi: voci.map((v) => v.testo) }); + const m = new Map(); + for (const v of voci) { const a = new Float32Array(24000); for (let k = 0; k < a.length; k++) a[k] = Math.sin(k / 20) * 0.3; m.set(v.id, { audio: a, sr: 24000 }); } + return m; + }, + }); + window.__dpv.edit('sottotitoli', (p) => { p.sottotitoli = { righe: [{ id: 'r1', da: 0, a: 50, testo: 'Buonasera Napoli.' }, { id: 'r2', da: 75, a: 140, testo: 'Questa è la voce.' }], nelVideo: true, dimensione: 46, fascia: true, alto: false, lingua: 'it' }; }); + const p = structuredClone(window.__dpv.doc); + const stessa = await DP.doppia(p, { voce: 0, lingua: 'it', silenzia: false }, () => {}); + const inglese = await DP.doppia(p, { voce: 0, lingua: 'en', silenzia: false }, () => {}); + const cinese = await DP.doppia(p, { voce: 1, lingua: 'zh', silenzia: false }, () => {}); + const senza = await DP.doppia(p, { voce: 0, lingua: 'en', silenzia: false, traduci: false }, () => {}); + return { visti, tradotti: [stessa.tradotto, inglese.tradotto, cinese.tradotto, senza.tradotto], posti: inglese.posti.map((x) => [x.id.slice(0, 1), +x.da.toFixed(2), +x.len.toFixed(2), x.righe.join()]), codiceZh: NM.codiceLingua('zh'), zhVoce: NM.LINGUE_VOCE.includes('zh') }; + }); + prova('stessa lingua: i sottotitoli si leggono così come sono (niente traduzione)', vo.visti[0].lingua === 'it' && vo.visti[0].testi.join('|') === 'Buonasera Napoli.|Questa è la voce.' && vo.tradotti[0] === false, JSON.stringify(vo.visti[0])); + prova('voce inglese su sottotitoli italiani: prima si traducono, poi si leggono in inglese', vo.visti[1].lingua === 'en' && vo.visti[1].testi.every((t) => t.startsWith('en:')) && vo.tradotti[1] === true, JSON.stringify(vo.visti[1])); + prova('la voce parla anche cinese (il motore la riceve come zh-CN) con i testi tradotti', vo.visti[2].lingua === 'zh' && vo.visti[2].testi.every((t) => t.startsWith('zh:')) && vo.tradotti[2] && vo.codiceZh === 'zh-CN' && vo.zhVoce, JSON.stringify(vo.visti[2])); + prova('se si spegne la traduzione la voce legge il testo com\'è', vo.visti[3].testi.every((t) => !/^en:/.test(t)) && vo.tradotti[3] === false, JSON.stringify(vo.visti[3])); + prova('ogni frase ha il suo posto (dove comincia, quanto dura) e le righe che contiene', vo.posti.length >= 1 && vo.posti[0][1] === 0 && vo.posti.every((x) => x[2] > 0.3 && x[3]), JSON.stringify(vo.posti)); + + // la pagina Finale: due passi, la voce in cinese fra le lingue, e la riga dei sottotitoli sceglie la sua voce + await page.click('.pagina-btn[data-p=finale]'); + await page.waitForTimeout(500); + await page.click('.fin-voce[data-s=lingue]'); + await page.waitForTimeout(400); + const fin = await page.evaluate(() => ({ + passi: [...document.querySelectorAll('.fin-pagina[data-s=lingue] .fin-sezione h4')].map((e) => e.textContent.trim()), + cinese: [...document.querySelectorAll('.fin-pagina[data-s=lingue] .chip')].some((c) => c.textContent === 'Cinese'), + traduci: [...document.querySelectorAll('.fin-pagina[data-s=lingue] .ai-vai')].map((e) => e.textContent), + })); + prova('Voce e lingue: i due passi, "Cinese" fra le lingue della voce e i due tasti', fin.passi.some((t) => t.includes('1 · Sottotitoli')) && fin.passi.some((t) => t.includes('2 · Voce')) && fin.cinese && fin.traduci.length === 2, JSON.stringify(fin)); + await page.screenshot({ path: path.join(OUT, 'finale-voce-lingue.png') }); + // traduzione dei sottotitoli dal pannello (traduttore finto), si annulla con Ctrl+Z + await page.evaluate(() => window.__dpvTest.TD.impostaTraduttore({ carica: async () => {}, traduci: async (testi, da, a) => testi.map((t) => `(${a}) ${t}`) })); + await page.locator('.fin-pagina[data-s=lingue] select').nth(1).selectOption('en'); + await page.waitForTimeout(250); + await page.locator('.fin-pagina[data-s=lingue] .ai-vai', { hasText: 'Traduci i sottotitoli' }).click(); + await page.waitForFunction(() => window.__dpv.doc.sottotitoli.lingua === 'en', null, { timeout: 15000 }); + const sott = await page.evaluate(() => ({ testi: window.__dpv.doc.sottotitoli.righe.map((r) => r.testo), tempi: window.__dpv.doc.sottotitoli.righe.map((r) => [r.da, r.a]) })); + prova('"Traduci i sottotitoli": cambia il testo e la lingua, i tempi restano', sott.testi.every((t) => t.startsWith('(en) ')) && sott.tempi.join(';') === '0,50;75,140', JSON.stringify(sott)); + await page.keyboard.press('Control+z'); + await page.waitForTimeout(250); + prova('Ctrl+Z rimette i sottotitoli com\'erano', await page.evaluate(() => window.__dpv.doc.sottotitoli.lingua === 'it' && window.__dpv.doc.sottotitoli.righe[0].testo === 'Buonasera Napoli.')); + + // una riga con la sua voce: un clic sulla riga sceglie la clip audio + await page.evaluate(async () => { + const { PR, DP } = window.__dpvTest; + const p = structuredClone(window.__dpv.doc); + const r = await DP.doppia(p, { voce: 0, lingua: 'it', silenzia: false }, () => {}); + // l'audio composto diventa un file, e ogni frase una clip + const wav = (() => { const n = r.audio.length; const b = new ArrayBuffer(44 + n * 2); const d = new DataView(b); const s = (o, t) => [...t].forEach((c, i) => d.setUint8(o + i, c.charCodeAt(0))); s(0, 'RIFF'); d.setUint32(4, 36 + n * 2, true); s(8, 'WAVEfmt '); d.setUint32(16, 16, true); d.setUint16(20, 1, true); d.setUint16(22, 1, true); d.setUint32(24, r.sr, true); d.setUint32(28, r.sr * 2, true); d.setUint16(32, 2, true); d.setUint16(34, 16, true); s(36, 'data'); d.setUint32(40, n * 2, true); for (let i = 0; i < n; i++) d.setInt16(44 + i * 2, Math.max(-1, Math.min(1, r.audio[i])) * 32767, true); return b; })(); + const [m] = await window.__dpvTest.importaFile([{ name: 'voce-prova.wav', file: new File([wav], 'voce-prova.wav', { type: 'audio/wav' }) }], { chiediFormato: false }); + window.__dpv.edit('Voce AI', (pp) => window.__dpvTest.DP.posaVoce(pp, m.id, r.audio.length / r.sr, false, r.posti)); + window.__dpv.select([]); + }); + await page.click('.pagina-btn[data-p=montaggio]'); + await page.waitForTimeout(800); + const rigaClic = await page.evaluate(() => { + const tl = window.__dpvTest.ui().tl; + tl.righe(window.__dpv.doc); + const r = tl.cv.getBoundingClientRect(); + const rs = tl.rigaSott; + const riga = window.__dpv.doc.sottotitoli.righe[0]; + return { x: r.left + tl.fX((riga.da + riga.a) / 2), y: r.top + rs.y + rs.h / 2, voce: riga.voce }; + }); + await page.mouse.click(rigaClic.x, rigaClic.y); + await page.waitForTimeout(500); + const scelta = await page.evaluate(() => [...window.__dpv.sel]); + prova('un clic su una riga dei sottotitoli sceglie la sua clip di voce (si gestisce come ogni altra clip)', scelta.length === 1 && scelta[0] === rigaClic.voce, JSON.stringify([scelta, rigaClic.voce])); + await page.evaluate(() => window.__dpvTest.NM.impostaMotoreNemo(null)); + await page.evaluate(() => window.__dpvTest.TD.impostaTraduttore(null)); + + // ——————————————————————————————————————————————————————————— + console.log('▶ File a velocità variabile: si convertono da soli'); + const nz = await page.evaluate(async () => { + const { NZ, ripresaVfrDiProva, ripresaDiProva, branoVbrDiProva, importaFile } = window.__dpvTest; + const vfr = await ripresaVfrDiProva(4); + const cfr = await ripresaDiProva(3, 25); + const vbr = await branoVbrDiProva(8); + const dVfr = await NZ.esamina({ name: vfr.name, file: vfr }); + const dCfr = await NZ.esamina({ name: cfr.name, file: cfr }); + const dVbr = await NZ.esamina({ name: vbr.name, file: vbr }); + // com'è, senza convertire (per confrontare la durata) + localStorage.setItem('dpv-normalizza', 'no'); + const [orig] = await importaFile([{ name: vfr.name, file: vfr }], { chiediFormato: false }); + localStorage.removeItem('dpv-normalizza'); + const a = await importaFile([{ name: vfr.name, file: vfr }], { chiediFormato: false }); + const b = await importaFile([{ name: vbr.name, file: vbr }], { chiediFormato: false }); + const c = await importaFile([{ name: cfr.name, file: cfr }], { chiediFormato: false }); + // il video importato è davvero a frame rate costante? + const rt = window.__dpvTest.mediaRT(a[0].id); + const fr = await rt.v.computeFrameRateMetrics({ targetPacketCount: 300 }); + return { + dVfr, dCfr, dVbr, vfrNome: [vfr.name, a[0]?.name, a[0]?.container], costante: fr.frameRateIsConstant, spread: fr.maxFrameRate / fr.minFrameRate, fps: a[0]?.fps, + durata: [a[0]?.duration, orig?.duration], brano: [b[0]?.name, b[0]?.acodec, b[0]?.container, b[0]?.duration], normale: [c[0]?.name, c[0]?.fps], + }; + }); + prova('si riconosce un video a frame rate variabile e un brano a bitrate variabile; un video normale va bene com\'è', nz.dVfr?.vfr === true && nz.dVbr?.vbr === true && nz.dCfr === null, JSON.stringify([nz.dVfr, nz.dVbr, nz.dCfr])); + prova('il video variabile entra già rifatto a frame rate costante (stessa durata) e con un frame rate pulito', nz.costante === true && Math.abs(nz.durata[0] - nz.durata[1]) < 0.5 && nz.fps > 20 && nz.fps < 31, JSON.stringify(nz)); + prova('il brano a bitrate variabile entra rifatto a bitrate costante (stessa durata)', !!nz.brano[0] && Math.abs(nz.brano[3] - 8) < 0.5 && !!nz.brano[1], JSON.stringify(nz.brano)); + prova('un video normale non si tocca (nome e frame rate com\'erano)', nz.normale[0] === 'Lunga 3s.mp4' || /^Lunga 3s\./.test(nz.normale[0]), JSON.stringify(nz.normale)); + + // ——————————————————————————————————————————————————————————— + console.log('▶ Pagina iniziale'); + const h0 = await page.evaluate(() => document.querySelector('.home')?.style.display); + prova('nelle prove automatiche la pagina iniziale non si apre da sola', h0 === 'none'); + await page.evaluate(() => { const { PR } = window.__dpvTest; window.__dpv.doc.name = 'Il mio viaggio'; PR.registraRecente(window.__dpv.doc, undefined, JSON.stringify(window.__dpv.doc)); document.dispatchEvent(new CustomEvent('dpv:home')); }); + await page.waitForTimeout(500); + const home = await page.evaluate(() => ({ + visibile: getComputedStyle(document.querySelector('.home')).display !== 'none', + carte: [...document.querySelectorAll('.home-carta')].map((c) => c.querySelector('b').textContent), + nuovo: !!document.querySelector('.home .btn.primario.home-grande'), apri: document.querySelectorAll('.home .home-grande').length, montage: !!document.querySelector('.home-montage'), + continua: !!document.querySelector('.home-continua'), + })); + prova('la pagina iniziale ha Nuovo, Apri, DaProdMontage, "Continua" e il progetto salvato fra i recenti', home.visibile && home.carte.includes('Il mio viaggio') && home.nuovo && home.apri === 3 && home.montage && home.continua, JSON.stringify(home)); + await page.screenshot({ path: path.join(OUT, 'pagina-iniziale.png') }); + // si riapre un recente: torna quel progetto + await page.evaluate(() => { window.__dpv.dirty = false; return window.__dpvTest.PR.nuovo(); }); + await page.waitForTimeout(500); + await page.evaluate(() => document.dispatchEvent(new CustomEvent('dpv:home'))); + await page.waitForTimeout(300); + await page.click('.home-carta:has(b:has-text("Il mio viaggio"))'); + await page.waitForTimeout(1200); + const riaperto = await page.evaluate(() => ({ nome: window.__dpv.doc.name, clip: window.__dpv.doc.clips.length, home: getComputedStyle(document.querySelector('.home')).display })); + prova('un clic su un progetto recente lo riapre (con le sue clip) e chiude la pagina iniziale', riaperto.nome === 'Il mio viaggio' && riaperto.clip > 3 && riaperto.home === 'none', JSON.stringify(riaperto)); + await page.evaluate(() => document.dispatchEvent(new CustomEvent('dpv:home'))); + await page.waitForTimeout(300); + await page.keyboard.press('Escape'); + prova('Esc chiude la pagina iniziale', (await page.evaluate(() => getComputedStyle(document.querySelector('.home')).display)) === 'none'); + await page.evaluate(() => document.dispatchEvent(new CustomEvent('dpv:home'))); + await page.click('.home-carta .home-togli'); + await page.waitForTimeout(300); + prova('la X toglie un progetto dall\'elenco', (await page.evaluate(() => document.querySelectorAll('.home-carta').length)) === 0); + await page.keyboard.press('Escape'); + + prova('nessun errore nella pagina delle novità', errori.length === 0, errori.slice(0, 4).join(' | ')); + await contesto.close(); +} diff --git a/test/prove.mjs b/test/prove.mjs index b356930..2549dac 100644 --- a/test/prove.mjs +++ b/test/prove.mjs @@ -777,7 +777,7 @@ try { prova('il titolo d\'apertura entra all\'inizio e sposta avanti il resto', fine1 === fine0 + 75 && dd.clips.some((c) => c.kind === 'title' && c.start === 0 && c.name.includes('apertura')), `${fine0} → ${fine1}`); await tasto('Control+z'); await page.click('.fin-voce[data-s=lingue]'); - prova('Lingue e AI: i sottotitoli con l\'AI pronti e la voce AI accesa (niente più "presto")', (await page.locator('.fin-presto.pronto').count()) === 2 && (await page.locator('.fin-presto[disabled]').count()) === 0 && (await page.locator('.fin-pagina[data-s=lingue] .ai-vai').count()) === 1); + prova('Voce e lingue: due passi (sottotitoli con la traduzione, voce), i tasti ci sono e nessuno è "presto"', (await page.locator('.fin-pagina[data-s=lingue] .fin-sezione h4', { hasText: '1 · Sottotitoli' }).count()) === 1 && (await page.locator('.fin-pagina[data-s=lingue] .fin-sezione h4', { hasText: '2 · Voce' }).count()) === 1 && (await page.locator('.fin-pagina[data-s=lingue] .ai-vai').count()) === 2); // le novità della versione (dal CHANGELOG dentro l'app) e il confronto fra versioni { const cmp = await page.evaluate(() => { const { AG } = window.__dpvTest; return [AG.piuNuova('1.0.10', '1.0.9'), AG.piuNuova('1.0.5', '1.0.5'), AG.piuNuova('1.0.4', '1.0.5'), /proxy/i.test(AG.noteDi('1.0.4')?.note ?? '')]; }); @@ -1304,16 +1304,20 @@ try { const dopo = await pa.evaluate(() => { const d = window.__dpv.doc; const tr = d.tracks.find((t) => t.name === 'Voce AI'); - const c = d.clips.find((x) => x.track === tr.id); + const cs = d.clips.filter((x) => x.track === tr.id).sort((a, b) => a.start - b.start); + const c = cs[0]; const m = d.media.find((x) => x.id === c.media); + const righe = d.sottotitoli.righe; return { - chiamate: window.__voceChiamate, traccia: tr.kind, start: c.start, len: c.len, tipo: m.type, durata: m.duration, + chiamate: window.__voceChiamate, traccia: tr.kind, start: c.start, len: c.len, tipo: m.type, durata: m.duration, nClip: cs.length, starts: cs.map((x) => x.start), + collegate: righe.every((x) => cs.some((k) => k.id === x.voce)), nomi: cs.map((k) => k.name), mute: d.tracks.filter((t) => t.mute).map((t) => t.name), gain: d.clips.filter((x) => x.gain <= -40).length, fine: document.querySelector('.fin-pagina[data-s=lingue] .ai-tempo').textContent, }; }); prova('la voce AI: due frasi, lingua italiana, la voce numero 1 (Sofia)', dopo.chiamate.join() === 'modello:tts,sintetizza:2:it:1', dopo.chiamate.join()); - prova('l\'audio della voce va su una traccia audio nuova "Voce AI", dall\'inizio, lungo quanto il parlato', dopo.traccia === 'audio' && dopo.start === 0 && dopo.tipo === 'audio' && Math.abs(dopo.len / 25 - dopo.durata) < 0.1 && dopo.durata > 3, JSON.stringify(dopo)); + prova('la voce va su una traccia audio nuova "Voce AI": una clip per frase (ognuna al suo posto) dallo stesso file', dopo.traccia === 'audio' && dopo.start === 0 && dopo.tipo === 'audio' && dopo.nClip === 2 && dopo.starts[1] === 60 && dopo.durata > 3 && dopo.len > 10 && dopo.len < dopo.durata * 25, JSON.stringify(dopo)); + prova('ogni riga dei sottotitoli sa qual è la sua clip di voce', dopo.collegate && dopo.nomi.every((n) => n.startsWith('Voce: ')), JSON.stringify(dopo.nomi)); prova('le voci originali vanno in silenzio (traccia muta o clip a −40 dB)', dopo.mute.length > 0 || dopo.gain > 0, JSON.stringify(dopo)); prova('a lavoro finito la barra dice quanto ci ha messo', /finito in/.test(dopo.fine), dopo.fine); await pa.keyboard.press('Control+z'); await pa.waitForTimeout(150); From 23332a7f969f54a986b13d299aec518a0a5352b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:22:49 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Prova=20della=20voce=20AI:=20la=20barra=20d?= =?UTF-8?q?el=20lavoro=20=C3=A8=20l'ultima=20della=20pagina=20(ora=20ce=20?= =?UTF-8?q?n'=C3=A8=20una=20anche=20per=20la=20traduzione)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014EwuMzmAMyXoJ9EFxHhdGk --- test/prove.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/prove.mjs b/test/prove.mjs index 2549dac..40ebba1 100644 --- a/test/prove.mjs +++ b/test/prove.mjs @@ -1312,7 +1312,7 @@ try { chiamate: window.__voceChiamate, traccia: tr.kind, start: c.start, len: c.len, tipo: m.type, durata: m.duration, nClip: cs.length, starts: cs.map((x) => x.start), collegate: righe.every((x) => cs.some((k) => k.id === x.voce)), nomi: cs.map((k) => k.name), mute: d.tracks.filter((t) => t.mute).map((t) => t.name), gain: d.clips.filter((x) => x.gain <= -40).length, - fine: document.querySelector('.fin-pagina[data-s=lingue] .ai-tempo').textContent, + fine: [...document.querySelectorAll('.fin-pagina[data-s=lingue] .ai-tempo')].pop().textContent, }; }); prova('la voce AI: due frasi, lingua italiana, la voce numero 1 (Sofia)', dopo.chiamate.join() === 'modello:tts,sintetizza:2:it:1', dopo.chiamate.join());