diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a0b49..0ce4757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ 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.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à**. + +### La barra delle attività (in basso a destra) +- Tutto quello che il programma fa dietro le quinte — **riaprire i file di un progetto, forme d'onda, colore automatico, copie leggere (proxy), conversioni a velocità costante, sottotitoli, voce e traduzione con l'AI, togliere lo sfondo, import e pacchetti** — ha **una riga sola nel centro attività**. In basso a destra c'è sempre **una barra** con cosa sta facendo, la percentuale e il tempo che manca; se le cose da fare sono più di una compare **+N**. +- **Un clic sulla barra apre il pannello**: per ogni attività la barra di avanzamento, **quanto è passato, quanto manca**, e per i gruppi "12 fatti · 2 al lavoro · 26 in coda"; sotto, **le finite da poco** (con quanto ci hanno messo). **✕** ferma una riga, **⏸ Pausa** non fa partire lavori nuovi, **Pulisci** toglie le finite, Esc chiude. Sul telefono la barra sta sopra i pulsanti in basso. +- Le barre del Finale, di DaProdMontage e del ritaglio con l'AI (e quelle che comparivano in un riquadro sopra l'interfaccia) ora **stanno tutte qui**. + +### Riaprire un progetto con tante clip (era lento e a scatti) +- **I file si riaprono quattro alla volta, e prima quelli che stanno in timeline** (prima uno per volta, nell'ordine del contenitore). Con file nuovi: 3 volte più veloce; **riaprendo un progetto già visto: da 12 secondi a 0,2** (14 riprese). +- **Le misure già fatte si ricordano** (forma d'onda, colore automatico e locandina di ogni file, in un deposito a parte del browser/app: Vista → svuota copie leggere non lo tocca). Se il file è lo stesso, non si rifanno. +- **I lavori di fondo vanno a turno**, non tutti insieme: due leggeri alla volta (forme d'onda, colore) e **una sola copia pesante alla volta** (ferma finché il montaggio suona), e passa avanti chi sta in timeline. Prima, riaprendo un progetto con 90 riprese, partivano decine di decodifiche insieme e il programma scendeva a 5 fotogrammi al secondo. +- **La copia leggera (proxy) si fa solo per le riprese che stanno in timeline**: quelle che stanno solo nel contenitore aspettano (prima se ne facevano 96 per niente). Se ne metti una in timeline parte da sola; se la copia c'è già sul disco si usa subito. +- **Il contenitore non salta più**: mentre i file si riaprono e le forme d'onda crescono, le schede si aggiornano **al loro posto** (prima si rifaceva tutta la griglia a ogni passo e la pagina tornava in cima). + +### File a velocità costante, dietro le quinte +- Un file a frame rate o bitrate variabile **entra subito** com'è e si lavora subito; **la copia a velocità costante si fa dietro le quinte** (una alla volta, col suo avanzamento, in pausa mentre il montaggio suona) e **prende il posto dell'originale** da sola quando è pronta: clip, segni e proprietà restano dove sono, e Annulla non rimette i dati vecchi. L'originale non si tocca. Prima l'import aspettava la conversione. + +### Altro +- Sistemata la lettura dei pixel per il colore e la locandina (fuori dal thread principale dove si può), meno attese all'interfaccia. + ## [1.1.6] — 2026-10-02 · Pagina iniziale, scelta multipla nel contenitore, voce in altre lingue (anche cinese), file a velocità costante 🗂 ### Pagina iniziale: i tuoi progetti diff --git a/CLAUDE.md b/CLAUDE.md index 8082a26..ceb6e41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,21 @@ Editor video "vecchio stile, moderno dentro" (ispirato a EDIUS e alle centraline - **Contenitore, scelta multipla (1.1.6)**: `scelti` + `cliccaCarta` (Ctrl/Maiusc) in `src/ui/contenitore.ts`; il dato del trascinamento è `m:id1|id2|…` (timeline: `mediaDaDato`, posa uno dopo l'altro con un solo `store.edit`). **Aggancio**: `fDrop` in `timeline.ts` (14 px, fine delle clip della traccia preferita, seconda metà di una clip → subito dopo) usato **sia** dal fantasma (`anteprimaDrop`) **sia** da `rilascia`. Classe `.in-timeline` (bordo verde) sulle carte con `usi > 0`. - **Voce e lingue (1.1.6)**: `src/media/traduci.ts` (`traduciTesti`, NLLB-200 600M in `traduci.worker.ts` da transformers.js/CDN; `LINGUE_TRADUZIONE`; `impostaTraduttore` finto per le prove). `doppia` traduce da sola i testi se `lingua` ≠ `sottotitoliDi(p).lingua` (solo per la voce) e ritorna `posti` (`componiVoceConPosti`); `posaVoce(p, mediaId, dur, silenzia, posti)` fa **una clip per frase** dallo stesso WAV (`srcIn = da`) e mette `Sottotitolo.voce` = id della clip; in timeline un clic sulla riga SOTT sceglie quella clip. Il cinese è in `LINGUE_VOCE`/`LINGUE_PARLATE` (`nemo.ts`, `zh-CN`) ma **non** in `LINGUE_MOTORE` (riconoscimento). Finale: sezione `lingue` = "Voce e lingue" (`paginaLingue`, `traduciSottotitoli`, `pannelloVoce`). - **File a velocità costante (1.1.6)**: `src/media/normalizza.ts` (`esamina` → `Verdetto`, `converti`) chiamato da `importaFile` prima di `importa`: video VFR (`computeFrameRateMetrics`, max/min > 1,25) → CFR con `Conversion({video:{frameRate}})`; audio-solo MP3/Opus/Vorbis con pacchetti molto variabili → AAC (o Opus) a bitrate costante. Output su disco nell'app (`registrazione_percorso` + `export_*`), in memoria nel browser; l'originale non si tocca. Spento con localStorage `dpv-normalizza = no`. +- **Centro attività (1.2.0, "prima release ufficiale")**: `src/media/attivita.ts` è il registro di tutto il lavoro di fondo (righe `Attivita`, gruppi dello + stesso tipo in una riga sola, `Lavoro` = la maniglia di chi lavora: `imposta(k, dettaglio)`, `fine`, `errore`, `segnale` per lo stop) e lo scheduler a corsie: + `inCoda({corsia:'leggero'|'pesante', titolo, categoria, gruppo, priorita}, fn)` (leggero = 2 alla volta, pesante = 1, ferma mentre il montaggio suona via + `pausaPerRiproduzione`, `pausaLavori` per la pausa dell'utente; priorità = funzione riletta a ogni turno, chi sta in timeline passa avanti con + `prioritaMedia`/`impostaUsoMedia` di `libreria.ts`). La UI è `src/ui/attivita.ts` (`CentroAttivita`: pezzo `.att` nella barra di stato + pannello `.att-pannello`, + righe aggiornate al loro posto). Chi lavora: `nuova()` (a mano) o `inCoda()`; `BarraLavoro('titolo', categoria, alAnnulla)` e `avvisoLungo()` si agganciano da soli; + `Stima`/`durataTesto` stanno in `src/core/stima.ts` (puri). Prove: `test/prove-attivita.mjs`; banchi di misura (non nelle prove) `test/_bench.mjs`. +- **Riapertura veloce (1.2.0)**: `riapriMedia` (progetti.ts) apre 4 file alla volta, prima quelli in timeline, con una sola riga "Riapro i file del progetto" e aggiorna l'interfaccia + al massimo ogni 600 ms. `src/media/cache.ts` (IndexedDB `dpv-cache`: `picchi` a un byte per valore con radice, `colore`, `poster` JPEG; chiave `chiaveMedia` = percorso|nome|dimensione|data|durata|WxH). + **La copia leggera (proxy) si fa solo per chi sta in timeline** (`proxyStato = 'attesa'` per gli altri; `risvegliaProxy` da `app.ts` a ogni modifica del documento, `proxyEsistente` apre subito + quelle già sul disco). Il contenitore, se cambia solo lo stato dei file o le locandine, aggiorna le schede al loro posto (`aggiornaSchede`, mai rifare la griglia: la pagina salterebbe); + la forma d'onda cresce con `ridisegnaCarta`. +- **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. - **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 388f562..e13fdd5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ ed elimini. Gira su **Windows, Mac e Android** e, in versione prova, **nel brows 15. **File → Salva il pacchetto .daprod**: il progetto **con dentro tutti i suoi file** (uno zip vero, i file restano identici). Lo apri su un altro computer e ritrovi tutto. Il **.dpv** invece è il progetto leggero: tiene il montaggio, i file restano dove sono. 17. **`F8`** apre **DaProdMontage**, la quarta pagina: butta dentro **foto e video alla rinfusa** (anche solo foto), scegli **cosa festeggi** (28 stili: matrimonio, battesimo, comunione, cresima, laurea, compleanno, Natale, viaggio, reel verticale…), quanto deve durare e, se vuoi, il brano. **✨ CREA IL MONTAGGIO** e il programma ordina le foto (per data di scatto), le tiene il tempo giusto, le muove, mette transizioni, titoli e quello che cade sopra, appoggia i tagli ai battiti. Lo vedi **in anteprima nel monitor in alto**: se non ti piace **🎲 Rigenera** (ogni volta cambia, anche con le stesse scelte), se ti piace **✅ Importa nella timeline** e lo ritocchi. 18. **Togliere lo sfondo**: sulla clip, **Proprietà → Sfondo** (o il tasto **SFONDO** sul monitor). **Colore** (green screen con fino a 3 colori, contagocce, via il riflesso), **Luce** o **AI** (soggetto, persona, oggetti coi clic). **Generatori → Animazioni**: 49 animazioni personalizzabili (sottopancia, testi, grafici, social, fondi, cerimonie). +19. **La barra delle attività** (in basso a destra) mostra sempre cosa fa il programma dietro le quinte — riaprire i file, forme d'onda, colore, copie leggere, conversioni, sottotitoli e voce con l'AI — con la percentuale e il tempo che manca. **Un clic** apre il pannello con tutte le righe (passati, mancano, in coda, finite da poco): **✕** ferma una riga, **⏸ Pausa** ferma i lavori in coda. I lavori pesanti vanno uno alla volta e aspettano mentre il montaggio suona; riaprendo un progetto con tante clip si riaprono prima i file che stanno in timeline, e le misure già fatte (forme d'onda, colore, locandine) si ricordano. 16. A **schermo pieno** le tracce crescono da sole; **`Ctrl+Shift`+rotella** le alza e le abbassa. Nell'app **`Ctrl +`** e **`Ctrl −`** ingrandiscono tutta l'interfaccia. ### 🎛 La pulsantiera diff --git a/package-lock.json b/package-lock.json index 7bada5d..3d88ec1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.1.6", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.1.6", + "version": "1.2.0", "license": "MIT", "dependencies": { "@fontsource/archivo-black": "^5.3.0", diff --git a/package.json b/package.json index 3b40bbb..2fef96d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.1.6", + "version": "1.2.0", "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 4ef2cf3..c70fb59 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -624,7 +624,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "daprod-video" -version = "1.1.6" +version = "1.2.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index edbe777..b8101e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.1.6" +version = "1.2.0" description = "DaProd Video: il montaggio vecchio stile, moderno dentro" authors = ["DaProd"] license = "MIT" diff --git a/src/core/stima.ts b/src/core/stima.ts new file mode 100644 index 0000000..eb4ea17 --- /dev/null +++ b/src/core/stima.ts @@ -0,0 +1,41 @@ +// Quanto è passato e quanto manca: la stima parte dalla velocità degli ultimi secondi (così regge anche quando una +// fase va più piano dell'altra, come lo scarico dei modelli e poi il lavoro vero). Pura: la usano la barra del lavoro +// e il centro attività. + +/** "45 s", "2 min 10 s", "1 h 05 min" */ +export function durataTesto(sec: number): string { + const s = Math.max(0, Math.round(sec)); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min${s % 60 ? ' ' + String(s % 60).padStart(2, '0') + ' s' : ''}`; + return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`; +} + +/** la stima del tempo che manca */ +export class Stima { + private punti: { t: number; k: number }[] = []; + private t0: number; + constructor(private ora: () => number = () => performance.now(), private finestra = 25000) { this.t0 = this.ora(); } + + registra(k: number) { + const t = this.ora(); + const u = this.punti[this.punti.length - 1]; + // un passo indietro (nuova fase) non conta come velocità + if (u && k < u.k) this.punti = []; + this.punti.push({ t, k }); + while (this.punti.length > 2 && t - this.punti[0].t > this.finestra) this.punti.shift(); + } + + /** secondi passati da quando è partito */ + trascorso(): number { return (this.ora() - this.t0) / 1000; } + + /** secondi che mancano, o null se ancora non si può dire */ + restante(): number | null { + const p = this.punti; + if (p.length < 2) return null; + const a = p[0], b = p[p.length - 1]; + const dt = (b.t - a.t) / 1000, dk = b.k - a.k; + if (dt < 2 || dk <= 1e-4 || b.k >= 1) return null; + return ((1 - b.k) * dt) / dk; + } +} diff --git a/src/core/store.ts b/src/core/store.ts index a9ad6b7..86079f9 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -1,6 +1,6 @@ // Lo stato dell'editor: il progetto, la selezione, il cursore e l'annulla/ripeti. // Ogni modifica passa da edit(): prima si fa la fotografia, poi si cambia, poi si avvisa chi disegna. -import type { Project } from './tipi'; +import type { Clip, MediaItem, Project, Sequenza } from './tipi'; import { newProject, progettoAperto } from './progetto'; import { FORMATI } from './tipi'; @@ -75,6 +75,28 @@ class Store { return r; } + /** + * Cambia i dati di un file "da fuori" (per esempio quando arriva la copia a velocità costante): vale nel progetto + * e in tutta la cronologia (se no un Annulla rimetterebbe i dati vecchi) e non è una modifica da annullare. + * spostaSrc = di quanti secondi si è mosso l'inizio del file: le clip e i segni lo seguono. + */ + aggiornaMedia(id: string, campi: Partial, spostaSrc = 0) { + const tocca = (d: { media: MediaItem[]; clips: Clip[]; sequenze?: Sequenza[] }) => { + const m = d.media.find((x) => x.id === id); + if (m) { + Object.assign(m, campi); + if (spostaSrc) { if (m.markIn != null) m.markIn += spostaSrc; if (m.markOut != null) m.markOut += spostaSrc; } + } + if (!spostaSrc) return; + for (const c of d.clips) if (c.media === id) c.srcIn += spostaSrc; + for (const s of d.sequenze ?? []) for (const c of s.clips ?? []) if (c.media === id) c.srcIn += spostaSrc; + }; + tocca(this.doc); + for (const s of [...this.undo, ...this.redo, ...(this.live ? [this.live] : [])]) tocca(s); + this.dirty = true; + this.emit('doc'); + } + /** modifica "dal vivo" (trascinamenti): la fotografia la prende begin(), la chiude commit() */ private live: Snap | null = null; begin(label: string) { diff --git a/src/main.ts b/src/main.ts index e296881..0d4525d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,4 +111,7 @@ import * as MP from './core/montagePreset'; import * as EX from './media/exif'; import * as QL from './media/qualita'; import * as RI from './media/ritmo'; -(window as unknown as Record).__dpvTest = { M, TC, P, esporta, creaEdl, motore, guadagnoClip, pianoVideo, Z, statoDecoder, ripresaDiProva, ripresaVfrDiProva, branoVbrDiProva, NZ, TD, PR, importaFile, apriFile, PK, proxyStato: (id: string) => mediaRT(id)?.proxyStato, mediaRT, B, S, SU, V, AG, LV, SQ, SOT, FE, G, mixaggio, PV, TR, TK, ST, Compositore, NM, DP, WV, LAV, SF, MK, RT, CP, USF, AN, RA, fontAnim, MT, MP, EX, QL, RI, ui: () => banco }; +import * as AT from './media/attivita'; +import * as CH from './media/cache'; +import * as SM from './core/stima'; +(window as unknown as Record).__dpvTest = { M, TC, P, esporta, creaEdl, motore, guadagnoClip, pianoVideo, Z, statoDecoder, ripresaDiProva, ripresaVfrDiProva, branoVbrDiProva, NZ, TD, PR, importaFile, apriFile, PK, proxyStato: (id: string) => mediaRT(id)?.proxyStato, mediaRT, B, S, SU, V, AG, LV, SQ, SOT, FE, G, mixaggio, PV, TR, TK, ST, Compositore, NM, DP, WV, LAV, SF, MK, RT, CP, USF, AN, RA, fontAnim, MT, MP, EX, QL, RI, AT, CH, SM, ui: () => banco }; diff --git a/src/media/attivita.ts b/src/media/attivita.ts new file mode 100644 index 0000000..4e92f9b --- /dev/null +++ b/src/media/attivita.ts @@ -0,0 +1,334 @@ +// Il centro attività: tutto quello che il programma fa dietro le quinte (riaprire i file, forme d'onda, colore, +// copie leggere, conversioni, sottotitoli e voce con l'AI…) passa da qui. Serve a due cose: +// · si vede sempre cosa sta succedendo e quanto manca (la barra in basso a destra, e il pannello col clic); +// · i lavori pesanti non partono tutti insieme: stanno in code a corsie con una priorità (prima quello che è già +// in timeline), così l'interfaccia resta fluida anche riaprendo un progetto con centinaia di file. +// Le attività dello stesso tipo si raccolgono in una riga sola ("Forme d'onda: 23 di 60"), con una barra sola. +import { Stima } from '../core/stima'; + +export type Categoria = 'file' | 'analisi' | 'proxy' | 'conversione' | 'ai' | 'esporta' | 'altro'; +export type StatoAttivita = 'coda' | 'lavoro' | 'finita' | 'errore' | 'annullata'; +export type Corsia = 'leggero' | 'pesante'; + +/** una riga del centro attività (una cosa sola, o un gruppo di cose dello stesso tipo) */ +export interface Attivita { + readonly id: number; + titolo: string; + dettaglio: string; + categoria: Categoria; + stato: StatoAttivita; + /** 0..1, o null se non si può dire (indeterminata) */ + k: number | null; + creata: number; + /** quando ha cominciato a lavorare (ms) */ + inizio: number; + /** quando ha finito (ms) */ + fine: number; + /** come è finita: "60 file", l'errore… */ + messaggio: string; + /** per i gruppi: quante cose ci sono e quante sono fatte */ + totale: number; + fatte: number; + /** quanti pezzi stanno lavorando adesso (gli altri aspettano il loro turno) */ + attivi: number; + errori: number; + stima: Stima; + /** si può fermare (il pulsante ✕) */ + annullabile: boolean; + gruppo?: string; +} + +/** la maniglia che chi lavora tiene in mano: dice a che punto è */ +export class Lavoro { + readonly segnale: AbortSignal; + /** quanto è avanti questo pezzo (dentro il suo gruppo) */ + k: number | null = null; + private chiuso = false; + private ultimo = 0; + /** ha cominciato a lavorare (non è più in coda) */ + avviato = false; + constructor(readonly riga: Attivita, private ctrl: AbortController, private suFine: (l: Lavoro, stato: StatoAttivita, msg: string) => void, private suCambio: (l: Lavoro) => void) { + this.segnale = ctrl.signal; + } + + /** a che punto è (0..1, o null se non si sa) e, se serve, cosa sta facendo adesso */ + imposta(k: number | null, dettaglio?: string) { + if (this.chiuso) return; + this.k = k === null ? null : Math.max(0, Math.min(1, k)); + if (dettaglio !== undefined) this.riga.dettaglio = dettaglio; + // chi lavora può chiamare a ogni fotogramma: i conti e l'avviso si fanno al massimo ogni 100 ms + const t = adesso(); + if (t - this.ultimo < 100 && dettaglio === undefined) return; + this.ultimo = t; + this.suCambio(this); + avvisa(); + } + get attivo() { return !this.chiuso; } + get fermato() { return this.segnale.aborted; } + fine(msg = '') { this.chiudi('finita', msg); } + errore(msg: string) { this.chiudi('errore', msg); } + annullata(msg = 'annullata') { this.chiudi('annullata', msg); } + private chiudi(stato: StatoAttivita, msg: string) { + if (this.chiuso) return; + this.chiuso = true; + if (stato === 'finita') this.k = 1; + this.suFine(this, stato, msg); + } + /** per chi la ferma da fuori (il pulsante ✕) */ + ferma() { this.ctrl.abort(); } +} + +const adesso = () => performance.now(); +let prossimo = 1; +/** le righe in coda o al lavoro */ +const righe: Attivita[] = []; +/** le ultime finite, per vederle ancora un poco */ +const storico: Attivita[] = []; +const STORICO_MAX = 12; +const lavoriDi = new Map>(); +const annullaDi = new Map void>(); + +// ——— chi ascolta (la barra e il pannello): avvisi al massimo ogni 150 ms ——— +const ascoltatori = new Set<() => void>(); +let programmato = 0; +function avvisa() { + if (programmato) return; + programmato = window.setTimeout(() => { programmato = 0; for (const f of ascoltatori) f(); }, 150); +} +export const quandoAttivita = (fn: () => void) => { ascoltatori.add(fn); return () => ascoltatori.delete(fn); }; +/** subito, senza aspettare (per le prove) */ +export const avvisaOra = () => { clearTimeout(programmato); programmato = 0; for (const f of ascoltatori) f(); }; + +export const attivitaInCorso = (): readonly Attivita[] => righe; +export const attivitaFinite = (): readonly Attivita[] => storico; + +function creaRiga(o: { titolo: string; categoria: Categoria; dettaglio?: string; gruppo?: string; annullabile?: boolean }): Attivita { + const t = adesso(); + return { + id: prossimo++, titolo: o.titolo, dettaglio: o.dettaglio ?? '', categoria: o.categoria, stato: 'coda', k: null, + creata: t, inizio: 0, fine: 0, messaggio: '', totale: 0, fatte: 0, attivi: 0, errori: 0, stima: new Stima(), annullabile: o.annullabile ?? true, gruppo: o.gruppo, + }; +} + +/** la riga di un gruppo (se c'è già una in corso la usa, se no ne apre una) */ +function rigaDi(o: { titolo: string; categoria: Categoria; dettaglio?: string; gruppo?: string; annullabile?: boolean }): Attivita { + if (o.gruppo) { + const r = righe.find((x) => x.gruppo === o.gruppo); + if (r) return r; + } + const r = creaRiga(o); + righe.push(r); + lavoriDi.set(r.id, new Set()); + return r; +} + +function ricalcola(r: Attivita) { + const lavori = lavoriDi.get(r.id); + let somma = r.fatte; + let indet = 0; + let attivi = 0; + if (lavori) for (const l of lavori) { if (l.avviato) attivi++; if (l.k === null) indet++; else somma += l.k; } + r.attivi = attivi; + r.k = r.totale ? Math.min(1, somma / r.totale) : null; + // tutti indeterminati e niente di finito: la barra resta "in attesa" + if (indet && indet === (lavori?.size ?? 0) && !r.fatte && r.totale === 1) r.k = null; + if (r.k !== null) r.stima.registra(r.k); +} + +function chiudiRiga(r: Attivita, stato: StatoAttivita, msg: string) { + r.stato = stato; + r.fine = adesso(); + r.messaggio = msg; + r.k = stato === 'finita' ? 1 : r.k; + const i = righe.indexOf(r); + if (i >= 0) righe.splice(i, 1); + lavoriDi.delete(r.id); + annullaDi.delete(r.id); + storico.unshift(r); + while (storico.length > STORICO_MAX) storico.pop(); + avvisa(); +} + +function fineLavoro(l: Lavoro, stato: StatoAttivita, msg: string) { + const r = l.riga; + lavoriDi.get(r.id)?.delete(l); + r.fatte++; + if (stato === 'errore') { r.errori++; r.messaggio = msg; } + ricalcola(r); + if (r.fatte >= r.totale) { + const durata = (adesso() - (r.inizio || r.creata)) / 1000; + const tempo = durata < 60 ? `${Math.round(durata)} s` : `${Math.floor(durata / 60)} min ${String(Math.round(durata % 60)).padStart(2, '0')} s`; + if (stato === 'annullata' && r.fatte === r.errori + 1 && r.totale === 1) chiudiRiga(r, 'annullata', 'annullata'); + else if (r.errori && r.errori === r.totale) chiudiRiga(r, 'errore', msg || 'non riuscito'); + else chiudiRiga(r, 'finita', (r.totale > 1 ? `${r.totale} in ${tempo}` : msg && stato === 'finita' ? `${msg} in ${tempo}` : `finito in ${tempo}`) + (r.errori ? ` · ${r.errori} non riusciti` : '')); + } + avvisa(); +} + +// ——— attività "a mano": chi lavora dice a che punto è ——— +export interface OpzioniAttivita { + titolo: string; + categoria: Categoria; + dettaglio?: string; + /** il pezzo di lavoro dice a che punto è; con un gruppo, tutti i pezzi dello stesso gruppo stanno in una riga sola */ + gruppo?: string; + /** chiamata quando si preme ✕ (oltre a fermare il segnale) */ + alAnnulla?: () => void; + annullabile?: boolean; +} + +function nuovoLavoro(o: OpzioniAttivita, comincia: boolean): Lavoro { + const r = rigaDi(o); + r.totale++; + const ctrl = new AbortController(); + const l: Lavoro = new Lavoro(r, ctrl, fineLavoro, (x) => ricalcola(x.riga)); + lavoriDi.get(r.id)!.add(l); + if (!r.annullabile) r.annullabile = false; + if (o.annullabile === false) r.annullabile = false; + if (o.alAnnulla) { const prima = annullaDi.get(r.id); annullaDi.set(r.id, () => { prima?.(); o.alAnnulla!(); }); } + if (comincia) avviaLavoro(l); + ricalcola(r); + avvisa(); + return l; +} + +function avviaLavoro(l: Lavoro) { + const r = l.riga; + l.avviato = true; + if (r.stato === 'coda') { r.stato = 'lavoro'; r.inizio = adesso(); r.stima = new Stima(); } + ricalcola(r); +} + +/** una cosa che lavora già (le barre di sottotitoli, voce, traduzione…) */ +export function nuova(o: OpzioniAttivita): Lavoro { return nuovoLavoro(o, true); } + +/** ferma una riga (il pulsante ✕ del pannello): i pezzi in coda spariscono, quelli al lavoro ricevono il segnale */ +export function fermaRiga(id: number) { + const r = righe.find((x) => x.id === id); + if (!r) return; + r.dettaglio = 'annullo…'; + for (const corsia of Object.values(corsie)) { + for (const v of [...corsia.coda]) if (v.lavoro.riga === r) { corsia.coda.splice(corsia.coda.indexOf(v), 1); v.lavoro.annullata(); v.risolvi(null); } + } + for (const l of [...(lavoriDi.get(id) ?? [])]) l.ferma(); + annullaDi.get(id)?.(); + avvisa(); +} + +// ——— la coda a corsie ——— +interface Voce { lavoro: Lavoro; priorita: () => number; fn: (l: Lavoro) => Promise; risolvi: (x: unknown) => void; ordine: number } +const corsie: Record = { + // quasi tutto: forme d'onda, colore, locandine + leggero: { max: 2, attivi: 0, coda: [] }, + // copie leggere, conversioni: un solo lavoro alla volta (usano il codificatore) + pesante: { max: 1, attivi: 0, coda: [] }, +}; +let ordine = 0; +let pausaUtente = false; +let pausaRiproduzione = false; + +export const inPausa = () => pausaUtente; +/** i lavori pesanti aspettano perché il montaggio sta suonando */ +export const inAttesaPerRiproduzione = () => pausaRiproduzione; +/** il pulsante "metti in pausa" del pannello: non partono lavori nuovi (quelli al lavoro finiscono) */ +export function pausaLavori(on: boolean) { pausaUtente = on; if (!on) pompa(); avvisa(); } +/** il montaggio suona: la corsia pesante aspetta (quella leggera può andare, ma i lavori brevi cedono il passo) */ +export function pausaPerRiproduzione(on: boolean) { pausaRiproduzione = on; if (!on) pompa(); } + +function fermaCorsia(c: Corsia) { return pausaUtente || (c === 'pesante' && pausaRiproduzione); } + +function pompa() { + for (const nome of Object.keys(corsie) as Corsia[]) { + const c = corsie[nome]; + while (c.attivi < c.max && c.coda.length && !fermaCorsia(nome)) { + // il più importante, e a parità il più vecchio + let mi = 0, mp = -Infinity; + for (let i = 0; i < c.coda.length; i++) { + const p = c.coda[i].priorita(); + if (p > mp || (p === mp && c.coda[i].ordine < c.coda[mi].ordine)) { mp = p; mi = i; } + } + const v = c.coda.splice(mi, 1)[0]; + c.attivi++; + avviaLavoro(v.lavoro); + void esegui(nome, v); + } + } +} + +async function esegui(nome: Corsia, v: Voce) { + const l = v.lavoro; + try { + if (l.fermato) { l.annullata(); v.risolvi(null); return; } + const x = await v.fn(l); + if (l.attivo) l.fine(); + v.risolvi(x ?? null); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (l.fermato || msg === 'fermato') l.annullata(); else l.errore(msg); + v.risolvi(null); + } finally { + corsie[nome].attivi--; + // una pausa per respirare: l'interfaccia ridisegna prima del lavoro dopo + setTimeout(pompa, 0); + } +} + +export interface OpzioniCoda extends OpzioniAttivita { + corsia: Corsia; + /** più alta = prima; può essere una funzione (si rilegge a ogni turno: un file che entra in timeline passa avanti) */ + priorita?: number | (() => number); +} + +/** + * Mette un lavoro in coda. Ritorna quello che il lavoro ritorna, o null se è stato fermato o è andato male. + * Se è già al lavoro troppa roba, aspetta il suo turno (prima chi ha la priorità più alta). + */ +export function inCoda(o: OpzioniCoda, fn: (l: Lavoro) => Promise): Promise { + return new Promise((risolvi) => { + const lavoro = nuovoLavoro(o, false); + const p = o.priorita ?? 0; + corsie[o.corsia].coda.push({ lavoro, priorita: typeof p === 'function' ? p : () => p, fn: fn as (l: Lavoro) => Promise, risolvi: risolvi as (x: unknown) => void, ordine: ordine++ }); + pompa(); + }); +} + +// ——— il riassunto per la barra ——— +export interface Riassunto { + /** righe in coda o al lavoro */ + righe: number; + inCorso: number; + inCoda: number; + /** 0..1 su tutto (null se non c'è niente) */ + k: number | null; + /** secondi che mancano (il più lontano fra quelli che si sa), o null */ + restante: number | null; + /** cosa sta facendo di più importante */ + principale: Attivita | null; + inPausa: boolean; +} + +export function riassunto(): Riassunto { + const lista = righe; + if (!lista.length) return { righe: 0, inCorso: 0, inCoda: 0, k: null, restante: null, principale: null, inPausa: pausaUtente }; + let somma = 0, n = 0, rest: number | null = null, principale: Attivita | null = null; + for (const r of lista) { + if (r.k !== null) { somma += r.k; n++; } else n++; + const t = r.stima.restante(); + if (t !== null && (rest === null || t > rest)) rest = t; + if (r.stato === 'lavoro' && (!principale || r.creata < principale.creata)) principale = r; + } + const inCorso = lista.filter((r) => r.stato === 'lavoro').length; + return { righe: lista.length, inCorso, inCoda: lista.length - inCorso, k: n ? somma / n : null, restante: rest, principale: principale ?? lista[0], inPausa: pausaUtente }; +} + +/** togli le finite dall'elenco (il pulsante "pulisci") */ +export function puliscoFinite() { storico.length = 0; avvisa(); } + +/** solo per le prove: azzera tutto */ +export function azzeraAttivita() { + for (const r of [...righe]) fermaRiga(r.id); + righe.length = 0; storico.length = 0; lavoriDi.clear(); annullaDi.clear(); + for (const c of Object.values(corsie)) { c.coda.length = 0; c.attivi = 0; } + pausaUtente = false; pausaRiproduzione = false; + avvisa(); +} diff --git a/src/media/cache.ts b/src/media/cache.ts new file mode 100644 index 0000000..50438af --- /dev/null +++ b/src/media/cache.ts @@ -0,0 +1,100 @@ +// La memoria delle misure già fatte: forma d'onda, colore automatico e locandina di ogni file, tenute nel browser +// (IndexedDB, un deposito a parte che si può svuotare senza toccare i progetti). Riaprire un progetto con tanti file +// non rifà tutto da capo: se il file è lo stesso (nome, grandezza, data, durata) le misure ci sono già. +import type { MediaItem } from '../core/tipi'; + +export type Deposito = 'picchi' | 'colore' | 'poster'; +const NOME = 'dpv-cache'; +const DEPOSITI: Deposito[] = ['picchi', 'colore', 'poster']; +/** quante voci per deposito: oltre, si buttano le più vecchie */ +const MASSIMO = 600; + +let aperto: Promise | null = null; +function db(): Promise { + aperto ??= new Promise((ok) => { + try { + const r = indexedDB.open(NOME, 1); + r.onupgradeneeded = () => { for (const d of DEPOSITI) if (!r.result.objectStoreNames.contains(d)) r.result.createObjectStore(d); }; + r.onsuccess = () => ok(r.result); + r.onerror = () => ok(null); + r.onblocked = () => ok(null); + } catch { ok(null); } + }); + return aperto; +} + +/** la chiave di un file: cambia se cambia il file (o dove sta dentro un pacchetto) */ +export function chiaveMedia(m: MediaItem): string { + return [m.path ?? '', m.dentro?.off ?? '', m.name, m.size, m.lastModified, m.duration.toFixed(3), m.width + 'x' + m.height].join('|'); +} + +export async function leggiCache(deposito: Deposito, chiave: string): Promise { + const d = await db(); + if (!d) return undefined; + try { + return await new Promise((ok) => { + const r = d.transaction(deposito, 'readonly').objectStore(deposito).get(chiave); + r.onsuccess = () => ok((r.result as { v: T } | undefined)?.v); + r.onerror = () => ok(undefined); + }); + } catch { return undefined; } +} + +let scritture = 0; +export async function scriviCache(deposito: Deposito, chiave: string, valore: unknown): Promise { + const d = await db(); + if (!d) return; + try { + await new Promise((ok) => { + const tx = d.transaction(deposito, 'readwrite'); + tx.objectStore(deposito).put({ t: Date.now(), v: valore }, chiave); + tx.oncomplete = () => ok(); + tx.onerror = () => ok(); + tx.onabort = () => ok(); + }); + if (++scritture % 40 === 0) void pulisci(deposito); + } catch { /* spazio pieno: pazienza, si rifà */ } +} + +/** butta le voci più vecchie quando sono troppe */ +async function pulisci(deposito: Deposito) { + const d = await db(); + if (!d) return; + try { + const voci: { k: IDBValidKey; t: number }[] = []; + await new Promise((ok) => { + const r = d.transaction(deposito, 'readonly').objectStore(deposito).openCursor(); + r.onsuccess = () => { const c = r.result; if (c) { voci.push({ k: c.key, t: (c.value as { t: number }).t ?? 0 }); c.continue(); } else ok(); }; + r.onerror = () => ok(); + }); + if (voci.length <= MASSIMO) return; + voci.sort((a, b) => a.t - b.t); + const tx = d.transaction(deposito, 'readwrite'); + for (const v of voci.slice(0, voci.length - MASSIMO)) tx.objectStore(deposito).delete(v.k); + } catch { /* niente */ } +} + +export async function svuotaCache(): Promise { + const d = await db(); + if (!d) return; + try { + await new Promise((ok) => { + const tx = d.transaction(DEPOSITI, 'readwrite'); + for (const n of DEPOSITI) tx.objectStore(n).clear(); + tx.oncomplete = () => ok(); + tx.onerror = () => ok(); + }); + } catch { /* niente */ } +} + +// ——— la forma d'onda in piccolo: un byte per valore, con la radice per tenere i dettagli dei suoni bassi ——— +export function comprimiPicchi(p: Float32Array): Uint8Array { + const o = new Uint8Array(p.length); + for (let i = 0; i < p.length; i++) o[i] = Math.round(Math.sqrt(Math.min(1, Math.max(0, p[i]))) * 255); + return o; +} +export function espandiPicchi(b: Uint8Array): Float32Array { + const o = new Float32Array(b.length); + for (let i = 0; i < b.length; i++) { const x = b[i] / 255; o[i] = x * x; } + return o; +} diff --git a/src/media/libreria.ts b/src/media/libreria.ts index 75ee9c7..6a9c79e 100644 --- a/src/media/libreria.ts +++ b/src/media/libreria.ts @@ -7,7 +7,9 @@ import { import type { MediaItem } from '../core/tipi'; import { uid } from '../core/progetto'; import { invoke, isTauri, type FileScelto } from '../platform'; -import { accodaProxy, type Proxy } from './proxy'; +import { accodaProxy, modoProxy, proxyEsistente, type Proxy } from './proxy'; +import { inCoda as lavoraInCoda, type Lavoro } from './attivita'; +import { chiaveMedia, comprimiPicchi, espandiPicchi, leggiCache, scriviCache } from './cache'; export type StatoMedia = 'caricamento' | 'ok' | 'offline' | 'errore'; @@ -34,7 +36,8 @@ export interface MediaRT { colore?: Analisi; /** la copia leggera per i monitor (src/media/proxy.ts) */ proxy?: Proxy; - proxyStato?: 'coda' | 'lavoro' | 'pronto' | 'no' | 'errore'; + /** 'attesa' = il proxy si farà quando la ripresa entra in timeline */ + proxyStato?: 'attesa' | 'coda' | 'lavoro' | 'pronto' | 'no' | 'errore'; proxyProg?: number; } @@ -50,6 +53,13 @@ const rt = new Map(); export const mediaRT = (id: string) => rt.get(id); export const PEAKS_PER_SEC = 100; +/** quante clip della timeline usano un file: chi sta in timeline passa avanti nelle code dei lavori di fondo */ +let usoMedia: (id: string) => number = () => 0; +export const impostaUsoMedia = (fn: (id: string) => number) => { usoMedia = fn; }; +const prioritaDi = (id: string) => () => { const n = usoMedia(id); return n > 0 ? 100 + Math.min(50, n) : 0; }; +/** per chi mette in coda lavori su un file: chi sta in timeline passa avanti */ +export const prioritaMedia = prioritaDi; + const IMMAGINI = /\.(jpe?g|png|webp|gif|bmp|avif)$/i; let ac3Pronto: Promise | null = null; @@ -106,15 +116,9 @@ export async function apri(item: MediaItem, sel: { file?: File; path?: string }) r.vDecodable = r.v ? await r.v.canDecode() : false; r.aDecodable = r.a ? await r.a.canDecode() : false; r.stato = 'ok'; - if (r.v && r.vDecodable) { - const dur = item.duration || 1; - const cs = new CanvasSink(r.v, { width: 320, fit: 'contain' }); - const w = await cs.getCanvas(Math.min(dur * 0.1, 2)).catch(() => null) ?? await cs.getCanvas(0).catch(() => null); - if (w) r.poster = w.canvas; - void misuraColore(r, dur); - void accodaProxy(item, r, () => sorgente(r)); - } - if (r.a && r.aDecodable) void calcolaPicchi(r, item.duration); + const chiave = chiaveMedia(item); + if (r.v && r.vDecodable) await locandina(item, r, r.v, chiave); + preparaLavori(item, r, chiave); } catch (e) { r.stato = 'errore'; r.errore = e instanceof Error ? e.message : String(e); @@ -122,8 +126,86 @@ export async function apri(item: MediaItem, sel: { file?: File; path?: string }) return r; } +/** la locandina: dalla memoria se c'è già, se no un fotogramma vero (e poi si ricorda) */ +async function locandina(item: MediaItem, r: MediaRT, v: InputVideoTrack, chiave: string) { + const salvata = await leggiCache('poster', chiave); + if (salvata) { + try { r.poster = await createImageBitmap(salvata); return; } catch { /* locandina rovinata: si rifà */ } + } + const dur = item.duration || 1; + const cs = new CanvasSink(v, { width: 320, fit: 'contain' }); + const w = await cs.getCanvas(Math.min(dur * 0.1, 2)).catch(() => null) ?? await cs.getCanvas(0).catch(() => null); + if (!w) return; + r.poster = w.canvas; + void aBlob(w.canvas).then((b) => { if (b) void scriviCache('poster', chiave, b); }); +} + +/** la locandina in JPEG, senza fermare l'interfaccia: la lettura dei pixel si fa fuori dal thread principale */ +async function aBlob(c: HTMLCanvasElement | OffscreenCanvas): Promise { + try { + const bmp = await createImageBitmap(c); + const o = new OffscreenCanvas(bmp.width, bmp.height); + o.getContext('2d')!.drawImage(bmp, 0, 0); + bmp.close(); + return await o.convertToBlob({ type: 'image/jpeg', quality: 0.82 }); + } catch { return null; } +} + +/** colore, forma d'onda e copia leggera: dietro le quinte, a turno, prima i file che stanno in timeline */ +function preparaLavori(item: MediaItem, r: MediaRT, chiave: string) { + const priorita = prioritaDi(item.id); + if (r.v && r.vDecodable) { + void (async () => { + const salvato = await leggiCache('colore', chiave); + if (salvato) { r.colore = salvato; for (const fn of onAnalisi) fn(); return; } + await lavoraInCoda({ corsia: 'leggero', titolo: 'Colore automatico', categoria: 'analisi', gruppo: 'colore', priorita }, async (l) => { + l.imposta(0, item.name); + await misuraColore(r, item.duration || 1, chiave); + }); + })(); + void preparaProxy(item, r); + } + if (r.a && r.aDecodable) { + void (async () => { + const n = Math.max(1, Math.ceil(item.duration * PEAKS_PER_SEC)); + const salvati = await leggiCache('picchi', chiave); + if (salvati && salvati.length === n && rt.get(r.id) === r) { + r.peaks = espandiPicchi(salvati); + r.peaksDone = n; + for (const fn of onPicchi) fn(r.id); + return; + } + await lavoraInCoda({ corsia: 'leggero', titolo: 'Forme d\'onda', categoria: 'analisi', gruppo: 'picchi', priorita }, async (l) => { + l.imposta(0, item.name); + await calcolaPicchi(r, item.duration, chiave, l); + }); + })(); + } +} + +/** la copia leggera serve a chi sta in timeline: per gli altri file si aspetta che qualcuno li usi */ +async function preparaProxy(item: MediaItem, r: MediaRT) { + const fonte = () => sorgente(r); + if (usoMedia(item.id) > 0 || modoProxy() === 'sempre') { await accodaProxy(item, r, fonte, prioritaDi(item.id)); return; } + // un proxy già fatto si usa subito (costa poco); gli altri si fanno quando serve + if (await proxyEsistente(item, r)) return; + r.proxyStato = 'attesa'; +} + +/** un file è entrato in timeline: se aspettava la sua copia leggera, adesso si fa */ +export function risvegliaProxy(media: MediaItem[]) { + for (const m of media) { + const r = rt.get(m.id); + if (r?.proxyStato === 'attesa' && r.v && usoMedia(m.id) > 0) { + r.proxyStato = undefined; + void accodaProxy(m, r, () => sorgente(r), prioritaDi(m.id)); + } + } +} + /** legge un file nuovo e crea la sua scheda per il contenitore */ -export async function importa(sel: FileScelto): Promise<{ item: MediaItem; rt: MediaRT } | { errore: string; nome: string }> { +/** soloDescrizione: legge com'è fatto il file e basta (niente locandina né lavori di fondo): serve a chi sostituisce un file con la sua copia convertita */ +export async function importa(sel: FileScelto, opz: { soloDescrizione?: boolean } = {}): Promise<{ item: MediaItem; rt: MediaRT } | { errore: string; nome: string }> { const id = uid('m'); const nome = sel.name; const base: MediaItem = { @@ -157,7 +239,7 @@ export async function importa(sel: FileScelto): Promise<{ item: MediaItem; rt: M const img = await createImageBitmap(await leggiTutto(r)); img.close(); rt.delete(id); - return importa({ ...sel, name: /\.[a-z0-9]{2,4}$/i.test(nome) ? nome : nome + '.jpg' }); + return importa({ ...sel, name: /\.[a-z0-9]{2,4}$/i.test(nome) ? nome : nome + '.jpg' }, opz); } catch { /* non è nemmeno un'immagine */ } rt.delete(id); return { errore: 'formato non riconosciuto', nome }; @@ -193,14 +275,10 @@ export async function importa(sel: FileScelto): Promise<{ item: MediaItem; rt: M return { errore: `il video ${base.vcodec.toUpperCase()} non si decodifica su questo sistema`, nome }; } r.stato = 'ok'; - if (v && r.vDecodable) { - const cs = new CanvasSink(v, { width: 320, fit: 'contain' }); - const w = await cs.getCanvas(Math.min(base.duration * 0.1, 2)).catch(() => null) ?? await cs.getCanvas(0).catch(() => null); - if (w) r.poster = w.canvas; - void misuraColore(r, base.duration); - void accodaProxy(base, r, () => sorgente(r)); - } - if (a && r.aDecodable) void calcolaPicchi(r, base.duration); + if (opz.soloDescrizione) { input.dispose(); rt.delete(id); return { item: base, rt: r }; } + const chiave = chiaveMedia(base); + if (v && r.vDecodable) await locandina(base, r, v, chiave); + preparaLavori(base, r, chiave); return { item: base, rt: r }; } catch (e) { rt.delete(id); @@ -251,7 +329,7 @@ export function analizza(immagini: CanvasImageSource[]): Analisi { return { lo, hi, wb, gamma }; } -async function misuraColore(r: MediaRT, durata: number) { +async function misuraColore(r: MediaRT, durata: number, chiave: string) { if (!r.v) return; try { // prima il monitor: la misura aspetta che il decoder sia libero @@ -260,8 +338,14 @@ async function misuraColore(r: MediaRT, durata: number) { const d = Math.max(0.1, durata); const ts = [0.08, 0.3, 0.5, 0.7, 0.92].map((k) => (r.v ? k * d : 0)); const tele: CanvasImageSource[] = []; - for await (const w of cs.canvasesAtTimestamps(ts)) if (w) tele.push(w.canvas); - if (tele.length) r.colore = analizza(tele); + // ogni fotogramma si rimpicciolisce con createImageBitmap (lavora fuori dal thread principale): leggere i pixel + // direttamente dalla tela della scheda video farebbe aspettare l'interfaccia + for await (const w of cs.canvasesAtTimestamps(ts)) { + if (!w) continue; + try { tele.push(await createImageBitmap(w.canvas, { resizeWidth: 64, resizeHeight: 36, resizeQuality: 'low' })); } catch { tele.push(w.canvas); } + } + if (tele.length) { r.colore = analizza(tele); void scriviCache('colore', chiave, r.colore); } + for (const t of tele) if (t instanceof ImageBitmap) t.close(); for (const fn of onAnalisi) fn(); } catch { /* niente colore automatico per questa ripresa */ } } @@ -271,13 +355,14 @@ type Ascoltatore = (id: string) => void; const onPicchi = new Set(); export const quandoPicchi = (fn: Ascoltatore) => { onPicchi.add(fn); return () => onPicchi.delete(fn); }; -async function calcolaPicchi(r: MediaRT, durata: number) { +async function calcolaPicchi(r: MediaRT, durata: number, chiave: string, lavoro?: Lavoro) { if (!r.a) return; const n = Math.max(1, Math.ceil(durata * PEAKS_PER_SEC)); const peaks = new Float32Array(n); r.peaks = peaks; r.peaksDone = 0; const sink = new AudioBufferSink(r.a); + let completa = true; let ultimo = performance.now(); let fetta = performance.now(); try { @@ -298,6 +383,7 @@ async function calcolaPicchi(r: MediaRT, durata: number) { r.peaksDone = Math.min(n, first + Math.ceil(len / per)); if (performance.now() - ultimo > 250) { ultimo = performance.now(); + lavoro?.imposta(r.peaksDone / n); for (const fn of onPicchi) fn(r.id); } // a fette brevi: la forma d'onda si calcola senza bloccare l'interfaccia @@ -305,10 +391,11 @@ async function calcolaPicchi(r: MediaRT, durata: number) { await new Promise((ok) => setTimeout(ok, 0)); fetta = performance.now(); } - if (rt.get(r.id) !== r) return; + if (rt.get(r.id) !== r || lavoro?.fermato) return; } - } catch { /* forma d'onda incompleta: pazienza */ } + } catch { completa = false; /* forma d'onda incompleta: pazienza (e non si ricorda) */ } r.peaksDone = n; + if (completa) void scriviCache('picchi', chiave, comprimiPicchi(peaks)); for (const fn of onPicchi) fn(r.id); } diff --git a/src/media/proxy.ts b/src/media/proxy.ts index f402646..44d50f7 100644 --- a/src/media/proxy.ts +++ b/src/media/proxy.ts @@ -9,6 +9,7 @@ import { } from 'mediabunny'; import type { MediaItem } from '../core/tipi'; import type { MediaRT } from './libreria'; +import { inCoda, pausaPerRiproduzione, type Lavoro } from './attivita'; export interface Proxy { input: Input; v: NonNullable; w: number; h: number } @@ -81,9 +82,7 @@ function metti(r: MediaRT, p: Proxy) { avvisa(); } -// ——— la coda: un proxy alla volta, in pausa mentre si suona ——— -const coda: { m: MediaItem; r: MediaRT; fonte: () => Source }[] = []; -let lavorando = false; +// ——— la coda: un proxy alla volta (nel centro attività), in pausa mentre si suona ——— let fermo = false; let pausa: AbortController | null = null; let riprendi: (() => void) | null = null; @@ -91,50 +90,50 @@ let riprendi: (() => void) | null = null; /** il montaggio suona (true) o si è fermato (false): i proxy aspettano */ export function pausaProxy(on: boolean) { fermo = on; + pausaPerRiproduzione(on); if (on) pausa?.abort(); else { const f = riprendi; riprendi = null; f?.(); } } -/** la ripresa è entrata nel contenitore: se il proxy c'è già sul disco si usa, se serve si fa. - * fonte = una lettura nuova del file originale (il proxy lo legge per conto suo, senza disturbare il monitor) */ -export async function accodaProxy(m: MediaItem, r: MediaRT, fonte: () => Source) { - if (r.proxy || r.proxyStato === 'coda' || r.proxyStato === 'lavoro') return; +/** il proxy di questo file è già sul disco? Se sì lo si apre e si usa subito */ +export async function proxyEsistente(m: MediaItem, r: MediaRT): Promise { + if (r.proxy) return true; const dir = await cartella(); - if (dir) { - try { - const fh = await dir.getFileHandle(nomeProxy(m)); - const p = await apriProxy(await fh.getFile()); - if (p) { metti(r, p); return; } - } catch { /* non c'è ancora */ } - } + if (!dir) return false; + try { + const fh = await dir.getFileHandle(nomeProxy(m)); + const p = await apriProxy(await fh.getFile()); + if (p) { metti(r, p); return true; } + } catch { /* non c'è ancora */ } + return false; +} + +/** la ripresa deve avere la sua copia leggera: se c'è già sul disco si usa, se serve si mette in coda. + * fonte = una lettura nuova del file originale (il proxy lo legge per conto suo, senza disturbare il monitor); + * priorita = chi è in timeline passa avanti */ +export async function accodaProxy(m: MediaItem, r: MediaRT, fonte: () => Source, priorita: number | (() => number) = 0) { + if (r.proxy || r.proxyStato === 'coda' || r.proxyStato === 'lavoro') return; + if (await proxyEsistente(m, r)) return; if (!(await serve(m, r))) { r.proxyStato = 'no'; return; } r.proxyStato = 'coda'; r.proxyProg = 0; - coda.push({ m, r, fonte }); avvisa(); - if (!lavorando) void lavora(); -} - - -async function lavora() { - lavorando = true; - while (coda.length) { - const { m, r, fonte } = coda.shift()!; - if (r.proxy || !r.input) continue; + void inCoda({ corsia: 'pesante', titolo: 'Copie leggere per il monitor', categoria: 'proxy', gruppo: 'proxy', dettaglio: m.name, priorita }, async (l) => { + if (r.proxy || !r.input) return; r.proxyStato = 'lavoro'; avvisa(); + l.imposta(0, m.name); try { - const p = await creaProxy(m, r, fonte); - if (p) metti(r, p); else r.proxyStato = 'errore'; + const p = await creaProxy(m, r, fonte, l); + if (p) metti(r, p); else r.proxyStato = l.fermato ? 'no' : 'errore'; } catch { r.proxyStato = 'errore'; } avvisa(); - } - lavorando = false; + }).then(() => { if (r.proxyStato === 'coda') { r.proxyStato = 'no'; avvisa(); } }); } -async function creaProxy(m: MediaItem, r: MediaRT, fonte: () => Source): Promise { +async function creaProxy(m: MediaItem, r: MediaRT, fonte: () => Source, l: Lavoro): Promise { const k = Math.min(1, LATO / Math.max(m.width, m.height, 1)); const w = Math.max(2, Math.round((m.width * k) / 2) * 2), h = Math.max(2, Math.round((m.height * k) / 2) * 2); const codec = await getFirstEncodableVideoCodec(['avc', 'vp9', 'vp8'], { width: w, height: h }); @@ -149,8 +148,10 @@ async function creaProxy(m: MediaItem, r: MediaRT, fonte: () => Source): Promise }); if (!conv.isValid) return null; // l'avanzamento si segna e basta: avvisare a ogni fotogramma farebbe ridisegnare il monitor per niente - conv.onProgress = (p) => { r.proxyProg = p; }; + conv.onProgress = (p) => { r.proxyProg = p; l.imposta(p); }; + l.segnale.addEventListener('abort', () => { void conv.cancel(); }); for (;;) { + if (l.fermato) return null; if (fermo) await new Promise((ok) => { riprendi = ok; }); pausa = new AbortController(); await conv.execute({ pauseSignal: pausa.signal }); @@ -178,12 +179,13 @@ async function creaProxy(m: MediaItem, r: MediaRT, fonte: () => Source): Promise /** quanti proxy sono pronti, in lavorazione o in coda (per la barra di stato) */ export function statoProxy(lista: MediaRT[]) { - let pronti = 0, lavoro = 0, prog = 0; + let pronti = 0, lavoro = 0, prog = 0, attesa = 0; for (const r of lista) { if (r.proxyStato === 'pronto') pronti++; + else if (r.proxyStato === 'attesa') attesa++; else if (r.proxyStato === 'lavoro' || r.proxyStato === 'coda') { lavoro++; if (r.proxyStato === 'lavoro') prog = r.proxyProg ?? 0; } } - return { pronti, lavoro, prog }; + return { pronti, lavoro, prog, attesa }; } /** svuota i proxy salvati (menu Vista): si rifanno quando servono */ diff --git a/src/progetti.ts b/src/progetti.ts index 50da99a..d2475db 100644 --- a/src/progetti.ts +++ b/src/progetti.ts @@ -6,7 +6,8 @@ import type { MediaItem, Project } from './core/tipi'; import { FORMATI } from './core/tipi'; import { ALTEZZA, MASTER0, newProject } from './core/progetto'; import { migraBlocchi } from './core/blocchi'; -import { apri as apriMedia, chiudi as chiudiMedia, importa, mediaRT } from './media/libreria'; +import { apri as apriMedia, chiudi as chiudiMedia, importa, mediaRT, prioritaMedia } from './media/libreria'; +import { inCoda, nuova } from './media/attivita'; import { dimenticaMedia } from './media/fotogrammi'; import { converti, esamina, normalizzaAttivo } from './media/normalizza'; import { dialogoApri, ESTENSIONI_DI, invoke, isTauri, nomeDaPercorso, salvaTesto, scegliFileBrowser, scegliMedia, type FileScelto, type TipoMedia } from './platform'; @@ -47,38 +48,28 @@ async function idb(store: 'kv' | 'maniglie' | 'file', op: 'get' | 'put' | 'de type Maniglia = FileSystemFileHandle & { queryPermission?: (o: object) => Promise; requestPermission?: (o: object) => Promise }; // ——— importazione ——— +const IMMAGINE = /\.(jpe?g|png|webp|gif|bmp|avif)$/i; + export async function importaFile(lista: (FileScelto & { maniglia?: Maniglia })[], opzioni: { chiediFormato?: boolean; cartella?: string } = {}): Promise { if (!lista.length) return []; const nuovi: MediaItem[] = []; const errori: string[] = []; - const barra = avvisoLungo(`Importo ${lista.length} file…`); + const lav = nuova({ titolo: lista.length === 1 ? 'Importo un file' : `Importo ${lista.length} file`, categoria: 'file' }); let i = 0; for (const f of lista) { - barra.testo(`Importo ${++i}/${lista.length}: ${f.name}`); - // frame rate o bitrate variabili: il file si rifà a velocità costante (se no in montaggio audio e video slittano) - let sel: FileScelto & { maniglia?: Maniglia } = f; - if (normalizzaAttivo() && !/\.(jpe?g|png|webp|gif|bmp|avif)$/i.test(f.name)) { - try { - const v = await esamina(f); - if (v) { - const nome = f.name; - barra.testo(`Converto ${nome}: ${v.motivo}…`); - const conv = await converti(f, v, (k) => barra.testo(`Converto ${nome} (${v.motivo}): ${Math.round(k * 100)}%`)); - sel = conv; - avviso(`🔧 ${nome}: ${v.motivo} (l'originale non è stato toccato)`, 'info', 3600); - } - } catch { sel = f; /* se la conversione non riesce si importa il file com'è */ } - } - const r = await importa(sel); + if (lav.fermato) break; + lav.imposta(i / lista.length, `${++i} di ${lista.length} · ${f.name}`); + const r = await importa(f); if ('errore' in r) { errori.push(`${r.nome}: ${r.errore}`); continue; } if (opzioni.cartella) r.item.cartella = opzioni.cartella; nuovi.push(r.item); - if (f.maniglia && sel === f) void idb('maniglie', 'put', r.item.id, f.maniglia); + if (f.maniglia) void idb('maniglie', 'put', r.item.id, f.maniglia); // nel browser, senza "maniglia" (Firefox, Safari, file trascinati, istantanee) i file piccoli si tengono // dentro il browser: così il progetto li ritrova alla prossima apertura senza ricollegarli - else if (!isTauri && sel.file && sel.file.size <= FILE_LOCALE_MAX) void idb('file', 'put', r.item.id, sel.file); + else if (!isTauri && f.file && f.file.size <= FILE_LOCALE_MAX) void idb('file', 'put', r.item.id, f.file); + controllaVelocita(r.item, f); } - barra.chiudi(); + lav.fine(nuovi.length === 1 ? '1 file' : `${nuovi.length} file`); if (nuovi.length) { store.edit(`Importa ${nuovi.length} file`, (p) => { p.media.push(...nuovi); }); // il primo video decide il formato del progetto se il montaggio è ancora vuoto @@ -96,6 +87,54 @@ export async function importaFile(lista: (FileScelto & { maniglia?: Maniglia })[ return nuovi; } +/** + * Frame rate o bitrate variabili: il file si rifà a velocità costante (se no in montaggio audio e video slittano). + * Si lavora subito col file com'è; la copia si fa dietro le quinte (una alla volta, ferma mentre il montaggio suona) + * e, quando è pronta, prende il posto dell'originale. L'originale non si tocca. + */ +function controllaVelocita(m: MediaItem, sel: FileScelto) { + if (!normalizzaAttivo() || m.type === 'image' || IMMAGINE.test(sel.name)) return; + const priorita = prioritaMedia(m.id); + void inCoda({ corsia: 'leggero', titolo: 'Controllo la velocità dei file', categoria: 'analisi', gruppo: 'esame', priorita }, async (l) => { + l.imposta(0, m.name); + const v = await esamina(sel); + if (!v) return; + // la conversione ha la sua riga (e il suo posto in coda): qui non si aspetta + void inCoda({ corsia: 'pesante', titolo: 'Conversione a velocità costante', categoria: 'conversione', gruppo: 'converti', priorita }, async (c) => { + c.imposta(0, `${m.name} · ${v.motivo}`); + const copia = await converti(sel, v, (k) => c.imposta(k), c.segnale); + c.imposta(1, `${m.name} · metto la copia al suo posto`); + if (await mettiCopia(m.id, copia)) avviso(`🔧 ${m.name}: ${v.motivo} (l'originale non è stato toccato)`, 'info', 3600); + }); + }); +} + +/** la copia a velocità costante prende il posto del file nel progetto (clip, segni e proprietà restano dove sono) */ +async function mettiCopia(id: string, copia: FileScelto): Promise { + if (!store.doc.media.some((x) => x.id === id)) return false; + const d = await importa(copia, { soloDescrizione: true }); + if ('errore' in d) throw new Error(d.errore); + // il file non si cambia sotto i piedi di chi sta guardando + while (motore.playing) await new Promise((ok) => setTimeout(ok, 400)); + const m = store.doc.media.find((x) => x.id === id); + if (!m) return false; + const n = d.item; + store.aggiornaMedia(id, { + duration: n.duration, t0: n.t0, width: n.width, height: n.height, fps: n.fps, rotation: n.rotation, hasVideo: n.hasVideo, hasAudio: n.hasAudio, + channels: n.channels, sampleRate: n.sampleRate, vcodec: n.vcodec, acodec: n.acodec, container: n.container, size: n.size, lastModified: n.lastModified, path: copia.path, + }, (n.t0 || 0) - (m.t0 || 0)); + dimenticaMedia(id); + await apriMedia(store.doc.media.find((x) => x.id === id)!, { file: copia.file, path: copia.path }); + // nel browser la copia sta in memoria: per ritrovarla alla prossima apertura si tiene dentro il browser (se non è enorme) + if (!isTauri && copia.file) { + void idb('maniglie', 'delete', id); + if (copia.file.size <= FILE_LOCALE_MAX) void idb('file', 'put', id, copia.file); + } + store.emit('doc'); + motore.ridisegna(); + return true; +} + async function propostaFormato(m: MediaItem) { const p = store.doc; const w = m.rotation % 180 ? m.height : m.width, hh = m.rotation % 180 ? m.width : m.height; @@ -299,7 +338,7 @@ async function apriPacchetto(s: Sorgente) { let mancano = 0; let i = 0; for (const m of p.media) { - barra.testo(`Apro il pacchetto: ${++i}/${p.media.length} ${m.name}`); + barra.testo(`Apro il pacchetto: ${++i}/${p.media.length} ${m.name}`, (i - 1) / p.media.length); if (!m.dentro) { mancano++; continue; } if (s.path) { const r = await apriMedia(m, { path: s.path }); if (r.stato !== 'ok') mancano++; continue; } const file = new File([s.file!.slice(m.dentro.off, m.dentro.off + m.dentro.len)], m.name, { lastModified: m.lastModified || Date.now() }); @@ -382,39 +421,64 @@ export async function carica(p: Project) { await riapriMedia(false); } -/** riapre i media del progetto: dai percorsi (app) o dalle maniglie ricordate (browser) */ +/** riapre un media: dal percorso (app), dalla maniglia ricordata o dal file tenuto nel browser */ +async function riapriUno(m: MediaItem, conGesto: boolean): Promise { + if (mediaRT(m.id)?.stato === 'ok') return true; + if (isTauri && m.path) return (await apriMedia(m, { path: m.path })).stato === 'ok'; + const hh = await idb('maniglie', 'get', m.id); + if (hh) { + try { + let perm = (await hh.queryPermission?.({ mode: 'read' })) ?? 'granted'; + if (perm !== 'granted' && conGesto) perm = (await hh.requestPermission?.({ mode: 'read' })) ?? 'denied'; + if (perm === 'granted') { + const file = await hh.getFile(); + if ((await apriMedia(m, { file })).stato === 'ok') return true; + } + } catch { /* file spostato */ } + } + const tenuto = await idb('file', 'get', m.id); + if (tenuto) { + const file = tenuto instanceof File ? tenuto : new File([tenuto], m.name, { type: tenuto.type }); + if ((await apriMedia(m, { file })).stato === 'ok') return true; + } + return false; +} + +/** + * Riapre i media del progetto. Prima quelli che stanno in timeline, quattro alla volta (col permesso da chiedere, + * uno alla volta), e a mano a mano che sono pronti l'interfaccia si aggiorna. Il centro attività mostra a che punto è. + */ export async function riapriMedia(conGesto: boolean): Promise { const p = store.doc; - let mancano = 0; - for (const m of p.media) { - if (mediaRT(m.id)?.stato === 'ok') continue; - if (isTauri && m.path) { - const r = await apriMedia(m, { path: m.path }); - if (r.stato !== 'ok') mancano++; - continue; + const da = p.media.filter((m) => mediaRT(m.id)?.stato !== 'ok'); + if (!da.length) { store.emit('doc'); motore.ridisegna(); return 0; } + const usi = new Map(); + for (const c of p.clips) if (c.media) usi.set(c.media, (usi.get(c.media) ?? 0) + 1); + da.sort((a, b) => (usi.get(b.id) ?? 0) - (usi.get(a.id) ?? 0)); + const lav = nuova({ titolo: 'Riapro i file del progetto', categoria: 'file' }); + let fatti = 0, mancano = 0, prossimo = 0, sporco = false, timer = 0; + const mostra = () => { timer = 0; if (!sporco) return; sporco = false; store.emit('doc'); motore.ridisegna(); }; + const operaio = async () => { + for (;;) { + if (lav.fermato) return; + const m = da[prossimo++]; + if (!m) return; + lav.imposta(fatti / da.length, `${fatti + 1} di ${da.length} · ${m.name}`); + let ok = false; + try { ok = await riapriUno(m, conGesto); } catch { ok = false; } + if (!ok) mancano++; + fatti++; + lav.imposta(fatti / da.length, `${fatti} di ${da.length}`); + sporco = true; + if (!timer) timer = window.setTimeout(mostra, 600); } - const hh = await idb('maniglie', 'get', m.id); - if (hh) { - try { - let perm = (await hh.queryPermission?.({ mode: 'read' })) ?? 'granted'; - if (perm !== 'granted' && conGesto) perm = (await hh.requestPermission?.({ mode: 'read' })) ?? 'denied'; - if (perm === 'granted') { - const file = await hh.getFile(); - const r = await apriMedia(m, { file }); - if (r.stato === 'ok') continue; - } - } catch { /* file spostato */ } - } - const tenuto = await idb('file', 'get', m.id); - if (tenuto) { - const file = tenuto instanceof File ? tenuto : new File([tenuto], m.name, { type: tenuto.type }); - const r = await apriMedia(m, { file }); - if (r.stato === 'ok') continue; - } - mancano++; - } - store.emit('doc'); - motore.ridisegna(); + }; + await Promise.all(Array.from({ length: conGesto ? 1 : Math.min(4, da.length) }, operaio)); + clearTimeout(timer); + sporco = true; + mostra(); + if (lav.fermato) { lav.annullata(); return mancano + (da.length - fatti); } + lav.fine(mancano ? `${da.length - mancano} file aperti, ${mancano} mancano` : `${da.length} file`); return mancano; } @@ -468,14 +532,13 @@ export async function togliMedia(id: string) { void idb('file', 'delete', id); } -/** barra d'avanzamento in basso per le operazioni lunghe (con annulla, se serve) */ +/** un'operazione lunga (con annulla, se serve): sta nel centro attività, la barra in basso a destra */ export function avvisoLungo(t: string, annulla?: () => void) { - const el = h('div', { class: 'avviso-lungo' }, h('span', { class: 'led acceso lampeggia' }), h('span', { class: 'testo' }, t), - annulla ? h('button', { class: 'btn piccolo', on: { click: annulla } }, 'Annulla') : null); - document.body.appendChild(el); + const l = nuova({ titolo: t, categoria: 'file', alAnnulla: annulla, annullabile: !!annulla }); return { - testo: (x: string) => { el.querySelector('.testo')!.textContent = x; }, - chiudi: () => el.remove(), + /** il testo di cosa sta facendo; se c'è una percentuale ("45%") o k (0..1) la barra avanza */ + testo: (x: string, k?: number) => { const m = /(\d{1,3})\s*%/.exec(x); l.imposta(k ?? (m ? Number(m[1]) / 100 : l.k), x); }, + chiudi: () => { if (l.attivo) l.fine(); }, }; } diff --git a/src/stile/editor.css b/src/stile/editor.css index 4371a32..ef54d92 100644 --- a/src/stile/editor.css +++ b/src/stile/editor.css @@ -638,6 +638,45 @@ button svg { pointer-events: none; } .stato-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; } .stato-ver { color: var(--testo3); } +/* ——— la barra delle attività (in basso a destra) e il suo pannello ——— */ +.att { display: flex; align-items: center; gap: 8px; height: 100%; min-width: 0; max-width: 52%; padding: 0 8px; margin: 0; background: none; border: 0; border-radius: 4px; color: inherit; font: inherit; cursor: pointer; } +.att:hover, .att:focus-visible { background: rgba(255, 255, 255, 0.06); color: var(--testo); } +.att-led { width: 8px; height: 8px; border-radius: 50%; flex: none; background: radial-gradient(circle at 35% 35%, #fff4cf, var(--oro) 60%); box-shadow: 0 0 7px var(--oro); animation: lampeggia 0.9s infinite; } +.att-ferma .att-led { background: #2b2f2c; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.6); animation: none; } +.att-fatto .att-led { background: radial-gradient(circle at 35% 35%, #d8ffe9, var(--verde) 60%); box-shadow: 0 0 7px var(--verde); animation: none; } +.att-pausa .att-led { animation: none; } +.att-testo { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.att-tempo { flex: none; color: var(--testo2); font-variant-numeric: tabular-nums; } +.att-altre { flex: none; padding: 0 5px; border-radius: 8px; background: #2a2833; color: var(--testo2); font-size: 10.5px; } +.att-altre:empty { display: none; } +.att-pista { position: relative; width: 130px; height: 7px; flex: none; border-radius: 4px; background: #000; box-shadow: inset 0 1px 2px #000; overflow: hidden; } +.att-pista i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--mag), var(--oro)); transition: width 0.25s; } +.att-fatto .att-pista i { background: var(--verde); } +.att-ferma .att-pista { opacity: 0.35; } +.indeterminata .att-pista i { width: 35%; animation: att-scorre 1.3s linear infinite; transition: none; } +@keyframes att-scorre { from { transform: translateX(-100%); } to { transform: translateX(290%); } } +.att-pannello { position: fixed; z-index: 1200; width: min(480px, calc(100vw - 12px)); max-height: min(72vh, 660px); overflow-y: auto; overscroll-behavior: contain; background: #1d1c24; border: 1px solid #000; border-radius: 10px; box-shadow: var(--ombra), inset 0 0 0 1px var(--bordo); font-size: 12.5px; color: var(--testo); } +.att-p-testa { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: #23212b; border-bottom: 1px solid #000; } +.att-p-testa b { font-size: 13px; } +.att-p-sotto { flex: 1; color: var(--testo3); font-size: 11.5px; } +.att-nota { margin: 0; padding: 7px 12px; background: #2a2412; color: var(--oro); font-size: 11.5px; } +.att-riga { display: grid; gap: 4px; padding: 9px 12px; border-bottom: 1px solid #0d0c12; } +.att-riga.in-coda { opacity: 0.62; } +.att-r-testa { display: flex; align-items: center; gap: 7px; } +.att-r-titolo { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.att-r-pct { color: var(--testo2); font-variant-numeric: tabular-nums; } +.att-r-dett { color: var(--testo2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-height: 1.2em; } +.att-r-info { color: var(--testo3); font-size: 11.5px; font-variant-numeric: tabular-nums; } +.att-riga .att-pista { width: 100%; } +.att-r-stop { width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; background: #2a2833; color: var(--testo2); cursor: pointer; } +.att-r-stop:hover { background: var(--rosso); color: #fff; } +.att-p-titolo { margin: 0; padding: 8px 12px 4px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--testo3); } +.att-f { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto auto; gap: 8px; padding: 3px 12px; color: var(--testo2); } +.att-f.errore { color: var(--rosso); } +.att-f-nome, .att-f-msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.att-f-msg, .att-f-quando { color: var(--testo3); font-variant-numeric: tabular-nums; } +.att-vuoto { margin: 0; padding: 10px 12px; color: var(--testo3); } + /* ——— menu e dialoghi ——— */ .menu-contesto { position: fixed; z-index: 1000; min-width: 230px; background: #1d1c24; border: 1px solid #000; border-radius: 8px; padding: 4px; box-shadow: var(--ombra), inset 0 0 0 1px var(--bordo); } .menu-contesto { overflow-y: auto; overscroll-behavior: contain; } @@ -710,7 +749,12 @@ button svg { pointer-events: none; } #app.stretto .finale { display: flex; } .stretto .menu { display: none; } .stretto .scritta i, .stretto .nome-progetto, .stretto .formato, .stretto .badge.edizione { display: none; } -.stretto .stato { display: none; } +/* sul telefono la barra di stato sparisce, resta solo quella delle attività, sopra i pulsanti */ +.stretto .stato { position: fixed; left: 0; right: 0; bottom: 56px; height: 26px; z-index: 60; padding: 0; background: #0b0a0f; } +.stretto .stato > :not(.att) { display: none; } +.stretto .att { flex: 1; max-width: none; } +.stretto .att-pista { width: 70px; } +.stretto .stato:has(.att-ferma) { display: none; } .stretto .barra-tel { grid-area: tel; display: flex; background: #121118; border-top: 1px solid #000; } .barra-tel button { flex: 1; background: none; border: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--testo2); font-size: 11px; } .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); } diff --git a/src/ui/app.ts b/src/ui/app.ts index 25829c5..793add0 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -25,8 +25,8 @@ import { finestraAggiornamenti, finestraNovita, novitaDopoAggiornamento, tastoAg import { montaggioDimostrativo } from '../demo'; import { FORMATI } from '../core/tipi'; import { statoDecoder } from '../media/fotogrammi'; -import { statoProxy } from '../media/proxy'; -import { mediaRT } from '../media/libreria'; +import { CentroAttivita } from './attivita'; +import { impostaUsoMedia, risvegliaProxy } from '../media/libreria'; import { banco } from '../media/audio'; export function avvia(radice: HTMLElement) { @@ -218,14 +218,24 @@ export function avvia(radice: HTMLElement) { // ——— barra di stato ——— const msg = h('span', { class: 'stato-msg' }, 'Pronto. 1 taglia · 2 elimina · S separa/unisci · rotella = un fotogramma · Q/W scarto a sinistra/destra · F9 Finale · F1 tutti i tasti'); const dec = h('span', { class: 'stato-dec' }); - const statoBar = h('footer', { class: 'stato' }, msg, dec, h('span', { class: 'stato-ver' }, `DaProd Video ${VERSIONE}`)); + const centro = new CentroAttivita(); + const statoBar = h('footer', { class: 'stato' }, msg, dec, centro.el, h('span', { class: 'stato-ver' }, `DaProd Video ${VERSIONE}`)); motore.ogniGiro(() => { if (Math.random() > 0.05) return; const s = statoDecoder(); - const px = statoProxy(store.doc.media.map((m) => mediaRT(m.id)).filter((r): r is NonNullable => !!r)); - const proxy = px.lavoro ? ` · proxy ${px.pronti}/${px.pronti + px.lavoro} (${Math.round(px.prog * 100)}%)` : px.pronti ? ` · proxy ${px.pronti} pronti` : ''; - dec.textContent = `${motore.fpsMisurati} fps · decoder ${s.flussi + s.ricerche}${proxy}`; + dec.textContent = `${motore.fpsMisurati} fps · decoder ${s.flussi + s.ricerche}`; }); + // chi sta in timeline passa avanti nelle code dei lavori di fondo; una ripresa appena messa in timeline si prende la sua copia leggera + impostaUsoMedia((id) => { + let n = 0; + for (const c of store.doc.clips) if (c.media === id) n++; + for (const q of store.doc.sequenze ?? []) for (const c of q.clips ?? []) if (c.media === id) n++; + return n; + }); + let svegliaProxy = 0; + // si aspetta un attimo di calma (2 s dall'ultima modifica): appena messa una ripresa in timeline quasi sempre la si guarda, e la copia + // leggera che parte subito ruberebbe il decoder proprio al primo play + store.on('doc', () => { clearTimeout(svegliaProxy); svegliaProxy = window.setTimeout(() => { svegliaProxy = 0; risvegliaProxy(store.doc.media); }, 2000); }); // ——— fogli per il telefono ——— const apriFoglio = (f: 'bin' | 'lato' | 'finale' | 'nessuno') => { diff --git a/src/ui/attivita.ts b/src/ui/attivita.ts new file mode 100644 index 0000000..8d0221f --- /dev/null +++ b/src/ui/attivita.ts @@ -0,0 +1,183 @@ +// La barra delle attività, in basso a destra: mostra sempre cosa sta facendo il programma dietro le quinte (riaprire i +// file, forme d'onda, copie leggere, conversioni, sottotitoli…) con una barra e il tempo che manca. Un clic apre il +// pannello con tutte le righe: a che punto sono, quanto è passato, quanto manca, cosa è in coda, cosa è finito da poco. +// I dati stanno in src/media/attivita.ts (il registro e le code); qui si disegna e basta. +import { + attivitaFinite, attivitaInCorso, fermaRiga, inAttesaPerRiproduzione, inPausa, pausaLavori, puliscoFinite, quandoAttivita, riassunto, + type Attivita, type Categoria, +} from '../media/attivita'; +import { durataTesto } from '../core/stima'; +import { h } from './dom'; + +const ICONE: Record = { file: '📂', analisi: '📊', proxy: '🎞', conversione: '🔧', ai: '✨', esporta: '📤', altro: '⚙' }; +const adesso = () => performance.now(); + +/** il tempo di una riga, in parole: "in coda", "mancano circa 2 min", "da 40 s" */ +export function tempoDi(a: Attivita): string { + if (a.stato === 'coda') return 'in coda'; + const passato = (adesso() - (a.inizio || a.creata)) / 1000; + const r = a.stima.restante(); + if (r !== null) return `mancano circa ${durataTesto(r)}`; + return a.k !== null && a.k > 0 ? 'calcolo il tempo…' : `da ${durataTesto(passato)}`; +} + +/** "12 fatti · 2 al lavoro · 26 in coda" per le righe che raccolgono più pezzi */ +function conteggio(a: Attivita): string { + if (a.totale <= 1) return ''; + return `${a.fatte} fatti · ${a.attivi} al lavoro · ${Math.max(0, a.totale - a.fatte - a.attivi)} in coda`; +} + +class RigaVista { + readonly el: HTMLElement; + private titolo = h('b', { class: 'att-r-titolo' }); + private dett = h('span', { class: 'att-r-dett' }); + private riempi = h('i'); + private pct = h('span', { class: 'att-r-pct' }); + private info = h('span', { class: 'att-r-info' }); + private stop: HTMLButtonElement; + constructor(readonly a: Attivita) { + this.stop = h('button', { class: 'att-r-stop', title: 'Ferma questa attività', on: { click: (e: MouseEvent) => { e.stopPropagation(); fermaRiga(a.id); } } }, '✕'); + this.el = h('div', { class: 'att-riga' }, + h('div', { class: 'att-r-testa' }, h('span', { class: 'att-r-ic' }, ICONE[a.categoria]), this.titolo, this.pct, a.annullabile ? this.stop : null), + this.dett, + h('div', { class: 'att-pista' }, this.riempi), + this.info); + this.aggiorna(); + } + aggiorna() { + const a = this.a; + this.titolo.textContent = a.titolo; + this.dett.textContent = a.dettaglio; + this.el.classList.toggle('in-coda', a.stato === 'coda'); + this.el.classList.toggle('indeterminata', a.k === null && a.stato === 'lavoro'); + this.riempi.style.width = a.k === null ? '' : Math.round(a.k * 100) + '%'; + this.pct.textContent = a.k === null ? '' : Math.round(a.k * 100) + '%'; + const passato = a.stato === 'coda' ? '' : `passati ${durataTesto((adesso() - (a.inizio || a.creata)) / 1000)} · `; + const quanti = conteggio(a); + this.info.textContent = `${passato}${tempoDi(a)}${quanti ? ' · ' + quanti : ''}`; + } +} + +export class CentroAttivita { + /** il pezzo da mettere nella barra di stato */ + readonly el: HTMLElement; + private led = h('span', { class: 'att-led' }); + private testo = h('span', { class: 'att-testo' }, 'Nessuna attività'); + private riempi = h('i'); + private pista = h('span', { class: 'att-pista' }, this.riempi); + private tempo = h('span', { class: 'att-tempo' }); + private altre = h('span', { class: 'att-altre' }); + private pannello: HTMLElement | null = null; + private vive = new Map(); + private listaVive = h('div', { class: 'att-lista' }); + private listaFinite = h('div', { class: 'att-finite' }); + private nota = h('p', { class: 'att-nota' }); + private bPausa = h('button', { class: 'btn piccolo', on: { click: () => { pausaLavori(!inPausa()); this.aggiorna(); } } }); + + constructor() { + this.el = h('button', { class: 'att att-ferma', title: 'Cosa sta facendo il programma · clic per i dettagli e i tempi', on: { click: () => { this.alterna(); this.el.blur(); } } }, + this.led, this.testo, this.pista, this.tempo, this.altre); + quandoAttivita(() => this.aggiorna()); + // i secondi passano anche quando nessuno avvisa + window.setInterval(() => { if (attivitaInCorso().length || this.pannello) this.aggiorna(); }, 1000); + document.addEventListener('pointerdown', (e) => { + if (this.pannello && !this.pannello.contains(e.target as Node) && !this.el.contains(e.target as Node)) this.chiudi(); + }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.pannello) this.chiudi(); }); + this.aggiorna(); + } + + get aperto() { return !!this.pannello; } + + aggiorna() { + const r = riassunto(); + const ultima = attivitaFinite()[0]; + const el = this.el; + const attesa = inAttesaPerRiproduzione(); + el.classList.toggle('att-pausa', r.righe > 0 && (r.inPausa || (attesa && r.principale?.categoria === 'proxy'))); + if (r.righe && r.principale) { + const p = r.principale; + el.classList.remove('att-ferma', 'att-fatto'); + el.classList.toggle('indeterminata', p.k === null); + this.testo.textContent = p.titolo + (p.dettaglio ? ' · ' + p.dettaglio : ''); + this.riempi.style.width = p.k === null ? '' : Math.round(p.k * 100) + '%'; + this.tempo.textContent = (p.k !== null ? Math.round(p.k * 100) + '% · ' : '') + (r.inPausa ? 'in pausa' : tempoDi(p)); + this.altre.textContent = r.righe > 1 ? `+${r.righe - 1}` : ''; + this.altre.title = r.righe > 1 ? `${r.righe - 1} altre attività in corso o in coda` : ''; + } else if (ultima && adesso() - ultima.fine < 7000) { + el.classList.remove('att-ferma', 'indeterminata'); + el.classList.add('att-fatto'); + this.testo.textContent = `${ultima.stato === 'errore' ? '✕' : '✓'} ${ultima.titolo}${ultima.messaggio ? ' · ' + ultima.messaggio : ''}`; + this.riempi.style.width = ultima.stato === 'finita' ? '100%' : '0%'; + this.tempo.textContent = ''; + this.altre.textContent = ''; + } else { + el.classList.remove('att-fatto', 'indeterminata'); + el.classList.add('att-ferma'); + this.testo.textContent = 'Nessuna attività'; + this.riempi.style.width = '0%'; + this.tempo.textContent = ''; + this.altre.textContent = ''; + } + if (this.pannello) this.disegnaPannello(); + } + + private alterna() { if (this.pannello) this.chiudi(); else this.apri(); } + + apri() { + if (this.pannello) return; + this.pannello = h('section', { class: 'att-pannello', role: 'dialog', 'aria-label': 'Attività del programma' }, + h('header', { class: 'att-p-testa' }, h('b', null, 'Attività'), h('span', { class: 'att-p-sotto' }), this.bPausa, + h('button', { class: 'btn piccolo', title: 'Toglie dall\'elenco quelle già finite', on: { click: () => puliscoFinite() } }, 'Pulisci'), + h('button', { class: 'btn-icona piccolo', title: 'Chiudi (Esc)', on: { click: () => this.chiudi() } }, '✕')), + this.nota, this.listaVive, h('h5', { class: 'att-p-titolo' }, 'Finite da poco'), this.listaFinite); + document.body.appendChild(this.pannello); + this.disegnaPannello(); + } + + chiudi() { + this.pannello?.remove(); + this.pannello = null; + this.vive.clear(); + } + + private disegnaPannello() { + const p = this.pannello; + if (!p) return; + // il pannello sta sopra il pezzo nella barra di stato, attaccato a destra, sempre dentro lo schermo + const r = this.el.getBoundingClientRect(); + p.style.right = Math.max(6, innerWidth - r.right) + 'px'; + p.style.bottom = Math.max(6, innerHeight - r.top + 6) + 'px'; + // le righe in corso: si aggiornano al loro posto (così un clic su ✕ non si perde mentre si ridisegna) + const attuali = attivitaInCorso(); + const ids = new Set(attuali.map((a) => a.id)); + for (const [id, v] of this.vive) if (!ids.has(id)) { v.el.remove(); this.vive.delete(id); } + for (const a of attuali) { + let v = this.vive.get(a.id); + if (!v) { v = new RigaVista(a); this.vive.set(a.id, v); this.listaVive.appendChild(v.el); } + v.aggiorna(); + } + // al lavoro prima, poi in coda + const ordinate = [...attuali].sort((x, y) => (x.stato === 'coda' ? 1 : 0) - (y.stato === 'coda' ? 1 : 0) || x.creata - y.creata); + ordinate.forEach((a, i) => { const e = this.vive.get(a.id)!.el; if (this.listaVive.children[i] !== e) this.listaVive.insertBefore(e, this.listaVive.children[i] ?? null); }); + if (!attuali.length) { + if (!this.listaVive.querySelector('.att-vuoto')) this.listaVive.appendChild(h('p', { class: 'att-vuoto' }, 'Il programma non sta facendo niente: tutto è pronto.')); + } else this.listaVive.querySelector('.att-vuoto')?.remove(); + const rs = riassunto(); + (p.querySelector('.att-p-sotto') as HTMLElement).textContent = attuali.length ? `${rs.inCorso} al lavoro · ${rs.inCoda} in coda` : ''; + this.bPausa.textContent = inPausa() ? '▶ Riprendi' : '⏸ Pausa'; + this.bPausa.title = inPausa() ? 'Fa ripartire i lavori in coda' : 'Non parte nessun lavoro nuovo (quelli al lavoro finiscono)'; + this.nota.textContent = inPausa() + ? 'In pausa: i lavori in coda aspettano. Quelli già partiti finiscono.' + : inAttesaPerRiproduzione() && attuali.some((a) => a.categoria === 'proxy' || a.categoria === 'conversione') + ? 'Il montaggio sta suonando: le copie e le conversioni aspettano che ti fermi, per non rubare il decoder.' + : ''; + this.nota.style.display = this.nota.textContent ? '' : 'none'; + const fin = attivitaFinite(); + this.listaFinite.replaceChildren(...(fin.length ? fin.map((a) => h('div', { class: 'att-f ' + a.stato }, + h('span', { class: 'att-f-seg' }, a.stato === 'finita' ? '✓' : a.stato === 'errore' ? '✕' : '–'), + h('span', { class: 'att-f-nome' }, a.titolo), + h('span', { class: 'att-f-msg' }, a.messaggio), + h('span', { class: 'att-f-quando' }, durataTesto(Math.max(0, (adesso() - a.fine) / 1000)) + ' fa'))) : [h('p', { class: 'att-vuoto' }, 'Ancora niente.')])); + } +} diff --git a/src/ui/contenitore.ts b/src/ui/contenitore.ts index 03b1937..e3b4b65 100644 --- a/src/ui/contenitore.ts +++ b/src/ui/contenitore.ts @@ -144,7 +144,12 @@ export class Contenitore { store.on('doc', () => { this.disegnaAlbero(); this.disegnaDestra(); }); store.on('status', () => this.evidenzia()); store.on('sel', () => { if (this.nodo.startsWith('fx') || this.nodo === 'effetti') this.aggiornaEffetti(); }); - quandoPicchi(() => { this.firma = ''; this.disegnaDestra(); }); + // la forma d'onda cresce mentre si calcola: si rinfresca solo la tela di quella scheda (se si ridisegnasse tutta la + // griglia a ogni passo, la pagina salterebbe e il programma andrebbe a scatti) + quandoPicchi((id) => { + this.picchiSporchi.add(id); + if (!this.picchiRaf) this.picchiRaf = requestAnimationFrame(() => { this.picchiRaf = 0; for (const x of this.picchiSporchi) this.ridisegnaCarta(x); this.picchiSporchi.clear(); }); + }); quandoAnalisi(() => {}); quandoFotogramma(() => { if (this.scorre) this.disegnaScorre(); }); if (this.nodo.startsWith('dir:') && !cartelleDi().some((c) => 'dir:' + c.id === this.nodo)) this.nodo = 'tutto'; @@ -305,6 +310,10 @@ export class Contenitore { // ——— la parte destra ——— private firma = ''; + /** com'è messa ogni scheda (stato del file e locandina): se cambia solo questo, le schede si aggiornano al loro posto */ + private firmaVis = ''; + private picchiSporchi = new Set(); + private picchiRaf = 0; private disegnaDestra(forza = false) { const n = this.nodo; if (n === 'transizioni' || n.startsWith('tr:')) { if (forza || this.firma !== n + this.filtro) { this.firma = n + this.filtro; this.paginaTransizioni(); } return; } @@ -325,9 +334,16 @@ export class Contenitore { const n = this.nodo; const usi = new Map(); for (const c of p.clips) if (c.media) usi.set(c.media, (usi.get(c.media) ?? 0) + 1); - const firma = n + '|' + this.filtro + '|' + JSON.stringify(p.cartelle ?? []) + '|' + p.media.map((m) => [m.id, m.name, m.cartella, m.markIn, m.markOut, usi.get(m.id) ?? 0, mediaRT(m.id)?.stato, !!mediaRT(m.id)?.poster].join(',')).join(';'); - if (!forza && firma === this.firma) return; + const firma = n + '|' + this.filtro + '|' + JSON.stringify(p.cartelle ?? []) + '|' + p.media.map((m) => [m.id, m.name, m.cartella, m.markIn, m.markOut, usi.get(m.id) ?? 0].join(',')).join(';'); + const vis = p.media.map((m) => { const r = mediaRT(m.id); return (r?.stato ?? '-') + (r?.poster ? 'p' : ''); }).join(','); + if (!forza && firma === this.firma) { + // sono arrivati file riaperti o locandine: si ritoccano le schede senza rifare la griglia (e senza far saltare la pagina) + if (vis !== this.firmaVis) { this.firmaVis = vis; this.aggiornaSchede(); } + return; + } this.firma = firma; + this.firmaVis = vis; + const scorrimento = this.corpo.scrollTop; this.scorre = null; const cart = n.startsWith('dir:') ? cartelleDi().find((c) => c.id === n.slice(4)) : undefined; // dove sei: Progetto › cartella › sottocartella @@ -369,9 +385,37 @@ export class Contenitore { } } this.corpo.replaceChildren(...out); + this.corpo.scrollTop = scorrimento; this.evidenzia(); } + /** stato e locandina di ogni scheda, al loro posto */ + private aggiornaSchede() { + for (const el of this.corpo.querySelectorAll('.carta[data-id]')) { + const m = store.doc.media.find((x) => x.id === el.dataset.id); + if (!m) continue; + el.classList.toggle('offline', mediaRT(m.id)?.stato !== 'ok'); + const info = el.querySelector('.carta-info'); + if (info) info.textContent = this.infoCarta(m); + const cv = el.querySelector('canvas'); + if (cv && this.scorre?.cv !== cv) this.locandina(cv, m); + } + } + + /** la forma d'onda di un audio (o la locandina) si rinfresca nella sua tela */ + private ridisegnaCarta(id: string) { + const m = store.doc.media.find((x) => x.id === id); + if (!m) return; + const cv = this.corpo.querySelector(`.carta[data-id="${CSS.escape(id)}"] canvas`); + if (cv && this.scorre?.cv !== cv) this.locandina(cv, m); + } + + private infoCarta(m: MediaItem): string { + const rt = mediaRT(m.id); + if (rt?.stato !== 'ok') return rt?.stato === 'caricamento' ? 'apro…' : 'OFFLINE · da ricollegare'; + return m.type === 'audio' ? `${m.acodec.toUpperCase()} · ${m.sampleRate / 1000} kHz` : `${m.width}×${m.height}${m.fps ? ' · ' + Math.round(m.fps * 100) / 100 + ' fps' : ''}${m.hasAudio && m.type === 'video' ? ' · audio' : ''}`; + } + private cartaCartella(c: Cartella): HTMLElement { const dentro = discendenti(c.id); const n = store.doc.media.filter((m) => m.cartella && dentro.has(m.cartella)).length; @@ -431,8 +475,7 @@ export class Contenitore { } private carta(m: MediaItem, usi: number): HTMLElement { - const rt = mediaRT(m.id); - const ok = rt?.stato === 'ok'; + const ok = mediaRT(m.id)?.stato === 'ok'; const cv = h('canvas', { width: 192, height: 108 }); this.locandina(cv, m); const linea = h('i', { class: 'carta-linea' }); @@ -445,10 +488,10 @@ export class Contenitore { usi ? h('span', { class: 'carta-usi', title: 'Clip nella timeline' }, '×' + usi) : null, m.markIn != null || m.markOut != null ? h('span', { class: 'carta-io', title: 'Attacco e stacco segnati' }, 'I/O') : null, h('button', { class: 'carta-piu', title: 'Metti al cursore (e il cursore va alla fine)', on: { click: (e: MouseEvent) => { e.stopPropagation(); mettiAlCursore(m); }, pointerdown: (e: PointerEvent) => e.stopPropagation() } }, icona('piu', 14))); - // passaggio del mouse: il video scorre avanti e indietro seguendo il puntatore - if (ok && m.type !== 'image') { + // passaggio del mouse: il video scorre avanti e indietro seguendo il puntatore (se il file è aperto) + if (m.type !== 'image') { img.addEventListener('pointermove', (e) => { - if (e.pointerType === 'touch') return; + if (e.pointerType === 'touch' || mediaRT(m.id)?.stato !== 'ok') return; const r = img.getBoundingClientRect(); const k = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); linea.style.left = k * 100 + '%'; @@ -490,7 +533,7 @@ export class Contenitore { { sep: true }, { nome: 'Sposta in una cartella', sotto: this.menuCartelle(m) }, { nome: 'Rinomina…', fn: async () => { const n = await chiedi('Rinomina', 'Nome', m.name); if (n) store.edit('Rinomina', () => { m.name = n; }); } }, - { nome: 'Ricollega file mancanti…', disattiva: ok, fn: () => ricollega() }, + { nome: 'Ricollega file mancanti…', disattiva: mediaRT(m.id)?.stato === 'ok', fn: () => ricollega() }, { nome: 'Togli dal contenitore', fn: () => togliMedia(m.id) }, ]); }, @@ -498,9 +541,7 @@ export class Contenitore { }, img, h('div', { class: 'carta-nome' }, m.name), - h('div', { class: 'carta-info' }, ok - ? (m.type === 'audio' ? `${m.acodec.toUpperCase()} · ${m.sampleRate / 1000} kHz` : `${m.width}×${m.height}${m.fps ? ' · ' + Math.round(m.fps * 100) / 100 + ' fps' : ''}${m.hasAudio && m.type === 'video' ? ' · audio' : ''}`) - : (rt?.stato === 'caricamento' ? 'apro…' : 'OFFLINE · da ricollegare'))); + h('div', { class: 'carta-info' }, this.infoCarta(m))); // trascinando un file scelto si portano dietro tutti i file scelti; trascinandone uno non scelto, resta solo quello trascinabile(el, () => this.datoTrascina(m.id), () => (this.scelti.size > 1 && this.scelti.has(m.id) ? `🎞 ${this.scelti.size} file` : (m.type === 'audio' ? '🎵 ' : m.type === 'image' ? '🖼 ' : '🎞 ') + m.name)); return el; diff --git a/src/ui/finale.ts b/src/ui/finale.ts index f478bb0..e6923db 100644 --- a/src/ui/finale.ts +++ b/src/ui/finale.ts @@ -280,7 +280,7 @@ export class Finale { this.aggiornaAI.push(() => bott.forEach((b) => b.classList.toggle('acceso', b.dataset.v === get()))); return h('div', { class: 'fin-scelte' }, h('span', null, nome), h('div', { class: 'isp-chips' }, bott)); }; - const lavoro = new BarraLavoro(); + const lavoro = new BarraLavoro('Sottotitoli con l\'AI', 'ai', () => { this.lavoroAI?.abort(); fermaVoce(); }); const vai = h('button', { class: 'btn primario ai-vai', on: { click: () => void this.scriviConAI(lavoro, vai, ferma) } }, '✨ Scrivi i sottotitoli con l\'AI'); const ferma = h('button', { class: 'btn-mini', style: 'display:none', on: { click: () => { this.lavoroAI?.abort(); fermaVoce(); } } }, '■ Ferma'); const nemotron = () => this.motoreAI() === 'nemotron'; @@ -382,7 +382,7 @@ export class Finale { sinc.push(() => bott.forEach((b) => b.classList.toggle('acceso', b.dataset.v === get()))); return h('div', { class: 'fin-scelte' }, h('span', null, nome), h('div', { class: 'isp-chips' }, bott)); }; - const lavoro = new BarraLavoro(); + const lavoro = new BarraLavoro('Voce AI dai sottotitoli', 'ai', () => this.lavoroVoce?.abort()); const ferma = h('button', { class: 'btn-mini', style: 'display:none', on: { click: () => this.lavoroVoce?.abort() } }, '■ Ferma'); const vai = h('button', { class: 'btn primario ai-vai', title: 'Legge i sottotitoli con la voce scelta e mette il risultato su una traccia audio nuova', on: { click: () => void this.faiParlare(lavoro, vai, ferma) } }, '🗣 Fai parlare i sottotitoli'); const silenzia = h('input', { type: 'checkbox' }) as HTMLInputElement; @@ -617,7 +617,7 @@ export class Finale { lingua.addEventListener('change', () => this.cambiaSott('Lingua dei sottotitoli', (x) => { x.lingua = lingua.value; })); const aTrad = h('select', { class: 'mini-select largo' }, LINGUE_TRADUZIONE.map(([v, , t]) => h('option', { value: v }, t))) as HTMLSelectElement; aTrad.addEventListener('change', () => { this.trad.a = aTrad.value; try { localStorage.setItem('dpv-trad', JSON.stringify(this.trad)); } catch { /* niente */ } this.campi.forEach((c) => c.aggiorna()); }); - const lavoro = new BarraLavoro(); + const lavoro = new BarraLavoro('Traduzione dei sottotitoli', 'ai', () => { this.lavoroTrad?.abort(); fermaTraduzione(); }); const ferma = h('button', { class: 'btn-mini', style: 'display:none', on: { click: () => { this.lavoroTrad?.abort(); fermaTraduzione(); } } }, '■ Ferma'); const vai = h('button', { class: 'btn primario ai-vai', on: { click: () => void this.traduciSottotitoli(lavoro, vai, ferma) } }, '🌍 Traduci i sottotitoli'); const nota = h('p', { class: 'nota' }); diff --git a/src/ui/lavoro.ts b/src/ui/lavoro.ts index 938bdc1..b6dc438 100644 --- a/src/ui/lavoro.ts +++ b/src/ui/lavoro.ts @@ -2,44 +2,10 @@ // quanto è passato e quanto manca, stimato dalla velocità degli ultimi secondi (così regge anche quando una // fase va più piano dell'altra, come lo scarico dei modelli e poi il lavoro vero). import { h } from './dom'; +import { nuova, type Categoria, type Lavoro } from '../media/attivita'; -/** "45 s", "2 min 10 s", "1 h 05 min" */ -export function durataTesto(sec: number): string { - const s = Math.max(0, Math.round(sec)); - if (s < 60) return `${s} s`; - const m = Math.floor(s / 60); - if (m < 60) return `${m} min${s % 60 ? ' ' + String(s % 60).padStart(2, '0') + ' s' : ''}`; - return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`; -} - -/** la stima del tempo che manca */ -export class Stima { - private punti: { t: number; k: number }[] = []; - private t0: number; - constructor(private ora: () => number = () => performance.now(), private finestra = 25000) { this.t0 = this.ora(); } - - registra(k: number) { - const t = this.ora(); - const u = this.punti[this.punti.length - 1]; - // un passo indietro (nuova fase) non conta come velocità - if (u && k < u.k) this.punti = []; - this.punti.push({ t, k }); - while (this.punti.length > 2 && t - this.punti[0].t > this.finestra) this.punti.shift(); - } - - /** secondi passati da quando è partito */ - trascorso(): number { return (this.ora() - this.t0) / 1000; } - - /** secondi che mancano, o null se ancora non si può dire */ - restante(): number | null { - const p = this.punti; - if (p.length < 2) return null; - const a = p[0], b = p[p.length - 1]; - const dt = (b.t - a.t) / 1000, dk = b.k - a.k; - if (dt < 2 || dk <= 1e-4 || b.k >= 1) return null; - return ((1 - b.k) * dt) / dk; - } -} +export { Stima, durataTesto } from '../core/stima'; +import { Stima, durataTesto } from '../core/stima'; /** la barra con il testo della fase e, sotto, percentuale, tempo passato e tempo che manca */ export class BarraLavoro { @@ -48,6 +14,11 @@ export class BarraLavoro { readonly tempo = h('div', { class: 'ai-tempo' }); private stima = new Stima(); private k = 0; + /** la riga nel centro attività (la barra in basso a destra): c'è solo se la barra ha un titolo */ + private riga: Lavoro | null = null; + + /** con un titolo, quello che fa la barra compare anche nel centro attività; alAnnulla = il pulsante ✕ di là */ + constructor(private titolo = '', private categoria: Categoria = 'ai', private alAnnulla?: () => void) {} /** i tre elementi, da mettere nella pagina */ get elementi(): HTMLElement[] { return [this.barra, this.fase, this.tempo]; } @@ -56,12 +27,15 @@ export class BarraLavoro { this.stima = new Stima(); this.k = 0; this.barra.classList.add('attiva'); + this.riga?.annullata(); + this.riga = this.titolo ? nuova({ titolo: this.titolo, categoria: this.categoria, alAnnulla: this.alAnnulla, annullabile: !!this.alAnnulla }) : null; this.imposta(testo, 0); } imposta(testo: string, k: number) { this.k = Math.max(0, Math.min(1, k)); this.stima.registra(this.k); + this.riga?.imposta(this.k, testo); this.fase.textContent = testo; (this.barra.firstChild as HTMLElement).style.width = Math.round(this.k * 100) + '%'; const r = this.stima.restante(); @@ -73,6 +47,8 @@ export class BarraLavoro { /** finito (bene): tempo totale al posto della stima */ fine(testo: string) { this.k = 1; + this.riga?.fine(testo.replace(/^Fatto:?\s*/i, '')); + this.riga = null; this.fase.textContent = testo; (this.barra.firstChild as HTMLElement).style.width = '100%'; this.tempo.textContent = `finito in ${durataTesto(this.stima.trascorso())}`; @@ -81,6 +57,7 @@ export class BarraLavoro { /** fermato o non riuscito */ ferma(testo: string) { + if (this.riga) { if (/^fermato/i.test(testo)) this.riga.annullata(testo); else this.riga.errore(testo); this.riga = null; } this.fase.textContent = testo; this.tempo.textContent = ''; this.barra.classList.remove('attiva'); diff --git a/src/ui/montage.ts b/src/ui/montage.ts index 2d19d27..67ac544 100644 --- a/src/ui/montage.ts +++ b/src/ui/montage.ts @@ -69,7 +69,7 @@ export class Montage { private conta: HTMLElement; private carte = new Map(); private riepilogo: HTMLElement; - private lavoro = new BarraLavoro(); + private lavoro = new BarraLavoro('DaProdMontage: preparo il montaggio', 'altro'); private bCrea: HTMLButtonElement; private selMusica: HTMLSelectElement; private campiTesto: HTMLElement; diff --git a/src/ui/sfondo.ts b/src/ui/sfondo.ts index 12616ac..4ddd41b 100644 --- a/src/ui/sfondo.ts +++ b/src/ui/sfondo.ts @@ -186,7 +186,7 @@ export function lavoroDi(id: string) { return lavori.get(id); } export async function elaboraClip(id: string) { const c = clipById(store.doc, id); if (!c?.ritaglio || lavori.get(id)?.finito === false) return; - const lav: Lavoro = { barra: new BarraLavoro(), ferma: false, finito: false }; + const lav: Lavoro = { barra: new BarraLavoro('Tolgo lo sfondo', 'ai', () => { lav.ferma = true; }), ferma: false, finito: false }; lavori.set(id, lav); lav.barra.avvia('Preparo…'); avvisaLavoro(); diff --git a/test/prove-attivita.mjs b/test/prove-attivita.mjs new file mode 100644 index 0000000..6235679 --- /dev/null +++ b/test/prove-attivita.mjs @@ -0,0 +1,180 @@ +// Le prove della 1.2.0: il centro attività (barra in basso a destra e pannello), le code dei lavori di fondo con la +// priorità, la memoria delle misure già fatte, la copia leggera che si fa solo per chi sta in timeline, la conversione +// a velocità costante dietro le quinte e la riapertura veloce dei progetti. +// npm run build && node test/prove-nuove.mjs attivita +import path from 'node:path'; + +export async function proveAttivita({ page: paginaDi, prova, OUT }) { + console.log('▶ Centro attività: registro, code e priorità'); + 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 + ' @ ' + String(e.stack ?? '').split('\n').slice(1, 5).join(' | '))); + await page.goto(paginaDi.url()); + await page.waitForSelector('.pulsantiera'); + await page.waitForTimeout(1200); + + // ——— le cose pure ——— + const pure = await page.evaluate(async () => { + const { AT, SM, CH } = window.__dpvTest; + AT.azzeraAttivita(); + const fuori = {}; + fuori.testo = [SM.durataTesto(45), SM.durataTesto(130), SM.durataTesto(3900)]; + // una riga a mano con la sua barra + const l = AT.nuova({ titolo: 'Prova a mano', categoria: 'altro' }); + l.imposta(0.4, 'a metà'); + fuori.mano = { righe: AT.attivitaInCorso().length, k: AT.attivitaInCorso()[0].k, dett: AT.attivitaInCorso()[0].dettaglio }; + l.fine('fatto'); + fuori.finita = { righe: AT.attivitaInCorso().length, storico: AT.attivitaFinite()[0]?.stato, k: AT.attivitaFinite()[0]?.k }; + // la coda: la corsia pesante fa un lavoro alla volta, e passa avanti chi ha la priorità più alta + const ordine = []; let insieme = 0, maxInsieme = 0; + const lavoro = (nome, ms) => async () => { ordine.push(nome); insieme++; maxInsieme = Math.max(maxInsieme, insieme); await new Promise((r) => setTimeout(r, ms)); insieme--; return nome; }; + AT.pausaLavori(true); + const p = [ + AT.inCoda({ corsia: 'pesante', titolo: 'Copie', categoria: 'proxy', gruppo: 'prova', priorita: 0 }, lavoro('basso', 30)), + AT.inCoda({ corsia: 'pesante', titolo: 'Copie', categoria: 'proxy', gruppo: 'prova', priorita: 0 }, lavoro('basso2', 30)), + AT.inCoda({ corsia: 'pesante', titolo: 'Copie', categoria: 'proxy', gruppo: 'prova', priorita: 150 }, lavoro('alto', 30)), + ]; + fuori.gruppo = { righe: AT.attivitaInCorso().length, totale: AT.attivitaInCorso()[0]?.totale, inCoda: AT.attivitaInCorso()[0]?.stato }; + AT.pausaLavori(false); + const res = await Promise.all(p); + fuori.coda = { ordine, maxInsieme, res }; + fuori.chiusa = { righe: AT.attivitaInCorso().length, msg: AT.attivitaFinite()[0]?.messaggio, stato: AT.attivitaFinite()[0]?.stato }; + // un lavoro che va male non ferma gli altri + const ko = await AT.inCoda({ corsia: 'leggero', titolo: 'Rotto', categoria: 'altro' }, async () => { throw new Error('boom'); }); + fuori.errore = { ko, stato: AT.attivitaFinite()[0]?.stato, msg: AT.attivitaFinite()[0]?.messaggio }; + // annullare una riga in coda: il lavoro non parte + AT.pausaLavori(true); + let partito = false; + const q = AT.inCoda({ corsia: 'leggero', titolo: 'Da annullare', categoria: 'altro' }, async () => { partito = true; }); + AT.fermaRiga(AT.attivitaInCorso()[0].id); + AT.pausaLavori(false); + const r = await q; + fuori.annulla = { r, partito, stato: AT.attivitaFinite()[0]?.stato }; + // la memoria delle misure: la forma d'onda si comprime in un byte per valore e si ritrova + const pk = new Float32Array([0, 0.01, 0.25, 0.5, 1]); + const ritorno = CH.espandiPicchi(CH.comprimiPicchi(pk)); + fuori.picchi = [...ritorno].map((x, i) => Math.abs(x - pk[i]) < 0.02); + await CH.scriviCache('colore', 'prova|chiave', { gamma: 0.9 }); + fuori.cache = (await CH.leggiCache('colore', 'prova|chiave'))?.gamma; + fuori.cacheNo = (await CH.leggiCache('colore', 'non|esiste')) === undefined; + AT.azzeraAttivita(); + return fuori; + }); + prova('i tempi si dicono in parole', pure.testo[0] === '45 s' && pure.testo[1] === '2 min 10 s' && pure.testo[2] === '1 h 05 min', JSON.stringify(pure.testo)); + prova('un lavoro a mano ha la sua riga con la barra e, finito, passa fra le finite', pure.mano.righe === 1 && Math.abs(pure.mano.k - 0.4) < 0.01 && pure.mano.dett === 'a metà' && pure.finita.righe === 0 && pure.finita.storico === 'finita' && pure.finita.k === 1, JSON.stringify([pure.mano, pure.finita])); + prova('i lavori dello stesso tipo stanno in una riga sola', pure.gruppo.righe === 1 && pure.gruppo.totale === 3 && pure.gruppo.inCoda === 'coda', JSON.stringify(pure.gruppo)); + prova('la corsia pesante fa un lavoro alla volta e passa avanti chi ha la priorità', pure.coda.maxInsieme === 1 && pure.coda.ordine[0] === 'alto' && pure.coda.ordine.length === 3, JSON.stringify(pure.coda)); + prova('finito il gruppo, la riga si chiude con "3 in …"', pure.chiusa.righe === 0 && /^3 in /.test(pure.chiusa.msg) && pure.chiusa.stato === 'finita', JSON.stringify(pure.chiusa)); + prova('un lavoro che va male finisce in errore e il resto continua', pure.errore.ko === null && pure.errore.stato === 'errore' && /boom/.test(pure.errore.msg), JSON.stringify(pure.errore)); + prova('una riga ancora in coda si annulla e il lavoro non parte', pure.annulla.r === null && pure.annulla.partito === false && pure.annulla.stato === 'annullata', JSON.stringify(pure.annulla)); + prova('la forma d\'onda compressa si ritrova uguale (entro il 2%)', pure.picchi.every(Boolean), JSON.stringify(pure.picchi)); + prova('la memoria delle misure scrive e rilegge (e dice "non c\'è")', pure.cache === 0.9 && pure.cacheNo); + + // ——— 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') })); + prova('a riposo la barra dice "Nessuna attività"', ferma0.ferma && ferma0.testo === 'Nessuna attività', JSON.stringify(ferma0)); + await page.evaluate(() => { + const { AT } = window.__dpvTest; + window.__prova = { a: AT.nuova({ titolo: 'Riapro i file del progetto', categoria: 'file' }), b: AT.nuova({ titolo: "Forme d'onda", categoria: 'analisi', gruppo: 'pk' }), fermata: false }; + window.__prova.a.imposta(0.35, '14 di 40 · Ripresa 14.mp4'); + window.__prova.b.imposta(0.1, 'musica.mp3'); + AT.avvisaOra(); + }); + await page.waitForTimeout(250); + const barra = await page.evaluate(() => { + const el = document.querySelector('.stato .att'); + const r = el.getBoundingClientRect(), s = document.querySelector('.stato').getBoundingClientRect(); + return { testo: el.querySelector('.att-testo').textContent, larg: el.querySelector('.att-pista i').style.width, tempo: el.querySelector('.att-tempo').textContent, altre: el.querySelector('.att-altre').textContent, destra: r.right > innerWidth * 0.5, dentro: r.bottom <= innerHeight + 1 && r.top >= s.top - 1, ferma: el.classList.contains('att-ferma') }; + }); + prova('la barra mostra l\'attività, la percentuale e quante altre ce ne sono', !barra.ferma && /Riapro i file/.test(barra.testo) && barra.larg === '35%' && /35%/.test(barra.tempo) && barra.altre === '+1', JSON.stringify(barra)); + prova('la barra sta in basso a destra, dentro lo schermo', barra.destra && barra.dentro, JSON.stringify(barra)); + await page.screenshot({ path: path.join(OUT, 'attivita-barra.png') }); + await page.click('.stato .att'); + await page.waitForSelector('.att-pannello'); + const pan = await page.evaluate(() => { + const p = document.querySelector('.att-pannello'); + const r = p.getBoundingClientRect(); + const righe = [...p.querySelectorAll('.att-riga')].map((x) => ({ t: x.querySelector('.att-r-titolo').textContent, pct: x.querySelector('.att-r-pct').textContent, info: x.querySelector('.att-r-info').textContent, stop: !!x.querySelector('.att-r-stop') })); + return { righe, dentro: r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight }; + }); + prova('il clic apre il pannello con una riga per attività: titolo, percentuale, tempi', pan.righe.length === 2 && pan.righe[0].t === 'Riapro i file del progetto' && pan.righe[0].pct === '35%' && /passati/.test(pan.righe[0].info) && pan.righe.every((r) => r.stop), JSON.stringify(pan)); + prova('il pannello sta dentro lo schermo', pan.dentro); + await page.screenshot({ path: path.join(OUT, 'attivita-pannello.png') }); + // ✕ ferma la riga: chi lavora riceve il segnale + await page.click('.att-riga:first-child .att-r-stop'); + await page.waitForTimeout(300); + const dopo = await page.evaluate(() => ({ fermato: window.__prova.a.fermato, righe: document.querySelectorAll('.att-riga').length })); + prova('✕ manda il segnale di stop a chi lavora', dopo.fermato === true, JSON.stringify(dopo)); + await page.evaluate(() => { window.__prova.a.annullata(); window.__prova.b.fine('ok'); window.__dpvTest.AT.avvisaOra(); }); + await page.waitForTimeout(300); + const fin = await page.evaluate(() => ({ righe: document.querySelectorAll('.att-riga').length, finite: document.querySelectorAll('.att-f').length })); + prova('finite le attività, il pannello le sposta fra "Finite da poco"', fin.righe === 0 && fin.finite >= 2, JSON.stringify(fin)); + await page.keyboard.press('Escape'); + await page.waitForTimeout(150); + prova('Esc chiude il pannello', (await page.locator('.att-pannello').count()) === 0); + await page.evaluate(() => window.__dpvTest.AT.azzeraAttivita()); + + // ——— riaprire un progetto, la copia leggera, la conversione dietro le quinte ——— + console.log('▶ Riapertura veloce, copia leggera solo se serve, conversione dietro le quinte'); + const dati = await page.evaluate(async () => { + const { importaFile, ripresaDiProva, P, mediaRT, AT } = window.__dpvTest; + const base = await ripresaDiProva(3, 25); + const nomi = []; + const files = []; + for (let i = 0; i < 6; i++) { nomi.push(`Ripresa ${i}.webm`); files.push({ name: nomi[i], file: new File([await base.arrayBuffer(), new Uint8Array([i])], nomi[i], { type: 'video/webm' }) }); } + const m = await importaFile(files, { chiediFormato: false }); + for (let i = 0; i < 100; i++) { if (m.every((x) => mediaRT(x.id)?.stato === 'ok' && mediaRT(x.id).colore)) break; await new Promise((r) => setTimeout(r, 100)); } + const prima = m.map((x) => mediaRT(x.id).proxyStato); + // se ne mette uno in timeline: solo lui si prende la copia leggera + const alta = window.__dpv.doc.tracks.filter((t) => t.kind === 'video')[0].id; + window.__dpv.edit('clip', (pp) => { pp.clips.push(P.newClip('media', alta, 0, 60, { media: m[0].id, name: m[0].name })); }); + let pronto = false; + for (let i = 0; i < 400; i++) { if (mediaRT(m[0].id).proxyStato === 'pronto') { pronto = true; break; } await new Promise((r) => setTimeout(r, 200)); } + const dopo = m.map((x) => mediaRT(x.id).proxyStato); + return { prima, dopo, pronto, doc: JSON.stringify(window.__dpv.doc), ids: m.map((x) => x.id) }; + }); + prova('i file fuori dalla timeline non fanno la copia leggera (aspettano)', dati.prima.every((s) => s !== 'coda' && s !== 'lavoro'), JSON.stringify(dati.prima)); + prova('il file messo in timeline se la prende, gli altri restano in attesa', dati.pronto && dati.dopo.slice(1).every((s) => s !== 'pronto'), JSON.stringify(dati.dopo)); + + const ria = await page.evaluate(async (doc) => { + const { PR, mediaRT, AT, CH } = window.__dpvTest; + const proj = JSON.parse(doc); + const visto = { righe: [], max: 0 }; + const t = setInterval(() => { for (const r of AT.attivitaInCorso()) visto.righe.push(r.titolo); }, 20); + const t0 = performance.now(); + await PR.carica(proj); + const ms = performance.now() - t0; + clearInterval(t); + // la forma d'onda e il colore vengono dalla memoria: niente da rifare + for (let i = 0; i < 30; i++) { if (proj.media.every((m) => mediaRT(m.id)?.colore)) break; await new Promise((r) => setTimeout(r, 100)); } + return { ms, ok: proj.media.every((m) => mediaRT(m.id)?.stato === 'ok'), colore: proj.media.every((m) => mediaRT(m.id)?.colore), visto: [...new Set(visto.righe)], proxy0: mediaRT(proj.media[0].id).proxyStato, finite: AT.attivitaFinite().map((a) => a.titolo + ': ' + a.messaggio) }; + }, dati.doc); + prova('riaprendo il progetto i file tornano tutti, col colore dalla memoria', ria.ok && ria.colore, JSON.stringify(ria)); + prova('la riapertura passa dal centro attività ("Riapro i file")', ria.finite.some((t) => /^Riapro i file del progetto: 6 file in /.test(t)), JSON.stringify(ria.finite)); + prova('la copia leggera già fatta si ritrova subito', ria.proxy0 === 'pronto', ria.proxy0); + + // la conversione dietro le quinte: si importa subito il file com'è, poi la copia a velocità costante lo sostituisce + const vfr = await page.evaluate(async () => { + const { importaFile, ripresaVfrDiProva, mediaRT, AT } = window.__dpvTest; + const f = await ripresaVfrDiProva(); + const t0 = performance.now(); + const [m] = await importaFile([{ name: 'telefono.webm', file: new File([f], 'telefono.webm', { type: 'video/webm' }) }], { chiediFormato: false }); + const subito = performance.now() - t0; + const fpsPrima = m.fps, sizePrima = m.size; + let righe = []; + for (let i = 0; i < 600; i++) { + for (const r of AT.attivitaInCorso()) righe.push(r.categoria + ':' + r.titolo); + const x = window.__dpv.doc.media.find((z) => z.id === m.id); + if (x.size !== sizePrima) break; + await new Promise((r) => setTimeout(r, 200)); + } + const x = window.__dpv.doc.media.find((z) => z.id === m.id); + return { subito, sostituito: x.size !== sizePrima, ok: mediaRT(m.id)?.stato === 'ok', fps: x.fps, nome: x.name, righe: [...new Set(righe)] }; + }); + prova('il file a frame rate variabile entra subito, e la copia a velocità costante prende il suo posto', vfr.sostituito && vfr.ok && vfr.nome === 'telefono.webm', JSON.stringify(vfr)); + prova('la conversione ha la sua riga nel centro attività', vfr.righe.some((x) => /^conversione:/.test(x)), JSON.stringify(vfr.righe)); + prova('nessun errore nella pagina', errori.length === 0, errori.slice(0, 3).join(' | ')); + await contesto.close(); +} diff --git a/test/prove-nuove.mjs b/test/prove-nuove.mjs index 8fb3217..fffaf29 100644 --- a/test/prove-nuove.mjs +++ b/test/prove-nuove.mjs @@ -12,12 +12,13 @@ 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']) { +export async function proveNuove(ctx, quali = ['sfondo', 'animazioni', 'montage', 'pannello', 'seguito', 'attivita']) { 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('montage')) { const m = await import('./prove-montage.mjs').catch(() => null); if (m) await m.proveMontage({ page, prova, OUT }); } } diff --git a/test/prove-seguito.mjs b/test/prove-seguito.mjs index 1845591..008ae4f 100644 --- a/test/prove-seguito.mjs +++ b/test/prove-seguito.mjs @@ -249,6 +249,14 @@ export async function proveSeguito({ page: paginaDi, prova, OUT }) { const a = await importaFile([{ name: vfr.name, file: vfr }], { chiediFormato: false }); const b = await importaFile([{ name: vbr.name, file: vbr }], { chiediFormato: false }); const c = await importaFile([{ name: cfr.name, file: cfr }], { chiediFormato: false }); + // dalla 1.2.0 la copia si fa dietro le quinte: si aspetta che il controllo e le conversioni siano finiti + const AT = window.__dpvTest.AT; + for (let i = 0; i < 600; i++) { + if (!AT.attivitaInCorso().some((r) => /velocità/.test(r.titolo))) break; + await new Promise((r) => setTimeout(r, 200)); + } + const doc = (x) => window.__dpv.doc.media.find((m) => m.id === x.id); + a[0] = doc(a[0]); b[0] = doc(b[0]); c[0] = doc(c[0]); // il video importato è davvero a frame rate costante? const rt = window.__dpvTest.mediaRT(a[0].id); const fr = await rt.v.computeFrameRateMetrics({ targetPacketCount: 300 }); diff --git a/test/prove.mjs b/test/prove.mjs index 40ebba1..29b4033 100644 --- a/test/prove.mjs +++ b/test/prove.mjs @@ -25,7 +25,7 @@ const exe = process.env.CHROME || (fs.existsSync('/opt/pw-browsers/chromium') ? const browser = await chromium.launch({ executablePath: exe, args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'] }); const page = await browser.newPage({ viewport: { width: 1600, height: 950 }, acceptDownloads: true }); const errori = []; -page.on('pageerror', (e) => errori.push(e.message)); +page.on('pageerror', (e) => errori.push(e.message + ' @ ' + String(e.stack ?? '').split('\n').slice(1, 5).join(' | '))); page.on('console', (m) => { if (m.type() === 'error') errori.push(m.text()); }); const doc = () => page.evaluate(() => window.__dpv.doc); @@ -1924,10 +1924,10 @@ try { const inizio = performance.now(); m.play(1); // il play aspetta i decoder al massimo 0,9 s (poi parte comunque); nel banco di prova appena importato il - // proxy si sta facendo e il disegno è lento, quindi si danno 2 s. Quello che conta è che poi il video si + // proxy si sta facendo e il disegno è lento (il primo play, a decoder freddo, qui impiega anche 2-3 s: lo stesso con la versione di prima), quindi si danno 4 s. Quello che conta è che poi il video si // muova senza ripartire (nati): prima l'orologio tornava indietro di 60 ms e il flusso si buttava const partito = await new Promise((ok) => { - const scade = setTimeout(() => ok(false), 2000); + const scade = setTimeout(() => ok(false), 4000); const controlla = () => { if (window.__dpv.head > testa) { clearTimeout(scade); ok(true); } else requestAnimationFrame(controlla);