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; };