Sei moduli avanzati per la produzione cinematografica

Estende il motore con stabilizzazione sub-pixel, deflicker per regioni,
transizioni giorno-notte, movimento di macchina virtuale, rimappatura non
lineare del tempo e accumulo temporale. Tutto in-house, nessuna dipendenza
aggiunta: il progetto continua a non contenere un solo PackageReference.

Perche' questa forma. Il rendering non percorre piu' la sequenza sorgente ma
un piano di fotogrammi d'uscita, ognuno con posizione anche frazionaria e
durata propria. Le quattro modalita' temporali producono tutte quella stessa
forma, quindi il ciclo di rendering e' uno solo e non ha un ramo per ciascun
caso; da li' discende anche la sfocatura, perche' un fotogramma che copre v
scatti ha angolo di otturatore diviso v.

Gli spostamenti si misurano con la correlazione di fase, che ignora per
costruzione le differenze di luminosita' fra scatti - in un time-lapse ci sono
sempre - e reagisce alla sola geometria. Il picco intero non basta: la
superficie di correlazione viene ricostruita a passo fine valutando la somma
di Fourier sulle posizioni intermedie invece di interpolare con una parabola
tre campioni di una cresta che parabola non e'. L'errore misurato scende da
0,14 a 0,08 px.

I gradini di esposizione non sono rumore da mediare: l'ampiezza si legge
esatta nei metadati e viene ridistribuita su una transizione a derivata nulla
agli estremi. Il deflicker lavora poi su una serie gia' priva di gradini,
invece di trasformare lo scalino in una rampa con due spigoli.

La maschera delle regioni nasce dalla mediana temporale di un campione di
fotogrammi, che toglie di mezzo proprio le nuvole di passaggio, e la linea
d'orizzonte viene agganciata al massimo del gradiente verticale. Sulla scena
di prova il terreno passa da 0,062 a 0,026 stop di oscillazione.

Ritaglio virtuale e correzione di stabilizzazione sono entrambi affini e
vengono composti in una sola trasformazione: due ricampionamenti in fila
costerebbero il doppio di nitidezza senza dare nulla in cambio.

Sulla memoria: i moduli avanzati hanno rotto l'assunto che bastassero due
fotogrammi vivi alla volta, quindi il disco entra ora in gioco - come annotato
nel commit precedente, e' questa la porta che si apriva. La finestra attiva
resta sempre in memoria perche' la mediana ha bisogno di tutti i suoi
fotogrammi insieme; solo la lettura in anticipo viene parcheggiata su disco
oltre il tetto, e ripresa una volta sola. Non esiste un caso in cui lo stesso
fotogramma vada e torni piu' volte. Il file di parcheggio si cancella da se'.

Verifica: da 26 a 51 controlli. Nessuna soglia scelta a posteriori - il
tremolio ha un percorso noto, il gradino un'ampiezza dichiarata nei metadati e
visibile nei pixel, la nuvola attraversa il solo cielo. Il controllo
conclusivo rende una sequenza con tutti i moduli attivi insieme e tetto di
memoria volutamente stretto, poi la rilegge con il lettore di sistema.
Verificato anche sui DNG GoPro reali: 580 file, render di prova conforme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 16:55:27 +02:00
co-authored by Claude Opus 5
parent 332f02d17d
commit a815a91006
33 changed files with 6167 additions and 523 deletions
+412 -51
View File
@@ -1,13 +1,15 @@
using Titano.Analysis;
using Titano.Motion;
using Titano.Pipeline;
using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Pannello di configurazione avanzata, suddiviso nelle tre sezioni richieste:
/// Generale, Elaborazione immagini (deflicker, motion blur, campo vettoriale) ed
/// Esportazione video. Ogni controllo scrive direttamente nel progetto e segnala
/// quale parte della pipeline va ricalcolata.
/// Pannello di configurazione avanzata, diviso nelle sezioni Generale, Immagine, Movimento,
/// Tempo ed Esportazione. Ogni controllo scrive direttamente nel progetto e dichiara quale
/// parte della pipeline va rifatta: c'è differenza fra spostare un cursore che cambia solo
/// una curva già calcolata e sceglierne uno che obbliga a rileggere ogni file.
/// </summary>
internal sealed class SettingsPanel : Panel
{
@@ -17,9 +19,19 @@ internal sealed class SettingsPanel : Panel
private readonly TitanoProject _project;
private readonly TabStrip _tabs;
private readonly Panel[] _pages;
private LabeledCombo? _orientationCombo;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</summary>
private LabeledCombo? _orientationCombo;
private Label? _analysisNote;
private CameraEditor? _cameraEditor;
private SplineEditor? _rampEditor;
private ParameterSlider? _keyframeCentreX;
private ParameterSlider? _keyframeCentreY;
private ParameterSlider? _keyframeZoom;
private ParameterSlider? _keyframeEaseOut;
private ParameterSlider? _keyframeEaseIn;
private bool _syncingKeyframe;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare le curve già misurate.</summary>
public event EventHandler? DeflickerChanged;
/// <summary>È cambiato un parametro che invalida l'analisi già svolta.</summary>
@@ -38,7 +50,10 @@ internal sealed class SettingsPanel : Panel
BackColor = Theme.Surface;
Padding = new Padding(0);
_tabs = new TabStrip("Generale", "Elaborazione immagini", "Esportazione") { Dock = DockStyle.Top };
_tabs = new TabStrip("Generale", "Immagine", "Movimento", "Tempo", "Esportazione")
{
Dock = DockStyle.Top,
};
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
OutputPathBox = new TextBox
@@ -58,7 +73,7 @@ internal sealed class SettingsPanel : Panel
// L'ordine di inserimento determina l'ordine di ancoraggio: i controlli in coda alla
// collezione vengono disposti per primi, quindi la barra delle schede va aggiunta
// dopo le pagine per riservarsi la propria fascia in alto.
_pages = [BuildGeneralPage(), BuildImagePage(), BuildExportPage()];
_pages = [BuildGeneralPage(), BuildImagePage(), BuildMotionPage(), BuildTimePage(), BuildExportPage()];
foreach (var page in _pages)
{
page.Dock = DockStyle.Fill;
@@ -82,7 +97,52 @@ internal sealed class SettingsPanel : Panel
_orientationCombo.Combo.SelectedIndex = selected;
}
/// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary>
/// <summary>Aggiorna le voci che dipendono dall'analisi: maschera, transizioni, tremolio.</summary>
public void ShowAnalysis()
{
if (_analysisNote is null) return;
var lines = new List<string>();
if (_project.Regions.Mode != RegionMode.Off)
{
lines.Add(_project.Mask is { } mask
? "Regioni: " + mask.Description
: "Regioni: la scena non si divide in modo utile, resta la curva unica.");
}
if (_project.HolyGrail.Enabled && _project.Transitions is { } transitions)
{
lines.Add(transitions.StepCount == 0
? "Transizioni: nessun cambio di impostazione rilevato."
: $"Transizioni: {transitions.StepCount} cambi di impostazione, " +
$"il maggiore di {transitions.LargestStepStops:0.00} EV" +
(transitions.MetadataUsable ? " (dai metadati)." : " (dedotti dalla luminanza)."));
}
if (_project.Stabilization.Enabled && _project.Motion is { } motion)
{
var (width, _) = _project.ResolveSourceSize();
lines.Add($"Stabilizzazione: tremolio medio {motion.MeanShake * Math.Max(1, width):0.0} px, " +
$"ritaglio necessario {(_project.StabilizationZoom - 1) * 100:0.#}%.");
}
_analysisNote.Text = lines.Count > 0
? string.Join(Environment.NewLine, lines)
: "Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.";
LayoutNote(_analysisNote);
}
/// <summary>Aggiorna l'editor del movimento quando cambia la sequenza caricata.</summary>
public void ShowSequenceGeometry()
{
if (_cameraEditor is null) return;
var (width, height) = _project.ResolveNativeSize();
if (width > 0 && height > 0) _cameraEditor.Aspect = width / (double)height;
_cameraEditor.Invalidate();
}
/// <summary>Seleziona una delle sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
private void ShowPage(int index)
@@ -90,7 +150,7 @@ internal sealed class SettingsPanel : Panel
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
}
// ------------------------------------------------------------------ pagine
// ------------------------------------------------------------------ Generale
private Panel BuildGeneralPage()
{
@@ -140,11 +200,11 @@ internal sealed class SettingsPanel : Panel
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura e " +
"risoluzione della passata fotometrica, e sovrascrive i cursori delle sezioni " +
"avanzate. Più qualità significa più tempo, non un file più grande."));
stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura, " +
"riquadri della correlazione di fase e risoluzione delle passate di analisi, " +
"e sovrascrive i cursori delle sezioni avanzate."));
stack.Add(new SectionHeader("Prestazioni"));
stack.Add(new SectionHeader("Prestazioni e memoria"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
value =>
{
@@ -159,24 +219,40 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Note("Le decodifiche simultanee determinano anche quanti fotogrammi restano " +
"contemporaneamente in memoria: l'occupazione non dipende dalla lunghezza della sequenza."));
stack.Add(Slider("Lettura in anticipo", 0, 32, _project.Cache.PrefetchDepth, 1, "0", "fotogrammi",
value => _project.Cache.PrefetchDepth = (int)value));
stack.Add(Slider("Tetto di memoria per i fotogrammi", 256, 32768, _project.Cache.MemoryBudgetMiB, 256, "0", "MiB",
value => _project.Cache.MemoryBudgetMiB = (int)value));
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _project.Cache.AllowDiskSpill,
value => _project.Cache.AllowDiskSpill = value));
stack.Add(Note("La finestra attiva sta sempre in memoria; il tetto governa la lettura in " +
"anticipo, che serve a tenere occupati tutti i processori sulla decodifica dei " +
"RAW. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono ripresi " +
"una volta sola. Il file di parcheggio si cancella da sé alla chiusura."));
stack.Add(new SectionHeader("Esito dell'analisi"));
_analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.");
stack.Add(_analysisNote);
return stack.Panel;
}
// ------------------------------------------------------------------ Immagine
private Panel BuildImagePage()
{
var stack = NewStack();
// ---- Deflicker
stack.Add(new SectionHeader("Deflicker"));
var deflickerEnabled = Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
stack.Add(Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
{
_project.Deflicker.Enabled = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
});
stack.Add(deflickerEnabled);
}));
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
@@ -193,12 +269,6 @@ internal sealed class SettingsPanel : Panel
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Stabilizza il bilanciamento colore", _project.Deflicker.StabilizeColor, value =>
{
_project.Deflicker.StabilizeColor = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
{
_project.Deflicker.ProtectHighlights = value;
@@ -208,7 +278,182 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty,
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
// ---- Motion blur
// ---- Regioni
stack.Add(new SectionHeader("Deflicker per regioni"));
stack.Add(Combo("Divisione del fotogramma",
["Nessuna — una sola curva", "Cielo e paesaggio (linea d'orizzonte)", "Per luminanza"],
_project.Regions.Mode switch { RegionMode.SkyGround => 1, RegionMode.Luminance => 2, _ => 0 },
index =>
{
_project.Regions.Mode = index switch
{
1 => RegionMode.SkyGround,
2 => RegionMode.Luminance,
_ => RegionMode.Off,
};
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Indipendenza delle regioni", 0, 1, _project.Regions.Independence, 0.05, "0.00", string.Empty,
value => { _project.Regions.Independence = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Sfumatura del confine", 0.01, 0.20, _project.Regions.Feather, 0.01, "0.00", string.Empty,
value => { _project.Regions.Feather = value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Fotogrammi campionati", 3, 48, _project.Regions.SampleFrames, 1, "0", string.Empty,
value => { _project.Regions.SampleFrames = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La maschera nasce dalla mediana temporale di un campione di fotogrammi, che " +
"toglie di mezzo nuvole e passanti e lascia la struttura fissa della scena. " +
"Serve a impedire che il transito di una nuvola densa sul cielo faccia " +
"schiarire anche il paesaggio, che invece non è cambiato."));
// ---- Holy Grail
stack.Add(new SectionHeader("Transizioni giorno-notte"));
stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled, value =>
{
_project.HolyGrail.Enabled = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Lunghezza della transizione", 4, 240, _project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
value => { _project.HolyGrail.TransitionFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Soglia di riconoscimento", 0.05, 1.0, _project.HolyGrail.StepThresholdStops, 0.05, "0.00", "EV",
value => { _project.HolyGrail.StepThresholdStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Ricava i salti dai metadati", _project.HolyGrail.UseMetadata, value =>
{
_project.HolyGrail.UseMetadata = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Liscia il bilanciamento del bianco", _project.HolyGrail.SmoothColor, value =>
{
_project.HolyGrail.SmoothColor = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0", "fotogrammi",
value => { _project.HolyGrail.ColorWindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità cromatica", 0, 1, _project.HolyGrail.ColorStrength, 0.05, "0.00", string.Empty,
value => { _project.HolyGrail.ColorStrength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni " +
"salto è nota senza incertezza e viene ridistribuita su una transizione a " +
"derivata nulla agli estremi. La lisciatura cromatica agisce sul rapporto fra " +
"i canali: toglie il tremolio del bilanciamento automatico e lascia intatto " +
"il viaggio verso il caldo del tramonto."));
return stack.Panel;
}
// ------------------------------------------------------------------ Movimento
private Panel BuildMotionPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Stabilizzazione sub-pixel"));
stack.Add(Check("Compensa i micro-urti", _project.Stabilization.Enabled, value =>
{
_project.Stabilization.Enabled = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra del percorso", 5, 151, _project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
value => { _project.Stabilization.SmoothingFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.Stabilization.Strength, 0.05, "0.00", string.Empty,
value => { _project.Stabilization.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005, "0.000", "×L",
value => { _project.Stabilization.MaxCorrectionFraction = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation, value =>
{
_project.Stabilization.CompensateRotation = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Lato dei riquadri di correlazione", 64, 512, _project.Stabilization.PatchSize, 64, "0", "px",
value =>
{
// La trasformata vuole una potenza di due: il cursore si muove per gradini validi.
_project.Stabilization.PatchSize = Fourier.FloorPowerOfTwo((int)value);
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Riquadri per lato", 1, 5, _project.Stabilization.Grid, 1, "0", string.Empty,
value => { _project.Stabilization.Grid = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("Lo spostamento fra fotogrammi adiacenti si misura con la correlazione di " +
"fase, che ignora le differenze di luminosità e reagisce alla sola geometria. " +
"Il percorso ricostruito viene lisciato: quello che resta fra percorso vero e " +
"percorso liscio è il tremolio, e la sua inversa è la correzione. Una " +
"panoramica voluta sopravvive perché è già liscia."));
// ---- Virtual camera
stack.Add(new SectionHeader("Movimento di macchina virtuale"));
stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled, value =>
{
_project.Camera.Enabled = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 210 };
_cameraEditor.Changed += (_, _) =>
{
SyncKeyframeControls();
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
_cameraEditor.SelectionChanged += (_, _) => SyncKeyframeControls();
stack.Add(_cameraEditor);
var keyframeButtons = new Panel { Height = 32, BackColor = Theme.Surface };
var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 130, Height = 28, Left = 0, Top = 0 };
var removeButton = new DarkButton { Text = "Togli nodo", Width = 118, Height = 28, Left = 138, Top = 0 };
addButton.Click += (_, _) => { _cameraEditor.AddKeyframe(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
removeButton.Click += (_, _) => { _cameraEditor.RemoveSelected(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
keyframeButtons.Controls.Add(addButton);
keyframeButtons.Controls.Add(removeButton);
stack.Add(keyframeButtons);
_keyframeCentreX = Slider("Nodo — centro orizzontale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
value => UpdateSelectedKeyframe(k => k.CentreX = value));
_keyframeCentreY = Slider("Nodo — centro verticale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
value => UpdateSelectedKeyframe(k => k.CentreY = value));
_keyframeZoom = Slider("Nodo — ingrandimento", 1, 8, 1, 0.05, "0.00", "×",
value => UpdateSelectedKeyframe(k => k.Zoom = value));
_keyframeEaseOut = Slider("Nodo — indugio in partenza", 0, 1, 0.42, 0.02, "0.00", string.Empty,
value => UpdateSelectedKeyframe(k => k.EaseOut = value));
_keyframeEaseIn = Slider("Nodo — frenata in arrivo", 0, 1, 0.42, 0.02, "0.00", string.Empty,
value => UpdateSelectedKeyframe(k => k.EaseIn = value));
stack.Add(_keyframeCentreX);
stack.Add(_keyframeCentreY);
stack.Add(_keyframeZoom);
stack.Add(_keyframeEaseOut);
stack.Add(_keyframeEaseIn);
stack.Add(Check("Tieni l'inquadratura dentro il fotogramma", _project.Camera.KeepInsideFrame, value =>
{
_project.Camera.KeepInsideFrame = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Note("Le inquadrature si trascinano dentro il riquadro e si stringono dalla " +
"maniglia d'angolo; la striscia in basso è la linea del tempo. Le tacche " +
"lungo il percorso sono equispaziate nel tempo: dove si addensano il " +
"movimento rallenta. Con il movimento attivo i fotogrammi vengono letti a " +
"risoluzione nativa, perché il ritaglio deve avere pixel da cui attingere."));
// ---- Motion blur e campo vettoriale
stack.Add(new SectionHeader("Motion blur sintetico"));
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
@@ -232,7 +477,6 @@ internal sealed class SettingsPanel : Panel
stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " +
"√(obiettivo² reale²). A 180° si ottiene la resa cinematografica."));
// ---- Optical flow
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
@@ -250,9 +494,122 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
SyncKeyframeControls();
return stack.Panel;
}
private void UpdateSelectedKeyframe(Action<CameraKeyframe> change)
{
if (_syncingKeyframe || _cameraEditor?.Selected is not { } keyframe) return;
change(keyframe);
_cameraEditor.Invalidate();
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}
/// <summary>Riporta nei cursori i valori del nodo scelto, senza farli reagire.</summary>
private void SyncKeyframeControls()
{
if (_cameraEditor?.Selected is not { } keyframe) return;
_syncingKeyframe = true;
_keyframeCentreX?.SetValueSilently(keyframe.CentreX);
_keyframeCentreY?.SetValueSilently(keyframe.CentreY);
_keyframeZoom?.SetValueSilently(keyframe.Zoom);
_keyframeEaseOut?.SetValueSilently(keyframe.EaseOut);
_keyframeEaseIn?.SetValueSilently(keyframe.EaseIn);
_syncingKeyframe = false;
}
// ------------------------------------------------------------------ Tempo
private Panel BuildTimePage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Andamento temporale"));
stack.Add(Combo("Durata dei fotogrammi",
["Costante — un fotogramma per scatto",
"Adattiva — durata proporzionale all'intervallo",
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
(int)_project.Export.Timing,
index =>
{
_project.Export.Timing = (FrameTimingMode)index;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
value => _project.Export.MaxAdaptiveStretch = value));
// ---- Time ramping
stack.Add(new SectionHeader("Rimappatura non lineare"));
stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled, value =>
{
_project.TimeRamp.Enabled = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
_rampEditor = new SplineEditor
{
Minimum = 0.1,
Maximum = 8.0,
Height = 168,
StartLabel = "primo scatto",
EndLabel = "ultimo scatto",
};
_rampEditor.SetKnots(_project.TimeRamp.Speed);
_rampEditor.Changed += (_, _) =>
{
_project.TimeRamp.Speed = [.. _rampEditor.Knots];
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
stack.Add(_rampEditor);
stack.Add(Note("Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro. " +
"La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: " +
"sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta e i fotogrammi " +
"mancanti vengono sintetizzati dal campo vettoriale. La velocità del filmato " +
"resta fissa. La spline è monotona per costruzione, quindi il tempo non può " +
"tornare indietro fra due nodi."));
// ---- Stacking
stack.Add(new SectionHeader("Accumulo temporale"));
stack.Add(Combo("Modalità",
["Nessuna", "Mediana — rimuove gli elementi di passaggio", "Massimo — scie stellari"],
_project.Stacking.Mode switch { StackingMode.Median => 1, StackingMode.Maximum => 2, _ => 0 },
index =>
{
_project.Stacking.Mode = index switch
{
1 => StackingMode.Median,
2 => StackingMode.Maximum,
_ => StackingMode.Off,
};
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra della mediana", 3, 49, _project.Stacking.WindowFrames, 2, "0", "fotogrammi",
value => { _project.Stacking.WindowFrames = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità dell'accumulo", 0, 1, _project.Stacking.Strength, 0.05, "0.00", string.Empty,
value => { _project.Stacking.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza delle scie", 0, 400, _project.Stacking.TrailFrames, 5, "0", "fotogrammi",
value => { _project.Stacking.TrailFrames = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la " +
"scena stabile resta se stessa, chi attraversa l'inquadratura una volta sola " +
"sparisce. Il massimo conserva il valore più alto incontrato e trasforma le " +
"stelle in archi continui; a zero le scie non si spengono mai. La finestra " +
"della mediana tiene occupata memoria: sono tutti fotogrammi vivi insieme."));
return stack.Panel;
}
// ------------------------------------------------------------------ Esportazione
private Panel BuildExportPage()
{
var stack = NewStack();
@@ -283,21 +640,6 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
value => _project.Export.KeyframeIntervalSeconds = (int)value));
stack.Add(new SectionHeader("Andamento temporale"));
stack.Add(Combo("Durata dei fotogrammi",
["Costante — un fotogramma per scatto",
"Adattiva — durata proporzionale all'intervallo",
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
(int)_project.Export.Timing,
index =>
{
_project.Export.Timing = (FrameTimingMode)index;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
value => _project.Export.MaxAdaptiveStretch = value));
stack.Add(new SectionHeader("Codifica"));
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
value => _project.Export.PreferHardware = value));
@@ -309,8 +651,9 @@ internal sealed class SettingsPanel : Panel
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
stack.Add(browse);
stack.Add(Note("Il video viene scritto in un unico flusso continuo: l'elaborazione non " +
"genera alcun file temporaneo su disco."));
stack.Add(Note("Il video viene scritto in un unico flusso continuo. L'unico altro file che " +
"l'elaborazione può creare è il parcheggio temporaneo dei fotogrammi, che si " +
"cancella da sé e non contiene nulla di riutilizzabile."));
return stack.Panel;
}
@@ -332,18 +675,36 @@ internal sealed class SettingsPanel : Panel
// Le note esplicative variano in lunghezza: si misurano sulla larghezza reale
// della colonna, altrimenti le più lunghe finirebbero tagliate a metà frase.
if (control is Label note && !note.AutoSize)
{
var measured = TextRenderer.MeasureText(note.Text, note.Font,
new Size(control.Width, 0), TextFormatFlags.WordBreak);
control.Height = measured.Height + 8;
}
if (control is Label note && !note.AutoSize) MeasureNote(note);
Panel.Controls.Add(control);
_y += control.Height + 6;
}
}
private static void MeasureNote(Label note)
{
var measured = TextRenderer.MeasureText(note.Text, note.Font,
new Size(Math.Max(40, note.Width), 0),
TextFormatFlags.WordBreak);
note.Height = measured.Height + 8;
}
/// <summary>Rimisura una nota il cui testo è cambiato dopo la costruzione della pagina.</summary>
private static void LayoutNote(Label note)
{
int before = note.Height;
MeasureNote(note);
if (note.Height == before || note.Parent is null) return;
// Le note sotto vanno fatte scorrere: la colonna è posizionata a coordinate assolute.
int delta = note.Height - before;
foreach (Control sibling in note.Parent.Controls)
{
if (sibling != note && sibling.Top > note.Top) sibling.Top += delta;
}
}
private static Stack NewStack()
{
var panel = new Panel