diff --git a/CHANGELOG.md b/CHANGELOG.md index ba05f26..69ffdb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.5] — 2026-10-01 · DaProdMontage con l'anteprima in alto, ogni montaggio diverso, proprietà riordinabili 🎲 + +### DaProdMontage: prima lo guardi, poi lo importi +- **L'anteprima sta in alto, nel monitor**: premi **✨ CREA IL MONTAGGIO** e il montaggio parte da solo lì sopra, con tutti i comandi del monitor (play, scorri, fotogramma). La timeline non si tocca finché non sei contento. +- **🎲 RIGENERA**: un altro montaggio con le stesse scelte, tutte le volte che vuoi. **✅ IMPORTA NELLA TIMELINE** lo tiene (resta come scheda nuova, da ritoccare) e **✖ Scarta** toglie l'anteprima e torna a com'era. Rigenerare non accumula timeline: l'anteprima di prima si toglie da sola. Se premi Ctrl+Z i bottoni tornano a "Crea". +- **Ogni volta è originale** anche con le stesse impostazioni: a ogni generazione cambiano i movimenti delle foto (mai due uguali di fila), quanto sta ognuna, le transizioni (anche qualcuna fuori dalla lista dello stile, ma del suo ritmo), quando cadono gli effetti e le animazioni sopra, quali foto restano fuori o si ripetono se sono troppe o poche. Il tempo totale resta quello chiesto, al fotogramma. +- Ogni montaggio ha il suo codice (**Variante K7F2Q**): lo stesso codice dà lo stesso montaggio (utile per le prove e per ritrovarne uno). Le misure delle foto e il ritmo del brano si fanno una volta sola, quindi rigenerare è veloce. +- **Sistemato**: con il pannello Proprietà fissato (📌) la pagina Montage si apriva con la griglia rotta (una zona nera in alto e le colonne in fondo). Ora è a posto in ogni combinazione di pannelli aperti e chiusi. Sul telefono l'anteprima resta visibile sopra le scelte, e c'è il tasto **Monta** nella barra. + +### Proprietà: tutto chiuso e riordinabile +- **Di partenza tutte le sezioni sono chiuse**: scegli una clip e vedi l'elenco; apri solo quello che ti serve (una volta aperta resta aperta passando da una clip all'altra). +- **Trascina la maniglia ⠿** a destra di ogni sezione per spostarla: l'ordine si **ricorda per ogni tipo di elemento** (immagine, video, audio, titolo, animazione, generatore, effetto a tempo, transizione), anche chiudendo e riaprendo il programma. Il bottone **↺ Ordine di partenza** in fondo lo rimette com'era per quel tipo. + ## [1.1.4] — 2026-10-01 · Togliere lo sfondo, animazioni personalizzate e DaProdMontage ✨ > 🤖 **Android in pausa**: questa versione esce per Windows e Mac (e nel browser). diff --git a/CLAUDE.md b/CLAUDE.md index bc0c950..f4f9da2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,8 @@ 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à). +- **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`. - **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 b59913d..9a30ff5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ ed elimini. Gira su **Windows, Mac e Android** e, in versione prova, **nel brows 13. **`P`** fa un'**istantanea** del fotogramma nel contenitore, da allungare quanto vuoi (**`Shift+P`**: fermo immagine al cursore). 14. **Sull'immagine del monitor**: clic su quello che vedi e lo scegli; trascina per spostarlo, tira un angolo per ingrandirlo, il pallino in alto lo gira. tira un lato per stirare (Maiusc sugli angoli, Ctrl dal centro, **✂** per ritagliare) e Ctrl+clic sceglie quella sotto. **↝ Movimento** gli dà una posizione d'inizio e una di fine e tutte le **tappe** che vuoi (**+**, o fermo fra due tappe muovi) e ci passa dentro piano piano; **🎯** segue un oggetto (tracking) e titoli, immagini ed effetti lo inseguono; nelle proprietà ci sono i movimenti pronti (entra da sinistra, sale dal basso, si avvicina…). Gli effetti come **bolla**, **vortice** e **zoom** hanno un mirino: mettilo dove vuoi, anche in movimento. Più **transizioni sullo stesso taglio si sommano**. 15. **File → Salva il pacchetto .daprod**: il progetto **con dentro tutti i suoi file** (uno zip vero, i file restano identici). Lo apri su un altro computer e ritrovi tutto. Il **.dpv** invece è il progetto leggero: tiene il montaggio, i file restano dove sono. -17. **`F8`** apre **DaProdMontage**, la quarta pagina: butta dentro **foto e video alla rinfusa** (anche solo foto), scegli **cosa festeggi** (28 stili: matrimonio, battesimo, comunione, cresima, laurea, compleanno, Natale, viaggio, reel verticale…), quanto deve durare e, se vuoi, il brano. **✨ CREA IL MONTAGGIO** e il programma ordina le foto (per data di scatto), le tiene il tempo giusto, le muove, mette transizioni, titoli e quello che cade sopra, appoggia i tagli ai battiti e ti lascia una timeline normale da ritoccare. +17. **`F8`** apre **DaProdMontage**, la quarta pagina: butta dentro **foto e video alla rinfusa** (anche solo foto), scegli **cosa festeggi** (28 stili: matrimonio, battesimo, comunione, cresima, laurea, compleanno, Natale, viaggio, reel verticale…), quanto deve durare e, se vuoi, il brano. **✨ CREA IL MONTAGGIO** e il programma ordina le foto (per data di scatto), le tiene il tempo giusto, le muove, mette transizioni, titoli e quello che cade sopra, appoggia i tagli ai battiti. Lo vedi **in anteprima nel monitor in alto**: se non ti piace **🎲 Rigenera** (ogni volta cambia, anche con le stesse scelte), se ti piace **✅ Importa nella timeline** e lo ritocchi. 18. **Togliere lo sfondo**: sulla clip, **Proprietà → Sfondo** (o il tasto **SFONDO** sul monitor). **Colore** (green screen con fino a 3 colori, contagocce, via il riflesso), **Luce** o **AI** (soggetto, persona, oggetti coi clic). **Generatori → Animazioni**: 49 animazioni personalizzabili (sottopancia, testi, grafici, social, fondi, cerimonie). 16. A **schermo pieno** le tracce crescono da sole; **`Ctrl+Shift`+rotella** le alza e le abbassa. Nell'app **`Ctrl +`** e **`Ctrl −`** ingrandiscono tutta l'interfaccia. diff --git a/package-lock.json b/package-lock.json index 7e379c1..9cf6a62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.1.4", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.1.4", + "version": "1.1.5", "license": "MIT", "dependencies": { "@fontsource/archivo-black": "^5.3.0", diff --git a/package.json b/package.json index 11655fb..e3da928 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.1.4", + "version": "1.1.5", "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 ca15b56..f73d213 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -624,7 +624,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "daprod-video" -version = "1.1.4" +version = "1.1.5" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f575ed3..eeb148b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.1.4" +version = "1.1.5" 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 e192bf8..df0b926 100644 --- a/src/core/montage.ts +++ b/src/core/montage.ts @@ -62,6 +62,10 @@ export interface Voce { moto: Moto; /** è la seconda volta che compare (non c'erano abbastanza file per riempire il tempo) */ ripetuta: boolean; + /** quanto è forte il movimento rispetto a quello dello stile (cambia a ogni generazione) */ + forza: number; + /** da che parte parte il movimento (0 o 1) */ + verso: number; } export interface PianoMontage { @@ -191,9 +195,12 @@ export function pianifica(entrate: EntrataMontage[], o: OpzMontage): PianoMontag // troppe per il tempo: via le peggiori, ma non la prima e l'ultima const min = (e: EntrataMontage) => limiti(e, pr).min; let sfoltite = 0; + // il caso di questa generazione: stesse scelte, esito diverso (lo stesso seme dà sempre lo stesso montaggio) + const rnd = casoSeme(Math.imul(o.seme | 0, 2654435761) ^ 0x5bd1e995); + const rumore = new Map(lista.map((e) => [e, (rnd() - 0.5) * 0.14])); while (lista.length > 2 && lista.reduce((a, e) => a + min(e), 0) > A) { let idx = -1, peggio = Infinity; - for (let i = 1; i < lista.length - 1; i++) if (lista[i].punteggio <= peggio) { peggio = lista[i].punteggio; idx = i; } + for (let i = 1; i < lista.length - 1; i++) { const v = lista[i].punteggio + (rumore.get(lista[i]) ?? 0); if (v <= peggio) { peggio = v; idx = i; } } if (idx < 0) break; lista.splice(idx, 1); sfoltite++; } @@ -203,16 +210,18 @@ export function pianifica(entrate: EntrataMontage[], o: OpzMontage): PianoMontag // pochi per il tempo: si ripetono i più belli (in fondo, in ordine di data) finché non basta let tutte = lista.map((e) => ({ e, ripetuta: false })); let ripetute = 0; - let lim = tutte.map((x) => limiti(x.e, pr)); + // ogni foto sta un po' di più o un po' di meno: il ritmo non è mai uguale + const variata = (e: EntrataMontage): Lim => { const l = limiti(e, pr); return { ...l, tip: Math.min(l.max, Math.max(l.min, l.tip * (0.72 + rnd() * 0.56))) }; }; + let lim = tutte.map((x) => variata(x.e)); const sommaMax = (l: Lim[]) => l.reduce((a, b) => a + b.max, 0); if (sommaMax(lim) < A) { - const migliori = [...lista].sort((a, b) => b.punteggio - a.punteggio); + const migliori = [...lista].sort((a, b) => (b.punteggio + (rumore.get(b) ?? 0)) - (a.punteggio + (rumore.get(a) ?? 0))); const giri = Math.min(4, Math.ceil(A / Math.max(1, sommaMax(lim)))); for (let g = 0; g < giri && sommaMax(lim) < A; g++) { const extra = migliori.slice(0, Math.max(1, Math.ceil(lista.length * 0.6))).sort((a, b) => lista.indexOf(a) - lista.indexOf(b)); for (const e of extra) { if (sommaMax(lim) >= A) break; - tutte.push({ e, ripetuta: true }); lim.push(limiti(e, pr)); ripetute++; + tutte.push({ e, ripetuta: true }); lim.push(variata(e)); ripetute++; } } if (ripetute) note.push(`${ripetute} foto compaiono due volte: con quelle che hai non si arriva a ${fmtDur(A)}`); @@ -232,6 +241,16 @@ export function pianifica(entrate: EntrataMontage[], o: OpzMontage): PianoMontag const voci: Voce[] = []; let t = 0; const mosse: Moto[] = pr.moto.length ? pr.moto : ['zoomIn']; + const EXTRA: Moto[] = ['zoomIn', 'zoomOut', 'panSx', 'panDx', 'diagonale']; + let precedente: Moto | null = null; + /** un movimento a caso fra quelli dello stile (ogni tanto uno in più), mai due uguali di fila */ + const scegliMoto = (): Moto => { + const da = rnd() < 0.18 ? EXTRA : mosse; + const pool = da.filter((m) => m !== precedente); + const m = (pool.length ? pool : da)[Math.floor(rnd() * (pool.length ? pool.length : da.length))]; + precedente = m; + return m; + }; const usati = new Map(); tutte.forEach((x, i) => { const e = x.e; @@ -244,17 +263,27 @@ export function pianifica(entrate: EntrataMontage[], o: OpzMontage): PianoMontag const base = e.inizioMigliore ?? Math.min(e.durata * 0.12, Math.max(0, e.durata - len)); srcIn = Math.max(0, Math.min(e.durata - len, base + vol * len)); } - voci.push({ entrata: e, start: t, len, srcIn, moto: mosse[i % mosse.length], ripetuta: x.ripetuta }); + voci.push({ entrata: e, start: t, len, srcIn, moto: scegliMoto(), ripetuta: x.ripetuta, forza: 0.8 + rnd() * 0.45, verso: rnd() < 0.5 ? 0 : 1 }); t += len; }); // transizioni sui tagli: mai più lunghe del 45% della più corta delle due vicine const transizioni: PianoMontage['transizioni'] = []; const idsTr = pr.transizioni.length ? pr.transizioni : ['mix']; + // ogni tanto una transizione fuori dalla lista dello stile, ma del suo ritmo (se no sarebbero sempre le stesse) + const FANTASIA: Record = { + lento: ['mix', 'dip'], + medio: ['mix', 'dve:301', 'dve:351', 'dip'], + veloce: ['dve:301', 'dve:351', 'dve:321', 'dve:331', 'mix'], + }; + let trPrima = ''; for (let i = 1; i < voci.length; i++) { - const len = Math.min(pr.durTr, 0.45 * Math.min(voci[i - 1].len, voci[i].len)); + const len = Math.min(pr.durTr * (0.85 + rnd() * 0.35), 0.45 * Math.min(voci[i - 1].len, voci[i].len)); if (len < 0.2) continue; - transizioni.push({ id: idsTr[(i - 1) % idsTr.length], centro: voci[i].start, len }); + const pool = (rnd() < 0.25 ? FANTASIA[pr.ritmo] : idsTr).filter((x) => x !== trPrima); + const id = pool.length ? pool[Math.floor(rnd() * pool.length)] : idsTr[0]; + trPrima = id; + transizioni.push({ id, centro: voci[i].start, len }); } // titoli: apertura sopra le prime foto, chiusura sopra le ultime @@ -270,18 +299,23 @@ export function pianifica(entrate: EntrataMontage[], o: OpzMontage): PianoMontag const sovr: PianoMontage['sovrapposizioni'] = []; const eff: PianoMontage['effetti'] = []; if (o.effetti) { - for (const s of pr.sovrapposizioni) { + pr.sovrapposizioni.forEach((s, k) => { + // si sposta un po' e ogni tanto salta il giro (ma la prima c'è sempre) + if (k > 0 && pr.sovrapposizioni.length >= 3 && rnd() < 0.15) return; const v = typeof s.v === 'function' ? s.v(o.testi) : s.v ?? {}; const an = animazione(s.anim) ? nuovaAnim(s.anim, v) : null; - const start = s.da * A, len = (s.a - s.da) * A; + // la durata varia un po' ma non esce mai dal montaggio (quelle che durano quasi tutto restano com'erano) + const intera = (s.a - s.da) * A; + const len = Math.min(intera * (0.85 + rnd() * 0.3), intera >= 0.9 * A ? intera : 0.96 * A); + const start = Math.max(Math.min(s.da * A, 0.02 * A), Math.min(A - len - 0.02 * A, s.da * A + (rnd() - 0.5) * 0.08 * A)); if (an && len >= 2) sovr.push({ anim: an, start, len }); - } + }); for (const e of pr.effetti) { if (e.quando === 'tutto') { eff.push({ id: e.id, start: 0, len: e.durata ?? A }); continue; } if (e.quando === 'tagli') { - for (let i = e.ogni; i < voci.length; i += e.ogni) eff.push({ id: e.id, start: Math.max(0, voci[i].start - e.durata / 2), len: e.durata }); + for (let i = e.ogni + (rnd() < 0.5 ? 0 : 1); i < voci.length; i += e.ogni + (rnd() < 0.3 ? 1 : 0)) eff.push({ id: e.id, start: Math.max(0, voci[i].start - e.durata / 2), len: e.durata * (0.85 + rnd() * 0.3) }); } else { - for (let s = e.da ?? e.ogni; s + e.durata < A - 2; s += e.ogni) eff.push({ id: e.id, start: s, len: e.durata }); + for (let s = (e.da ?? e.ogni) * (0.8 + rnd() * 0.4); s + e.durata < A - 2; s += e.ogni * (0.8 + rnd() * 0.4)) eff.push({ id: e.id, start: s, len: e.durata }); } } } @@ -370,7 +404,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, i) : { tf: { ...TF0 } }; + const mt = m ? moto(p, m, v.moto, pr.forzaMoto * v.forza, v.verso) : { 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/stile/editor.css b/src/stile/editor.css index 020d24d..4896ee1 100644 --- a/src/stile/editor.css +++ b/src/stile/editor.css @@ -68,7 +68,7 @@ button svg { pointer-events: none; } #app.senza-bin .contenitore, #app.senza-lato .lato { visibility: hidden; } #app.senza-puls .pulsantiera { display: none; } /* timeline stretta (tasto V): VU, proprietà e mixer scendono fino in fondo, la timeline si stringe a sinistra */ -#app.lato-lungo:not(.stretto):not([data-pagina='finale']):not([data-pagina='live']) { +#app.lato-lungo:not(.stretto):not([data-pagina='finale']):not([data-pagina='live']):not([data-pagina='montage']) { grid-template-areas: 'testa testa testa testa testa' 'bin bbin monitor blato lato' @@ -77,7 +77,7 @@ button svg { pointer-events: none; } 'tl tl tl blato lato' 'stato stato stato stato stato'; } -#app.lato-lungo.senza-lato:not(.stretto):not([data-pagina='finale']):not([data-pagina='live']) { grid-template-areas: 'testa testa testa testa testa' 'bin bbin monitor blato lato' 'puls puls puls puls puls' 'div div div div div' 'tl tl tl tl tl' 'stato stato stato stato stato'; } +#app.lato-lungo.senza-lato:not(.stretto):not([data-pagina='finale']):not([data-pagina='live']):not([data-pagina='montage']) { grid-template-areas: 'testa testa testa testa testa' 'bin bbin monitor blato lato' 'puls puls puls puls puls' 'div div div div div' 'tl tl tl tl tl' 'stato stato stato stato stato'; } #app.lato-lungo .lato { border-bottom: 1px solid #000; } /* la pagina Finale: monitor grande, i ritocchi a destra, la timeline sotto */ #app[data-pagina='finale'] { @@ -97,13 +97,15 @@ 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'] { +#app[data-pagina='montage'][class] { grid-template-columns: minmax(0, 1fr); - grid-template-rows: 44px minmax(0, 1fr) 7px var(--tlfin) 22px; - grid-template-areas: 'testa' 'montage' 'div' 'tl' 'stato'; + grid-template-rows: 44px minmax(210px, 42vh) 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 */ #app[data-pagina='montage'] .contenitore, #app[data-pagina='montage'] .lato, #app[data-pagina='montage'] .bordo-bin, #app[data-pagina='montage'] .bordo-lato, -#app[data-pagina='montage'] .pulsantiera, #app[data-pagina='montage'] .monitor, #app[data-pagina='montage'] .finale, #app[data-pagina='montage'] .live { display: none !important; } +#app[data-pagina='montage'] .pulsantiera, #app[data-pagina='montage'] .timeline, #app[data-pagina='montage'] .divisore, +#app[data-pagina='montage'] .finale, #app[data-pagina='montage'] .live, #app[data-pagina='montage'] .linguetta-lato { display: none !important; } #app .montage { grid-area: montage; display: none; } #app[data-pagina='montage'] .montage { display: flex; } .testata { grid-area: testa; } @@ -589,6 +591,12 @@ button svg { pointer-events: none; } .isp-gruppo summary::before { content: '▸'; color: var(--testo3); transition: transform 0.15s; } .isp-gruppo[open] summary::before { transform: rotate(90deg); } .isp-gruppo > :not(summary) { margin: 0 9px 7px; } +.isp-grip { margin-left: auto; padding: 0 4px; color: var(--testo3); font-size: 15px; line-height: 1; cursor: grab; touch-action: none; border-radius: 4px; } +.isp-grip:hover { color: var(--oro); background: #23212b; } +.isp-gruppo.trascinato { outline: 2px solid var(--oro); opacity: 0.88; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5); } +.isp-gruppo.trascinato .isp-grip { cursor: grabbing; } +.isp-ripristina { display: block; margin: 4px auto 10px; padding: 3px 10px; border-radius: 14px; border: 1px solid #000; background: #23212b; color: var(--testo3); font: 700 10.5px var(--ui); } +.isp-ripristina:hover { color: var(--testo); } .isp-riga { display: grid; grid-template-columns: 96px 1fr auto; gap: 6px; align-items: center; font-size: 12px; color: var(--testo2); } .isp-riga label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .isp-riga input[type='range'] { width: 100%; accent-color: var(--oro); } @@ -704,7 +712,7 @@ button svg { pointer-events: none; } .stretto .contenitore, .stretto .lato, .stretto .finale { grid-area: auto; position: fixed; left: 0; right: 0; bottom: 56px; height: 62vh; z-index: 50; transform: translateY(110%); transition: transform 0.22s; border-radius: 14px 14px 0 0; box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.6); } .stretto[data-foglio='bin'] .contenitore, .stretto[data-foglio='lato'] .lato, .stretto[data-foglio='finale'] .finale { transform: none; visibility: visible; } /* DaProdMontage sul telefono: a tutto schermo fra la testata e la barra in basso, le tre colonne una sotto l'altra */ -#app.stretto[data-pagina='montage'] .montage { grid-area: auto; position: fixed; left: 0; right: 0; top: 44px; bottom: 56px; z-index: 55; background: var(--fondo); overflow: auto; } +#app.stretto[data-pagina='montage'] .montage { grid-area: auto; position: fixed; left: 0; right: 0; top: max(224px, calc(44px + 38vh)); bottom: 56px; z-index: 55; background: var(--fondo); overflow: auto; } .stretto .mt-dentro { grid-template-columns: 1fr; overflow: visible; flex: none; } .stretto .mt-col { overflow: visible; } .stretto .mt-file { grid-template-columns: repeat(4, 1fr); } @@ -999,6 +1007,12 @@ body.pip-live { margin: 0; background: #111016; overflow: hidden; } /* ——— DaProdMontage ——— */ .montage { flex-direction: column; min-height: 0; border-left: 0; overflow: hidden; } .mt-m { color: var(--oro); } +.mt-azioni { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid #000; background: #15141b; flex-wrap: wrap; } +.mt-azioni .btn { padding: 9px 16px; font-size: 13px; letter-spacing: 0.8px; white-space: nowrap; } +.mt-importa { background: linear-gradient(180deg, #5dd39e, #2ea36f) !important; color: #04170e !important; border-color: #0b3a26 !important; } +.mt-variante { font: 800 11px var(--ui); letter-spacing: 1.5px; color: var(--oro); padding: 3px 9px; border-radius: 20px; background: #23212b; box-shadow: inset 0 0 0 1px #000; } +.mt-variante:empty { display: none; } +.mt-azioni .mt-lavoro { flex: 1; min-width: 180px; } .mt-dentro { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(240px, 0.9fr) minmax(320px, 1.5fr) minmax(300px, 1fr); gap: 10px; padding: 10px; } .mt-col { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 8px; padding: 10px; border-radius: 8px; background: var(--pannello, #1b1a21); box-shadow: inset 0 0 0 1px #000; } .mt-col h3 { display: flex; align-items: center; gap: 8px; margin: 0; font: 800 14px var(--ui); color: var(--testo); } @@ -1029,6 +1043,5 @@ body.pip-live { margin: 0; background: #111016; overflow: hidden; } .mt-mmss .num { width: 52px; } .mt-riepilogo { display: flex; flex-direction: column; gap: 3px; padding: 8px 10px; border-radius: 6px; background: #14131a; box-shadow: inset 0 0 0 1px #000; font: 600 12px var(--ui); color: var(--testo2); } .mt-riepilogo b { color: var(--testo); font-size: 13px; } -.mt-crea { padding: 12px 14px; font-size: 14px; letter-spacing: 1px; } .mt-lavoro { min-height: 0; } @media (max-width: 900px) { .mt-dentro { grid-template-columns: 1fr; overflow: auto; } .mt-col { overflow: visible; } } diff --git a/src/ui/ispettore.ts b/src/ui/ispettore.ts index 6bf5e59..041c748 100644 --- a/src/ui/ispettore.ts +++ b/src/ui/ispettore.ts @@ -39,7 +39,10 @@ export class Ispettore { private corpo: HTMLElement; private campi: Campo[] = []; private firma = ''; - private aperti = new Set(['fxv', 'fxa', 'immagine', 'titolo', 'audio', 'generatore', 'trin', 'durata', 'sfondo', 'animazione']); + /** le sezioni aperte (di partenza sono tutte chiuse; si ricorda finché il programma è aperto) */ + private aperti = new Set(); + /** di che tipo è quello che si sta guardando (clip video, audio, titolo…): l'ordine delle sezioni si ricorda per ognuno */ + private chiave = ''; constructor() { this.corpo = h('div', { class: 'isp-corpo' }); @@ -79,7 +82,7 @@ export class Ispettore { return; } const blocchi = cs.filter((c) => c.kind === 'fx'); - if (blocchi.length === cs.length) { this.corpo.replaceChildren(...this.blocchi(blocchi)); return; } + if (blocchi.length === cs.length) { const tr = blocchi[0].fxb?.tipo === 'transizione'; this.corpo.replaceChildren(...this.conOrdine(tr ? 'blocco:transizione' : 'blocco:effetto', this.blocchi(blocchi))); return; } const video = cs.filter(isVideoClip); const audio = cs.filter((c) => !isVideoClip(c) && c.kind !== 'fx'); const primo = video[0] ?? audio[0]; @@ -149,7 +152,7 @@ export class Ispettore { this.cursore('Temperatura', -100, 100, 1, (c) => Math.round((c.fx.temp ?? 0) * 100), (c, v) => { c.fx.temp = v / 100; }, '', video), this.pulsanti([['Azzera', () => store.edit('Azzera colore', () => { for (const c of video) { c.fx.bright = 0; c.fx.contrast = 1; c.fx.sat = 1; c.fx.hue = 0; c.fx.temp = 0; c.fx.look = 'none'; c.fx.effetti = undefined; } })]]), h('p', { class: 'nota' }, 'Il colore automatico e il look di tutto il montaggio sono nella pagina Finale.'), - ], true)); + ])); } if (audio.length) { out.push(this.gruppo('audio', 'Volume', [ @@ -207,9 +210,10 @@ export class Ispettore { this.cursore('Ritaglio sopra', 0, 50, 0.5, (c) => c.tf.cropT * 100, (c, v) => { c.tf.cropT = v / 100; }, '%', video), this.cursore('Ritaglio sotto', 0, 50, 0.5, (c) => c.tf.cropB * 100, (c, v) => { c.tf.cropB = v / 100; }, '%', video), this.cursore('Tinta', -180, 180, 1, (c) => c.fx.hue, (c, v) => { c.fx.hue = v; }, '°', video), - ], true)); + ])); } - this.corpo.replaceChildren(...out); + const chiaveClip = (primo.kind === 'title' ? (primo.gen?.anim ? 'animazione' : 'titolo') : primo.kind === 'media' ? (m?.type === 'image' ? 'immagine' : isVideoClip(primo) ? 'video' : 'audio') : 'generatore'); + this.corpo.replaceChildren(...this.conOrdine('clip:' + chiaveClip, out)); } /** le animazioni del catalogo: quale, e tutti i campi che ha (testi, colori, numeri), con i loro valori */ @@ -357,7 +361,7 @@ export class Ispettore { righe.push(this.scelta('Segue', opz, get, (c, v) => agganciaSegue(store.doc, c, v), cs)); if (get(c0)) righe.push(h('p', { class: 'nota' }, 'Segue l\'oggetto: trascinandolo sul monitor lo sposti rispetto a lui (lo scarto).')); } - return righe.length ? this.gruppo('segui', 'Segui un oggetto', righe, !c0.traccia && !get(c0)) : null; + return righe.length ? this.gruppo('segui', 'Segui un oggetto', righe) : null; } /** le transizioni all'inizio e alla fine della clip: i blocchetti che stanno su quei bordi */ @@ -551,13 +555,80 @@ export class Ispettore { } // ——— mattoncini ——— - private gruppo(id: string, titolo: string, righe: (HTMLElement | null)[], chiuso = false): HTMLElement { - const aperto = this.aperti.has(id) || (!chiuso && !this.aperti.has('!' + id)); - const el = h('details', { class: 'isp-gruppo', open: aperto }, h('summary', null, titolo), ...righe.filter(Boolean) as HTMLElement[]); - el.addEventListener('toggle', () => { if ((el as HTMLDetailsElement).open) { this.aperti.add(id); this.aperti.delete('!' + id); } else { this.aperti.delete(id); this.aperti.add('!' + id); } }); + private gruppo(id: string, titolo: string, righe: (HTMLElement | null)[]): HTMLElement { + const grip = h('span', { class: 'isp-grip', title: 'Trascina per spostare la sezione (l\'ordine si ricorda)' }, '⠿'); + const el = h('details', { class: 'isp-gruppo', open: this.aperti.has(id), 'data-g': id }, h('summary', null, titolo, grip), ...righe.filter(Boolean) as HTMLElement[]); + el.addEventListener('toggle', () => { if ((el as HTMLDetailsElement).open) this.aperti.add(id); else this.aperti.delete(id); }); + // la maniglia non apre né chiude: serve solo a trascinare + grip.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }); + grip.addEventListener('pointerdown', (e) => this.trascina(el, e)); return el; } + // ——— l'ordine delle sezioni: si riordinano trascinando la maniglia e l'ordine si salva per ogni tipo di elemento ——— + private ordiniSalvati(): Record { + try { return JSON.parse(localStorage.getItem('dpv-isp-ordine') ?? '{}') ?? {}; } catch { return {}; } + } + private scriviOrdini(o: Record) { + try { localStorage.setItem('dpv-isp-ordine', JSON.stringify(o)); } catch { /* niente */ } + } + + /** rimette le sezioni nell'ordine salvato per questo tipo (quelle nuove o mai spostate vanno in fondo); il resto resta dov'è */ + private conOrdine(chiave: string, out: HTMLElement[]): HTMLElement[] { + this.chiave = chiave; + const posti = out.map((e, i) => (e.dataset?.g ? i : -1)).filter((i) => i >= 0); + const gruppi = posti.map((i) => out[i]); + const ids = gruppi.map((g) => g.dataset.g!); + const salvato = this.ordiniSalvati()[chiave]; + if (!salvato?.length) return out; + const ordine = [...salvato.filter((id) => ids.includes(id)), ...ids.filter((id) => !salvato.includes(id))]; + const per = new Map(gruppi.map((g) => [g.dataset.g!, g])); + posti.forEach((i, k) => { out[i] = per.get(ordine[k])!; }); + out.push(h('button', { class: 'isp-ripristina', title: 'Rimette le sezioni nell\'ordine di partenza, per questo tipo di elemento', on: { click: () => { const o = this.ordiniSalvati(); delete o[chiave]; this.scriviOrdini(o); this.firma = ''; this.costruisci(); } } }, '↺ Ordine di partenza')); + return out; + } + + private trascina(el: HTMLElement, e: PointerEvent) { + if (e.button !== 0 || !el.parentElement) return; + e.preventDefault(); + const cont = el.parentElement; + el.classList.add('trascinato'); + const muovi = (ev: PointerEvent) => { + const altri = [...cont.children].filter((x) => x !== el && (x as HTMLElement).dataset?.g) as HTMLElement[]; + const dopo = altri.find((x) => { const b = x.getBoundingClientRect(); return ev.clientY < b.top + b.height / 2; }); + if (dopo) { if (el.nextElementSibling !== dopo) cont.insertBefore(el, dopo); } + else { const ultimo = altri[altri.length - 1]; if (ultimo && ultimo.nextElementSibling !== el) cont.insertBefore(el, ultimo.nextSibling); } + }; + const fine = () => { + document.removeEventListener('pointermove', muovi); + document.removeEventListener('pointerup', fine); + document.removeEventListener('pointercancel', fine); + el.classList.remove('trascinato'); + this.salvaOrdineDa(cont); + }; + document.addEventListener('pointermove', muovi); + document.addEventListener('pointerup', fine); + document.addEventListener('pointercancel', fine); + } + + /** salva l'ordine che si vede: le sezioni in quel momento nascoste restano dove stavano */ + private salvaOrdineDa(cont: HTMLElement) { + if (!this.chiave) return; + const visibili = [...cont.querySelectorAll(':scope > [data-g]')].map((x) => (x as HTMLElement).dataset.g!); + const tutti = this.ordiniSalvati(); + const vecchio = tutti[this.chiave] ?? []; + let k = 0; + const nuovo = vecchio.map((id) => (visibili.includes(id) ? visibili[k++] : id)); + for (; k < visibili.length; k++) nuovo.push(visibili[k]); + // chi non c'era ancora (mai spostato) va comunque nell'ordine che si vede + const completo = [...new Set([...nuovo.filter((id) => visibili.includes(id) || vecchio.includes(id))])]; + for (const id of visibili) if (!completo.includes(id)) completo.push(id); + tutti[this.chiave] = completo; + this.scriviOrdini(tutti); + // il bottone "Ordine di partenza" compare subito + this.firma = ''; this.costruisci(); + } + private cursore(nome: string, min: number, max: number, step: number, get: (c: Clip) => number, put: (c: Clip, v: number) => void, unita: string, quali: Clip[]): HTMLElement { const ids = quali.map((c) => c.id); const trova = () => store.doc.clips.filter((c) => ids.includes(c.id)); diff --git a/src/ui/montage.ts b/src/ui/montage.ts index 18bba1f..a3faca9 100644 --- a/src/ui/montage.ts +++ b/src/ui/montage.ts @@ -32,6 +32,13 @@ interface Scelte { musica: string; } +/** l'inizio del nome con cui l'anteprima sta nell'annulla (serve a riconoscerla: ogni variante ha il suo) */ +const ETICHETTA = 'DaProdMontage'; +/** un seme nuovo a ogni montaggio */ +const nuovoSeme = () => (Math.random() * 0x7fffffff) >>> 0; +/** il seme in breve, da far vedere ("Variante K7F2Q") e da scriversi se un montaggio piace */ +const codiceSeme = (n: number) => n.toString(36).toUpperCase().padStart(5, '0').slice(-5); + const DURATE = [30, 60, 90, 120, 180, 300, 600]; const MISURE: Record, [number, number]> = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080] }; @@ -67,6 +74,15 @@ export class Montage { private sinc: (() => void)[] = []; private visibile = false; private occupato = false; + /** l'anteprima in timeline (finché non la importi): il seme con cui è nata */ + private anteprima: { seme: number; etichetta: string } | null = null; + private bRigenera: HTMLButtonElement; + private bImporta: HTMLButtonElement; + private bScarta: HTMLButtonElement; + private etichetta: HTMLElement; + /** quello che si è già misurato (qualità, pezzo migliore, battiti): rigenerare è subito */ + private analisi = new Map(); + private ritmi = new Map(); /** finisce quando il montaggio è pronto (per le prove) */ ultimo: Promise = Promise.resolve(); @@ -83,6 +99,10 @@ export class Montage { this.campiTesto = h('div', { class: 'mt-testi' }); this.selMusica = h('select', { class: 'mini-select largo', title: 'La musica del video', on: { change: () => { this.s.musica = this.selMusica.value; this.aggiorna(); } } }) as HTMLSelectElement; this.bCrea = h('button', { class: 'btn primario mt-crea', on: { click: () => void this.crea() } }, '✨ CREA IL MONTAGGIO') as HTMLButtonElement; + this.bRigenera = h('button', { class: 'btn mt-rigenera', title: 'Un altro montaggio con le stesse scelte: cambiano movimenti, durate, transizioni ed effetti', on: { click: () => void this.crea() } }, '🎲 RIGENERA') as HTMLButtonElement; + this.bImporta = h('button', { class: 'btn primario mt-importa', title: 'Tiene questo montaggio e apre la timeline per ritoccarlo', on: { click: () => this.importa() } }, '✅ IMPORTA NELLA TIMELINE') as HTMLButtonElement; + this.bScarta = h('button', { class: 'btn mt-scarta', title: 'Toglie l\'anteprima e torna a com\'era prima', on: { click: () => this.scarta() } }, '✖ Scarta') as HTMLButtonElement; + this.etichetta = h('span', { class: 'mt-variante' }); const chip = (testo: string, acceso: () => boolean, fn: () => void, title = '') => { const b = h('button', { class: 'chip', title, on: { click: () => { fn(); this.salva(); this.sinc.forEach((f) => f()); this.aggiorna(); } } }, testo); @@ -145,13 +165,18 @@ export class Montage { 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\'è.')), - this.riepilogo, this.bCrea, h('div', { class: 'mt-lavoro' }, ...this.lavoro.elementi)); + this.riepilogo); this.el = h('section', { class: 'montage' }, h('header', { class: 'fin-testa' }, h('b', null, 'DAPROD'), h('b', { class: 'mt-m' }, 'MONTAGE'), h('span', null, 'butta dentro le foto, scegli la festa e il programma monta da solo'), 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)); - store.on('doc', () => { if (this.visibile) this.rinfresca(); }); + 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; + if (this.visibile) this.rinfresca(); else this.aggiornaBarra(); + }); this.sincronizza(); } @@ -167,6 +192,7 @@ export class Montage { mostrata(si: boolean) { this.visibile = si; + if (!si) this.motoreFerma(); if (si) { this.rinfresca(); void caricaFontAnimazioni().then(() => { if (this.visibile) this.disegnaFeste(); }); } } @@ -180,6 +206,7 @@ export class Montage { this.disegnaMusica(); this.sincronizza(); this.aggiorna(); + this.aggiornaBarra(); } private disegnaFile() { @@ -251,8 +278,8 @@ export class Montage { })); } - private opzioni(musica?: OpzMontage['musica']): 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: 12345, scarta: this.s.scarta, musica }; + 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 }; } /** il riassunto sotto le scelte: quante foto, quanto sta ognuna (senza analisi: serve solo a dare un'idea) */ @@ -262,7 +289,7 @@ export class Montage { const e = this.entrate(); const nF = e.filter((x) => x.tipo === 'image').length, nV = e.length - nF; this.conta.textContent = e.length ? `${nF} foto · ${nV} video scelti` : 'Nessun file scelto'; - this.bCrea.disabled = this.occupato || !e.length; + this.aggiornaBarra(); if (!e.length) { this.riepilogo.textContent = 'Scegli almeno un file.'; return; } const mus = this.s.musica ? this.brani().find((m) => m.id === this.s.musica) : undefined; const pi = pianifica(e, this.opzioni(mus ? { media: mus.id, durata: mus.duration } : undefined)); @@ -285,18 +312,72 @@ export class Montage { if (nuovo) { this.s.musica = nuovo.id; this.selMusica.value = nuovo.id; this.aggiorna(); } } - /** il montaggio vero: legge le date, misura le foto, trova i battiti, pianifica e costruisce la timeline */ - async crea(): Promise { + /** i bottoni: prima "Crea", poi "Rigenera" e "Importa" finché c'è un'anteprima */ + private aggiornaBarra() { + const c = !!this.anteprima; + const n = this.entrate().length; + this.bCrea.style.display = c ? 'none' : ''; + this.bRigenera.style.display = c ? '' : 'none'; + this.bImporta.style.display = c ? '' : 'none'; + this.bScarta.style.display = c ? '' : 'none'; + this.bCrea.disabled = this.occupato || !n; + this.bRigenera.disabled = this.occupato || !n; + this.bImporta.disabled = this.occupato; + this.bScarta.disabled = this.occupato; + this.etichetta.textContent = this.anteprima ? `Variante ${codiceSeme(this.anteprima.seme)}` : ''; + } + + /** l'anteprima è ancora quella che abbiamo fatto noi? (se hai cambiato qualcosa o hai annullato, non lo è più) */ + private anteprimaViva(): boolean { + return !!this.anteprima && store.canUndo() && store.undoLabel() === this.anteprima.etichetta; + } + + /** toglie l'anteprima (un solo annulla: il progetto torna com'era), se nel frattempo non è stata toccata */ + private togliAnteprima() { + if (this.anteprimaViva()) store.doUndo(); + this.anteprima = null; + } + + private scarta() { + if (this.occupato) return; + this.motoreFerma(); + this.togliAnteprima(); + this.aggiornaBarra(); + motore.vaiA(0); + document.dispatchEvent(new CustomEvent('dpv:adatta')); + avviso('Anteprima scartata', 'info', 1600); + } + + /** tiene il montaggio: è già una timeline vera, si apre per ritoccarla */ + private importa() { + if (!this.anteprima) return; + this.motoreFerma(); + this.anteprima = null; + this.aggiornaBarra(); + store.select([]); + document.dispatchEvent(new CustomEvent('dpv:pagina', { detail: 'montaggio' })); + motore.setMonitor('recorder'); + motore.vaiA(0); + document.dispatchEvent(new CustomEvent('dpv:adatta')); + avviso('Montaggio messo nella timeline: ritoccalo come vuoi', 'ok', 2600); + } + + private motoreFerma() { try { motore.stop(); } catch { /* niente */ } } + + /** il montaggio vero: legge le date, misura le foto, trova i battiti, pianifica e lo mette in timeline come anteprima. + * Ogni volta il seme è nuovo (o quello dato, per le prove): stesse scelte, montaggio diverso. */ + async crea(seme?: number): Promise { if (this.occupato) return this.ultimo as Promise; this.occupato = true; - this.bCrea.disabled = true; + this.aggiornaBarra(); const lavoro = (async () => { const lav = this.lavoro; lav.avvia('Guardo i file…'); try { + this.motoreFerma(); const lista = this.disponibili().filter((m) => this.incluse.has(m.id)); const entrate = this.entrate(); - // quando sono state scattate (EXIF) e, se serve, quanto sono venute bene + // quando sono state scattate (EXIF) e, se serve, quanto sono venute bene (una volta sola per file) for (let i = 0; i < lista.length; i++) { const m = lista[i], e = entrate[i]; lav.imposta(`Guardo ${m.name} (${i + 1} di ${lista.length})`, (i / lista.length) * 0.6); @@ -306,6 +387,8 @@ export class Montage { if (d) e.data = d; } if (this.s.scarta) { + const gia = this.analisi.get(m.id); + if (gia) { Object.assign(e, gia); continue; } try { if (m.type === 'image') { const px = await pixelAl(m.id, 0, 128); if (px) { const q = misuraQualita(px); e.punteggio = q.punteggio; e.firma = q.firma; } } else if (m.duration > 4) { @@ -314,47 +397,67 @@ export class Montage { for (let k = 1; k <= 6; k++) { const t = (m.duration * k) / 7; const px = await pixelAl(m.id, t, 96); if (!px) continue; const q = misuraQualita(px); if (q.punteggio > best) { best = q.punteggio; tb = t; } } if (best >= 0) { e.punteggio = best; e.inizioMigliore = Math.max(0, tb - 1); } } + this.analisi.set(m.id, { punteggio: e.punteggio, firma: e.firma, inizioMigliore: e.inizioMigliore }); } catch { /* resta il valore di mezzo */ } } } let musica: OpzMontage['musica']; const mus = this.s.musica ? this.brani().find((m) => m.id === this.s.musica) : undefined; if (mus) { - lav.imposta('Ascolto il ritmo del brano…', 0.65); - const rit = await battitiDi(mus.id, (k) => lav.imposta('Ascolto il ritmo del brano…', 0.65 + k * 0.2)).catch(() => null); - musica = { media: mus.id, durata: mus.duration, nome: mus.name, battiti: rit && rit.sicurezza > 0.15 ? rit.battiti : undefined }; - if (rit && musica.battiti) avviso(`♪ Ritmo trovato: ${Math.round(rit.bpm)} battiti al minuto`, 'info', 2200); + let battiti = this.ritmi.get(mus.id); + if (!this.ritmi.has(mus.id)) { + lav.imposta('Ascolto il ritmo del brano…', 0.65); + const rit = await battitiDi(mus.id, (k) => lav.imposta('Ascolto il ritmo del brano…', 0.65 + k * 0.2)).catch(() => null); + battiti = rit && rit.sicurezza > 0.15 ? rit.battiti : undefined; + this.ritmi.set(mus.id, battiti); + if (rit && battiti) avviso(`♪ Ritmo trovato: ${Math.round(rit.bpm)} battiti al minuto`, 'info', 2200); + } + musica = { media: mus.id, durata: mus.duration, nome: mus.name, battiti }; } lav.imposta('Monto…', 0.9); - const opz = this.opzioni(musica); + const usato = seme ?? nuovoSeme(); + const opz = this.opzioni(musica, usato); const piano = pianifica(entrate, opz); if (!piano.voci.length) throw new Error('Non c\'è niente da montare'); const pr = presetMontage(this.s.preset)!; + // la scorsa anteprima si toglie (un annulla), poi si fa la nuova + this.togliAnteprima(); // il formato, solo se il progetto è ancora vuoto const vuoto = store.doc.clips.length === 0 && !(store.doc.sequenze ?? []).some((q) => q.clips?.length); const f = this.s.formato === 'auto' ? (pr.formato === 'verticale' ? '9:16' : pr.formato === 'quadrato' ? '1:1' : 'progetto') : this.s.formato; - const info = store.edit('DaProdMontage', (pp) => { + const etichetta = `${ETICHETTA} · variante ${codiceSeme(usato)}`; + store.edit(etichetta, (pp) => { if (vuoto && f !== 'progetto') { const [w, hh] = MISURE[f]; pp.w = w; pp.h = hh; } return costruisciSequenza(pp, piano, opz); }); + this.anteprima = { seme: usato, etichetta }; lav.fine(`Fatto: ${piano.voci.length} cose in ${fmtDur(piano.durata)}`); store.select([]); - document.dispatchEvent(new CustomEvent('dpv:pagina', { detail: 'montaggio' })); motore.setMonitor('recorder'); motore.vaiA(0); document.dispatchEvent(new CustomEvent('dpv:adatta')); - avviso(`✨ ${pr.nome}: ${piano.voci.length} cose in ${fmtDur(piano.durata)}${piano.note.length ? ' · ' + piano.note[0] : ''}. Premi Spazio per vederlo (${info.tracce} tracce)`, 'ok', 6000); + if (this.partenza) motore.play(1); + else motore.vaiA(Math.round(Math.min(1.5, piano.durata / 10) * (store.doc.rate.num / store.doc.rate.den))); + this.riassuntoAnteprima(piano.voci.length, piano.durata, piano.note); this.ultimo = Promise.resolve(piano); } catch (e) { lav.ferma('Non riuscito'); avviso('DaProdMontage: ' + (e instanceof Error ? e.message : String(e)), 'errore', 5000); } finally { this.occupato = false; + this.aggiornaBarra(); this.aggiorna(); } })(); this.ultimo = lavoro; return lavoro; } -} + /** il montaggio parte da solo appena è pronto (le prove lo spengono) */ + partenza = true; + + private riassuntoAnteprima(n: number, durata: number, note: string[]) { + const pr = presetMontage(this.s.preset)!; + avviso(`${pr.emoji} ${pr.nome}: ${n} cose in ${fmtDur(durata)}${note.length ? ' · ' + note[0] : ''}. Ti piace? Importalo; se no, rigenera`, 'ok', 5000); + } +} diff --git a/test/aiuti.mjs b/test/aiuti.mjs index d4aeff2..e7cc78d 100644 --- a/test/aiuti.mjs +++ b/test/aiuti.mjs @@ -15,3 +15,9 @@ export const leggi = (page, punti, vista = 0) => page.evaluate(async ([punti, vi /** a vicino a b (ogni canale entro tol) */ export const vicino = (a, b, tol = 40) => a.every((v, i) => Math.abs(v - b[i]) <= tol); + +/** le sezioni delle proprietà partono chiuse: le prove che ci lavorano dentro le aprono (e restano aperte) */ +export const apriGruppi = async (page) => { + await page.evaluate(() => { document.querySelectorAll('.isp-gruppo:not([open]) > summary').forEach((x) => x.click()); }); + await page.waitForTimeout(150); +}; diff --git a/test/foto.mjs b/test/foto.mjs index cda218e..4fc665e 100644 --- a/test/foto.mjs +++ b/test/foto.mjs @@ -158,6 +158,14 @@ async function scatta(nome, viewport, extra = {}) { await p.click('.pagina-btn[data-p=montage]'); await p.waitForTimeout(1500); await p.screenshot({ path: path.join(OUT, nome + '-montage.png') }); + // l'anteprima in alto: si crea (ferma sul fotogramma) e si guarda, poi si scarta + await p.evaluate(() => { window.__dpvTest.ui().montage.partenza = false; }); + await p.click('.mt-crea'); + await p.evaluate(() => window.__dpvTest.ui().montage.ultimo); + await p.waitForTimeout(1800); + await p.screenshot({ path: path.join(OUT, nome + '-montage-anteprima.png') }); + await p.click('.mt-scarta'); + await p.waitForTimeout(400); await p.click('.pagina-btn[data-p=montaggio]'); // le novità della versione await p.click('.voce-menu:has-text("Aiuto")'); diff --git a/test/prove-animazioni.mjs b/test/prove-animazioni.mjs index 0429d74..db1efe5 100644 --- a/test/prove-animazioni.mjs +++ b/test/prove-animazioni.mjs @@ -1,6 +1,6 @@ // Le prove delle animazioni (catalogo, disegno, inserimento, proprietà, libreria). Vedi prove-nuove.mjs. import path from 'node:path'; -import { leggi, vicino } from './aiuti.mjs'; +import { leggi, vicino, apriGruppi } from './aiuti.mjs'; export async function proveAnimazioni({ page, prova, OUT }) { console.log('▶ Animazioni: il catalogo e il disegno'); @@ -107,6 +107,7 @@ export async function proveAnimazioni({ page, prova, OUT }) { console.log('▶ Animazioni: le proprietà'); await page.waitForTimeout(300); + await apriGruppi(page); const gruppo = '.isp-gruppo:has(summary:has-text("Animazione"))'; prova('nelle proprietà c\'è il gruppo "Animazione" con i suoi campi (nome, ruolo, colore, posizione, grandezza)', (await page.locator(gruppo + ' textarea, ' + gruppo + ' input[type=text]').count()) >= 2 && (await page.locator(gruppo + ' input[type=color]').count()) === 1 && (await page.locator(gruppo + ' label:has-text("Dove sta")').count()) === 1 && (await page.locator(gruppo + ' label:has-text("Grandezza")').count()) === 1); const campo = page.locator(gruppo + ' input.campo-testo').first(); diff --git a/test/prove-montage.mjs b/test/prove-montage.mjs index 8b1c106..0204a79 100644 --- a/test/prove-montage.mjs +++ b/test/prove-montage.mjs @@ -115,12 +115,12 @@ export async function proveMontage({ page, prova, OUT }) { const fps = doc.rate.num / doc.rate.den; const esiti = []; for (const pr of MP.PRESET_MONTAGE) { - for (const durata of [30, 95]) { - const o = { preset: pr.id, durata, ordine: 'data', titoli: true, effetti: true, audioVideo: -14, testi: { titolo: 'Titolo', sottotitolo: 'Sotto', nomi: 'Giulia e Marco', data: '12 giugno 2026' }, seme: 1, scarta: true }; + for (const [durata, seme] of [[30, 1], [95, 1], [30, 99], [95, 2024], [30, 31337]]) { + const o = { preset: pr.id, durata, ordine: 'data', titoli: true, effetti: true, audioVideo: -14, testi: { titolo: 'Titolo', sottotitolo: 'Sotto', nomi: 'Giulia e Marco', data: '12 giugno 2026' }, seme, scarta: true }; const copia = JSON.parse(JSON.stringify(doc)); let r, err = null, piano; try { piano = MT.pianifica(entrate, o); r = MT.costruisciSequenza(copia, piano, o); } catch (e) { err = String(e && e.stack || e); } - if (err) { esiti.push({ id: pr.id, durata, err }); continue; } + if (err) { esiti.push({ id: pr.id, durata, seme, err }); continue; } const totF = Math.round(durata * fps); const linea = copia.tracks.filter((t) => t.kind === 'video'); // nessuna sovrapposizione fra clip vere (non blocchi) sulla stessa traccia @@ -137,13 +137,13 @@ export async function proveMontage({ page, prova, OUT }) { const fine = Math.max(...foto.map((c) => c.start + c.len)); const dalla = foto.slice().sort((a, b) => a.start - b.start); let buchi = 0; for (let i = 1; i < dalla.length; i++) if (dalla[i].start !== dalla[i - 1].start + dalla[i - 1].len) buchi++; - esiti.push({ id: pr.id, durata, totF, fine, inizio: dalla[0]?.start, buchi, sovrap, fuori, senzaMedia, nFoto: foto.length, usate: piano.usate, ripetute: piano.ripetute, nome: copia.sequenze?.find((s) => s.id === copia.seqAttiva)?.nome, tracce: copia.tracks.length, brevi: foto.filter((c) => c.len < 2).length }); + esiti.push({ id: pr.id, durata, seme, totF, fine, inizio: dalla[0]?.start, buchi, sovrap, fuori, senzaMedia, nFoto: foto.length, usate: piano.usate, ripetute: piano.ripetute, nome: copia.sequenze?.find((s) => s.id === copia.seqAttiva)?.nome, tracce: copia.tracks.length, brevi: foto.filter((c) => c.len < 2).length }); } } return esiti; }); const errori = tutti.filter((e) => e.err); - prova(`i ${tutti.length / 2} preset montano senza errori, a 30 e a 95 secondi`, !errori.length && tutti.length >= 44, JSON.stringify(errori.slice(0, 2))); + prova(`i ${tutti.length / 5} preset montano senza errori, a 30 e a 95 secondi e con semi diversi`, !errori.length && tutti.length >= 22 * 5, JSON.stringify(errori.slice(0, 2))); const sbagliati = tutti.filter((e) => !e.err && (e.fine !== e.totF || e.inizio !== 0 || e.buchi || e.sovrap || e.fuori || e.senzaMedia || e.brevi)); prova('in ogni montaggio le foto riempiono tutto il tempo chiesto al fotogramma, senza buchi, sovrapposizioni o fuori misura', !sbagliati.length, JSON.stringify(sbagliati.slice(0, 3))); const conRipetute = tutti.filter((e) => e.durata === 95 && e.ripetute > 0).length; @@ -201,27 +201,42 @@ export async function proveMontage({ page, prova, OUT }) { await page.waitForTimeout(400); const prima = await page.evaluate(() => ({ riep: document.querySelector('.mt-riepilogo')?.textContent, seq: (window.__dpv.doc.sequenze ?? []).length, salvate: localStorage.getItem('dpv-montage') })); prova('il riepilogo dice stile e durata, e le scelte si ricordano', /Battesimo/.test(prima.riep) && /20 s/.test(prima.riep) && /battesimo/.test(prima.salvate ?? ''), JSON.stringify(prima)); + // l'anteprima parte da sola nell'uso vero; nelle prove si ferma sul fotogramma + await page.evaluate(() => { window.__dpvTest.ui().montage.partenza = false; document.querySelector('#app').classList.add('lato-lungo'); }); + const prima2 = await page.evaluate(() => (window.__dpv.doc.sequenze ?? []).length); await page.click('.mt-crea'); await page.evaluate(() => window.__dpvTest.ui().montage.ultimo); - await page.waitForFunction(() => document.getElementById('app').dataset.pagina === 'montaggio', null, { timeout: 30000 }).catch(() => {}); - await page.waitForTimeout(800); - const dopo = await page.evaluate(() => { + await page.waitForTimeout(1000); + const stato = () => page.evaluate(() => { const d = window.__dpv.doc; const fps = d.rate.num / d.rate.den; const foto = d.clips.filter((c) => c.kind === 'media' && (window.__fotoMontage ?? []).includes(c.media)); - const fine = Math.max(...foto.map((c) => c.start + c.len)); + const fine = foto.length ? Math.max(...foto.map((c) => c.start + c.len)) : 0; const titoli = d.clips.filter((c) => c.kind === 'title'); const nome = d.sequenze?.find((s) => s.id === d.seqAttiva)?.nome; - const testo = JSON.stringify(titoli.map((c) => c.gen?.anim)); - return { pagina: document.getElementById('app').dataset.pagina, nome, fine, atteso: Math.round(20 * fps), nFoto: foto.length, titoli: titoli.length, testo, tracce: d.tracks.length, blocchi: d.clips.filter((c) => c.kind === 'fx').length }; + const vis = (q) => { const e = document.querySelector(q); return !!e && getComputedStyle(e).display !== 'none'; }; + const box = (q) => { const e = document.querySelector(q); if (!e) return null; const r = e.getBoundingClientRect(); return { y: Math.round(r.top), h: Math.round(r.height), w: Math.round(r.width) }; }; + const forma = JSON.stringify([ + foto.slice().sort((a, b) => a.start - b.start).map((c) => [c.media.slice(-5), c.len, Math.round((c.tfFine?.scale ?? 0) * 1000), Math.round(c.tf?.x ?? 0), Math.round(c.tfFine?.x ?? 0)]), + d.clips.filter((c) => c.kind === 'fx').map((c) => (c.fxb?.id ?? '') + ':' + c.start + ':' + c.len), + titoli.map((c) => c.gen?.anim?.id + ':' + c.start), + ]); + return { + pagina: document.getElementById('app').dataset.pagina, nome, fine, atteso: Math.round(20 * fps), nFoto: foto.length, titoli: titoli.length, testo: JSON.stringify(titoli.map((c) => c.gen?.anim)), + blocchi: d.clips.filter((c) => c.kind === 'fx').length, seq: (d.sequenze ?? []).length, undo: window.__dpv.undoLabel(), forma, + crea: vis('.mt-crea'), rigenera: vis('.mt-rigenera'), importa: vis('.mt-importa'), scarta: vis('.mt-scarta'), variante: document.querySelector('.mt-variante')?.textContent ?? '', + monitor: box('.monitor'), pannello: box('.montage'), timeline: vis('.timeline'), + }; }); - prova('"Crea il montaggio" costruisce la timeline e torna al Montaggio', dopo.pagina === 'montaggio' && /Battesimo/.test(dopo.nome ?? '') && dopo.nFoto >= 3, JSON.stringify(dopo)); - prova('le foto riempiono esattamente 20 secondi e il nome inserito sta nel titolo', dopo.fine === dopo.atteso && /Leonardo/.test(dopo.testo) && dopo.titoli >= 1, JSON.stringify(dopo)); + const dopo = await stato(); + prova('"Crea il montaggio" fa l\'anteprima e resta sulla pagina: ci sono Rigenera, Importa e Scarta, e la variante', dopo.pagina === 'montage' && /Battesimo/.test(dopo.nome ?? '') && dopo.nFoto >= 3 && !dopo.crea && dopo.rigenera && dopo.importa && dopo.scarta && /^Variante [0-9A-Z]{5}$/.test(dopo.variante), JSON.stringify({ ...dopo, forma: undefined })); + prova('le foto riempiono esattamente 20 secondi e il nome inserito sta nel titolo', dopo.fine === dopo.atteso && /Leonardo/.test(dopo.testo) && dopo.titoli >= 1, JSON.stringify([dopo.fine, dopo.atteso])); prova('ci sono transizioni o effetti come blocchetti sopra le clip', dopo.blocchi >= 2, String(dopo.blocchi)); + prova('il monitor sta in alto e grande (anche col pannello Proprietà fissato), il pannello sotto, la timeline nascosta', dopo.monitor && dopo.pannello && dopo.monitor.h > 200 && dopo.monitor.w > 900 && dopo.monitor.y < dopo.pannello.y && dopo.pannello.h > 250 && !dopo.timeline, JSON.stringify([dopo.monitor, dopo.pannello, dopo.timeline])); await page.waitForTimeout(500); await page.screenshot({ path: path.join(OUT, 'montage-risultato.png') }); - // il montaggio si guarda: il monitor a metà non è nero e la riproduzione parte + // l'anteprima si guarda nel monitor di sopra: non è nera, ha colori const guarda = await page.evaluate(async () => { const m = window.__motore; const rec = m.rec; const d = window.__dpv.doc; const fps = d.rate.num / d.rate.den; @@ -238,6 +253,76 @@ export async function proveMontage({ page, prova, OUT }) { prova('il monitor mostra il montaggio (non nero, con colori) a 3, 8 e 14 secondi', guarda.every((p) => p.luce > 25 && p.colori > 4), JSON.stringify(guarda)); await page.screenshot({ path: path.join(OUT, 'montage-monitor.png') }); + console.log('▶ DaProdMontage: rigenera, importa, scarta'); + // rigenera: stesse scelte, ma ogni volta un montaggio diverso; l'anteprima di prima si toglie (non si accumulano timeline) + const forme = [dopo.forma]; + const sequenze = [dopo.seq]; + for (let k = 0; k < 3; k++) { + await page.click('.mt-rigenera'); + await page.evaluate(() => window.__dpvTest.ui().montage.ultimo); + await page.waitForTimeout(500); + const x = await stato(); + forme.push(x.forma); sequenze.push(x.seq); + if (k === 2) prova('dopo ogni rigenera la variante cambia e il tempo resta quello chiesto (20 s)', x.fine === x.atteso && x.variante !== dopo.variante && x.pagina === 'montage', JSON.stringify([x.variante, dopo.variante, x.fine, x.atteso])); + } + prova('rigenerare dà sempre un montaggio diverso (movimenti, durate, transizioni, effetti)', new Set(forme).size === 4, `${new Set(forme).size} diversi su 4`); + prova('le timeline non si accumulano: dopo quattro anteprime ce n\'è una sola in più, e un solo "annulla" le toglie', sequenze.every((n) => n === sequenze[0]) && sequenze[0] <= Math.max(1, prima2) + 1 && sequenze[0] > prima2, JSON.stringify([prima2, sequenze])); + // lo stesso seme dà lo stesso montaggio (serve a ritrovare una variante che piaceva) + const stessi = []; + for (let k = 0; k < 2; k++) { + await page.evaluate(() => window.__dpvTest.ui().montage.crea(424242)); + stessi.push((await stato()).forma); + } + prova('con lo stesso seme esce lo stesso montaggio', stessi[0] === stessi[1] && stessi[0] !== forme[0], ''); + // lo stesso preset, 12 semi: le scelte variano davvero (non solo l'ordine) + const variet = await page.evaluate(() => { + const { MT } = window.__dpvTest; + const doc = window.__dpv.doc; const ids = window.__fotoMontage; + const entrate = ids.map((id, i) => { const m = doc.media.find((x) => x.id === id); return { media: id, nome: m.name, tipo: 'image', durata: 0, w: m.width, h: m.height, data: i, audio: false, punteggio: 0.5 + (i % 3) * 0.1 }; }); + const base = { preset: 'compleanno', durata: 60, ordine: 'data', titoli: true, effetti: true, audioVideo: -14, testi: { titolo: 'T', sottotitolo: '', nomi: 'Sofia', data: '' }, scarta: true }; + const piani = Array.from({ length: 12 }, (_, k) => MT.pianifica(entrate, { ...base, seme: 1000 + k * 7919 })); + const set = (f) => new Set(piani.map(f)).size; + return { + moti: set((p) => p.voci.map((v) => v.moto).join()), durate: set((p) => p.voci.map((v) => v.len.toFixed(2)).join()), transizioni: set((p) => p.transizioni.map((t) => t.id).join()), + effetti: set((p) => JSON.stringify(p.effetti.map((e) => [e.id, Math.round(e.start)]))), sovr: set((p) => p.sovrapposizioni.map((x) => x.anim.id + Math.round(x.start)).join()), + somme: piani.every((p) => Math.abs(p.voci.reduce((a, v) => a + v.len, 0) - 60) < 1e-6), mossa: piani.every((p) => p.voci.every((v, i, a) => !i || v.moto !== a[i - 1].moto)), + limiti: piani.every((p) => p.voci.every((v) => v.len >= 2 && v.forza >= 0.8 && v.forza <= 1.25)), + }; + }); + prova('con 12 semi cambiano movimenti, durate, transizioni, effetti e sovrapposizioni; sempre 60 s esatti e mai due movimenti uguali di fila', variet.moti >= 10 && variet.durate >= 10 && variet.transizioni >= 8 && variet.effetti >= 6 && variet.somme && variet.mossa && variet.limiti, JSON.stringify(variet)); + + // importa: il montaggio resta come timeline vera e si apre il Montaggio + const nSeq = (await stato()).seq; + await page.click('.mt-importa'); + await page.waitForFunction(() => document.getElementById('app').dataset.pagina === 'montaggio', null, { timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(400); + const imp = await stato(); + prova('"Importa nella timeline" tiene il montaggio e apre il Montaggio', imp.pagina === 'montaggio' && imp.seq === nSeq && /Battesimo/.test(imp.nome ?? '') && imp.fine === imp.atteso, JSON.stringify([imp.pagina, imp.seq, nSeq, imp.nome])); + await page.screenshot({ path: path.join(OUT, 'montage-importato.png') }); + await page.keyboard.press('F8'); + await page.waitForTimeout(500); + const dopoImp = await stato(); + prova('tornando sulla pagina c\'è di nuovo solo "Crea" (l\'importato non si tocca)', dopoImp.crea && !dopoImp.rigenera && !dopoImp.importa && dopoImp.seq === nSeq, JSON.stringify([dopoImp.crea, dopoImp.rigenera, dopoImp.seq])); + // un'altra anteprima va in una timeline nuova; "Scarta" la toglie e si torna a com'era + await page.click('.mt-crea'); + await page.evaluate(() => window.__dpvTest.ui().montage.ultimo); + await page.waitForTimeout(500); + const altra = await stato(); + prova('dopo l\'import un\'altra anteprima è una timeline nuova (l\'importata resta)', altra.seq === nSeq + 1 && altra.rigenera, JSON.stringify([altra.seq, nSeq])); + await page.click('.mt-scarta'); + await page.waitForTimeout(400); + const scartata = await stato(); + prova('"Scarta" toglie l\'anteprima: le timeline tornano com\'erano e c\'è di nuovo "Crea"', scartata.seq === nSeq && scartata.crea && !scartata.rigenera && scartata.nome === imp.nome, JSON.stringify([scartata.seq, nSeq, scartata.nome, scartata.undo])); + // un annulla (Ctrl+Z) sull'anteprima: i bottoni tornano a "Crea" + await page.click('.mt-crea'); + await page.evaluate(() => window.__dpvTest.ui().montage.ultimo); + await page.waitForTimeout(400); + await page.evaluate(() => { window.__dpv.doUndo(); window.__dpv.flushNow(); }); + await page.waitForTimeout(500); + const annullata = await stato(); + prova('se annulli l\'anteprima con Ctrl+Z i bottoni tornano a "Crea"', annullata.crea && !annullata.rigenera && annullata.seq === nSeq, JSON.stringify([annullata.crea, annullata.rigenera, annullata.seq])); + await page.evaluate(() => document.querySelector('#app').classList.remove('lato-lungo')); + // solo foto, poche: 3 foto per 40 s → si ripetono, e lo dice await page.keyboard.press('F8'); await page.waitForTimeout(500); diff --git a/test/prove-nuove.mjs b/test/prove-nuove.mjs index 2b29e6a..e35baaf 100644 --- a/test/prove-nuove.mjs +++ b/test/prove-nuove.mjs @@ -9,13 +9,14 @@ import { servi } from './servi.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -import { leggi, vicino } from './aiuti.mjs'; +import { leggi, vicino, apriGruppi } from './aiuti.mjs'; export { leggi, vicino }; -export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage']) { +export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage', 'pannello']) { 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('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 }); } } @@ -118,6 +119,7 @@ async function proveSfondo({ page, prova, OUT }) { console.log('▶ Togliere lo sfondo: le proprietà e il contagocce'); await page.evaluate(() => { const c = window.__dpv.doc.clips.find((x) => x.name === 'Fondale verde'); window.__dpv.select([c.id]); window.__motore.vaiA(10); }); await page.waitForTimeout(300); + await apriGruppi(page); prova('nelle proprietà c\'è "Togli lo sfondo" con tutti i modi', (await page.locator('.isp-gruppo summary:has-text("Togli lo sfondo")').count()) === 1 && (await page.locator('.isp-gruppo:has(summary:has-text("Togli lo sfondo")) .chip').count()) >= 6); prova('con la chiave a colori si vedono contagocce, colori pronti e i cursori', (await page.locator('button:has-text("Contagocce")').count()) === 1 && (await page.locator('button:has-text("Trovalo da solo")').count()) === 1 && (await page.locator('.isp-riga:has(label:has-text("Via il riflesso"))').count()) === 1); // il contagocce: un clic sull'angolo verde del monitor mette quel colore diff --git a/test/prove-pannello.mjs b/test/prove-pannello.mjs new file mode 100644 index 0000000..7728d98 --- /dev/null +++ b/test/prove-pannello.mjs @@ -0,0 +1,123 @@ +// Le prove del pannello Proprietà: tutto chiuso di partenza, le sezioni si riordinano trascinando e l'ordine si ricorda +// per ogni tipo di elemento (immagine, titolo, animazione… e blocchi FX), anche dopo aver riaperto il programma. +import path from 'node:path'; + +export async function provePannello({ page: paginaDi, prova, OUT }) { + console.log('▶ Proprietà: sezioni chiuse e riordinabili'); + // una pagina pulita: le prove di prima hanno aperto delle sezioni e riempito il progetto + const contesto = await paginaDi.context().browser().newContext({ viewport: { width: 1600, height: 950 } }); + const page = await contesto.newPage(); + const errori = []; + page.on('pageerror', (e) => errori.push(e.message)); + 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); + await page.evaluate(() => { try { localStorage.removeItem('dpv-isp-ordine'); } catch { /* niente */ } }); + const ids = await page.evaluate(async () => { + const { importaFile, P, B } = window.__dpvTest; + const c = new OffscreenCanvas(640, 360); const x = c.getContext('2d'); x.fillStyle = '#c33'; x.fillRect(0, 0, 640, 360); + const [m] = await importaFile([{ name: 'pannello.png', file: new File([await c.convertToBlob({ type: 'image/png' })], 'pannello.png', { type: 'image/png' }) }], { chiediFormato: false }); + const p = window.__dpv.doc; + const v = p.tracks.filter((t) => t.kind === 'video'); + const alta = v[0].id; + let foto1, foto2, titolo, blocco; + window.__dpv.edit('Prova pannello', (pp) => { + const a = P.newClip('media', alta, 0, 100, { media: m.id, name: 'Foto A' }); + const b = P.newClip('media', alta, 120, 100, { media: m.id, name: 'Foto B' }); + const t = P.newClip('title', v[1]?.id ?? alta, 300, 100, { name: 'Un titolo', gen: { title: { ...P.TITLE0 } } }); + pp.clips.push(a, b, t); + foto1 = a.id; foto2 = b.id; titolo = t.id; + const bl = B.posaBlocco(pp, B.nuovoBlocco('effetto', 'sogno'), 130, 25, alta); + blocco = bl.id; + }); + return { foto1, foto2, titolo, blocco }; + }); + await page.evaluate(() => { const a = document.querySelector('#app'); a.classList.remove('senza-lato', 'stretto'); }); + const scegli = async (id) => { await page.evaluate((i) => window.__dpv.select([i]), id); await page.click('.lato .scheda[data-s=clip]').catch(() => {}); await page.waitForTimeout(450); }; + const gruppi = () => page.evaluate(() => [...document.querySelectorAll('.isp-corpo > .isp-gruppo')].map((d) => ({ g: d.dataset.g, aperto: d.open, titolo: d.querySelector('summary').textContent.replace('⠿', '').trim() }))); + + await scegli(ids.foto1); + const g0 = await gruppi(); + prova('scegliendo una clip le sezioni del pannello sono tutte chiuse (di partenza)', g0.length >= 4 && g0.every((x) => !x.aperto), JSON.stringify(g0)); + await page.screenshot({ path: path.join(OUT, 'pannello-chiuso.png') }); + + // si apre una sezione e resta aperta anche scegliendo un'altra clip dello stesso tipo + await page.click('.isp-corpo > .isp-gruppo[data-g="durata"] > summary'); + await page.waitForTimeout(200); + await scegli(ids.foto2); + const g1 = await gruppi(); + prova('una sezione aperta resta aperta passando a un\'altra clip; le altre restano chiuse', g1.find((x) => x.g === 'durata')?.aperto && g1.filter((x) => x.aperto).length === 1, JSON.stringify(g1)); + + // il clic sulla maniglia non apre né chiude + const prima = (await gruppi()).find((x) => x.g === 'immagine').aperto; + await page.click('.isp-gruppo[data-g="immagine"] .isp-grip'); + prova('un clic sulla maniglia non apre né chiude la sezione', (await gruppi()).find((x) => x.g === 'immagine').aperto === prima); + + // si trascina "Durata" in cima + const ordineDi = async () => (await gruppi()).map((x) => x.g); + const prima2 = await ordineDi(); + const trascina = async (g, sopraA) => { + const grip = await page.locator(`.isp-gruppo[data-g="${g}"] .isp-grip`).boundingBox(); + const meta = await page.locator(`.isp-gruppo[data-g="${sopraA}"]`).boundingBox(); + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); + await page.mouse.down(); + await page.mouse.move(grip.x + grip.width / 2, grip.y - 20, { steps: 4 }); + await page.mouse.move(grip.x + grip.width / 2, meta.y + 3, { steps: 8 }); + await page.mouse.up(); + await page.waitForTimeout(500); + }; + await trascina('durata', prima2[0]); + const dopo = await ordineDi(); + prova('trascinando la maniglia di "Durata" in cima, la sezione va per prima', dopo[0] === 'durata' && dopo.length === prima2.length && prima2.every((g) => dopo.includes(g)), JSON.stringify([prima2, dopo])); + const salvato = await page.evaluate(() => JSON.parse(localStorage.getItem('dpv-isp-ordine') ?? '{}')); + prova('l\'ordine è salvato per questo tipo di elemento (clip:immagine)', salvato['clip:immagine']?.[0] === 'durata' && Object.keys(salvato).length === 1, JSON.stringify(salvato)); + prova('durante il trascinamento la sezione aperta è rimasta com\'era', (await gruppi()).find((x) => x.g === 'durata')?.aperto === true); + await page.screenshot({ path: path.join(OUT, 'pannello-riordinato.png') }); + + // lo stesso ordine per ogni clip dello stesso tipo + await scegli(ids.foto1); + prova('scegliendo un\'altra clip dello stesso tipo l\'ordine è quello salvato', (await ordineDi())[0] === 'durata', JSON.stringify(await ordineDi())); + // un altro tipo (titolo) ha il suo ordine, che non si è mosso + await scegli(ids.titolo); + const gt = await ordineDi(); + prova('un titolo ha il suo ordine, non toccato da quello delle immagini', gt.length >= 2 && gt[0] !== 'durata', JSON.stringify(gt)); + // un blocco FX ha il suo ordine, e si può riordinare anche lì + await scegli(ids.blocco); + const gb = await ordineDi(); + if (gb.length >= 2) { + await trascina(gb[gb.length - 1], gb[0]); + const gb2 = await ordineDi(); + const s2 = await page.evaluate(() => JSON.parse(localStorage.getItem('dpv-isp-ordine') ?? '{}')); + prova('anche i blocchi FX si riordinano, con il loro ordine salvato a parte', gb2[0] === gb[gb.length - 1] && !!s2['blocco:effetto'] && s2['clip:immagine'][0] === 'durata', JSON.stringify([gb, gb2, s2])); + } else prova('i blocchi FX hanno almeno due sezioni', false, JSON.stringify(gb)); + + // dopo aver riaperto il programma l'ordine c'è ancora + await page.reload(); + await page.waitForSelector('.pulsantiera'); + await page.waitForTimeout(1200); + const rimasto = await page.evaluate(() => JSON.parse(localStorage.getItem('dpv-isp-ordine') ?? '{}')); + prova('riaprendo il programma l\'ordine è ancora salvato', rimasto['clip:immagine']?.[0] === 'durata', JSON.stringify(rimasto)); + // (il progetto non c'è più dopo il ricaricamento: si rifà una clip per vedere l'ordine applicato) + await page.evaluate(async () => { + const { importaFile, P } = window.__dpvTest; + const c = new OffscreenCanvas(640, 360); c.getContext('2d').fillRect(0, 0, 640, 360); + const [m] = await importaFile([{ name: 'pannello2.png', file: new File([await c.convertToBlob({ type: 'image/png' })], 'pannello2.png', { type: 'image/png' }) }], { chiediFormato: false }); + const alta = window.__dpv.doc.tracks.filter((t) => t.kind === 'video')[0].id; + window.__dpv.edit('Prova', (pp) => { const a = P.newClip('media', alta, 0, 100, { media: m.id, name: 'Foto C' }); pp.clips.push(a); window.__dpv.select([a.id]); }); + }); + await page.click('.lato .scheda[data-s=clip]').catch(() => {}); + await page.waitForTimeout(600); + const dopoRiavvio = await ordineDi(); + prova('con una clip nuova, dopo il riavvio, le sezioni sono già nell\'ordine scelto', dopoRiavvio[0] === 'durata' && (await gruppi()).every((x) => !x.aperto), JSON.stringify(dopoRiavvio)); + + // "Ordine di partenza" rimette tutto com'era + const haBottone = await page.locator('.isp-ripristina').count(); + await page.click('.isp-ripristina'); + await page.waitForTimeout(500); + const ripristinato = await ordineDi(); + const rim2 = await page.evaluate(() => JSON.parse(localStorage.getItem('dpv-isp-ordine') ?? '{}')); + prova('"↺ Ordine di partenza" rimette le sezioni come all\'inizio per quel tipo (e si toglie dal salvataggio)', haBottone === 1 && ripristinato[0] !== 'durata' && !rim2['clip:immagine'], JSON.stringify([haBottone, ripristinato, rim2])); + prova('nessun errore nella pagina del pannello', errori.length === 0, errori.slice(0, 3).join(' | ')); + await contesto.close(); +} diff --git a/test/prove.mjs b/test/prove.mjs index 67b692a..b356930 100644 --- a/test/prove.mjs +++ b/test/prove.mjs @@ -260,6 +260,8 @@ try { await page.evaluate((id) => window.__dpv.select([id]), clipFoto.id); await page.click('.lato .scheda[data-s=clip]'); await page.waitForTimeout(200); + await page.evaluate(() => document.querySelectorAll('.isp-gruppo:not([open]) > summary').forEach((x) => x.click())); + await page.waitForTimeout(150); await page.click('.isp-pulsanti button:has-text("+5 s")'); dd = await doc(); prova('l\'istantanea si allunga (+5 s dalle proprietà)', dd.clips.find((c) => c.id === clipFoto.id).len === 50 + 125, dd.clips.find((c) => c.id === clipFoto.id).len);