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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.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",
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.5"
version = "1.8.6"
description = "DaProd Video: il montaggio vecchio stile, moderno dentro"
authors = ["DaProd"]
license = "MIT"
Expand Down
48 changes: 48 additions & 0 deletions src-tauri/src/cursore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Rett> {
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::<Rett>() 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,
Expand Down Expand Up @@ -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<Avviato, String> {
Expand Down Expand Up @@ -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()
Expand All @@ -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())?;
Expand Down
2 changes: 2 additions & 0 deletions src/core/tipi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
164 changes: 130 additions & 34 deletions src/core/zoomCursore.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -71,42 +73,136 @@ 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[] = [];
// 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 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;
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 {
// 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;
}

/**
* 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<number, Transform>();
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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading
Loading