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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,42 @@ Tutte le versioni notevoli del banco di montaggio. Le date sono in formato AAAA-
Ogni versione pubblicata ha la sua [release GitHub](https://github.com/cammo22/DaProdVideo/releases) con le app
per Windows (portatile e installabile), Mac e Android, e va online su [GitHub Pages](https://cammo22.github.io/DaProdVideo/) come versione prova.

## [1.2.1] — 2026-10-07 · Le rifiniture: più sicuro, più preciso, niente sorprese 🔧

Una versione di pulizia: nessuno strumento nuovo, ma tante piccole cose che prima potevano andare storte.

### Salvare senza paura
- **Il progetto si salva in modo sicuro** (nell'app): si scrive accanto e poi si sostituisce, così se il programma o il computer si fermano a metà il progetto di prima resta intero. Prima il file si svuotava e si riscriveva: un'interruzione poteva lasciarlo rovinato.
- **Se il salvataggio non riesce lo dice** (disco pieno, cartella protetta, chiavetta tolta), con l'invito a usare *Salva come…*. Prima non compariva niente e sembrava salvato.
- **Ricollegare i file mancanti ora resta nel progetto**: il percorso nuovo si salva (prima, chiudendo senza altre modifiche, al giro dopo il file risultava di nuovo mancante).
- **Una modifica che si rompe a metà non lascia il montaggio mezzo cambiato**: si torna a com'era.
- L'autosalvataggio scrive solo quando è cambiato qualcosa (prima riscriveva tutto il progetto ogni 15 secondi finché non salvavi).
- Nel browser: **"Nuovo progetto" non butta più i file dei progetti recenti** (riaprendoli dalla pagina iniziale mancavano), e le copie dei progetti usciti dall'elenco si tolgono da sole. Togliere un file dal contenitore e poi Ctrl+Z lo ritrova anche dopo aver riaperto il browser.

### Montaggio più preciso
- **Tagliare (tasto 1) una clip che si muove** — per esempio una foto col Ken Burns di DaProdMontage, o una clip con le tappe — ora **continua il movimento dov'era**: il pezzo di sinistra fa la prima parte della strada e quello di destra il resto. Prima tutti e due ripartivano da capo e l'immagine "tornava indietro" sul taglio.
- **Copia e incolla portano anche gli effetti e le transizioni** che stanno sulla clip (e la seguono anche se finisce su un'altra traccia). Prima restavano indietro.
- **Abbina fotogramma (F)** porta la sorgente al fotogramma giusto anche nelle clip accelerate o rallentate.
- Dopo il taglio (tasto 1) viene scelto sempre un pezzo di clip, mai un blocchetto FX che finiva lì per caso (premendo 2 spariva l'effetto invece dello scarto).
- **Loop (Ctrl+L) senza attacco e stacco**: rifà tutto il montaggio da capo (prima si fermava in fondo).

### Riproduzione
- **Niente più lampi neri durante il play**: quando la lettura del video ripartiva a metà (un salto al fotogramma chiave più avanti, o la copia leggera che diventa pronta mentre suona) per un attimo il monitor diventava nero o tornava al fotogramma di quando avevi premuto play. Ora resta l'ultimo fotogramma vero finché arriva il nuovo.

### Export
- **L'audio esportato non ha più cuciture ogni 10 secondi**: il mixaggio si fa a pezzi, e a ogni pezzo filtri (voce, radio, ovattato), eco e limitatore ripartivano da zero, con un piccolo scatto e la coda dell'eco tagliata. Ora ogni pezzo parte "già caldo".
- **La finestra Esporta resta aperta mentre lavora**: Esc o un clic fuori la chiudevano lasciando l'export a girare senza modo di fermarlo. Si ferma col tasto Annulla.
- **"Fatto" chiude la finestra** a export finito (prima lo faceva ripartire da capo, con la richiesta di dove salvare).

### Tasti e pagine
- **Ctrl+Alt+A aggiunge di nuovo una traccia audio**: lo stesso tasto era preso anche dall'animazione, che ora sta su **Ctrl+Alt+N**.
- **Con la pagina iniziale aperta i tasti non toccano il montaggio che sta sotto** (prima Spazio lo faceva suonare e 2 toglieva una clip senza vederla). Valgono Esc, Ctrl+O e F11.
- Sul telefono gli avvisi stanno sopra la barra delle attività e i pulsanti in basso (prima li coprivano).
- Le copie leggere (proxy) in pausa perché il montaggio suona ora si fermano subito col ✕ del centro attività.

### Per chi ha curiosità
- Prove nuove in `test/prove-rifiniture.mjs` (girano dentro `node test/prove.mjs`, o da sole con `node test/prove-nuove.mjs rifiniture`): falliscono sulla 1.2.0 e passano qui.

## [1.2.0] — 2026-10-03 · La prima release ufficiale: più veloce, più chiaro, la barra delle attività ⚡

Questa versione non aggiunge strumenti: rende **più fluido e più chiaro quello che c'è già**.
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ Editor video "vecchio stile, moderno dentro" (ispirato a EDIUS e alle centraline
- **Conversione VFR/CBR dietro le quinte (1.2.0)**: `controllaVelocita` in `progetti.ts` (dopo l'import: esame nel gruppo "esame", conversione nella corsia pesante) e `mettiCopia`: la copia
prende il posto del file con `store.aggiornaMedia(id, campi, spostaSrc)` (cambia progetto **e** cronologia annulla/ripeti, e sposta `srcIn` se l'inizio del file è cambiato), poi `apriMedia` di nuovo.
`importa(sel, {soloDescrizione:true})` legge com'è fatto un file senza locandina né lavori. Nel browser la copia si tiene in IndexedDB `file` (≤ 300 MB) e si toglie la maniglia.
- **Rifiniture (1.2.1)**: `store.edit` rimette il progetto com'era se la modifica lancia un errore; `store.revisione` sale a ogni
modifica e `autosalva` scrive solo se è cambiata. `progetto_scrivi` (Rust) scrive `<file>.salvo.tmp` e rinomina. `splitClip` divide anche
il movimento (`splitMovimento`: tf/tappe/tfFine ripartiti fra i due pezzi). `mixaggio` rende ogni pezzo da 10 s con 1,5 s prima
(`SCALDA`, poi buttati) così filtri, eco e limitatore non ripartono a ogni cucitura. `dialogo({chiudibile: () => bool})` = si chiude con
Esc/✕/clic fuori solo quando dice sì (Esporta mentre lavora). Con la pagina iniziale aperta (`html.home-aperta`) i tasti del montaggio
non passano. Nel browser `potaDeposito` (progetti.ts) tiene in IndexedDB solo i file del montaggio aperto e dei recenti. Copia/incolla
porta i blocchetti FX della clip (`padreDi`). In `fotogrammi.ts` un flusso buttato a metà play lascia il suo ultimo fotogramma in
`ponti` finché il nuovo non ne ha uno (niente nero). Prove: `test/prove-rifiniture.mjs`; nessun tasto deve stare su due comandi (lo controlla la prova).
- **Cursore che si aggancia** (`agganciaCursore` in `src/ui/timeline.ts`): sul righello si aggancia sempre (calamita `N`), Alt lo lascia libero.
- **Motore NVIDIA** (`src-tauri/src/motori.rs` + `src/media/nemo.ts`): NeMo-Speech.cpp v0.1.0 (Apache-2.0), programma a parte che l'app
scarica dalla release di NVIDIA (`nemo-speech-0.1.0-{windows,macos,linux}-{x86_64,aarch64}-{cpu,cuda,vulkan,metal}`, `.sha256` a fianco) in
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.2.0",
"version": "1.2.1",
"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.2.0"
version = "1.2.1"
description = "DaProd Video: il montaggio vecchio stile, moderno dentro"
authors = ["DaProd"]
license = "MIT"
Expand Down
21 changes: 20 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,28 @@ async fn progetto_leggi(app: AppHandle, path: String) -> Esito<String> {
.map_err(|e| format!("non riesco a leggere {path}: {e}"))
}

/// Scrive un progetto, una EDL o un altro file di testo.
/// Scrive un progetto, una EDL o un altro file di testo. Sul disco vero si scrive accanto e poi si rinomina: se il
/// programma o il computer si fermano a metà, il progetto di prima resta intero (prima si troncava e si riscriveva).
#[tauri::command]
async fn progetto_scrivi(app: AppHandle, path: String, text: String) -> Esito<()> {
if let FilePath::Path(p) = percorso(&path) {
let mut nome = p.file_name().map(|n| n.to_os_string()).unwrap_or_default();
nome.push(".salvo.tmp");
let tmp = p.with_file_name(nome);
let scritto = (|| -> std::io::Result<()> {
let mut f = std::fs::File::create(&tmp)?;
f.write_all(text.as_bytes())?;
f.sync_all()?;
std::fs::rename(&tmp, &p)
})();
match scritto {
Ok(()) => return Ok(()),
// cartelle dove non si possono creare file accanto (o rinominare): si scrive come prima
Err(_) => {
let _ = std::fs::remove_file(&tmp);
}
}
}
let mut o = OpenOptions::new();
o.write(true).create(true).truncate(true);
let mut f = app
Expand Down
40 changes: 30 additions & 10 deletions src/azioni.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { store } from './core/store';
import { motore } from './motore';
import * as M from './core/montaggio';
import { clipById, end, isVideoClip, newClip, newTrack, nextTrackName, projectEnd, TITLE0, trackOf, uid } from './core/progetto';
import { clipById, end, isVideoClip, newClip, newTrack, nextTrackName, projectEnd, srcTimeAt, TITLE0, trackOf, uid } from './core/progetto';
import { fps, frameToTc, s2f } from './core/timecode';
import { avviso } from './ui/dom';
import { finestraVelocita } from './ui/velocita';
Expand All @@ -12,7 +12,7 @@ import { STILI_CONTO, type StileConto } from './render/grafica';
import { applicaPresetTitolo, presetTitolo } from './core/generatori';
import { suonoTitolo } from './core/suoni';
import { ANIMAZIONI, animazione, nuovaAnim } from './core/animazioni';
import { durataBlocco, nomeBlocco, nuovoBlocco, posaBlocco, postoBlocco, taglioVicino, tracciaPerBlocco, transizioniSul } from './core/blocchi';
import { blocchiDi, durataBlocco, nomeBlocco, nuovoBlocco, posaBlocco, postoBlocco, taglioVicino, tracciaPerBlocco, transizioniSul } from './core/blocchi';

export interface Azione {
id: string;
Expand Down Expand Up @@ -109,7 +109,7 @@ reg({
const p = store.doc;
const peso = (c: Clip) => (isVideoClip(c) ? (c.kind === 'media' ? 0 : 1000) : 2000) + p.tracks.findIndex((t) => t.id === c.track);
const dx = destre.map((id) => clipById(p, id)!).filter(Boolean).sort((a, b) => peso(a) - peso(b))[0];
const sx = p.clips.find((c) => c.track === dx.track && end(c) === f && c.id !== dx.id);
const sx = p.clips.find((c) => c.track === dx.track && end(c) === f && c.id !== dx.id && M.solida(c));
const corto = sx && f - sx.start < dx.len ? sx : dx;
store.select(M.withLinked(p, [corto.id]));
avviso(`✂ Taglio a ${frameToTc(f, p.rate, p.drop)} · selezionato il pezzo ${corto === dx ? 'di destra' : 'di sinistra'} (più corto): 2 per toglierlo`, 'tasto', 1800);
Expand Down Expand Up @@ -258,9 +258,20 @@ reg({ id: 'annulla', nome: 'Annulla', gruppo: 'Modifica', tasti: ['Ctrl+Z'], fn:
reg({ id: 'ripeti', nome: 'Ripeti', gruppo: 'Modifica', tasti: ['Ctrl+Y', 'Ctrl+Shift+Z'], fn: () => { const l = store.doRedo(); avviso(l ? `↷ Ripetuto: ${l}` : 'Niente da ripetere', 'info', 1200); } });

let appunti: Clip[] = [];
/** per ogni blocchetto FX copiato, la clip su cui stava: incollando la segue (anche se finisce su un'altra traccia) */
let padreDi = new Map<string, string>();
reg({
id: 'copia', nome: 'Copia clip', gruppo: 'Modifica', tasti: ['Ctrl+C'],
fn: () => { const ids = M.withLinked(store.doc, store.sel); appunti = structuredClone(store.doc.clips.filter((c) => ids.has(c.id))); if (appunti.length) avviso(`${appunti.length} clip copiate`, 'info', 1000); },
fn: () => {
const p = store.doc;
const ids = M.withLinked(p, store.sel);
// gli effetti e le transizioni che stanno sulle clip copiate vanno con loro (prima restavano indietro)
padreDi = new Map();
for (const c of p.clips) if (ids.has(c.id) && M.solida(c)) for (const b of blocchiDi(p, c)) { ids.add(b.id); padreDi.set(b.id, c.id); }
appunti = structuredClone(p.clips.filter((c) => ids.has(c.id)));
const n = appunti.filter(M.solida).length || appunti.length;
if (appunti.length) avviso(`${n} clip copiat${n === 1 ? 'a' : 'e'}`, 'info', 1000);
},
});
reg({
id: 'tagliaAppunti', nome: 'Taglia clip negli appunti', gruppo: 'Modifica', tasti: ['Ctrl+X'],
Expand All @@ -274,12 +285,14 @@ reg({
const min = Math.min(...appunti.map((c) => c.start));
const ids = store.edit('Incolla', (p) => {
const links = new Map<string, string>();
const daVecchio = new Map<string, Clip>();
const nuove = appunti.map((c) => {
const n = structuredClone(c);
n.id = uid('c');
n.start = c.start - min + f;
if (c.link) { if (!links.has(c.link)) links.set(c.link, uid('l')); n.link = links.get(c.link); }
if (!p.tracks.some((t) => t.id === n.track)) n.track = p.tracks.find((t) => t.kind === (c.kind === 'fx' || isVideoClip(c) ? 'video' : 'audio'))!.id;
daVecchio.set(c.id, n);
return n;
});
const set = new Set(nuove.map((c) => c.id));
Expand All @@ -289,11 +302,17 @@ reg({
M.insertSpace(p, a, b - a, new Set(p.tracks.filter((t) => !t.lock).map((t) => t.id)), set);
} else {
// niente viene coperto: se la traccia è occupata la clip va su una libera
for (const c of nuove) {
// i blocchetti FX stanno sopra le clip: restano sulla loro traccia
if (c.kind !== 'fx') c.track = M.tracciaLibera(p, trackOf(p, c.track).kind, c.track, c.start, end(c));
for (const c of nuove.filter(M.solida)) {
c.track = M.tracciaLibera(p, trackOf(p, c.track).kind, c.track, c.start, end(c));
p.clips.push(c);
}
// i blocchetti FX stanno sopra le clip: seguono la loro clip (o restano sulla loro traccia)
for (const [vecchio, n] of daVecchio) {
if (M.solida(n)) continue;
const padre = daVecchio.get(padreDi.get(vecchio) ?? '');
if (padre) n.track = padre.track;
p.clips.push(n);
}
}
return [...set];
});
Expand Down Expand Up @@ -334,7 +353,7 @@ reg({
id: 'fine', nome: 'Alla fine', gruppo: 'Trasporto', tasti: ['End'],
fn: () => { if (motore.attivo === 'recorder') motore.vaiA(projectEnd(store.doc)); else { const m = store.doc.media.find((x) => x.id === motore.playerMedia); if (m) motore.playerVaiA(m.duration); } },
});
reg({ id: 'loop', nome: 'Riproduzione in loop (attacco-stacco)', gruppo: 'Trasporto', tasti: ['Ctrl+L'], fn: () => { motore.loop = !motore.loop; avviso(motore.loop ? '⟳ Loop acceso' : 'Loop spento', 'info', 1000); store.emit('status'); } });
reg({ id: 'loop', nome: 'Riproduzione in loop (attacco-stacco, o tutto il montaggio)', gruppo: 'Trasporto', tasti: ['Ctrl+L'], fn: () => { motore.loop = !motore.loop; avviso(motore.loop ? '⟳ Loop acceso' : 'Loop spento', 'info', 1000); store.emit('status'); } });
reg({
id: 'monitor', nome: 'Monitor: sorgente ↔ montaggio', gruppo: 'Trasporto', tasti: ['Tab'],
info: 'Il monitor mostra il montaggio; con doppio clic su un file del contenitore mostra la sorgente. Tab passa dall\'uno all\'altro.',
Expand Down Expand Up @@ -463,7 +482,8 @@ reg({
fn: () => {
const c = M.topClipAt(store.doc, head(), 'video') ?? M.topClipAt(store.doc, head());
if (!c?.media) { avviso('Sotto il cursore non c\'è una sorgente', 'info'); return; }
const t = c.srcIn + (head() - c.start) / r();
// la sorgente allo stesso fotogramma anche nelle clip accelerate o rallentate
const t = srcTimeAt(store.doc, c, head());
motore.caricaPlayer(c.media, t);
motore.setMonitor('player');
avviso('La sorgente nel monitor allo stesso fotogramma (Tab per tornare al montaggio)', 'info');
Expand Down Expand Up @@ -587,6 +607,6 @@ reg({ id: 'genNero', nome: 'Nero', gruppo: 'Generatori', fn: () => inserisciGene
reg({ id: 'genColore', nome: 'Colore pieno', gruppo: 'Generatori', fn: () => inserisciGeneratore('color') });
reg({ id: 'genCountdown', nome: 'Countdown da pellicola', gruppo: 'Generatori', tasti: ['Ctrl+Alt+C'], fn: () => inserisciGeneratore('countdown') });
reg({ id: 'genTitolo', nome: 'Titolo', gruppo: 'Generatori', tasti: ['T', 'Ctrl+T'], fn: () => inserisciGeneratore('title') });
reg({ id: 'genAnimazione', nome: 'Animazione (sottopancia, testo che si muove…)', gruppo: 'Generatori', tasti: ['Ctrl+Alt+A'], fn: () => inserisciGeneratore('anim') });
reg({ id: 'genAnimazione', nome: 'Animazione (sottopancia, testo che si muove…)', gruppo: 'Generatori', tasti: ['Ctrl+Alt+N'], fn: () => inserisciGeneratore('anim') });

export { head as cursore };
26 changes: 25 additions & 1 deletion src/core/montaggio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// I nomi sono quelli della centralina: taglia, elimina, elimina e chiudi, solleva (lift), estrai (extract),
// inserisci, sovrascrivi, trim, roll, slip.
import type { Clip, Key, Project, TrackKind, Transition } from './tipi';
import { clipsOn, end, handles, keyValue, mediaOf, newClip, newTrack, nextTrackName, srcTimeAt, trackOf, uid, isVideoClip } from './progetto';
import { clipsOn, end, handles, keyValue, mediaOf, newClip, newTrack, nextTrackName, srcTimeAt, tfAl, trackOf, uid, isVideoClip } from './progetto';
import { f2s } from './timecode';
import { blocchiDi, centro } from './blocchi';

Expand All @@ -22,6 +22,29 @@ function splitKeys(keys: Key[], cut: number, base: number): [Key[], Key[]] {
return [left, right];
}

/**
* Il movimento (tf → tappe → tfFine) diviso al fotogramma locale lf: ognuno dei due pezzi fa solo la sua parte
* di strada (prima si ripartiva da capo in tutti e due: il Ken Burns di una foto tagliata tornava indietro).
*/
function splitMovimento(c: Clip, right: Clip, lf: number) {
const n = Math.max(1, c.len - 1), rlen = c.len - lf;
const fine = { ...c.tf, ...tfAl(c, lf - 1) }, inizio = { ...c.tf, ...tfAl(c, lf) };
const via = c.via ?? [];
// a destra: dal punto del taglio all'arrivo di prima
right.tf = inizio;
if (rlen > 1) {
right.tfFine = structuredClone(c.tfFine);
right.via = via.map((v) => ({ t: (v.t * n - lf) / (rlen - 1), tf: structuredClone(v.tf) })).filter((v) => v.t > 0.001 && v.t < 0.999);
} else { right.tfFine = undefined; right.via = undefined; }
// a sinistra: dalla partenza a dove era l'ultimo fotogramma prima del taglio
if (lf > 1) {
c.via = via.map((v) => ({ t: (v.t * n) / (lf - 1), tf: v.tf })).filter((v) => v.t > 0.001 && v.t < 0.999);
c.tfFine = fine;
} else { c.tf = fine; c.tfFine = undefined; c.via = undefined; }
if (!c.via?.length) c.via = undefined;
if (!right.via?.length) right.via = undefined;
}

/** taglia una clip al fotogramma f: ritorna il pezzo di destra (nuovo) o null */
export function splitClip(p: Project, c: Clip, f: number, newLink?: string): Clip | null {
if (f <= c.start || f >= end(c)) return null;
Expand All @@ -36,6 +59,7 @@ export function splitClip(p: Project, c: Clip, f: number, newLink?: string): Cli
right.link = newLink ?? c.link;
[c.opKeys, right.opKeys] = splitKeys(c.opKeys, lf, c.opacity);
[c.gainKeys, right.gainKeys] = splitKeys(c.gainKeys, lf, c.gain);
if (c.tfFine) splitMovimento(c, right, lf);
c.len = lf;
c.fadeOut = 0;
if (c.trIn && c.trIn.len > c.len) c.trIn.len = c.len;
Expand Down
Loading
Loading