diff --git a/CHANGELOG.md b/CHANGELOG.md index b324952..df4fe73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ 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.8] — 2026-10-30 · Lo zoom si vede mentre registri + +- **Anteprima dello zoom in diretta**: mentre registri con lo zoom che segue il cursore, un riquadro piccolo nell'anteprima del LIVE e l'anteprima del telecomando mostrano lo zoom che sta facendo, con la stessa camera che poi finisce nel montaggio. + ## [1.8.7] — 2026-10-30 · Zoom sul cursore come si deve, README nuovo - **Zoom che segue il cursore**: quando muovi tanto il mouse si vede **tutto lo schermo**; si avvicina solo quando ti fermi o ti muovi piano, e dopo un movimento forte aspetta un attimo di calma prima di zoomare di nuovo. Quando è vicino segue più svelto e **il cursore non esce mai dall'inquadratura**. diff --git a/package-lock.json b/package-lock.json index 30e0c00..69126b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprod-video", - "version": "1.8.7", + "version": "1.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprod-video", - "version": "1.8.7", + "version": "1.8.8", "license": "MIT", "dependencies": { "@desert-ant-labs/clear": "3.6.0", diff --git a/package.json b/package.json index 3c19bd7..5f15b36 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "daprod-video", "productName": "DaProd Video", - "version": "1.8.7", + "version": "1.8.8", "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 1581450..7fd4cc2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -615,7 +615,7 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "daprod-video" -version = "1.8.7" +version = "1.8.8" dependencies = [ "getrandom 0.3.4", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dfaa3d6..03c40dc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daprod-video" -version = "1.8.7" +version = "1.8.8" description = "DaProd Video: il montaggio vecchio stile, moderno dentro" authors = ["DaProd"] license = "MIT" diff --git a/src-tauri/src/cursore.rs b/src-tauri/src/cursore.rs index 0b8559b..a99f78d 100644 --- a/src-tauri/src/cursore.rs +++ b/src-tauri/src/cursore.rs @@ -31,6 +31,18 @@ struct Sessione { #[derive(Default)] pub struct Cursore(Mutex>); +/// L'ultimo campione, per l'anteprima in diretta dello zoom (telecomando e LIVE). I clic si sommano fino alla lettura. +#[derive(Clone, Serialize)] +pub struct Ora { + cx: f64, + cy: f64, + visible: bool, + clicks: u8, + #[serde(skip_serializing_if = "Option::is_none")] + win: Option<[f64; 4]>, +} +static ORA: Mutex> = Mutex::new(None); + impl Cursore { pub fn chiudi(&self) { if let Ok(mut attiva) = self.0.lock() { @@ -265,6 +277,10 @@ mod win { win, }; prima = ora; + if let Ok(mut o) = ORA.lock() { + let vecchi = o.as_ref().map(|v| (v.clicks, v.win)).unwrap_or((0, None)); + *o = Some(Ora { cx: s.cx, cy: s.cy, visible: s.visible, clicks: vecchi.0 | s.clicks, win: s.win.or(vecchi.1) }); + } serde_json::to_writer(&mut out, &s).map_err(|e| e.to_string())?; out.write_all(b"\n").map_err(|e| e.to_string())?; // Flush periodico: niente ring buffer che perda l'inizio delle registrazioni lunghe. @@ -280,6 +296,7 @@ mod win { std::fs::read_to_string(&path).map_err(|e| e.to_string()) })(); let _ = std::fs::remove_file(path); + if let Ok(mut o) = ORA.lock() { *o = None; } risultato }) .map_err(|e| e.to_string())?; @@ -344,3 +361,12 @@ pub async fn cursore_ferma(stato: tauri::State<'_, Cursore>, id: u64) -> Result< .await .map_err(|e| e.to_string())? } + +/// Dov'è il cursore adesso (None se non si sta registrando). I clic tornano a zero dopo ogni lettura. +#[tauri::command] +pub fn cursore_ora() -> Option { + let mut o = ORA.lock().ok()?; + let v = o.clone(); + if let Some(x) = o.as_mut() { x.clicks = 0; } + v +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06de17f..56a2d8c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -608,6 +608,7 @@ pub fn run() { cursore::cursore_schermi, cursore::cursore_avvia, cursore::cursore_ferma, + cursore::cursore_ora, audio_apri, audio_decodifica, audio_chiudi, diff --git a/src/core/zoomCursore.ts b/src/core/zoomCursore.ts index c3cf8f9..25aa985 100644 --- a/src/core/zoomCursore.ts +++ b/src/core/zoomCursore.ts @@ -103,66 +103,87 @@ function finestraAl(campioni: CampioneCursore[], tMs: number): [number, number, */ export function camminoCamera(dati: DatiCursore, durataMs: number): PuntoCamera[] { const out: PuntoCamera[] = []; - // 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; - // l'ultimo istante di movimento forte: lo zoom torna solo dopo un po' di calma - let agitatoA = -Infinity; + const cam = new CameraCursore(); let ic = 0; - const storia: [number, number, number][] = []; for (let t = 0; t <= durataMs; t += PASSO_MS) { - const q = puntoCursoreAl(dati.campioni, t); - 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); + out.push(cam.passo(t, puntoCursoreAl(dati.campioni, t), clic, () => finestraAl(dati.campioni, t))); + } + return out; +} + +/** + * La camera, un passo da 100 ms alla volta: la usa `camminoCamera` a registrazione finita e l'anteprima in diretta + * (telecomando e LIVE) mentre si registra, così si vede lo stesso zoom che finirà nel montaggio. + */ +export class CameraCursore { + // due stadi per grandezza (molla «criticamente smorzata» alla buona): il secondo toglie gli strappi del primo + private cx1 = 0.5; private cy1 = 0.5; private z1 = 1; + cx = 0.5; cy = 0.5; z = 1; + private tx = 0.5; private ty = 0.5; + private ancora: [number, number] | null = null; private fermoDa = 0; + // l'ultimo istante di movimento forte: lo zoom torna solo dopo un po' di calma + private agitatoA = -Infinity; + private storia: [number, number, number][] = []; + + /** `clic` = un clic recente (o imminente, a registrazione finita); `finestra` si chiede solo da fermi */ + passo(t: number, q: [number, number] | null, clic: { cx: number; cy: number } | undefined, finestra: () => [number, number, number, number] | null): PuntoCamera { + let tz = 1; if (q) { // quanta strada ha fatto il cursore nell'ultimo secondo e mezzo, e quanto va veloce adesso + const storia = this.storia; storia.push([t, q[0], q[1]]); while (storia.length && t - storia[0][0] > 1500) storia.shift(); let strada = 0; for (let i = 1; i < storia.length; i++) strada += Math.hypot(storia[i][1] - storia[i - 1][1], storia[i][2] - storia[i - 1][2]); const recenti = storia.filter((x) => t - x[0] <= 300); const v = recenti.length > 1 ? Math.hypot(q[0] - recenti[0][1], q[1] - recenti[0][2]) / Math.max(0.1, (t - recenti[0][0]) / 1000) : 0; - if (v > 0.45 || strada > 0.6) agitatoA = t; - const calmo = t - agitatoA >= CALMA_MS; - 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; + if (v > 0.45 || strada > 0.6) this.agitatoA = t; + const calmo = t - this.agitatoA >= CALMA_MS; + if (!this.ancora || Math.hypot(q[0] - this.ancora[0], q[1] - this.ancora[1]) > RAGGIO_FERMO) { this.ancora = q; this.fermoDa = t; } + const fermo = t - this.fermoDa >= FERMO_MS; + const w = fermo ? finestra() : null; const ww = w ? w[2] - w[0] : 1, wh = w ? w[3] - w[1] : 1; - if (!calmo) { tx = 0.5; ty = 0.5; tz = 1; } // si muove tanto: tutto lo schermo - else if (clic) { tx = clic.cx; ty = clic.cy; tz = ZOOM_CLIC; } + if (!calmo) { this.tx = 0.5; this.ty = 0.5; tz = 1; } // si muove tanto: tutto lo schermo + else if (clic) { this.tx = clic.cx; this.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; + this.tx = (w[0] + w[2]) / 2; this.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 if (fermo) { this.tx = q[0]; this.ty = q[1]; tz = ZOOM_FERMO; } else { // movimenti lenti: si resta vicini e si segue con una zona morta piccola tz = ZOOM_LENTO; - const meta = 0.2 / 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; + const meta = 0.2 / Math.max(1, this.z); + if (q[0] < this.cx - meta) this.tx = q[0] + meta; else if (q[0] > this.cx + meta) this.tx = q[0] - meta; else this.tx = this.cx; + if (q[1] < this.cy - meta) this.ty = q[1] + meta; else if (q[1] > this.cy + meta) this.ty = q[1] - meta; else this.ty = this.cy; } - } else { ancora = null; storia.length = 0; tx = 0.5; ty = 0.5; } + } else { this.ancora = null; this.storia.length = 0; this.tx = 0.5; this.ty = 0.5; } // allontanarsi è svelto (si vede subito tutto), avvicinarsi è morbido - const kz = 1 - Math.exp(-PASSO_MS / (tz > z1 ? 550 : 260)); + const kz = 1 - Math.exp(-PASSO_MS / (tz > this.z1 ? 550 : 260)); const kc = 1 - Math.exp(-PASSO_MS / 160); - z1 += (tz - z1) * kz; z += (z1 - z) * kz; - cx1 += (tx - cx1) * kc; cy1 += (ty - cy1) * kc; + this.z1 += (tz - this.z1) * kz; this.z += (this.z1 - this.z) * kz; + this.cx1 += (this.tx - this.cx1) * kc; this.cy1 += (this.ty - this.cy1) * kc; // la camera non scatta: al massimo un quadro inquadrato e mezzo al secondo... - const passo = 0.15 / Math.max(1, z), dx = cx1 - cx, dy = cy1 - cy, d = Math.hypot(dx, dy); + const passo = 0.15 / Math.max(1, this.z), dx = this.cx1 - this.cx, dy = this.cy1 - this.cy, d = Math.hypot(dx, dy); const k = d > passo ? passo / d : 1; - cx += dx * k; cy += dy * k; + this.cx += dx * k; this.cy += dy * k; // ...ma il cursore non esce mai dall'inquadratura: se è vicino al bordo la camera lo raggiunge subito - if (q && z > 1.01) { - const bordo = 0.5 / z - 0.06 / z; - if (q[0] < cx - bordo) cx = q[0] + bordo; else if (q[0] > cx + bordo) cx = q[0] - bordo; - if (q[1] < cy - bordo) cy = q[1] + bordo; else if (q[1] > cy + bordo) cy = q[1] - bordo; - cx1 = cx; cy1 = cy; + if (q && this.z > 1.01) { + const bordo = 0.5 / this.z - 0.06 / this.z; + if (q[0] < this.cx - bordo) this.cx = q[0] + bordo; else if (q[0] > this.cx + bordo) this.cx = q[0] - bordo; + if (q[1] < this.cy - bordo) this.cy = q[1] + bordo; else if (q[1] > this.cy + bordo) this.cy = q[1] - bordo; + this.cx1 = this.cx; this.cy1 = this.cy; } - out.push({ tMs: t, cx, cy, z }); + return { tMs: t, cx: this.cx, cy: this.cy, z: this.z }; } - return out; +} + +/** Lo stesso zoom come trasformazione CSS di un elemento che mostra lo schermo intero (anteprima in diretta). */ +export function cssCamera(p: PuntoCamera): string { + const z = Math.max(1, p.z), m = 0.5 / z; + const cx = clamp(p.cx, m, 1 - m), cy = clamp(p.cy, m, 1 - m); + return `scale(${z.toFixed(3)}) translate(${((0.5 - cx) * 100).toFixed(2)}%, ${((0.5 - cy) * 100).toFixed(2)}%)`; } /** diff --git a/src/stile/live.css b/src/stile/live.css index cae1016..816b31d 100644 --- a/src/stile/live.css +++ b/src/stile/live.css @@ -199,3 +199,10 @@ .live-comandi :is(select, input[type=number]) { font: 400 13px var(--mono, 'Share Tech Mono', monospace); } .live-comandi .fin-interruttore { font-size: 15px; line-height: 1.3; } #app .live .live-tc { font-size: 22px; } + +/* anteprima in diretta dello zoom che segue il cursore */ +.live-zoom-anteprima { grid-area: 1 / 1; align-self: end; justify-self: end; margin: 12px; width: clamp(180px, 24%, 360px); aspect-ratio: 16 / 9; overflow: hidden; border: 2px solid var(--cia); border-radius: 6px; background: #000; box-shadow: 0 8px 24px rgba(0, 0, 0, .55); position: relative; z-index: 3; pointer-events: none; } +.live-zoom-anteprima video { width: 100%; height: 100%; object-fit: contain; transform-origin: center; transition: transform .1s linear; } +.live-zoom-etichetta { position: absolute; left: 6px; top: 6px; padding: 1px 6px; border-radius: 4px; background: rgba(5, 4, 7, .8); color: var(--cia); font: 700 11px/1.4 var(--ui); letter-spacing: 2px; } +.tele-video { transform-origin: center; transition: transform .1s linear; } +.tele-quadro { overflow: hidden; } diff --git a/src/ui/live.ts b/src/ui/live.ts index c79a572..8b18188 100644 --- a/src/ui/live.ts +++ b/src/ui/live.ts @@ -33,7 +33,7 @@ import { importaFile } from '../progetti'; import { invoke, isAndroid, isTauri, salvaMediaSulDisco, spazioLibero } from '../platform'; import { CursoreLive } from '../media/cursoreLive'; import { SceltaSchermoLive } from './liveCursore'; -import { applicaZoomCursore, bloccoClicLive } from '../core/zoomCursore'; +import { applicaZoomCursore, bloccoClicLive, CameraCursore, cssCamera } from '../core/zoomCursore'; import { motore } from '../motore'; import { avviso, h, icona } from './dom'; import { Telecomando, type StatoTelecomando } from './telecomando'; @@ -943,6 +943,36 @@ export class Live { this.el.classList.remove('con-cam'); } + /** l'anteprima in diretta dello zoom che segue il cursore: un riquadro nell'anteprima grande e il telecomando */ + private zoomDiretta: { ferma: () => void } | null = null; + private avviaAnteprimaZoom() { + this.fermaAnteprimaZoom(); + const cam = new CameraCursore(), t0 = performance.now(); + let clic: { cx: number; cy: number; t: number } | null = null; + let win: [number, number, number, number] | null = null; + const v = h('video', { muted: true, autoplay: true, playsinline: true }) as HTMLVideoElement; + v.muted = true; v.srcObject = this.anteprima; + const box = h('div', { class: 'live-zoom-anteprima', title: 'Così verrà lo zoom che segue il cursore' }, v, h('span', { class: 'live-zoom-etichetta' }, 'ZOOM')); + this.video.parentElement?.append(box); + let occupato = false; + const giro = window.setInterval(async () => { + if (occupato) return; + occupato = true; + try { + const t = performance.now() - t0; + const ora = await invoke<{ cx: number; cy: number; visible: boolean; clicks: number; win?: [number, number, number, number] } | null>('cursore_ora').catch(() => null); + if (ora?.win) win = ora.win; + if (ora?.visible && ora.clicks) clic = { cx: ora.cx, cy: ora.cy, t }; + if (clic && t - clic.t > 1900) clic = null; + const css = cssCamera(cam.passo(t, ora?.visible ? [ora.cx, ora.cy] : null, clic ?? undefined, () => win)); + v.style.transform = css; + this.tele.zoom(css); + } finally { occupato = false; } + }, 100); + this.zoomDiretta = { ferma: () => { clearInterval(giro); v.srcObject = null; box.remove(); this.tele.zoom(''); } }; + } + private fermaAnteprimaZoom() { this.zoomDiretta?.ferma(); this.zoomDiretta = null; } + private async apriMic(): Promise { if (microfonoProva) return risorseLive.flusso(await microfonoProva()); const f = this.opz.audio.filtri; @@ -1177,6 +1207,7 @@ export class Live { this.fase = 'fermo'; void this.cursoreLive?.ferma().catch(() => {}); this.cursoreLive = null; + this.fermaAnteprimaZoom(); this.chiudiFlussi(); // i registratori già partiti finiscono da soli: i loro file temporanei si tolgono appena sono chiusi const orfani = [...this.pezzi, ...this.segCam.flatMap((g) => g.pezzi), ...this.pezziMic]; @@ -1331,7 +1362,7 @@ export class Live { const monitor = this.sceltaSchermo.scelto; const rapporto = st0.width && st0.height && monitor ? Math.abs(st0.width / st0.height - monitor.width / monitor.height) : Infinity; if (!this.regia && st0.displaySurface === 'monitor' && monitor && rapporto < 0.02) { - try { this.cursoreLive = await CursoreLive.avvia(monitor); } + try { this.cursoreLive = await CursoreLive.avvia(monitor); if (o.zoomCursore) this.avviaAnteprimaZoom(); } catch (e) { avviso('Registro senza dati del cursore: ' + String(e), 'info', 5000); } } else avviso('Registro senza dati del cursore: serve uno schermo intero, scelto anche nelle Altre opzioni di LIVE', 'info', 6000); } @@ -1341,6 +1372,7 @@ export class Live { this.fase = 'fermo'; void this.cursoreLive?.ferma().catch(() => {}); this.cursoreLive = null; + this.fermaAnteprimaZoom(); this.chiudiFlussi(); this.aggiornaTasti(); avviso('Non riesco a registrare: ' + String(e), 'errore', 4000); @@ -1476,6 +1508,7 @@ export class Live { const scelteCursore = { ...this.scelteCursore }; const scene = structuredClone(this.scene); this.cursoreLive = null; + this.fermaAnteprimaZoom(); this.fase = 'fermo'; this.fermaGiro(); this.aggiornaTempo(); diff --git a/src/ui/telecomando.ts b/src/ui/telecomando.ts index f363cab..d1b2836 100644 --- a/src/ui/telecomando.ts +++ b/src/ui/telecomando.ts @@ -67,6 +67,9 @@ export class Telecomando { private conAnteprima = true; private stato: StatoTelecomando | null = null; + /** lo zoom che segue il cursore, in diretta sull'anteprima (trasformazione CSS; '' = quadro intero) */ + zoom(css: string) { this.video.style.transform = css; } + constructor(private c: ComandiTelecomando) { try { this.conAnteprima = localStorage.getItem(CHIAVE) !== 'no'; } catch { /* niente */ } const video = (cls: string) => { const v = h('video', { class: cls, muted: true, autoplay: true, playsinline: true }) as HTMLVideoElement; v.muted = true; return v; };