Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/cursore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ struct Sessione {
#[derive(Default)]
pub struct Cursore(Mutex<Option<Sessione>>);

/// 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<Option<Ora>> = Mutex::new(None);

impl Cursore {
pub fn chiudi(&self) {
if let Ok(mut attiva) = self.0.lock() {
Expand Down Expand Up @@ -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.
Expand All @@ -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())?;
Expand Down Expand Up @@ -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<Ora> {
let mut o = ORA.lock().ok()?;
let v = o.clone();
if let Some(x) = o.as_mut() { x.clicks = 0; }
v
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 55 additions & 34 deletions src/core/zoomCursore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}%)`;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/stile/live.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
37 changes: 35 additions & 2 deletions src/ui/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<MediaStream> {
if (microfonoProva) return risorseLive.flusso(await microfonoProva());
const f = this.opz.audio.filtri;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/ui/telecomando.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; };
Expand Down
Loading