From cc68ee52a5df7c0df256ebcad0792956cbd3f65a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:18:51 +0000 Subject: [PATCH 1/2] 1.2.1: le rifiniture (salvataggio sicuro, taglio col movimento, audio dell'export senza cuciture, niente lampi neri nel play, tasti) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - progetto salvato scrivendo accanto e rinominando (Rust); errore di salvataggio mostrato; ricollega salva il percorso - store.edit torna com'era se la modifica si rompe; revisione per l'autosalvataggio (scrive solo se è cambiato qualcosa) - browser: "Nuovo progetto" non butta i file dei recenti (potaDeposito), Ctrl+Z dopo "togli dal contenitore" ritrova il file - splitClip divide anche il movimento (tf, tappe, tfFine): niente ripartenza da capo sul taglio - copia/incolla porta gli effetti e le transizioni della clip; abbina fotogramma giusto con la velocità; il tasto 1 non sceglie un blocchetto FX - mixaggio: ogni pezzo da 10 s parte 1,5 s prima (filtri, eco e limitatore non ripartono a ogni cucitura) - fotogrammi: l'ultimo fotogramma resta come ponte quando un flusso riparte a metà play (niente nero) - Esporta: "Fatto" chiude (non riparte), la finestra non si chiude mentre lavora - Ctrl+Alt+A di nuovo traccia audio (animazione su Ctrl+Alt+N); tasti fermi sotto la pagina iniziale; loop senza attacco/stacco - proxy in pausa fermabile col ✕; avvisi più in alto sul telefono; panorama del mixer annullabile anche da tastiera - prove nuove: test/prove-rifiniture.mjs; CHANGELOG, CLAUDE.md, versione 1.2.1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TqgA6T2ncfxMbmoaKeJWS4 --- CHANGELOG.md | 36 ++++++++++ CLAUDE.md | 8 +++ package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 21 +++++- src/azioni.ts | 40 ++++++++--- src/core/montaggio.ts | 26 +++++++- src/core/store.ts | 15 ++++- src/media/audio.ts | 45 ++++++++----- src/media/fotogrammi.ts | 32 +++++++-- src/media/libreria.ts | 5 ++ src/media/proxy.ts | 4 +- src/motore.ts | 3 +- src/progetti.ts | 63 ++++++++++++++++-- src/stile/editor.css | 2 + src/ui/app.ts | 9 ++- src/ui/dialoghi.ts | 15 +++-- src/ui/dom.ts | 15 +++-- src/ui/home.ts | 3 + src/ui/mixer.ts | 3 +- src/ui/tastiera.ts | 1 + test/prove-nuove.mjs | 3 +- test/prove-rifiniture.mjs | 136 ++++++++++++++++++++++++++++++++++++++ 25 files changed, 433 insertions(+), 62 deletions(-) create mode 100644 test/prove-rifiniture.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce4757..2b75d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ 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.2.1] — 2026-10-07 · Le rifiniture: più sicuro, più preciso, niente sorprese 🔧 + +Una versione di pulizia: nessuno strumento nuovo, ma tante piccole cose che prima potevano andare storte. + +### Salvare senza paura +- **Il progetto si salva in modo sicuro** (nell'app): si scrive accanto e poi si sostituisce, così se il programma o il computer si fermano a metà il progetto di prima resta intero. Prima il file si svuotava e si riscriveva: un'interruzione poteva lasciarlo rovinato. +- **Se il salvataggio non riesce lo dice** (disco pieno, cartella protetta, chiavetta tolta), con l'invito a usare *Salva come…*. Prima non compariva niente e sembrava salvato. +- **Ricollegare i file mancanti ora resta nel progetto**: il percorso nuovo si salva (prima, chiudendo senza altre modifiche, al giro dopo il file risultava di nuovo mancante). +- **Una modifica che si rompe a metà non lascia il montaggio mezzo cambiato**: si torna a com'era. +- L'autosalvataggio scrive solo quando è cambiato qualcosa (prima riscriveva tutto il progetto ogni 15 secondi finché non salvavi). +- Nel browser: **"Nuovo progetto" non butta più i file dei progetti recenti** (riaprendoli dalla pagina iniziale mancavano), e le copie dei progetti usciti dall'elenco si tolgono da sole. Togliere un file dal contenitore e poi Ctrl+Z lo ritrova anche dopo aver riaperto il browser. + +### Montaggio più preciso +- **Tagliare (tasto 1) una clip che si muove** — per esempio una foto col Ken Burns di DaProdMontage, o una clip con le tappe — ora **continua il movimento dov'era**: il pezzo di sinistra fa la prima parte della strada e quello di destra il resto. Prima tutti e due ripartivano da capo e l'immagine "tornava indietro" sul taglio. +- **Copia e incolla portano anche gli effetti e le transizioni** che stanno sulla clip (e la seguono anche se finisce su un'altra traccia). Prima restavano indietro. +- **Abbina fotogramma (F)** porta la sorgente al fotogramma giusto anche nelle clip accelerate o rallentate. +- Dopo il taglio (tasto 1) viene scelto sempre un pezzo di clip, mai un blocchetto FX che finiva lì per caso (premendo 2 spariva l'effetto invece dello scarto). +- **Loop (Ctrl+L) senza attacco e stacco**: rifà tutto il montaggio da capo (prima si fermava in fondo). + +### Riproduzione +- **Niente più lampi neri durante il play**: quando la lettura del video ripartiva a metà (un salto al fotogramma chiave più avanti, o la copia leggera che diventa pronta mentre suona) per un attimo il monitor diventava nero o tornava al fotogramma di quando avevi premuto play. Ora resta l'ultimo fotogramma vero finché arriva il nuovo. + +### Export +- **L'audio esportato non ha più cuciture ogni 10 secondi**: il mixaggio si fa a pezzi, e a ogni pezzo filtri (voce, radio, ovattato), eco e limitatore ripartivano da zero, con un piccolo scatto e la coda dell'eco tagliata. Ora ogni pezzo parte "già caldo". +- **La finestra Esporta resta aperta mentre lavora**: Esc o un clic fuori la chiudevano lasciando l'export a girare senza modo di fermarlo. Si ferma col tasto Annulla. +- **"Fatto" chiude la finestra** a export finito (prima lo faceva ripartire da capo, con la richiesta di dove salvare). + +### Tasti e pagine +- **Ctrl+Alt+A aggiunge di nuovo una traccia audio**: lo stesso tasto era preso anche dall'animazione, che ora sta su **Ctrl+Alt+N**. +- **Con la pagina iniziale aperta i tasti non toccano il montaggio che sta sotto** (prima Spazio lo faceva suonare e 2 toglieva una clip senza vederla). Valgono Esc, Ctrl+O e F11. +- Sul telefono gli avvisi stanno sopra la barra delle attività e i pulsanti in basso (prima li coprivano). +- Le copie leggere (proxy) in pausa perché il montaggio suona ora si fermano subito col ✕ del centro attività. + +### Per chi ha curiosità +- Prove nuove in `test/prove-rifiniture.mjs` (girano dentro `node test/prove.mjs`, o da sole con `node test/prove-nuove.mjs rifiniture`): falliscono sulla 1.2.0 e passano qui. + ## [1.2.0] — 2026-10-03 · La prima release ufficiale: più veloce, più chiaro, la barra delle attività ⚡ Questa versione non aggiunge strumenti: rende **più fluido e più chiaro quello che c'è già**. diff --git a/CLAUDE.md b/CLAUDE.md index ceb6e41..a9784c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,14 @@ Editor video "vecchio stile, moderno dentro" (ispirato a EDIUS e alle centraline - **Conversione VFR/CBR dietro le quinte (1.2.0)**: `controllaVelocita` in `progetti.ts` (dopo l'import: esame nel gruppo "esame", conversione nella corsia pesante) e `mettiCopia`: la copia prende il posto del file con `store.aggiornaMedia(id, campi, spostaSrc)` (cambia progetto **e** cronologia annulla/ripeti, e sposta `srcIn` se l'inizio del file è cambiato), poi `apriMedia` di nuovo. `importa(sel, {soloDescrizione:true})` legge com'è fatto un file senza locandina né lavori. Nel browser la copia si tiene in IndexedDB `file` (≤ 300 MB) e si toglie la maniglia. +- **Rifiniture (1.2.1)**: `store.edit` rimette il progetto com'era se la modifica lancia un errore; `store.revisione` sale a ogni + modifica e `autosalva` scrive solo se è cambiata. `progetto_scrivi` (Rust) scrive `.salvo.tmp` e rinomina. `splitClip` divide anche + il movimento (`splitMovimento`: tf/tappe/tfFine ripartiti fra i due pezzi). `mixaggio` rende ogni pezzo da 10 s con 1,5 s prima + (`SCALDA`, poi buttati) così filtri, eco e limitatore non ripartono a ogni cucitura. `dialogo({chiudibile: () => bool})` = si chiude con + Esc/✕/clic fuori solo quando dice sì (Esporta mentre lavora). Con la pagina iniziale aperta (`html.home-aperta`) i tasti del montaggio + non passano. Nel browser `potaDeposito` (progetti.ts) tiene in IndexedDB solo i file del montaggio aperto e dei recenti. Copia/incolla + porta i blocchetti FX della clip (`padreDi`). In `fotogrammi.ts` un flusso buttato a metà play lascia il suo ultimo fotogramma in + `ponti` finché il nuovo non ne ha uno (niente nero). Prove: `test/prove-rifiniture.mjs`; nessun tasto deve stare su due comandi (lo controlla la prova). - **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/package-lock.json b/package-lock.json index 3d88ec1..c24eef6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.2.0", + "version": "1.2.1", "license": "MIT", "dependencies": { "@fontsource/archivo-black": "^5.3.0", diff --git a/package.json b/package.json index 2fef96d..892786b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.2.0", + "version": "1.2.1", "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 c70fb59..b8bec3e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -624,7 +624,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "daprod-video" -version = "1.2.0" +version = "1.2.1" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b8101e0..9c8ba7e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.2.0" +version = "1.2.1" description = "DaProd Video: il montaggio vecchio stile, moderno dentro" authors = ["DaProd"] license = "MIT" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 59a6123..bcd1df2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,9 +108,28 @@ async fn progetto_leggi(app: AppHandle, path: String) -> Esito { .map_err(|e| format!("non riesco a leggere {path}: {e}")) } -/// Scrive un progetto, una EDL o un altro file di testo. +/// Scrive un progetto, una EDL o un altro file di testo. Sul disco vero si scrive accanto e poi si rinomina: se il +/// programma o il computer si fermano a metà, il progetto di prima resta intero (prima si troncava e si riscriveva). #[tauri::command] async fn progetto_scrivi(app: AppHandle, path: String, text: String) -> Esito<()> { + if let FilePath::Path(p) = percorso(&path) { + let mut nome = p.file_name().map(|n| n.to_os_string()).unwrap_or_default(); + nome.push(".salvo.tmp"); + let tmp = p.with_file_name(nome); + let scritto = (|| -> std::io::Result<()> { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(text.as_bytes())?; + f.sync_all()?; + std::fs::rename(&tmp, &p) + })(); + match scritto { + Ok(()) => return Ok(()), + // cartelle dove non si possono creare file accanto (o rinominare): si scrive come prima + Err(_) => { + let _ = std::fs::remove_file(&tmp); + } + } + } let mut o = OpenOptions::new(); o.write(true).create(true).truncate(true); let mut f = app diff --git a/src/azioni.ts b/src/azioni.ts index 90101f5..c038b44 100644 --- a/src/azioni.ts +++ b/src/azioni.ts @@ -3,7 +3,7 @@ import { store } from './core/store'; import { motore } from './motore'; import * as M from './core/montaggio'; -import { clipById, end, isVideoClip, newClip, newTrack, nextTrackName, projectEnd, TITLE0, trackOf, uid } from './core/progetto'; +import { clipById, end, isVideoClip, newClip, newTrack, nextTrackName, projectEnd, srcTimeAt, TITLE0, trackOf, uid } from './core/progetto'; import { fps, frameToTc, s2f } from './core/timecode'; import { avviso } from './ui/dom'; import { finestraVelocita } from './ui/velocita'; @@ -12,7 +12,7 @@ import { STILI_CONTO, type StileConto } from './render/grafica'; import { applicaPresetTitolo, presetTitolo } from './core/generatori'; import { suonoTitolo } from './core/suoni'; import { ANIMAZIONI, animazione, nuovaAnim } from './core/animazioni'; -import { durataBlocco, nomeBlocco, nuovoBlocco, posaBlocco, postoBlocco, taglioVicino, tracciaPerBlocco, transizioniSul } from './core/blocchi'; +import { blocchiDi, durataBlocco, nomeBlocco, nuovoBlocco, posaBlocco, postoBlocco, taglioVicino, tracciaPerBlocco, transizioniSul } from './core/blocchi'; export interface Azione { id: string; @@ -109,7 +109,7 @@ reg({ const p = store.doc; const peso = (c: Clip) => (isVideoClip(c) ? (c.kind === 'media' ? 0 : 1000) : 2000) + p.tracks.findIndex((t) => t.id === c.track); const dx = destre.map((id) => clipById(p, id)!).filter(Boolean).sort((a, b) => peso(a) - peso(b))[0]; - const sx = p.clips.find((c) => c.track === dx.track && end(c) === f && c.id !== dx.id); + const sx = p.clips.find((c) => c.track === dx.track && end(c) === f && c.id !== dx.id && M.solida(c)); const corto = sx && f - sx.start < dx.len ? sx : dx; store.select(M.withLinked(p, [corto.id])); avviso(`✂ Taglio a ${frameToTc(f, p.rate, p.drop)} · selezionato il pezzo ${corto === dx ? 'di destra' : 'di sinistra'} (più corto): 2 per toglierlo`, 'tasto', 1800); @@ -258,9 +258,20 @@ reg({ id: 'annulla', nome: 'Annulla', gruppo: 'Modifica', tasti: ['Ctrl+Z'], fn: reg({ id: 'ripeti', nome: 'Ripeti', gruppo: 'Modifica', tasti: ['Ctrl+Y', 'Ctrl+Shift+Z'], fn: () => { const l = store.doRedo(); avviso(l ? `↷ Ripetuto: ${l}` : 'Niente da ripetere', 'info', 1200); } }); let appunti: Clip[] = []; +/** per ogni blocchetto FX copiato, la clip su cui stava: incollando la segue (anche se finisce su un'altra traccia) */ +let padreDi = new Map(); reg({ id: 'copia', nome: 'Copia clip', gruppo: 'Modifica', tasti: ['Ctrl+C'], - fn: () => { const ids = M.withLinked(store.doc, store.sel); appunti = structuredClone(store.doc.clips.filter((c) => ids.has(c.id))); if (appunti.length) avviso(`${appunti.length} clip copiate`, 'info', 1000); }, + fn: () => { + const p = store.doc; + const ids = M.withLinked(p, store.sel); + // gli effetti e le transizioni che stanno sulle clip copiate vanno con loro (prima restavano indietro) + padreDi = new Map(); + for (const c of p.clips) if (ids.has(c.id) && M.solida(c)) for (const b of blocchiDi(p, c)) { ids.add(b.id); padreDi.set(b.id, c.id); } + appunti = structuredClone(p.clips.filter((c) => ids.has(c.id))); + const n = appunti.filter(M.solida).length || appunti.length; + if (appunti.length) avviso(`${n} clip copiat${n === 1 ? 'a' : 'e'}`, 'info', 1000); + }, }); reg({ id: 'tagliaAppunti', nome: 'Taglia clip negli appunti', gruppo: 'Modifica', tasti: ['Ctrl+X'], @@ -274,12 +285,14 @@ reg({ const min = Math.min(...appunti.map((c) => c.start)); const ids = store.edit('Incolla', (p) => { const links = new Map(); + const daVecchio = new Map(); const nuove = appunti.map((c) => { const n = structuredClone(c); n.id = uid('c'); n.start = c.start - min + f; if (c.link) { if (!links.has(c.link)) links.set(c.link, uid('l')); n.link = links.get(c.link); } if (!p.tracks.some((t) => t.id === n.track)) n.track = p.tracks.find((t) => t.kind === (c.kind === 'fx' || isVideoClip(c) ? 'video' : 'audio'))!.id; + daVecchio.set(c.id, n); return n; }); const set = new Set(nuove.map((c) => c.id)); @@ -289,11 +302,17 @@ reg({ M.insertSpace(p, a, b - a, new Set(p.tracks.filter((t) => !t.lock).map((t) => t.id)), set); } else { // niente viene coperto: se la traccia è occupata la clip va su una libera - for (const c of nuove) { - // i blocchetti FX stanno sopra le clip: restano sulla loro traccia - if (c.kind !== 'fx') c.track = M.tracciaLibera(p, trackOf(p, c.track).kind, c.track, c.start, end(c)); + for (const c of nuove.filter(M.solida)) { + c.track = M.tracciaLibera(p, trackOf(p, c.track).kind, c.track, c.start, end(c)); p.clips.push(c); } + // i blocchetti FX stanno sopra le clip: seguono la loro clip (o restano sulla loro traccia) + for (const [vecchio, n] of daVecchio) { + if (M.solida(n)) continue; + const padre = daVecchio.get(padreDi.get(vecchio) ?? ''); + if (padre) n.track = padre.track; + p.clips.push(n); + } } return [...set]; }); @@ -334,7 +353,7 @@ reg({ id: 'fine', nome: 'Alla fine', gruppo: 'Trasporto', tasti: ['End'], fn: () => { if (motore.attivo === 'recorder') motore.vaiA(projectEnd(store.doc)); else { const m = store.doc.media.find((x) => x.id === motore.playerMedia); if (m) motore.playerVaiA(m.duration); } }, }); -reg({ id: 'loop', nome: 'Riproduzione in loop (attacco-stacco)', gruppo: 'Trasporto', tasti: ['Ctrl+L'], fn: () => { motore.loop = !motore.loop; avviso(motore.loop ? '⟳ Loop acceso' : 'Loop spento', 'info', 1000); store.emit('status'); } }); +reg({ id: 'loop', nome: 'Riproduzione in loop (attacco-stacco, o tutto il montaggio)', gruppo: 'Trasporto', tasti: ['Ctrl+L'], fn: () => { motore.loop = !motore.loop; avviso(motore.loop ? '⟳ Loop acceso' : 'Loop spento', 'info', 1000); store.emit('status'); } }); reg({ id: 'monitor', nome: 'Monitor: sorgente ↔ montaggio', gruppo: 'Trasporto', tasti: ['Tab'], info: 'Il monitor mostra il montaggio; con doppio clic su un file del contenitore mostra la sorgente. Tab passa dall\'uno all\'altro.', @@ -463,7 +482,8 @@ reg({ fn: () => { const c = M.topClipAt(store.doc, head(), 'video') ?? M.topClipAt(store.doc, head()); if (!c?.media) { avviso('Sotto il cursore non c\'è una sorgente', 'info'); return; } - const t = c.srcIn + (head() - c.start) / r(); + // la sorgente allo stesso fotogramma anche nelle clip accelerate o rallentate + const t = srcTimeAt(store.doc, c, head()); motore.caricaPlayer(c.media, t); motore.setMonitor('player'); avviso('La sorgente nel monitor allo stesso fotogramma (Tab per tornare al montaggio)', 'info'); @@ -587,6 +607,6 @@ reg({ id: 'genNero', nome: 'Nero', gruppo: 'Generatori', fn: () => inserisciGene reg({ id: 'genColore', nome: 'Colore pieno', gruppo: 'Generatori', fn: () => inserisciGeneratore('color') }); reg({ id: 'genCountdown', nome: 'Countdown da pellicola', gruppo: 'Generatori', tasti: ['Ctrl+Alt+C'], fn: () => inserisciGeneratore('countdown') }); reg({ id: 'genTitolo', nome: 'Titolo', gruppo: 'Generatori', tasti: ['T', 'Ctrl+T'], fn: () => inserisciGeneratore('title') }); -reg({ id: 'genAnimazione', nome: 'Animazione (sottopancia, testo che si muove…)', gruppo: 'Generatori', tasti: ['Ctrl+Alt+A'], fn: () => inserisciGeneratore('anim') }); +reg({ id: 'genAnimazione', nome: 'Animazione (sottopancia, testo che si muove…)', gruppo: 'Generatori', tasti: ['Ctrl+Alt+N'], fn: () => inserisciGeneratore('anim') }); export { head as cursore }; diff --git a/src/core/montaggio.ts b/src/core/montaggio.ts index 4090c17..b0b2b80 100644 --- a/src/core/montaggio.ts +++ b/src/core/montaggio.ts @@ -2,7 +2,7 @@ // I nomi sono quelli della centralina: taglia, elimina, elimina e chiudi, solleva (lift), estrai (extract), // inserisci, sovrascrivi, trim, roll, slip. import type { Clip, Key, Project, TrackKind, Transition } from './tipi'; -import { clipsOn, end, handles, keyValue, mediaOf, newClip, newTrack, nextTrackName, srcTimeAt, trackOf, uid, isVideoClip } from './progetto'; +import { clipsOn, end, handles, keyValue, mediaOf, newClip, newTrack, nextTrackName, srcTimeAt, tfAl, trackOf, uid, isVideoClip } from './progetto'; import { f2s } from './timecode'; import { blocchiDi, centro } from './blocchi'; @@ -22,6 +22,29 @@ function splitKeys(keys: Key[], cut: number, base: number): [Key[], Key[]] { return [left, right]; } +/** + * Il movimento (tf → tappe → tfFine) diviso al fotogramma locale lf: ognuno dei due pezzi fa solo la sua parte + * di strada (prima si ripartiva da capo in tutti e due: il Ken Burns di una foto tagliata tornava indietro). + */ +function splitMovimento(c: Clip, right: Clip, lf: number) { + const n = Math.max(1, c.len - 1), rlen = c.len - lf; + const fine = { ...c.tf, ...tfAl(c, lf - 1) }, inizio = { ...c.tf, ...tfAl(c, lf) }; + const via = c.via ?? []; + // a destra: dal punto del taglio all'arrivo di prima + right.tf = inizio; + if (rlen > 1) { + right.tfFine = structuredClone(c.tfFine); + right.via = via.map((v) => ({ t: (v.t * n - lf) / (rlen - 1), tf: structuredClone(v.tf) })).filter((v) => v.t > 0.001 && v.t < 0.999); + } else { right.tfFine = undefined; right.via = undefined; } + // a sinistra: dalla partenza a dove era l'ultimo fotogramma prima del taglio + if (lf > 1) { + c.via = via.map((v) => ({ t: (v.t * n) / (lf - 1), tf: v.tf })).filter((v) => v.t > 0.001 && v.t < 0.999); + c.tfFine = fine; + } else { c.tf = fine; c.tfFine = undefined; c.via = undefined; } + if (!c.via?.length) c.via = undefined; + if (!right.via?.length) right.via = undefined; +} + /** taglia una clip al fotogramma f: ritorna il pezzo di destra (nuovo) o null */ export function splitClip(p: Project, c: Clip, f: number, newLink?: string): Clip | null { if (f <= c.start || f >= end(c)) return null; @@ -36,6 +59,7 @@ export function splitClip(p: Project, c: Clip, f: number, newLink?: string): Cli right.link = newLink ?? c.link; [c.opKeys, right.opKeys] = splitKeys(c.opKeys, lf, c.opacity); [c.gainKeys, right.gainKeys] = splitKeys(c.gainKeys, lf, c.gain); + if (c.tfFine) splitMovimento(c, right, lf); c.len = lf; c.fadeOut = 0; if (c.trIn && c.trIn.len > c.len) c.trIn.len = c.len; diff --git a/src/core/store.ts b/src/core/store.ts index 86079f9..14c3bf7 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -19,6 +19,8 @@ class Store { /** traccia su cui si è cliccato per ultimo */ focusTrack: string | null = null; dirty = false; + /** sale a ogni modifica del documento: l'autosalvataggio scrive solo se è cambiato qualcosa dall'ultima volta */ + revisione = 0; private undo: Snap[] = []; private redo: Snap[] = []; private subs = new Map void>>(); @@ -64,11 +66,14 @@ class Store { /** esegue una modifica annullabile */ edit(label: string, fn: (p: Project) => T): T { const before = this.snap(label); - const r = fn(this.doc); + let r: T; + // una modifica che si rompe a metà non lascia il progetto mezzo cambiato: si torna a com'era + try { r = fn(this.doc); } catch (e) { this.restore(before); this.emit('doc', 'sel'); throw e; } this.undo.push(before); if (this.undo.length > LIMITE_ANNULLA) this.undo.shift(); this.redo.length = 0; this.dirty = true; + this.revisione++; const ids = new Set(this.doc.clips.map((c) => c.id)); for (const id of [...this.sel]) if (!ids.has(id)) this.sel.delete(id); this.emit('doc', 'sel'); @@ -94,6 +99,7 @@ class Store { tocca(this.doc); for (const s of [...this.undo, ...this.redo, ...(this.live ? [this.live] : [])]) tocca(s); this.dirty = true; + this.revisione++; this.emit('doc'); } @@ -104,12 +110,16 @@ class Store { } liveChange() { this.dirty = true; + this.revisione++; this.emit('doc'); } commit(changed = true) { if (this.live && changed) { this.undo.push(this.live); + if (this.undo.length > LIMITE_ANNULLA) this.undo.shift(); this.redo.length = 0; + this.dirty = true; + this.revisione++; } else if (this.live && !changed) { this.restore(this.live); } @@ -133,6 +143,7 @@ class Store { this.redo.push(this.snap(s.label)); this.restore(s); this.dirty = true; + this.revisione++; this.emit('doc', 'sel', 'view'); return s.label; } @@ -143,6 +154,7 @@ class Store { this.undo.push(this.snap(s.label)); this.restore(s); this.dirty = true; + this.revisione++; this.emit('doc', 'sel', 'view'); return s.label; } @@ -155,6 +167,7 @@ class Store { this.redo = []; this.head = 0; this.dirty = false; + this.revisione++; this.emit('doc', 'sel', 'head', 'view'); } diff --git a/src/media/audio.ts b/src/media/audio.ts index e2a5da2..4c03a05 100644 --- a/src/media/audio.ts +++ b/src/media/audio.ts @@ -647,6 +647,9 @@ export async function* mixaggio(p0: Project, fromSec: number, toSec: number): As const p = conIncroci(p0); const SR = p.sampleRate || 48000; const PEZZO = 10; + // ogni pezzo parte un po' prima e quel tratto si butta: filtri, eco e limitatore arrivano alla cucitura già + // "caldi" (prima ripartivano da zero ogni 10 s: un piccolo scatto con gli effetti e la coda dell'eco tagliata) + const SCALDA = 1.5; const solo = p.tracks.some((t) => t.kind === 'audio' && t.solo); const fr = p.rate; const continui = new Map; resto: PezzoAudio | null; finito: boolean }>(); @@ -654,7 +657,9 @@ export async function* mixaggio(p0: Project, fromSec: number, toSec: number): As for (let a = fromSec; a < toSec - 1e-6; a += PEZZO) { const b = Math.min(toSec, a + PEZZO); const len = Math.max(1, Math.round((b - a) * SR)); - const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: len, sampleRate: SR }); + const a0 = Math.max(fromSec, a - SCALDA); + const salta = Math.round((a - a0) * SR); + const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: salta + len, sampleRate: SR }); const master = ctx.createGain(); master.connect(uscitaFinale(ctx, p, ctx.destination)[0]); const bus = new Map(); @@ -673,24 +678,26 @@ export async function* mixaggio(p0: Project, fromSec: number, toSec: number): As if (!tb) continue; const coda = codaIncrocio(p, c); const cs = f2s(c.start, fr), ce = f2s(end(c) + coda, fr); - if (ce <= a || cs >= b) continue; + if (ce <= a0 || cs >= b) continue; const g = ctx.createGain(), pn = ctx.createStereoPanner(); pn.pan.value = c.pan; g.connect(pn).connect(tb); - applicaInviluppo(g, p, c, coda, 0, a, 0); + applicaInviluppo(g, p, c, coda, 0, a0, 0); const ingresso = catenaEffetti(ctx, c, g); if (c.kind === 'tone' || c.kind === 'beep') { - suonaTono(ctx, c, ingresso, cs - a, Math.max(0, cs - a), Math.max(0.001, Math.min(b, ce) - a)); + suonaTono(ctx, c, ingresso, cs - a0, Math.max(0, cs - a0), Math.max(0.001, Math.min(b, ce) - a0)); continue; } const r = c.media ? mediaRT(c.media) : undefined; if (!r?.a || !r.aDecodable) continue; - const from = Math.max(a, cs), to = Math.min(b, ce); - const srcFrom = c.srcIn + (from - cs) * c.speed; - const srcTo = c.srcIn + (to - cs) * c.speed; // le clip stirate col tono giusto hanno un flusso solo per tutto l'export: se ripartisse a ogni pezzo da 10 s, - // le onde non combacerebbero e ogni taglio farebbe uno scatto + // le onde non combacerebbero e ogni taglio farebbe uno scatto (per loro il tratto per scaldare non serve: il + // flusso va avanti da solo) const stirata = Math.abs(c.speed - 1) >= 0.005 && !c.nastro; + const from = Math.max(stirata ? a : a0, cs), to = Math.min(b, ce); + if (to <= from) continue; + const srcFrom = c.srcIn + (from - cs) * c.speed; + const srcTo = c.srcIn + (to - cs) * c.speed; lavori.push((async () => { const margine = 0.05 * c.speed; let flusso = stirata ? continui.get(c.id) : undefined; @@ -711,13 +718,13 @@ export async function* mixaggio(p0: Project, fromSec: number, toSec: number): As } const { buffer, tl: tlRel, rate } = pz; const durTl = buffer.duration / rate; - let when = cs + tlRel - a; + let when = cs + tlRel - a0; let offset = 0; - const lo = from - a; + const lo = from - a0; if (when < lo) { offset = lo - when; when = lo; } // il pezzo comincia dopo questo tratto: resta per il prossimo - if (stirata && when >= to - a) { flusso.resto = pz; break; } - const dur = Math.min(durTl - offset, to - a - when); + if (stirata && when >= to - a0) { flusso.resto = pz; break; } + const dur = Math.min(durTl - offset, to - a0 - when); if (dur > 0) { const s = ctx.createBufferSource(); s.buffer = buffer; @@ -726,26 +733,30 @@ export async function* mixaggio(p0: Project, fromSec: number, toSec: number): As s.start(when, offset * rate, dur * rate); } // il pezzo finisce dopo questo tratto: il resto va nel prossimo - if (stirata && offset + dur < durTl - 1e-6 && when + dur >= to - a - 1e-6) { flusso.resto = pz; break; } - if (!stirata && dur <= 0 && when >= to - a) break; + if (stirata && offset + dur < durTl - 1e-6 && when + dur >= to - a0 - 1e-6) { flusso.resto = pz; break; } + if (!stirata && dur <= 0 && when >= to - a0) break; } if (!stirata) void flusso.it.return(undefined).catch(() => {}); })()); } // i suoni degli FX for (const e of suoniFx(p)) { - if (e.at + e.buf.duration <= a || e.at >= b) continue; + if (e.at + e.buf.duration <= a0 || e.at >= b) continue; const g = ctx.createGain(); g.gain.value = e.gain; g.connect(master); const s = ctx.createBufferSource(); s.buffer = e.buf; s.connect(g); - const when = e.at - a; + const when = e.at - a0; if (when >= 0) s.start(when); else s.start(0, -when); } await Promise.all(lavori); - yield await ctx.startRendering(); + const fatto = await ctx.startRendering(); + if (!salta) { yield fatto; continue; } + const pezzo = new AudioBuffer({ numberOfChannels: 2, length: len, sampleRate: SR }); + for (let ch = 0; ch < 2; ch++) pezzo.copyToChannel(fatto.getChannelData(ch).subarray(salta, salta + len), ch); + yield pezzo; } } finally { for (const f of continui.values()) void f.it.return(undefined).catch(() => {}); diff --git a/src/media/fotogrammi.ts b/src/media/fotogrammi.ts index 3249f80..7b69615 100644 --- a/src/media/fotogrammi.ts +++ b/src/media/fotogrammi.ts @@ -215,6 +215,23 @@ const flussi = new Map(); const ricerche = new Map(); /** dove sta chiedendo ogni voce e da quando: il fotogramma nitido si chiede solo quando ci si ferma */ const fermo = new Map(); +/** + * L'ultimo fotogramma di un flusso buttato a metà play (ripartito più avanti, tornato indietro): si mostra finché il + * flusso nuovo non ha il suo. Prima in quel momento il monitor faceva un lampo nero, o tornava al fotogramma fermo di + * quando si era premuto play. + */ +const ponti = new Map(); +function mettiPonte(k: string, f: Flusso) { + if (!f.current) return; + ponti.get(k)?.close(); + ponti.set(k, f.current.clone()); +} +function togliPonte(k: string) { + const s = ponti.get(k); + if (!s) return; + s.close(); + ponti.delete(k); +} // il monitor ha la precedenza sulle miniature della timeline: una ricerca in corso o un flusso in riproduzione quandoDecoderOccupato(() => flussi.size > 0 || [...ricerche.values()].some((r) => r.busy)); @@ -270,7 +287,7 @@ function flussoDi(k: string, l: Lettore, t: number): Flusso | null { if (f) { const indietro = t < f.from - 0.05 || (!!f.current && t < f.current.timestamp - 0.05); const finito = f.done && !f.queue.length && !!f.current && t > f.current.timestamp + 1; - if (indietro || finito || f.ripartire) { f.close(); flussi.delete(k); f = undefined; } + if (indietro || finito || f.ripartire) { mettiPonte(k, f); f.close(); flussi.delete(k); f = undefined; } // indietro, ma solo dopo il primo fotogramma: un flusso appena ripartito va lasciato lavorare (se no si // ricomincia di continuo e si butta via la decodifica già fatta) else if (f.partito && f.ahead(t) < -0.6) f.valuta(t); @@ -296,9 +313,11 @@ export function fotogramma(key: string, mediaId: string, t: number, flusso: bool if (flusso) { const f = flussoDi(k, l, t); const s = f?.at(t); - if (s) return s; - // mentre il flusso parte si mostra quello che la ricerca aveva già (di una qualità o dell'altra) - return ricerche.get(k)?.current ?? ricerche.get(base + (q === 'p' ? 'o' : 'p'))?.current ?? f?.current ?? null; + if (s) { togliPonte(k); return s; } + // mentre il flusso parte si mostra l'ultimo fotogramma vero: quello del flusso appena buttato, quello dell'altra + // qualità (la copia leggera è diventata pronta a metà play), o quello che la ricerca aveva già + const altra = base + (q === 'p' ? 'o' : 'p'); + return ponti.get(k) ?? flussi.get(altra)?.current ?? ricerche.get(k)?.current ?? ricerche.get(altra)?.current ?? f?.current ?? null; } const fl = flussi.get(k); const veloce = ricerca(k, l).at(t); @@ -351,7 +370,8 @@ export function prepara(key: string, mediaId: string, t: number) { /** chiude i flussi non usati da un po' (libera i decoder e la memoria video) */ export function pulisci(maxEta = 1500) { const now = performance.now(); - for (const [k, f] of flussi) if (now - f.lastUse > maxEta) { f.close(); flussi.delete(k); } + for (const [k, f] of flussi) if (now - f.lastUse > maxEta) { f.close(); flussi.delete(k); togliPonte(k); } + for (const k of [...ponti.keys()]) if (!flussi.has(k)) togliPonte(k); for (const [k, r] of ricerche) { if (now - r.lastUse > 8000) { r.close(); ricerche.delete(k); } else if (now - r.lastUse > 2000) r.riposa(); @@ -372,6 +392,7 @@ export function fermaFlussi() { f.close(); } flussi.clear(); + for (const k of [...ponti.keys()]) togliPonte(k); } /** chi ha finito di guardare (l'anteprima del contenitore) libera il suo fotogramma */ @@ -388,6 +409,7 @@ export function dimenticaMedia(mediaId: string, soloProxy = false) { const tocca = (k: string) => k.includes('|' + mediaId + '|') && (!soloProxy || k.endsWith('|p')); for (const k of [...lettori.keys()]) if ((k + '|').startsWith(mediaId + '|') && (!soloProxy || k.endsWith('|p'))) lettori.delete(k); for (const [k, f] of flussi) if (tocca(k)) { f.close(); flussi.delete(k); } + for (const k of [...ponti.keys()]) if (tocca(k)) togliPonte(k); for (const [k, r] of ricerche) if (tocca(k)) { r.close(); ricerche.delete(k); } avvisa(); } diff --git a/src/media/libreria.ts b/src/media/libreria.ts index 6a9c79e..4abfe53 100644 --- a/src/media/libreria.ts +++ b/src/media/libreria.ts @@ -122,6 +122,8 @@ export async function apri(item: MediaItem, sel: { file?: File; path?: string }) } catch (e) { r.stato = 'errore'; r.errore = e instanceof Error ? e.message : String(e); + r.input?.dispose(); + r.input = undefined; } return r; } @@ -272,6 +274,8 @@ export async function importa(sel: FileScelto, opz: { soloDescrizione?: boolean } base.type = v ? 'video' : 'audio'; if (v && !r.vDecodable && !(a && r.aDecodable)) { + rt.delete(id); + input.dispose(); return { errore: `il video ${base.vcodec.toUpperCase()} non si decodifica su questo sistema`, nome }; } r.stato = 'ok'; @@ -281,6 +285,7 @@ export async function importa(sel: FileScelto, opz: { soloDescrizione?: boolean preparaLavori(base, r, chiave); return { item: base, rt: r }; } catch (e) { + rt.get(id)?.input?.dispose(); rt.delete(id); return { errore: e instanceof Error ? e.message : String(e), nome }; } diff --git a/src/media/proxy.ts b/src/media/proxy.ts index 44d50f7..c318834 100644 --- a/src/media/proxy.ts +++ b/src/media/proxy.ts @@ -152,7 +152,9 @@ async function creaProxy(m: MediaItem, r: MediaRT, fonte: () => Source, l: Lavor l.segnale.addEventListener('abort', () => { void conv.cancel(); }); for (;;) { if (l.fermato) return null; - if (fermo) await new Promise((ok) => { riprendi = ok; }); + // in pausa finché il montaggio suona; il ✕ del centro attività la sveglia subito (se no restava appesa) + if (fermo) await new Promise((ok) => { riprendi = ok; l.segnale.addEventListener('abort', () => ok(), { once: true }); }); + if (l.fermato) return null; pausa = new AbortController(); await conv.execute({ pauseSignal: pausa.signal }); pausa = null; diff --git a/src/motore.ts b/src/motore.ts index 5b46b65..7553727 100644 --- a/src/motore.ts +++ b/src/motore.ts @@ -234,7 +234,8 @@ class Motore { const endF = projectEnd(d); const limit = this.stopAt ?? endF; if (this.speed > 0 && f >= limit) { - if (this.loop && this.stopAt === null && d.inF !== null && d.outF !== null) { this.stop(); store.setHead(d.inF); this.play(1); } + // il loop senza attacco e stacco rifà tutto il montaggio da capo + if (this.loop && this.stopAt === null && endF > 0) { this.stop(); store.setHead(d.inF !== null && d.outF !== null ? d.inF : 0); this.play(1); } else { this.stop(); store.setHead(Math.min(limit, Math.max(endF, 0))); } } else if (this.speed < 0 && f <= 0) { this.stop(); diff --git a/src/progetti.ts b/src/progetti.ts index d2475db..ca65adb 100644 --- a/src/progetti.ts +++ b/src/progetti.ts @@ -45,6 +45,41 @@ async function idb(store: 'kv' | 'maniglie' | 'file', op: 'get' | 'put' | 'de } catch { return undefined; } } +/** tutte le chiavi di un deposito (per fare pulizia) */ +async function chiaviIdb(store: 'kv' | 'maniglie' | 'file'): Promise { + try { + const d = await db(); + return await new Promise((ok, ko) => { + const r = d.transaction(store, 'readonly').objectStore(store).getAllKeys(); + r.onsuccess = () => ok((r.result as IDBValidKey[]).map(String)); + r.onerror = () => ko(r.error); + }); + } catch { return []; } +} + +/** + * Pulizia del deposito del browser: si tengono i file (e le maniglie) dei media del montaggio aperto e di quelli + * dei progetti recenti, il resto si toglie. Prima "Nuovo progetto" buttava i file del montaggio di prima anche se + * era fra i recenti, e riaprendolo dalla pagina iniziale i file mancavano; e le copie dei progetti usciti + * dall'elenco restavano lì per sempre. + */ +async function potaDeposito() { + if (isTauri) return; + const lista = recenti(); + const chiavi = new Set(lista.map((r) => 'rec:' + r.chiave)); + const tieni = new Set(store.doc.media.map((m) => m.id)); + for (const k of await chiaviIdb('kv')) { + if (!k.startsWith('rec:')) continue; + if (!chiavi.has(k)) { void idb('kv', 'delete', k); continue; } + try { + const p = JSON.parse((await idb('kv', 'get', k)) ?? 'null') as Project | null; + for (const m of p?.media ?? []) tieni.add(m.id); + for (const s of p?.sequenze ?? []) for (const c of s.clips ?? []) if (c.media) tieni.add(c.media); + } catch { /* copia rovinata */ } + } + for (const dep of ['file', 'maniglie'] as const) for (const k of await chiaviIdb(dep)) if (!tieni.has(k)) void idb(dep, 'delete', k); +} + type Maniglia = FileSystemFileHandle & { queryPermission?: (o: object) => Promise; requestPermission?: (o: object) => Promise }; // ——— importazione ——— @@ -251,7 +286,14 @@ function serializza(): string { export async function salva(come = false) { const p = store.doc; const nome = (p.name || 'montaggio').replace(/[\\/:*?"<>|]/g, '_') + '.dpv'; - const r = await salvaTesto(nome, serializza(), 'dpv', come ? undefined : percorsoProgetto ?? undefined); + let r: string | null; + try { + r = await salvaTesto(nome, serializza(), 'dpv', come ? undefined : percorsoProgetto ?? undefined); + } catch (e) { + // disco pieno, cartella protetta, chiavetta tolta: lo si dice (prima non compariva niente e sembrava salvato) + avviso('⚠ Il progetto NON è stato salvato: ' + (e instanceof Error ? e.message : String(e)) + ' · prova Salva come…', 'errore', 7000); + return; + } if (!r) return; if (isTauri) percorsoProgetto = r; store.dirty = false; @@ -260,12 +302,19 @@ export async function salva(come = false) { store.emit('status'); } +/** la revisione del documento già scritta dall'autosalvataggio (-1 = niente ancora) */ +let autosalvata = -1; + export async function autosalva() { if (!store.dirty && store.doc.saved) return; + // niente di nuovo dall'ultima volta: non si riscrive tutto il progetto ogni 15 secondi + const rev = store.revisione; + if (rev === autosalvata) return; const testo = serializza(); try { if (isTauri) await invoke('autosalva', { text: testo }); else await idb('kv', 'put', 'autosalvataggio', testo); + autosalvata = rev; } catch { /* spazio pieno o permessi: si riprova al prossimo giro */ } } @@ -405,12 +454,13 @@ const mb = (b: number) => (b >= 1 << 30 ? (b / (1 << 30)).toFixed(2).replace('.' export async function nuovo(fmt: { w: number; h: number; rate: { num: number; den: number }; drop: boolean } = FORMATI[0]) { if (store.dirty && store.doc.clips.length && !(await conferma('Nuovo progetto', 'Il montaggio attuale ha modifiche non salvate. Ricominciare?', 'Nuovo', 'Annulla'))) return; - // i file tenuti nel browser per il montaggio vecchio non servono più: si libera lo spazio - for (const m of store.doc.media) { chiudiMedia(m.id); dimenticaMedia(m.id); void idb('file', 'delete', m.id); } + for (const m of store.doc.media) { chiudiMedia(m.id); dimenticaMedia(m.id); } percorsoProgetto = null; motore.caricaPlayer(null); store.load(newProject(fmt)); store.doc.saved = 0; + // i file tenuti nel browser che non servono più a nessun progetto recente: si libera lo spazio + void potaDeposito(); } /** carica un progetto e riapre i suoi media */ @@ -419,6 +469,7 @@ export async function carica(p: Project) { motore.caricaPlayer(null); store.load(p); await riapriMedia(false); + void potaDeposito(); } /** riapre un media: dal percorso (app), dalla maniglia ricordata o dal file tenuto nel browser */ @@ -495,7 +546,8 @@ export async function ricollega() { const f = scelti.find((s) => s.name === m.name && (!s.file || !m.size || s.file.size === m.size)) ?? scelti.find((s) => s.name === m.name); if (!f) continue; const r = await apriMedia(m, { file: f.file, path: f.path }); - if (r.stato === 'ok') { ok++; if (f.path) m.path = f.path; } + // il percorso nuovo va nel progetto (e lo segna da salvare): prima si perdeva alla chiusura + if (r.stato === 'ok') { ok++; if (f.path && f.path !== m.path) store.aggiornaMedia(m.id, { path: f.path }); } } store.emit('doc'); motore.ridisegna(); @@ -528,8 +580,7 @@ export async function togliMedia(id: string) { if (usi && !(await conferma('Togli dal contenitore', `Il file è usato da ${usi} clip nella timeline. Togliere anche quelle?`, 'Togli tutto', 'Annulla'))) return; store.edit('Togli media', (p) => { p.media = p.media.filter((m) => m.id !== id); p.clips = p.clips.filter((c) => c.media !== id); }); if (motore.playerMedia === id) motore.caricaPlayer(null); - void idb('maniglie', 'delete', id); - void idb('file', 'delete', id); + // il file tenuto nel browser resta finché non si cambia progetto: con Ctrl+Z il media torna e lo ritrova } /** un'operazione lunga (con annulla, se serve): sta nel centro attività, la barra in basso a destra */ diff --git a/src/stile/editor.css b/src/stile/editor.css index ef54d92..b5b2cd2 100644 --- a/src/stile/editor.css +++ b/src/stile/editor.css @@ -723,6 +723,8 @@ button svg { pointer-events: none; } .marchio-grande b { color: var(--oro); } .avvisi { position: fixed; left: 50%; bottom: 36px; transform: translateX(-50%); z-index: 1100; display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; } +/* sul telefono gli avvisi stanno sopra la barra delle attività e i pulsanti in basso (prima li coprivano) */ +@media (max-width: 899px) { .avvisi { bottom: 92px; width: max-content; max-width: calc(100vw - 24px); } } .avviso { padding: 7px 14px; border-radius: 20px; background: rgba(28, 27, 36, 0.96); border: 1px solid #000; box-shadow: var(--ombra), inset 0 0 0 1px var(--bordo-forte); font-weight: 700; animation: sale 0.15s; max-width: 80vw; text-align: center; } .avviso.ok { color: var(--verde); } .avviso.errore { color: #ffb1c0; box-shadow: var(--ombra), inset 0 0 0 1px rgba(255, 77, 109, 0.5); } diff --git a/src/ui/app.ts b/src/ui/app.ts index 793add0..1478239 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -343,8 +343,13 @@ export function avvia(radice: HTMLElement) { if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return; if (document.querySelector('.velo')) return; const k = [(e.ctrlKey || e.metaKey) ? 'ctrl' : '', e.shiftKey && e.key.length > 1 || (e.shiftKey && /^[a-z]$/i.test(e.key)) ? 'shift' : '', e.key.toLowerCase()].filter(Boolean).join('+'); - const fn = extra[k]; - if (fn) { e.preventDefault(); fn(); } + const fn = extra[k] as (() => void) | undefined; + if (!fn) return; + // sopra la pagina iniziale valgono solo Apri e lo schermo intero + if (home.visibile && k !== 'ctrl+o' && k !== 'f11') return; + e.preventDefault(); + if (k === 'ctrl+o') home.nascondi(); + fn(); }); const sveglia = () => banco.sveglia(); addEventListener('pointerdown', sveglia, { once: true }); diff --git a/src/ui/dialoghi.ts b/src/ui/dialoghi.ts index 69e13c2..501ebe6 100644 --- a/src/ui/dialoghi.ts +++ b/src/ui/dialoghi.ts @@ -19,7 +19,11 @@ export function finestraEsporta(preset: { formato?: string; qualita?: string; mi motore.stop(); const fine = projectEnd(p); if (!fine) { avviso('La timeline è vuota: prima monta qualcosa', 'info'); return; } - const d = dialogo('Esporta il master', { largo: true }); + let annulla = false; + let lavorando = false; + let fatto = false; + // mentre esporta la finestra resta aperta (Esc o un clic fuori non la chiudono): si ferma con Annulla + const d = dialogo('Esporta il master', { largo: true, chiudibile: () => { if (lavorando) avviso('L\'export è in corso: premi Annulla per fermarlo', 'info', 2200); return !lavorando; } }); const nome = h('input', { class: 'campo-testo', value: p.name.replace(/[\\/:*?"<>|]/g, '_') }) as HTMLInputElement; const formato = h('select', { class: 'mini-select' }, h('option', { value: 'mp4' }, 'MP4 · H.264 + AAC (va ovunque)'), @@ -61,11 +65,12 @@ export function finestraEsporta(preset: { formato?: string; qualita?: string; mi h('label', null, 'Solo attacco–stacco'), h('span', null, soloInOut, haInOut ? ` ${frameToTc(p.inF!, p.rate, p.drop)} → ${frameToTc(p.outF!, p.rate, p.drop)}` : ' (segna I e O sulla timeline)'), h('label', null, 'Codec'), codec), stato, barra); - let annulla = false; - let lavorando = false; const vai = h('button', { class: 'btn primario' }, 'Esporta') as HTMLButtonElement; - const chiudi = h('button', { class: 'btn', on: { click: () => { if (lavorando) annulla = true; else d.chiudi(); } } }, 'Annulla'); + const chiudi = h('button', { class: 'btn', on: { click: () => { if (lavorando) { annulla = true; chiudi.textContent = 'Fermo…'; } else d.chiudi(); } } }, 'Annulla') as HTMLButtonElement; vai.addEventListener('click', async () => { + // a lavoro finito il bottone dice "Fatto" e chiude (prima faceva ripartire l'export da capo) + if (fatto) { d.chiudi(); return; } + if (lavorando) return; const k = Number(misura.value); lavorando = true; vai.disabled = true; @@ -88,7 +93,7 @@ export function finestraEsporta(preset: { formato?: string; qualita?: string; mi (barra.firstChild as HTMLElement).style.width = '100%'; vai.textContent = 'Fatto'; chiudi.textContent = 'Chiudi'; - vai.onclick = () => d.chiudi(); + fatto = true; vai.disabled = false; } else { d.chiudi(); avviso('Export annullato', 'info'); } } catch (e) { diff --git a/src/ui/dom.ts b/src/ui/dom.ts index 10d20b9..fa5c833 100644 --- a/src/ui/dom.ts +++ b/src/ui/dom.ts @@ -125,16 +125,21 @@ export function avviso(testo: string, tipo: 'info' | 'ok' | 'errore' | 'tasto' = // ——— dialoghi ——— export interface Dialogo { el: HTMLElement; chiudi: () => void; corpo: HTMLElement; piede: HTMLElement } -export function dialogo(titolo: string, opzioni: { largo?: boolean; onChiudi?: () => void; chiudibile?: boolean } = {}): Dialogo { +/** chiudibile: false = solo dai suoi bottoni; una funzione = si chiude con Esc, ✕ o clic fuori solo quando dice sì + * (la finestra Esporta mentre lavora: chiuderla lasciava l'export a girare senza modo di fermarlo) */ +export function dialogo(titolo: string, opzioni: { largo?: boolean; onChiudi?: () => void; chiudibile?: boolean | (() => boolean) } = {}): Dialogo { const corpo = h('div', { class: 'dlg-corpo' }); const piede = h('div', { class: 'dlg-piede' }); - const chiudi = () => { velo.classList.add('via'); setTimeout(() => velo.remove(), 180); document.removeEventListener('keydown', esc, true); opzioni.onChiudi?.(); }; - const esc = (e: KeyboardEvent) => { if (e.key === 'Escape' && opzioni.chiudibile !== false) { e.stopPropagation(); chiudi(); } }; + let chiuso = false; + const chiudi = () => { if (chiuso) return; chiuso = true; velo.classList.add('via'); setTimeout(() => velo.remove(), 180); document.removeEventListener('keydown', esc, true); opzioni.onChiudi?.(); }; + const puo = () => (typeof opzioni.chiudibile === 'function' ? opzioni.chiudibile() : opzioni.chiudibile !== false); + const prova = () => { if (puo()) chiudi(); }; + const esc = (e: KeyboardEvent) => { if (e.key === 'Escape' && opzioni.chiudibile !== false) { e.stopPropagation(); prova(); } }; const el = h('div', { class: 'dialogo' + (opzioni.largo ? ' largo' : ''), role: 'dialog', 'aria-label': titolo }, h('div', { class: 'dlg-testa' }, h('span', { class: 'led acceso' }), h('b', null, titolo), - opzioni.chiudibile === false ? null : h('button', { class: 'btn-icona', title: 'Chiudi (Esc)', on: { click: chiudi } }, icona('x', 16))), + opzioni.chiudibile === false ? null : h('button', { class: 'btn-icona', title: 'Chiudi (Esc)', on: { click: prova } }, icona('x', 16))), corpo, piede); - const velo = h('div', { class: 'velo', on: { pointerdown: (e: PointerEvent) => { if (e.target === velo && opzioni.chiudibile !== false) chiudi(); } } }, el); + const velo = h('div', { class: 'velo', on: { pointerdown: (e: PointerEvent) => { if (e.target === velo && opzioni.chiudibile !== false) prova(); } } }, el); document.body.appendChild(velo); document.addEventListener('keydown', esc, true); return { el, chiudi, corpo, piede }; diff --git a/src/ui/home.ts b/src/ui/home.ts index 6666d0c..c64b67a 100644 --- a/src/ui/home.ts +++ b/src/ui/home.ts @@ -40,11 +40,14 @@ export class Home { this.disegna(); this.el.style.display = ''; this.visibile = true; + // i tasti del montaggio non arrivano al banco nascosto qui sotto (prima Spazio suonava e 2 toglieva una clip) + document.documentElement.classList.add('home-aperta'); } nascondi() { this.el.style.display = 'none'; this.visibile = false; + document.documentElement.classList.remove('home-aperta'); } private disegna() { diff --git a/src/ui/mixer.ts b/src/ui/mixer.ts index 9a64983..8ad0649 100644 --- a/src/ui/mixer.ts +++ b/src/ui/mixer.ts @@ -124,7 +124,8 @@ export class Mixer { if (!video) this.misure.set(t.id, mis); const pan = video ? null : h('input', { type: 'range', class: 'pan', min: -100, max: 100, step: 1, value: String(Math.round(t.pan * 100)), title: 'Panorama (doppio clic = centro)' }) as HTMLInputElement; if (pan) { - pan.addEventListener('input', () => { const tr = store.doc.tracks.find((x) => x.id === t.id)!; tr.pan = Number(pan.value) / 100; store.liveChange(); }); + // anche con le frecce della tastiera (senza pointerdown) il panorama si può annullare + pan.addEventListener('input', () => { if (!this.trascinando) { this.trascinando = true; store.begin('Panorama'); } const tr = store.doc.tracks.find((x) => x.id === t.id)!; tr.pan = Number(pan.value) / 100; store.liveChange(); }); pan.addEventListener('pointerdown', () => { this.trascinando = true; store.begin('Panorama'); }); pan.addEventListener('change', () => { this.trascinando = false; store.commit(true); }); pan.addEventListener('dblclick', () => store.edit('Panorama al centro', (pp) => { pp.tracks.find((x) => x.id === t.id)!.pan = 0; })); diff --git a/src/ui/tastiera.ts b/src/ui/tastiera.ts index 24ecc92..47f39e8 100644 --- a/src/ui/tastiera.ts +++ b/src/ui/tastiera.ts @@ -37,6 +37,7 @@ export function installaTastiera() { return; } if (document.querySelector('.velo')) return; // dialogo aperto + if (document.documentElement.classList.contains('home-aperta')) return; // la pagina iniziale copre il banco if (/^Numpad\d$/.test(e.code) && !e.ctrlKey && !e.altKey) { e.preventDefault(); tcInserimento?.(e.code.slice(6)); diff --git a/test/prove-nuove.mjs b/test/prove-nuove.mjs index fffaf29..5740591 100644 --- a/test/prove-nuove.mjs +++ b/test/prove-nuove.mjs @@ -12,13 +12,14 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); import { leggi, vicino, apriGruppi } from './aiuti.mjs'; export { leggi, vicino }; -export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage', 'pannello', 'seguito', 'attivita']) { +export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage', 'pannello', 'seguito', 'attivita', 'rifiniture']) { const { page, prova, OUT } = ctx; if (quali.includes('sfondo')) await proveSfondo({ page, prova, OUT }); if (quali.includes('animazioni')) { const m = await import('./prove-animazioni.mjs').catch(() => null); if (m) await m.proveAnimazioni({ page, prova, OUT }); } if (quali.includes('seguito')) { const m = await import('./prove-seguito.mjs').catch((e) => { console.log(' ✗ prove-seguito:', e.message); return null; }); if (m) await m.proveSeguito({ page, prova, OUT }); } if (quali.includes('pannello')) { const m = await import('./prove-pannello.mjs').catch((e) => { console.log(' ✗ prove-pannello:', e.message); return null; }); if (m) await m.provePannello({ page, prova, OUT }); } if (quali.includes('attivita')) { const m = await import('./prove-attivita.mjs').catch((e) => { console.log(' ✗ prove-attivita:', e.message); return null; }); if (m) await m.proveAttivita({ page, prova, OUT }); } + if (quali.includes('rifiniture')) { const m = await import('./prove-rifiniture.mjs').catch((e) => { console.log(' ✗ prove-rifiniture:', e.message); return null; }); if (m) await m.proveRifiniture({ page, prova, OUT }); } if (quali.includes('montage')) { const m = await import('./prove-montage.mjs').catch(() => null); if (m) await m.proveMontage({ page, prova, OUT }); } } diff --git a/test/prove-rifiniture.mjs b/test/prove-rifiniture.mjs new file mode 100644 index 0000000..4cd320d --- /dev/null +++ b/test/prove-rifiniture.mjs @@ -0,0 +1,136 @@ +// Le prove della 1.2.1 (le rifiniture): il taglio di una clip che si muove, l'annulla che non lascia il progetto +// a metà, le cuciture dell'audio nell'export, i tasti sopra la pagina iniziale, copia e incolla con gli effetti, +// le scorciatoie senza doppioni. +// Girano dentro prove.mjs (con le prove nuove), oppure da sole: `node test/prove-nuove.mjs rifiniture`. + +/** una pagina pulita (contesto nuovo): le prove di prima hanno riempito il progetto */ +async function pagina(paginaDi) { + const contesto = await paginaDi.context().browser().newContext({ viewport: { width: 1600, height: 950 } }); + const page = await contesto.newPage(); + const errori = []; + page.on('pageerror', (e) => errori.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error') errori.push(m.text()); }); + await page.goto(paginaDi.url()); + await page.waitForSelector('.pulsantiera'); + await page.waitForTimeout(1000); + return { page, contesto, errori }; +} + +export async function proveRifiniture({ page: paginaDi, prova }) { + const { page, contesto, errori } = await pagina(paginaDi); + try { + // ——————————————————————————————————————————————————————————— + console.log('▶ Rifiniture: il taglio di una clip che si muove'); + const moto = await page.evaluate(() => { + const { M, P } = window.__dpvTest; + const p = P.newProject({ w: 1920, h: 1080, rate: { num: 25, den: 1 }, drop: false }); + const v = p.tracks.find((t) => t.kind === 'video').id; + const c = P.newClip('color', v, 0, 100, { gen: { color: '#334455' } }); + c.tf = { ...P.TF0, x: 0, scale: 1 }; + c.tfFine = { ...P.TF0, x: 400, scale: 1.5 }; + c.via = [{ t: 0.3, tf: { ...P.TF0, x: 300, scale: 1.2 } }, { t: 0.8, tf: { ...P.TF0, x: 350, scale: 1.4 } }]; + p.clips.push(c); + const orig = structuredClone(c); + const dx = M.splitClip(p, c, 40); + const scarto = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.scale - b.scale) * 100); + let peggio = 0; + for (let f = 0; f < 100; f++) { + const atteso = P.tfAl(orig, f); + const vero = f < 40 ? P.tfAl(c, f) : P.tfAl(dx, f - 40); + peggio = Math.max(peggio, scarto(atteso, vero)); + } + return { peggio, sx: c.via?.length ?? 0, dx: dx.via?.length ?? 0, fineDx: dx.tfFine.x, inizioSx: c.tf.x }; + }); + prova('tagliata, la clip continua il movimento dov\'era (niente ripartenza da capo)', moto.peggio < 40, JSON.stringify(moto)); + prova('le tappe vanno al pezzo giusto (una a sinistra, una a destra), partenza e arrivo restano', moto.sx === 1 && moto.dx === 1 && moto.fineDx === 400 && moto.inizioSx === 0, JSON.stringify(moto)); + + // ——————————————————————————————————————————————————————————— + console.log('▶ Rifiniture: una modifica che si rompe non lascia il progetto a metà'); + const rotta = await page.evaluate(() => { + const s = window.__dpv; + const prima = s.doc.tracks.length, annulli = s.canUndo(); + let presa = false; + try { s.edit('Prova rotta', (p) => { p.tracks.push({ ...p.tracks[0], id: 'tx' }); throw new Error('rotta'); }); } catch { presa = true; } + return { presa, uguale: s.doc.tracks.length === prima, annulli: s.canUndo() === annulli }; + }); + prova('l\'errore arriva a chi chiama e il progetto torna com\'era (senza un passo di annulla in più)', rotta.presa && rotta.uguale && rotta.annulli, JSON.stringify(rotta)); + + // ——————————————————————————————————————————————————————————— + console.log('▶ Rifiniture: le cuciture dell\'audio nell\'export (ogni 10 s)'); + const cucitura = await page.evaluate(async () => { + const { P, mixaggio } = window.__dpvTest; + const p = P.newProject({ w: 1920, h: 1080, rate: { num: 25, den: 1 }, drop: false }); + const a = p.tracks.find((t) => t.kind === 'audio').id; + // un tono con l'eco: l'eco somma (il ritardo è un numero intero di periodi) e dura oltre la cucitura dei 10 s + p.clips.push(P.newClip('tone', a, 0, 25 * 14, { gen: { freq: 1000, level: -24 }, afx: { eco: true } })); + p.master.limiter = false; + const pezzi = []; + for await (const b of mixaggio(p, 0, 14)) pezzi.push(b.getChannelData(0).slice()); + const tutto = new Float32Array(pezzi.reduce((s, x) => s + x.length, 0)); + let o = 0; + for (const x of pezzi) { tutto.set(x, o); o += x.length; } + const rms = (da, a) => { let s = 0; for (let i = Math.round(da * 48000); i < Math.round(a * 48000); i++) s += tutto[i] * tutto[i]; return Math.sqrt(s / ((a - da) * 48000)); }; + return { campioni: tutto.length, prima: rms(9.7, 10), dopo: rms(10, 10.25) }; + }); + prova('il mixaggio è lungo giusto', cucitura.campioni === 14 * 48000, String(cucitura.campioni)); + prova('dopo la cucitura dei 10 s l\'eco c\'è ancora (prima ripartiva da zero e il volume calava)', cucitura.dopo / cucitura.prima > 0.93, JSON.stringify(cucitura)); + + // ——————————————————————————————————————————————————————————— + console.log('▶ Rifiniture: i tasti, la pagina iniziale, copia e incolla'); + await page.evaluate(() => { + const { P } = window.__dpvTest; + const s = window.__dpv; + const p = P.newProject({ w: 1920, h: 1080, rate: { num: 25, den: 1 }, drop: false }); + const v = p.tracks.filter((t) => t.kind === 'video')[1].id; + p.clips.push(P.newClip('color', v, 0, 50, { name: 'A', gen: { color: '#aa3333' } })); + s.load(p); + s.select([p.clips[0].id]); + }); + const clipVere = () => page.evaluate(() => window.__dpv.doc.clips.filter((c) => c.kind !== 'fx').length); + await page.evaluate(() => document.dispatchEvent(new CustomEvent('dpv:home'))); + await page.waitForTimeout(200); + await page.mouse.move(5, 5); + await page.keyboard.press('2'); + await page.waitForTimeout(150); + prova('con la pagina iniziale aperta il tasto 2 non tocca il montaggio nascosto', (await clipVere()) === 1); + await page.keyboard.press('Escape'); + await page.waitForTimeout(150); + prova('Esc chiude la pagina iniziale', !(await page.evaluate(() => document.documentElement.classList.contains('home-aperta')))); + + const tracce = () => page.evaluate(() => window.__dpv.doc.tracks.filter((t) => t.kind === 'audio').length); + const t0 = await tracce(); + await page.keyboard.press('Control+Alt+a'); + await page.waitForTimeout(150); + prova('Ctrl+Alt+A aggiunge davvero una traccia audio (prima lo prendeva l\'animazione)', (await tracce()) === t0 + 1); + const doppi = await page.evaluate(() => { + const visti = new Map(); + for (const a of window.__dpvTest.Z.azioni.values()) for (const t of a.tasti ?? []) { const k = t.toLowerCase(); visti.set(k, [...(visti.get(k) ?? []), a.id]); } + return [...visti].filter(([, ids]) => ids.length > 1).map(([k, ids]) => k + ': ' + ids.join(', ')); + }); + prova('nessun tasto è preso da due comandi', doppi.length === 0, doppi.join(' · ')); + + // un effetto sulla clip: copiando la clip si porta dietro l'effetto + await page.evaluate(() => { + const { B } = window.__dpvTest; + const s = window.__dpv; + const c = s.doc.clips.find((x) => x.kind !== 'fx'); + s.edit('Effetto', (p) => B.posaBlocco(p, B.nuovoBlocco('effetto', 'flash'), 10, 12, c.track)); + s.select([c.id]); + s.setHead(100); + }); + await page.keyboard.press('Control+c'); + await page.keyboard.press('Control+v'); + await page.waitForTimeout(150); + const incollato = await page.evaluate(() => { + const d = window.__dpv.doc; + const fx = d.clips.filter((c) => c.kind === 'fx'); + const nuova = d.clips.find((c) => c.kind !== 'fx' && c.start === 100); + return { fx: fx.length, nuova: !!nuova, sopra: !!nuova && fx.some((b) => b.track === nuova.track && b.start === 110) }; + }); + prova('copia e incolla portano anche l\'effetto che sta sulla clip, sulla stessa traccia della copia', incollato.fx === 2 && incollato.nuova && incollato.sopra, JSON.stringify(incollato)); + + prova('nessun errore nelle prove delle rifiniture', errori.length === 0, errori.slice(0, 5).join(' | ')); + } finally { + await contesto.close(); + } +} From dccf8e373b567ed0fe16d4ced7748b8579e7c791 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:37:36 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Prove:=20la=20barra=20delle=20attivit=C3=A0?= =?UTF-8?q?=20si=20legge=20dopo=20l'aggiornamento;=20il=20monitor=20del=20?= =?UTF-8?q?Montage=20si=20guarda=20dove=20non=20c'=C3=A8=20un=20blocchetto?= =?UTF-8?q?=20FX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prove-attivita: dopo azzeraAttivita la barra si aggiorna con 150 ms di ritardo; sul runner veloce si leggeva quella di prima ("Copie"). avvisaOra prima di leggere. - prove-montage: la variante è a caso e a volte un lampo cadeva proprio a 8 s (fotogramma tutto bianco): si guarda subito dopo il blocchetto. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TqgA6T2ncfxMbmoaKeJWS4 --- test/prove-attivita.mjs | 3 ++- test/prove-montage.mjs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/test/prove-attivita.mjs b/test/prove-attivita.mjs index 6235679..65c02b1 100644 --- a/test/prove-attivita.mjs +++ b/test/prove-attivita.mjs @@ -73,7 +73,8 @@ export async function proveAttivita({ page: paginaDi, prova, OUT }) { // ——— la barra e il pannello ——— console.log('▶ Centro attività: la barra in basso a destra e il pannello'); - const ferma0 = await page.evaluate(() => ({ testo: document.querySelector('.stato .att-testo')?.textContent, ferma: document.querySelector('.stato .att')?.classList.contains('att-ferma') })); + // la barra si aggiorna con un attimo di ritardo (150 ms): la si fa aggiornare subito, se no si legge quella di prima dell'azzeramento + const ferma0 = await page.evaluate(() => (window.__dpvTest.AT.avvisaOra(), { testo: document.querySelector('.stato .att-testo')?.textContent, ferma: document.querySelector('.stato .att')?.classList.contains('att-ferma') })); prova('a riposo la barra dice "Nessuna attività"', ferma0.ferma && ferma0.testo === 'Nessuna attività', JSON.stringify(ferma0)); await page.evaluate(() => { const { AT } = window.__dpvTest; diff --git a/test/prove-montage.mjs b/test/prove-montage.mjs index 0204a79..52f9d45 100644 --- a/test/prove-montage.mjs +++ b/test/prove-montage.mjs @@ -241,8 +241,10 @@ export async function proveMontage({ page, prova, OUT }) { const m = window.__motore; const rec = m.rec; const d = window.__dpv.doc; const fps = d.rate.num / d.rate.den; const punti = []; + // la variante è a caso: se lì sopra c'è un blocchetto FX (un lampo bianco, un passaggio al nero) ci si sposta subito dopo + const libero = (f) => { for (let g = 0; g < 40; g++) { const b = d.clips.find((c) => c.kind === 'fx' && c.start <= f && c.start + c.len > f); if (!b) return f; f = b.start + b.len + 1; } return f; }; for (const s of [3, 8, 14]) { - m.vaiA(Math.round(s * fps)); + m.vaiA(libero(Math.round(s * fps))); await new Promise((r) => setTimeout(r, 700)); const px = new Uint8Array(64 * 36 * 4); rec.leggiPiccolo(64, 36, px); let somma = 0, vari = new Set(); for (let i = 0; i < px.length; i += 4) { somma += px[i] + px[i + 1] + px[i + 2]; vari.add((px[i] >> 5) * 64 + (px[i + 1] >> 5) * 8 + (px[i + 2] >> 5)); }