From 59761614413355a626f6fec8eedebbdb5cbbe7f4 Mon Sep 17 00:00:00 2001 From: cammo22 <78992276+cammo22@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:43:24 +0100 Subject: [PATCH 1/6] Fotogrammi: niente giro infinito di decoder quando il monitor chiede un istante prima del primo fotogramma (riempiva RAM e VRAM dopo le registrazioni LIVE) Co-Authored-By: Claude Opus 5.5 --- src/media/fotogrammi.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/media/fotogrammi.ts b/src/media/fotogrammi.ts index 0df6599..d74bfb8 100644 --- a/src/media/fotogrammi.ts +++ b/src/media/fotogrammi.ts @@ -138,6 +138,10 @@ class Ricerca { want: number | null = null; /** il punto a cui sta decodificando adesso */ verso: number | null = null; + /** l'ultimo t chiesto che nessun fotogramma copre (prima del primo fotogramma, buchi del file): non si richiede più, + * se no la ricerca ripartiva dal fotogramma chiave a ogni disegno, con un decoder nuovo ogni volta (fino a 50 al + * secondo: nell'app con la scheda video riempivano RAM e VRAM del processo grafico di WebView2) */ + private scoperto: number | null = null; busy = false; dead = false; lastUse = performance.now(); @@ -149,7 +153,7 @@ class Ricerca { at(t: number): VideoSample | null { this.lastUse = performance.now(); - if (!copre(this.current, t)) { + if (!copre(this.current, t) && this.scoperto !== t) { this.want = t; if (!this.busy) void this.run(); } @@ -203,10 +207,14 @@ class Ricerca { this.iter = this.sink.samples(Math.max(0, t)); } // legge fino al fotogramma che copre t; quello dopo resta pronto per il passo seguente + let primo = !avanti; for (;;) { const s = await this.prossimo(); if (this.dead) { s?.close(); break; } if (!s) break; + // ripartiti da capo e il primo fotogramma è già dopo t (t prima dell'inizio del file): si mostra quello + if (primo && s.timestamp > t + SCARTO_TIMESTAMP) { this.current?.close(); this.current = s; break; } + primo = false; if (s.timestamp > t + SCARTO_TIMESTAMP && this.current) { this.dopo = s; break; } this.current?.close(); this.current = s; @@ -216,6 +224,7 @@ class Ricerca { // decoder in errore: si riparte da capo alla prossima richiesta this.chiudiCursore(); } + this.scoperto = copre(this.current, t) ? null : t; if (!this.dead) avvisa(); } this.verso = null; From 5e327a72e3880587c6878cc9f6024392647d9b0a Mon Sep 17 00:00:00 2001 From: cammo22 <78992276+cammo22@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:46:11 +0100 Subject: [PATCH 2/6] Miniature della timeline: gli istanti senza fotogramma non tornano in coda a ogni disegno (decoder a raffica dopo le registrazioni LIVE) Co-Authored-By: Claude Opus 5.5 --- src/media/libreria.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/media/libreria.ts b/src/media/libreria.ts index 120a097..6d1a34a 100644 --- a/src/media/libreria.ts +++ b/src/media/libreria.ts @@ -616,6 +616,9 @@ const LIMITE_MINIATURE = 1800; const cache = new Map(); const inCoda = new Map>(); const lavorando = new Set(); +/** istanti senza fotogramma (prima del primo fotogramma del file, per esempio): non si chiedono più, se no ogni + * disegno della timeline li rimetteva in coda e ogni giro apriva un decoder nuovo (nell'app riempiva RAM e VRAM) */ +const vuote = new Set(); const onMiniatura = new Set<() => void>(); export const quandoMiniature = (fn: () => void) => { onMiniatura.add(fn); return () => onMiniatura.delete(fn); }; @@ -631,7 +634,7 @@ export function miniatura(id: string, t: number): CanvasImageSource | null { cache.set(k, c); // LRU: torna in fondo return c; } - if (!r.v || !r.vDecodable) return null; + if (!r.v || !r.vDecodable || vuote.has(k)) return null; if (!inCoda.has(id)) inCoda.set(id, new Set()); inCoda.get(id)!.add(t); if (!lavorando.has(id)) void lavoraMiniature(id); @@ -654,10 +657,14 @@ async function lavoraMiniature(id: string) { const ts = [...inCoda.get(id)!].sort((a, b) => a - b).slice(0, 8); for (const t of ts) inCoda.get(id)!.delete(t); let i = 0; + const senza: string[] = []; + let buona: CanvasImageSource | null = null; for await (const w of sink.canvasesAtTimestamps(ts)) { - const t = ts[i++]; - if (w) cache.set(id + '@' + t.toFixed(3), w.canvas); + const k = id + '@' + ts[i++].toFixed(3); + if (w) { cache.set(k, w.canvas); buona ??= w.canvas; } else senza.push(k); } + // senza fotogramma: si mostra il primo buono di questo giro, o non si chiede più + for (const k of senza) if (buona) cache.set(k, buona); else vuote.add(k); while (cache.size > LIMITE_MINIATURE) cache.delete(cache.keys().next().value!); for (const fn of onMiniatura) fn(); } @@ -667,4 +674,5 @@ async function lavoraMiniature(id: string) { export function svuotaMiniature(id?: string) { for (const k of [...cache.keys()]) if (!id || k.startsWith(id + '@')) cache.delete(k); + for (const k of [...vuote]) if (!id || k.startsWith(id + '@')) vuote.delete(k); } From a23c82b4dfbbdfa3370ce9ea5a5c287b668fe0e7 Mon Sep 17 00:00:00 2001 From: cammo22 <78992276+cammo22@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:46:20 +0100 Subject: [PATCH 3/6] LIVE: lo schermo del cursore si sceglie da solo (il primo, poi l'ultimo scelto) Co-Authored-By: Claude Opus 5.5 --- src/ui/liveCursore.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/liveCursore.ts b/src/ui/liveCursore.ts index 0cf77e9..5ccf159 100644 --- a/src/ui/liveCursore.ts +++ b/src/ui/liveCursore.ts @@ -14,6 +14,8 @@ export class SceltaSchermoLive { constructor() { this.select.append(h('option', { value: '' }, 'Scegli lo schermo…')); this.select.addEventListener('focus', () => void this.carica()); + // lo schermo scelto a mano si ricorda per le volte dopo + this.select.addEventListener('change', () => { try { localStorage.setItem('dpv-live-schermo', this.select.value); } catch { /* niente */ } }); void this.carica(); } @@ -23,7 +25,11 @@ export class SceltaSchermoLive { const prima = this.select.value; this.select.replaceChildren(h('option', { value: '' }, 'Scegli lo schermo…'), ...this.schermi.map((s, i) => h('option', { value: s.id }, `Schermo ${i + 1} · ${s.width}×${s.height} (${s.left}, ${s.top})`))); - this.select.value = prima; + // di partenza: quello scelto l'ultima volta, se c'è ancora, se no il primo schermo + let ricordato = ''; + try { ricordato = localStorage.getItem('dpv-live-schermo') ?? ''; } catch { /* niente */ } + const ids = this.schermi.map((s) => s.id); + this.select.value = ids.includes(prima) ? prima : ids.includes(ricordato) ? ricordato : ids[0] ?? ''; } catch { this.select.disabled = true; } } From 638facef94937f7f1abf87d1aa5502f516135500 Mon Sep 17 00:00:00 2001 From: cammo22 <78992276+cammo22@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:47:09 +0100 Subject: [PATCH 4/6] Sentinella dei decoder: se si aprono troppi decoder di fila, miniature e ricerche aspettano (rete di sicurezza contro RAM e VRAM piene) Co-Authored-By: Claude Opus 5.5 --- src/main.ts | 2 ++ src/media/fotogrammi.ts | 2 ++ src/media/libreria.ts | 2 ++ src/media/sentinella.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 src/media/sentinella.ts diff --git a/src/main.ts b/src/main.ts index 5da0423..9b582b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ // Punto d'ingresso del banco di montaggio. +import { installaSentinella } from './media/sentinella'; +installaSentinella(); import '@fontsource/orbitron/latin-700.css'; import '@fontsource/orbitron/latin-900.css'; import '@fontsource/rajdhani/latin-500.css'; diff --git a/src/media/fotogrammi.ts b/src/media/fotogrammi.ts index d74bfb8..4062eca 100644 --- a/src/media/fotogrammi.ts +++ b/src/media/fotogrammi.ts @@ -6,6 +6,7 @@ // Se la ripresa ha il suo proxy (src/media/proxy.ts) si legge quello: leggero e con un fotogramma chiave ogni // mezzo secondo, così si parte da qualunque punto all'istante. Da fermo, se il monitor è più grande del proxy, // dopo un attimo arriva il fotogramma nitido dell'originale. +import { aspettaSentinella } from './sentinella'; import { EncodedPacketSink, VideoSampleSink, type VideoSample } from 'mediabunny'; import { mediaRT, quandoDecoderOccupato, type MediaRT } from './libreria'; import { modoProxy, quandoProxy } from './proxy'; @@ -203,6 +204,7 @@ class Ricerca { const c = this.current; const avanti = !!this.iter && !!c && t >= c.timestamp && t - c.timestamp < 3; if (!avanti) { + await aspettaSentinella(); this.chiudiCursore(); this.iter = this.sink.samples(Math.max(0, t)); } diff --git a/src/media/libreria.ts b/src/media/libreria.ts index 6d1a34a..363d669 100644 --- a/src/media/libreria.ts +++ b/src/media/libreria.ts @@ -4,6 +4,7 @@ // leggeri e si leggono dalla memoria delle misure, cache.ts, se già fatti). All'import le copie leggere dei video // pesanti partono in fondo alla coda; il controllo della velocità resta a richiesta nel Centro attività. // Le miniature della timeline restano «a richiesta»: si leggono solo i fotogrammi delle clip che si vedono. +import { aspettaSentinella } from './sentinella'; import { ALL_FORMATS, AudioBufferSink, BlobSource, CanvasSink, Input, StreamSource, type InputAudioTrack, type InputVideoTrack, @@ -654,6 +655,7 @@ async function lavoraMiniature(id: string) { while (inCoda.get(id)?.size) { // prima il fotogramma del monitor e la riproduzione, poi le miniature for (let attese = 0; decoderOccupato() && attese < 100; attese++) await new Promise((ok) => setTimeout(ok, 40)); + await aspettaSentinella(); const ts = [...inCoda.get(id)!].sort((a, b) => a - b).slice(0, 8); for (const t of ts) inCoda.get(id)!.delete(t); let i = 0; diff --git a/src/media/sentinella.ts b/src/media/sentinella.ts new file mode 100644 index 0000000..411dc15 --- /dev/null +++ b/src/media/sentinella.ts @@ -0,0 +1,41 @@ +// La sentinella dei decoder: conta i VideoDecoder che l'app apre. Nell'app vera (WebView2, decodifica sulla scheda video) +// ogni decoder lascia memoria nel processo grafico: un giro che ne apre decine al secondo riempiva RAM e VRAM in un +// minuto. Se se ne aprono troppi di fila, chi lavora sullo sfondo (miniature, ricerche) aspetta e lo si scrive in console. + +const FINESTRA_MS = 2000; +const MAX_IN_FINESTRA = 24; +const aperture: number[] = []; +let avvisato = false; + +export function installaSentinella() { + const VD = (globalThis as { VideoDecoder?: typeof VideoDecoder }).VideoDecoder; + if (!VD || (VD.prototype as { __sentinella?: boolean }).__sentinella) return; + const conf = VD.prototype.configure; + (VD.prototype as { __sentinella?: boolean }).__sentinella = true; + VD.prototype.configure = function (this: VideoDecoder & { __contato?: boolean }, c: VideoDecoderConfig) { + if (!this.__contato) { + this.__contato = true; + const ora = performance.now(); + aperture.push(ora); + while (aperture.length && ora - aperture[0] > FINESTRA_MS) aperture.shift(); + if (aperture.length > MAX_IN_FINESTRA && !avvisato) { + avvisato = true; + console.warn(`Sentinella: ${aperture.length} decoder in ${FINESTRA_MS / 1000} s, metto in attesa i lavori di sfondo`, new Error().stack); + } + } + return conf.call(this, c); + }; +} + +/** troppi decoder aperti di recente? */ +export function decoderInPiena(): boolean { + const ora = performance.now(); + while (aperture.length && ora - aperture[0] > FINESTRA_MS) aperture.shift(); + if (aperture.length <= MAX_IN_FINESTRA / 2) avvisato = false; + return aperture.length > MAX_IN_FINESTRA; +} + +/** aspetta che la piena passi (al massimo qualche secondo) */ +export async function aspettaSentinella() { + for (let i = 0; decoderInPiena() && i < 40; i++) await new Promise((ok) => setTimeout(ok, 100)); +} From f361d919df544174fe5467c4ce6415587a3f2a8b Mon Sep 17 00:00:00 2001 From: cammo22 <78992276+cammo22@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:08:41 +0100 Subject: [PATCH 5/6] Zoom che segue il cursore: camera morbida sempre attiva, inquadra la finestra su cui ti fermi, si avvicina sui clic e torna indietro piano; si somma alle scene LIVE (prima le scene lo cancellavano). Il campionatore legge anche la finestra sotto il cursore Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/cursore.rs | 48 +++++++++++ src/core/tipi.ts | 2 + src/core/zoomCursore.ts | 148 ++++++++++++++++++++++++-------- src/ui/ispettore.ts | 4 +- src/ui/live.ts | 15 +++- test/unit/live-cursore.test.mjs | 39 ++++++--- 6 files changed, 205 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/cursore.rs b/src-tauri/src/cursore.rs index aae309f..0b8559b 100644 --- a/src-tauri/src/cursore.rs +++ b/src-tauri/src/cursore.rs @@ -84,6 +84,11 @@ mod win { fn GetCursorInfo(p: *mut Info) -> i32; fn GetAsyncKeyState(key: i32) -> i16; fn SetThreadDpiAwarenessContext(ctx: isize) -> isize; + fn WindowFromPoint(p: Punto) -> *mut c_void; + fn GetAncestor(hwnd: *mut c_void, flags: u32) -> *mut c_void; + fn GetWindowRect(hwnd: *mut c_void, r: *mut Rett) -> i32; + fn GetShellWindow() -> *mut c_void; + fn GetDesktopWindow() -> *mut c_void; fn EnumDisplayMonitors( dc: *mut c_void, clip: *const Rett, @@ -92,6 +97,34 @@ mod win { ) -> i32; } + #[link(name = "dwmapi")] + extern "system" { + fn DwmGetWindowAttribute(hwnd: *mut c_void, attr: u32, out: *mut c_void, size: u32) -> i32; + } + + /// La finestra principale sotto il punto, in pixel fisici (senza l'ombra di Windows). None per desktop e barra. + fn finestra_sotto(p: &Punto) -> Option { + unsafe { + let h = WindowFromPoint(Punto { x: p.x, y: p.y }); + if h.is_null() { + return None; + } + let radice = GetAncestor(h, 2); // GA_ROOT + let radice = if radice.is_null() { h } else { radice }; + if radice == GetShellWindow() || radice == GetDesktopWindow() { + return None; + } + let mut r = Rett { left: 0, top: 0, right: 0, bottom: 0 }; + // DWMWA_EXTENDED_FRAME_BOUNDS: il bordo vero, senza i margini invisibili + let ok = DwmGetWindowAttribute(radice, 9, &mut r as *mut Rett as *mut c_void, std::mem::size_of::() as u32) == 0 + || GetWindowRect(radice, &mut r) != 0; + if !ok || r.right <= r.left || r.bottom <= r.top { + return None; + } + Some(r) + } + } + unsafe extern "system" fn monitor( _m: *mut c_void, _dc: *mut c_void, @@ -141,6 +174,9 @@ mod win { visible: bool, // Ogni bit in salita è un clic fisico: sinistro, destro, centrale. clicks: u8, + // La finestra sotto il cursore (sinistra, alto, destra, basso) in frazioni dello schermo, ogni 100 ms circa. + #[serde(skip_serializing_if = "Option::is_none")] + win: Option<[f64; 4]>, } pub fn avvia(stato: &Cursore, schermo: String) -> Result { @@ -184,7 +220,9 @@ mod win { let mut prima = tasti(); // un pulsante già premuto all'avvio non è un clic let mut controllo = Instant::now(); let mut geometria_valida = true; + let mut giro: u32 = 0; loop { + giro = giro.wrapping_add(1); if controllo.elapsed() >= Duration::from_secs(1) { // Se il monitor cambia risoluzione o viene scollegato, non inventiamo coordinate. geometria_valida = schermi() @@ -209,12 +247,22 @@ mod win { && ci.flags & 1 != 0 && (0.0..1.0).contains(&x) && (0.0..1.0).contains(&y); + let win = if visible && giro % 6 == 0 { + finestra_sotto(&p).map(|r| { + let fx = |v: i32| ((v - b.left) as f64 / b.width as f64).clamp(0.0, 1.0); + let fy = |v: i32| ((v - b.top) as f64 / b.height as f64).clamp(0.0, 1.0); + [fx(r.left), fy(r.top), fx(r.right), fy(r.bottom)] + }) + } else { + None + }; let s = Campione { t_ms: t0.elapsed().as_secs_f64() * 1000.0, cx: x, cy: y, visible, clicks: if visible { ora & !prima } else { 0 }, + win, }; prima = ora; serde_json::to_writer(&mut out, &s).map_err(|e| e.to_string())?; diff --git a/src/core/tipi.ts b/src/core/tipi.ts index 83a7737..7e97f5d 100644 --- a/src/core/tipi.ts +++ b/src/core/tipi.ts @@ -418,6 +418,8 @@ export interface Clip { ritaglio?: Ritaglio; /** keyframe su qualunque parametro numerico (src/core/automazioni.ts): chi ha una linea qui la usa al posto del valore fermo */ anim?: Animazioni; + /** zoom che segue il cursore (LIVE): le chiavi di tf.scale/tf.x/tf.y che c'erano prima, così si toglie o si rifà senza sommarlo due volte */ + zoomLive?: { scale?: ChiaveAnim[]; x?: ChiaveAnim[]; y?: ChiaveAnim[] }; /** le maschere a forma (rettangolo, ellisse, Bézier), sommate o sottratte in ordine (src/core/maschereForma.ts) */ maschere?: MascheraForma[]; /** la ripresa si tiene ferma sull'oggetto tracciato (stabilizza) */ diff --git a/src/core/zoomCursore.ts b/src/core/zoomCursore.ts index fa9bfe3..f49ab35 100644 --- a/src/core/zoomCursore.ts +++ b/src/core/zoomCursore.ts @@ -1,11 +1,13 @@ // Cursore LIVE, nello spirito di OpenScreen (MIT, vedi TERZE-PARTI.md). // La geometria del focus è adattata da videoPlayback/zoomTransform.ts all'origine centrale di DaProd. // Il movimento viene scritto una volta nelle tappe normali: seek, monitor ed export fanno lo stesso conto. -import type { Clip, MediaItem, Project, Transform } from './tipi'; +import type { ChiaveAnim, Clip, MediaItem, Project, Transform } from './tipi'; import { newClip } from './progetto'; +import { valoreAnim } from './automazioni'; export interface SchermoCursore { id: string; left: number; top: number; width: number; height: number } -export interface CampioneCursore { tMs: number; cx: number; cy: number; visible: boolean; clicks: number } +/** `win` = la finestra sotto il cursore (sinistra, alto, destra, basso) in frazioni dello schermo, quando il campionatore l'ha letta */ +export interface CampioneCursore { tMs: number; cx: number; cy: number; visible: boolean; clicks: number; win?: [number, number, number, number] } export interface ClicCursore { tMs: number; cx: number; cy: number; button: number } export interface SegmentoCursore { daMs: number; durataMs: number } export interface DatiCursore { @@ -71,42 +73,120 @@ export function puntoCursoreAl(campioni: CampioneCursore[], tMs: number): [numbe return [a.cx + (b.cx - a.cx) * k, a.cy + (b.cy - a.cy) * k]; } -/** Solo sulla clip appena creata da LIVE: non riscrive movimenti già lavorati a mano. */ -export function applicaZoomCursore(p: Project, c: Clip, m: MediaItem): boolean { +/** Un punto della camera: dove guarda (frazioni dello schermo registrato) e quanto è vicina (1 = quadro intero). */ +export interface PuntoCamera { tMs: number; cx: number; cy: number; z: number } + +const PASSO_MS = 100; +/** fermo = il cursore resta in un cerchio di questo raggio (frazione dello schermo) */ +const RAGGIO_FERMO = 0.025; +const FERMO_MS = 700; +const ZOOM_CLIC = 1.9, ZOOM_FERMO = 1.6, ZOOM_SEGUI = 1.18, ZOOM_MAX = 2.6; + +/** la finestra più recente letta vicino a tMs (il campionatore la legge ogni ~100 ms) */ +function finestraAl(campioni: CampioneCursore[], tMs: number): [number, number, number, number] | null { + let lo = 0, hi = campioni.length; + while (lo < hi) { const m = (lo + hi) >>> 1; if (campioni[m].tMs <= tMs) lo = m + 1; else hi = m; } + for (let i = lo - 1; i >= 0 && tMs - campioni[i].tMs < 400; i--) if (campioni[i].win) return campioni[i].win!; + return null; +} + +/** + * Il percorso della camera, un punto ogni 100 ms, calcolato a registrazione finita (si può guardare anche avanti): + * - un clic: si avvicina al punto un attimo prima e resta lì un paio di secondi; + * - cursore fermo su una finestra: la inquadra tutta, centrata e il più grande possibile; + * - cursore fermo senza finestra (desktop, finestra a tutto schermo): zoom medio sul cursore; + * - cursore che si muove: torna indietro piano e lo segue dolcemente. + * Il moto è filtrato (molla del primo ordine, costanti di tempo diverse per avvicinarsi e allontanarsi), così la + * camera non scatta mai. + */ +export function camminoCamera(dati: DatiCursore, durataMs: number): PuntoCamera[] { + const out: PuntoCamera[] = []; + let cx = 0.5, cy = 0.5, z = 1; + let ancora: [number, number] | null = null, fermoDa = 0; + let ic = 0; + for (let t = 0; t <= durataMs; t += PASSO_MS) { + const q = puntoCursoreAl(dati.campioni, t); + let tx = 0.5, ty = 0.5, tz = 1; + // clic nella finestra [t - 1,9 s, t + 0,35 s]: si arriva prima del clic e si resta dopo + while (ic < dati.clic.length && dati.clic[ic].tMs < t - 1900) ic++; + const clic = dati.clic.slice(ic).find((c) => c.tMs <= t + 350); + if (q) { + if (!ancora || Math.hypot(q[0] - ancora[0], q[1] - ancora[1]) > RAGGIO_FERMO) { ancora = q; fermoDa = t; } + const fermo = t - fermoDa >= FERMO_MS; + const w = fermo ? finestraAl(dati.campioni, t) : null; + const ww = w ? w[2] - w[0] : 1, wh = w ? w[3] - w[1] : 1; + if (clic) { tx = clic.cx; ty = clic.cy; tz = ZOOM_CLIC; } + else if (w && ww * wh < 0.8 && ww * wh > 0.01) { + tx = (w[0] + w[2]) / 2; ty = (w[1] + w[3]) / 2; + tz = clamp(0.94 / Math.max(ww, wh), 1, ZOOM_MAX); + } else if (fermo) { tx = q[0]; ty = q[1]; tz = ZOOM_FERMO; } + else { tx = q[0]; ty = q[1]; tz = ZOOM_SEGUI; } + } else ancora = null; + // avvicinarsi un po' più svelti, allontanarsi piano; il centro segue più rapido quando si è vicini + const kz = 1 - Math.exp(-PASSO_MS / (tz > z ? 450 : 900)); + const kc = 1 - Math.exp(-PASSO_MS / (z > 1.3 ? 380 : 600)); + z += (tz - z) * kz; + cx += (tx - cx) * kc; cy += (ty - cy) * kc; + out.push({ tMs: t, cx, cy, z }); + } + return out; +} + +/** + * Lo zoom che segue il cursore, scritto come keyframe di tf.scale/tf.x/tf.y (modificabili dopo come gli altri). + * Si somma alla posizione che la clip ha già in quel fotogramma (anche quella delle scene LIVE): la camera + * lavora solo quando lo schermo è a tutto quadro (`pieno`), altrove lascia la scena com'è. + */ +export function applicaZoomCursore(p: Project, c: Clip, m: MediaItem, pieno: (lf: number) => boolean = () => true): boolean { const dati = m.cursoreLive; - if (!dati || dati.v !== 1 || c.tfFine || c.via?.length || c.len < 3 || m.width <= 0 || m.height <= 0 || c.speed <= 0) return false; - const r = p.rate.num / p.rate.den, durata = (c.len - 1) * 1000 / r; + if (!dati || dati.v !== 1 || c.via?.length || c.len < 3 || m.width <= 0 || m.height <= 0 || c.speed <= 0) return false; + const r = p.rate.num / p.rate.den; const ingresso = (c.srcIn - (m.t0 || 0)) * 1000; - const clic = dati.clic.map((s) => ({ ...s, tMs: (s.tMs - ingresso) / c.speed })); - const regioni = regioniZoom(clic, durata); - if (!regioni.length) return false; - const fit = Math.min(p.w / m.width, p.h / m.height), base = { ...c.tf }; - const tappe: { t: number; tf: Transform }[] = [{ t: 0, tf: base }]; - for (const g of regioni) { - // Entrata e uscita hanno due tappe uguali ai lati: passaPer dà la S senza stato di playback. - const entrata = Math.min(g.fine, g.da + 500), uscita = Math.max(entrata, g.fine - 550); - tappe.push({ t: g.da / durata, tf: { ...base } }); - let x = g.clic[0].cx, y = g.clic[0].cy; - // Bake deterministico a 5 Hz nel tempo del contenuto, poi passaPer raccorda le tappe con la S. - // Il filtro usa sempre lo stesso passo, indipendente dai seek e dai fotogrammi del monitor. - for (let ms = entrata; ms < uscita; ms += 200) { - const q = puntoCursoreAl(dati.campioni, ingresso + ms * c.speed); - if (q) { const k = 1 - Math.exp(-200 / 280); x += (q[0] - x) * k; y += (q[1] - y) * k; } - const tf = trasformazioneFocus(base, x, y, 1.65, m.width * fit, m.height * fit); - const prima = tappe.at(-1)!; - if (ms === entrata || Math.hypot(tf.x - prima.tf.x, tf.y - prima.tf.y) >= 3) tappe.push({ t: ms / durata, tf }); + const fineMs = ingresso + (c.len * 1000 / r) * c.speed; + const cammino = camminoCamera(dati, Math.max(0, Math.min(dati.durataMs, fineMs))); + if (!cammino.length) return false; + const fit = Math.min(p.w / m.width, p.h / m.height), w = m.width * fit, h = m.height * fit; + c.anim ??= {}; + // rifarlo parte dalle chiavi di prima dello zoom: mai due zoom uno sopra l'altro + const prima = c.zoomLive ?? { scale: c.anim['tf.scale'], x: c.anim['tf.x'], y: c.anim['tf.y'] }; + const nuove: Record<'scale' | 'x' | 'y', ChiaveAnim[]> = { scale: [], x: [], y: [] }; + const passo = Math.max(1, Math.round(r * PASSO_MS / 1000)); + let mosso = false; + for (let lf = 0; lf <= c.len; lf += passo) { + const tMs = ingresso + (lf * 1000 / r) * c.speed; + const k = cammino[clamp(Math.round(tMs / PASSO_MS), 0, cammino.length - 1)]; + const base: Transform = { ...c.tf, scale: valoreAnim(prima.scale, lf, c.tf.scale), x: valoreAnim(prima.x, lf, c.tf.x), y: valoreAnim(prima.y, lf, c.tf.y) }; + const tf = pieno(lf) && k.z > 1.001 ? trasformazioneFocus(base, k.cx, k.cy, k.z, w, h) : base; + if (tf !== base) mosso = true; + for (const chiave of ['scale', 'x', 'y'] as const) { + const lista = nuove[chiave], v = tf[chiave]; + const ultima = lista.at(-1); + // niente chiavi inutili: si salta se il valore è uguale all'ultima (le prime e le ultime restano) + if (ultima && Math.abs(ultima.v - v) < (chiave === 'scale' ? 0.0005 : 0.25) && lf + passo <= c.len) continue; + lista.push({ f: lf, v }); } - tappe.push({ t: uscita / durata, tf: trasformazioneFocus(base, x, y, 1.65, m.width * fit, m.height * fit) }); - tappe.push({ t: g.fine / durata, tf: { ...base } }); } - tappe.push({ t: 1, tf: { ...base } }); - // Se un clic è sul primo/ultimo fotogramma, gli estremi restano comunque a quadro intero. - const uniche = new Map(); - for (const t of tappe) uniche.set(clamp(t.t, 0, 1), t.tf); - uniche.set(0, base); uniche.set(1, { ...base }); - const ordine = [...uniche].sort((a, b) => a[0] - b[0]); - c.tf = ordine[0][1]; c.tfFine = ordine.at(-1)![1]; - c.via = ordine.slice(1, -1).map(([t, tf]) => ({ t, tf })); + if (!mosso) return false; + // solo le chiavi che esistono: il progetto salvato (JSON) non ha campi vuoti + c.zoomLive = JSON.parse(JSON.stringify(prima)); + for (const chiave of ['scale', 'x', 'y'] as const) { + const lista = nuove[chiave]; + if (lista.at(-1)!.f < c.len) lista.push({ f: c.len, v: lista.at(-1)!.v }); + c.anim['tf.' + chiave] = lista; + } + return true; +} + +/** Toglie lo zoom che segue il cursore: tornano le chiavi di prima (anche quelle delle scene). */ +export function togliZoomCursore(c: Clip): boolean { + const z = c.zoomLive; + if (!z) return false; + c.anim ??= {}; + for (const chiave of ['scale', 'x', 'y'] as const) { + const k = z[chiave]; + if (k?.length) c.anim['tf.' + chiave] = k; else delete c.anim['tf.' + chiave]; + } + delete c.zoomLive; return true; } diff --git a/src/ui/ispettore.ts b/src/ui/ispettore.ts index 0b9343b..be38260 100644 --- a/src/ui/ispettore.ts +++ b/src/ui/ispettore.ts @@ -13,7 +13,7 @@ import { frameToTc, fps } from '../core/timecode'; import { avviso, conferma, dialogo, menuContesto, h, evidenzia } from './dom'; import { SCHEDE, copiaScheda, dbTesto, destinatarieDellaCopia, etichettaStato, funzioniAIPerSelezione, haProprieta, lufsStimati, lufsTesto, pesoEVram, posizioniChiavi, ripristinaScheda, schedaDaMostrare, schedaDiSezione, schedeUsabili, zoomSuiClic, zoomTesto, type Scheda } from '../core/proprieta'; import { mediaRT, PEAKS_PER_SEC, quandoPicchi } from '../media/libreria'; -import { applicaZoomCursore } from '../core/zoomCursore'; +import { applicaZoomCursore, togliZoomCursore } from '../core/zoomCursore'; import * as M from '../core/montaggio'; import { esegui, modi, mettiBlocco } from '../azioni'; import { DIREZIONALI, EFFETTI, TENDINE } from '../render/transizioni'; @@ -1083,7 +1083,7 @@ export class Ispettore { if (x && mm) applicaZoomCursore(pp, x, mm); }); }); - const togli = btn('Togli', 'Toglie il movimento della clip', () => store.edit('Toglie lo zoom sui clic', (pp) => { const x = clipById(pp, id); if (x) movimentoPronto(pp, x, 'fermo'); })); + const togli = btn('Togli', 'Toglie lo zoom che segue il cursore', () => store.edit('Toglie lo zoom sul cursore', (pp) => { const x = clipById(pp, id); if (x && !togliZoomCursore(x)) movimentoPronto(pp, x, 'fermo'); })); return h('div', { class: 'pp-blocco' }, h('div', { class: 'pp-inciso' }, 'ZOOM SUI CLIC'), h('div', { class: 'pp-riga-piena' }, diff --git a/src/ui/live.ts b/src/ui/live.ts index cc5445e..05a5aa4 100644 --- a/src/ui/live.ts +++ b/src/ui/live.ts @@ -21,7 +21,7 @@ import { Input, Mp4OutputFormat, Output, WebMOutputFormat, } from 'mediabunny'; import '../stile/live.css'; -import { SCENE_LIVE, cambiaScena, scenaDaTasto, animaScene, type CambioScena, type ScenaLive } from '../core/sceneLive'; +import { SCENE_LIVE, cambiaScena, scenaDaTasto, animaScene, riquadriScena, type CambioScena, type ScenaLive } from '../core/sceneLive'; import { mostraScena } from './sceneLive'; import { store } from '../core/store'; import { newClip, newTrack, nextTrackName, projectEnd, uid, TITLE0 } from '../core/progetto'; @@ -699,7 +699,7 @@ export class Live { h('section', { class: 'live-configurazione', 'aria-label': 'Altre opzioni' }, sezione('Altre opzioni'), sezione('Cursore e zoom'), griglia( - interruttore('zoomCursore', null, 'Zoom che segue il cursore', 'Zoom morbido sulle zone dei clic: dopo lo cambi dalle tappe del movimento della clip'), + interruttore('zoomCursore', null, 'Zoom che segue il cursore', 'Segue il cursore dolcemente, si avvicina sui clic e inquadra la finestra su cui ti fermi. Dopo lo ritocchi dai keyframe della clip'), interruttore('clicAnimati', null, 'Clic animati', 'Un cerchietto sui clic: dopo lo accorci o lo togli come un blocco effetto'), interruttore('cursore', null, 'Cursore visibile', 'Il puntatore del mouse nella registrazione (dove il sistema lo permette)')), this.sceltaSchermo.el, @@ -1682,7 +1682,6 @@ export class Live { if (o.presentazione) v.tf = presentazione(); link ??= v.link ?? (v.link = uid('l')); for (const c of p.clips) if (nuovi.includes(c.id)) { c.link = link; if (tMic && c.track !== tSchermo) c.name = 'Audio del computer'; } - if (cursore.zoom) applicaZoomCursore(p, v, m); if (cursore.clic && m.cursoreLive?.clic.length) { const blocco = bloccoClicLive(v); p.clips.push(blocco); ids.push(blocco.id); @@ -1694,6 +1693,16 @@ export class Live { ids.push(...voce); } if (scene.length) animaScene(v, p, m, scene, 'schermo', (v.start - inizio) / fps(p.rate)); + // lo zoom dopo le scene: si somma alla loro posizione, e solo quando lo schermo è a tutto quadro + if (cursore.zoom) { + const da = (v.start - inizio) / fps(p.rate), r = fps(p.rate); + applicaZoomCursore(p, v, m, (lf) => { + const s = scene.filter((x) => x.t <= da + lf / r).at(-1) ?? scene[0]; + if (!s) return !o.presentazione; + const q = riquadriScena(s).schermo; + return q.visibile && q.w === 1 && q.h === 1 && q.x === 0 && q.y === 0; + }); + } if (tSopra) { const sopra = structuredClone(v); sopra.id = uid('c'); sopra.track = tSopra; delete sopra.anim; animaScene(sopra, p, m, scene, 'schermo-sopra', (v.start - inizio) / fps(p.rate)); diff --git a/test/unit/live-cursore.test.mjs b/test/unit/live-cursore.test.mjs index 460e74e..1e01cf6 100644 --- a/test/unit/live-cursore.test.mjs +++ b/test/unit/live-cursore.test.mjs @@ -56,23 +56,38 @@ test('il focus usa fit, scala e rotazione; ai bordi non scopre il fondo della ri assert.deepEqual(Z.trasformazioneFocus(base, .8, .2, 1, 1000, 500), base); }); -test('zoom a tappe modificabili: segue il cursore, riparte a quadro intero, è deterministico e non sovrascrive il lavoro manuale', () => { +test('lo zoom segue il cursore con keyframe morbidi, è deterministico, rifatto non si somma e si toglie', () => { const { p, c, m } = scena(); const gemella = structuredClone(c); assert.equal(Z.applicaZoomCursore(p, c, m), true); assert.equal(Z.applicaZoomCursore(p, gemella, m), true); assert.deepEqual(c, gemella); - assert.ok(c.via.length > 4); - assert.equal(c.tf.scale, 1); assert.equal(c.tfFine.scale, 1); - assert.ok(c.via.some(v => v.tf.scale > 1.6)); - const spostamenti = c.via.filter(v => v.tf.scale > 1.6).map(v => v.tf.x); - assert.ok(Math.max(...spostamenti) - Math.min(...spostamenti) > 3); - assert.ok(c.via.every((v, i, a) => v.t > 0 && v.t < 1 && (!i || v.t > a[i - 1].t))); - const prima = structuredClone(c); - assert.equal(Z.applicaZoomCursore(p, c, m), false); - assert.deepEqual(c, prima); - const a = P.tfAl(c, 75), b = P.tfAl(c, 200); - assert.deepEqual(P.tfAl(c, 75), a); assert.deepEqual(P.tfAl(c, 200), b); + const sc = c.anim['tf.scale']; + assert.ok(sc.length > 4); + assert.equal(sc[0].f, 0); assert.ok(Math.abs(sc[0].v - 1) < 0.05); + assert.ok(sc.some(k => k.v > 1.6), 'sui clic si avvicina'); + assert.ok(sc.every((k, i, a) => !i || k.f > a[i - 1].f)); + // fra un fotogramma e il successivo la scala non salta mai (camera morbida) + for (let i = 1; i < sc.length; i++) assert.ok(Math.abs(sc[i].v - sc[i - 1].v) / Math.max(1, sc[i].f - sc[i - 1].f) < 0.08); + const una = structuredClone(c); + assert.equal(Z.applicaZoomCursore(p, c, m), true); + assert.deepEqual(c.anim, una.anim, 'rifatto parte dalle chiavi di prima: niente zoom doppio'); + assert.equal(Z.togliZoomCursore(c), true); + assert.equal(c.anim['tf.scale'], undefined); + assert.equal(c.zoomLive, undefined); +}); + +test('cursore fermo su una finestra: la inquadra centrata; muovendosi torna indietro piano', () => { + const raw = []; + for (let i = 0; i < 360; i++) raw.push({ tMs: i * 1000 / 60, cx: .3, cy: .4, clicks: 0, visible: true, win: [.2, .3, .6, .7] }); + for (let i = 360; i < 600; i++) raw.push({ tMs: i * 1000 / 60, cx: .3 + (i - 360) / 400, cy: .4, clicks: 0, visible: true }); + const d = Z.rimappaCursore(raw, schermo, [{ da: 0, fine: 10000, videoMs: 0, durataMs: 10000 }], 10000); + const cam = Z.camminoCamera(d, 10000); + const fermo = cam.find(k => k.tMs === 5800); + assert.ok(Math.abs(fermo.z - .94 / .4) < .12, 'zoom adatto alla finestra: ' + fermo.z); + assert.ok(Math.abs(fermo.cx - .4) < .03 && Math.abs(fermo.cy - .5) < .03, 'centrata sulla finestra'); + const dopo = cam.find(k => k.tMs === 6500), lontano = cam.find(k => k.tMs === 9000); + assert.ok(dopo.z > lontano.z && dopo.z < fermo.z, 'torna indietro gradualmente'); }); test('progetti senza telemetria e movimenti vecchi restano uguali; il roundtrip conserva dati, tappe e blocco', () => { From cba9827bb2d29a6fd4dc35fde5c590fe1430e2bc Mon Sep 17 00:00:00 2001 From: cammo22 <78992276+cammo22@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:15:26 +0100 Subject: [PATCH 6/6] =?UTF-8?q?1.8.6=20=C2=B7=20stabile:=20niente=20pi?= =?UTF-8?q?=C3=B9=20RAM=20e=20VRAM=20piene,=20zoom=20sul=20cursore=20rifat?= =?UTF-8?q?to,=20microfono=20di=20ripiego,=20forma=20d'onda=20in=20dB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src/core/zoomCursore.ts | 36 ++++++++++++++++++++++++--------- src/ui/live.ts | 15 ++++++++++---- src/ui/timeline.ts | 4 +++- test/unit/live-cursore.test.mjs | 9 +++++++++ 9 files changed, 62 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3841c4b..089c2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ per Windows (portatile e installabile), Mac e Android, e va online su [GitHub Pa - **Clear** (licenza source-available, non permissiva) si può spegnere dal Centro AI. - **Nota onesta**: i modelli veri non sono stati scaricati né eseguiti; prove con motori finti (`test/unit/funzioni-ai.test.mjs`, `test/unit/motore_ai_server*.py`). +## [1.8.6] — 2026-10-30 · Stabile: niente più RAM e VRAM piene, zoom sul cursore rifatto + +- **RAM e VRAM non si riempiono più** dopo una registrazione LIVE: il monitor e la striscia di miniature della timeline aprivano decine di decoder al secondo quando chiedevano un istante prima del primo fotogramma. Corretto, e in più una **sentinella** mette in attesa i lavori di sfondo se i decoder aperti diventano troppi. +- **Zoom che segue il cursore rifatto**: segue dolcemente, con il mouse veloce resta largo e non insegue, se ti fermi su una finestra la inquadra centrata e grande, sui clic si avvicina, poi torna indietro piano. Funziona anche con le scene LIVE (prima le scene lo cancellavano) e diventa keyframe ritoccabili; «Togli» rimette com'era. +- **Lo schermo per lo zoom si sceglie da solo**: il primo, poi quello che hai scelto l'ultima volta. +- **Microfono**: se quello scelto non risponde si usa quello predefinito di Windows; se non si apre proprio, un avviso chiaro dice che la voce non viene registrata. +- **Forma d'onda in decibel**: si vedono anche l'audio basso e la voce piano. + ## [1.8.5] — 2026-10-30 · Revisione finale: tutto in ordine - **Montaggio più pulito**: barra del contenitore con le sole voci principali, lettere dei tasti leggibili (la S non sembra più un 5), Esc chiude il menu del tasto destro, velocità scritte ×0,25 / ×0,5. diff --git a/package-lock.json b/package-lock.json index 488fd22..d117730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.8.5", + "version": "1.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.8.5", + "version": "1.8.6", "license": "MIT", "dependencies": { "@desert-ant-labs/clear": "3.6.0", diff --git a/package.json b/package.json index 522bc6f..6ad3c52 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.8.5", + "version": "1.8.6", "description": "DaProd Video: il montaggio video vecchio stile, moderno dentro. Timeline, due monitor, pulsantiera e VU a lancetta.", "author": "DaProd", "license": "MIT", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f08051b..20930e8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -615,7 +615,7 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "daprod-video" -version = "1.8.5" +version = "1.8.6" dependencies = [ "getrandom 0.3.4", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 30bfb46..cafd2e8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.8.5" +version = "1.8.6" description = "DaProd Video: il montaggio vecchio stile, moderno dentro" authors = ["DaProd"] license = "MIT" diff --git a/src/core/zoomCursore.ts b/src/core/zoomCursore.ts index f49ab35..01a48e7 100644 --- a/src/core/zoomCursore.ts +++ b/src/core/zoomCursore.ts @@ -101,16 +101,22 @@ function finestraAl(campioni: CampioneCursore[], tMs: number): [number, number, */ export function camminoCamera(dati: DatiCursore, durataMs: number): PuntoCamera[] { const out: PuntoCamera[] = []; - let cx = 0.5, cy = 0.5, z = 1; + // due stadi per grandezza (molla «criticamente smorzata» alla buona): il secondo toglie gli strappi del primo + let cx1 = 0.5, cy1 = 0.5, z1 = 1, cx = 0.5, cy = 0.5, z = 1; + let tx = 0.5, ty = 0.5; let ancora: [number, number] | null = null, fermoDa = 0; let ic = 0; + const storia: [number, number, number][] = []; for (let t = 0; t <= durataMs; t += PASSO_MS) { const q = puntoCursoreAl(dati.campioni, t); - let tx = 0.5, ty = 0.5, tz = 1; - // clic nella finestra [t - 1,9 s, t + 0,35 s]: si arriva prima del clic e si resta dopo + let tz = 1; while (ic < dati.clic.length && dati.clic[ic].tMs < t - 1900) ic++; const clic = dati.clic.slice(ic).find((c) => c.tMs <= t + 350); if (q) { + // velocità del cursore negli ultimi 300 ms, in schermi al secondo + storia.push([t, q[0], q[1]]); + while (storia.length && t - storia[0][0] > 300) storia.shift(); + const v = storia.length > 1 ? Math.hypot(q[0] - storia[0][1], q[1] - storia[0][2]) / Math.max(0.1, (t - storia[0][0]) / 1000) : 0; if (!ancora || Math.hypot(q[0] - ancora[0], q[1] - ancora[1]) > RAGGIO_FERMO) { ancora = q; fermoDa = t; } const fermo = t - fermoDa >= FERMO_MS; const w = fermo ? finestraAl(dati.campioni, t) : null; @@ -120,13 +126,23 @@ export function camminoCamera(dati: DatiCursore, durataMs: number): PuntoCamera[ tx = (w[0] + w[2]) / 2; ty = (w[1] + w[3]) / 2; tz = clamp(0.94 / Math.max(ww, wh), 1, ZOOM_MAX); } else if (fermo) { tx = q[0]; ty = q[1]; tz = ZOOM_FERMO; } - else { tx = q[0]; ty = q[1]; tz = ZOOM_SEGUI; } - } else ancora = null; - // avvicinarsi un po' più svelti, allontanarsi piano; il centro segue più rapido quando si è vicini - const kz = 1 - Math.exp(-PASSO_MS / (tz > z ? 450 : 900)); - const kc = 1 - Math.exp(-PASSO_MS / (z > 1.3 ? 380 : 600)); - z += (tz - z) * kz; - cx += (tx - cx) * kc; cy += (ty - cy) * kc; + else { + // in movimento: più è veloce, più si allarga (oltre 1,2 schermi al secondo torna al quadro intero) + tz = 1 + (ZOOM_SEGUI - 1) * clamp(1 - v / 1.2, 0, 1); + // zona morta: la camera si sposta solo se il cursore esce dal 60% centrale dell'inquadratura + const meta = 0.3 / Math.max(1, z); + if (q[0] < cx - meta) tx = q[0] + meta; else if (q[0] > cx + meta) tx = q[0] - meta; else tx = cx; + if (q[1] < cy - meta) ty = q[1] + meta; else if (q[1] > cy + meta) ty = q[1] - meta; else ty = cy; + } + } else { ancora = null; storia.length = 0; } + const kz = 1 - Math.exp(-PASSO_MS / (tz > z1 ? 320 : 650)); + const kc = 1 - Math.exp(-PASSO_MS / 300); + z1 += (tz - z1) * kz; z += (z1 - z) * kz; + cx1 += (tx - cx1) * kc; cy1 += (ty - cy1) * kc; + // la camera non corre: al massimo mezzo quadro inquadrato al secondo + const passo = 0.05 / Math.max(1, z), dx = cx1 - cx, dy = cy1 - cy, d = Math.hypot(dx, dy); + const k = d > passo ? passo / d : 1; + cx += dx * k; cy += dy * k; out.push({ tMs: t, cx, cy, z }); } return out; diff --git a/src/ui/live.ts b/src/ui/live.ts index 05a5aa4..c79a572 100644 --- a/src/ui/live.ts +++ b/src/ui/live.ts @@ -947,9 +947,16 @@ export class Live { if (microfonoProva) return risorseLive.flusso(await microfonoProva()); const f = this.opz.audio.filtri; // i filtri del browser (toglie i disturbi con il suo modello AI, eco, volume automatico) sono scelte tue - return risorseLive.flusso(await navigator.mediaDevices.getUserMedia({ - audio: { deviceId: this.opz.micId ? { exact: this.opz.micId } : undefined, echoCancellation: f.eco, noiseSuppression: f.disturbi, autoGainControl: f.livellaAuto }, - })); + const vincoli = { echoCancellation: f.eco, noiseSuppression: f.disturbi, autoGainControl: f.livellaAuto }; + if (this.opz.micId) { + try { + return risorseLive.flusso(await navigator.mediaDevices.getUserMedia({ audio: { ...vincoli, deviceId: { exact: this.opz.micId } } })); + } catch { + // il microfono scelto non c'è più (cuffie staccate, Windows gli ha cambiato nome): si usa quello predefinito + avviso('Il microfono scelto non risponde: uso quello predefinito di Windows', 'info', 4000); + } + } + return risorseLive.flusso(await navigator.mediaDevices.getUserMedia({ audio: vincoli })); } /** i filtri del browser cambiati mentre il microfono è già aperto: si riapplicano alla traccia viva (senza richiuderla) */ @@ -1252,7 +1259,7 @@ export class Live { mic = await this.apriMic(); this.flussi.push(mic); this.micReg = mic; - } catch { avviso('Il microfono non si apre: registro senza', 'info', 2400); } + } catch (e) { avviso('⚠ Il microfono non si apre, la voce NON viene registrata. Controlla in Windows → Privacy → Microfono. (' + String(e).slice(0, 80) + ')', 'errore', 9000); } } await mix.pronto; this.micSep = null; diff --git a/src/ui/timeline.ts b/src/ui/timeline.ts index 9c8e4fe..2007fba 100644 --- a/src/ui/timeline.ts +++ b/src/ui/timeline.ts @@ -1422,7 +1422,9 @@ export class Timeline { let mx = 0; for (let i = i0; i < i1 && i < peaks.length; i++) if (peaks[i] > mx) mx = peaks[i]; if (!orig && c.gainKeys.length) gain = dbToGain(keyValue(c.gainKeys, f0 - c.start, c.gain)); - const v = Math.min(1, mx * gain) * hh * 0.48; + // in decibel (da −60 a 0): anche una voce bassa o l'audio del computer piano si vedono, come nei banchi di montaggio + const a = Math.min(1, mx * gain); + const v = (a > 0.001 ? Math.max(0, 1 + Math.log10(a) / 3) : 0) * hh * 0.48; if (v > 0.3) ctx.fillRect(px, mid - v, 1, v * 2); } ctx.fillStyle = 'rgba(0,0,0,.25)'; diff --git a/test/unit/live-cursore.test.mjs b/test/unit/live-cursore.test.mjs index 1e01cf6..d73b5bf 100644 --- a/test/unit/live-cursore.test.mjs +++ b/test/unit/live-cursore.test.mjs @@ -283,3 +283,12 @@ test('il compositore libera le immagini uscite dal quadro e le ricarica quando t assert.notEqual(cp.texs.get('img:foto').tex, prima); cp.distruggi(); }); + +test('mouse veloce: la camera non va nel panico (resta larga e si sposta piano)', () => { + const raw = []; + for (let i = 0; i < 600; i++) raw.push({ tMs: i * 1000 / 60, cx: (Math.floor(i / 6) % 2) ? .9 : .1, cy: (Math.floor(i / 9) % 2) ? .85 : .15, clicks: 0, visible: true }); + const d = Z.rimappaCursore(raw, schermo, [{ da: 0, fine: 10000, videoMs: 0, durataMs: 10000 }], 10000); + const cam = Z.camminoCamera(d, 10000); + assert.ok(cam.every(k => k.z < 1.12), 'resta quasi a quadro intero'); + for (let i = 1; i < cam.length; i++) assert.ok(Math.hypot(cam[i].cx - cam[i - 1].cx, cam[i].cy - cam[i - 1].cy) <= .0501, 'si sposta piano'); +});