Navigazione verticale e strumenti di misura nell'interfaccia

Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che
governa insieme il contenuto principale e la colonna delle impostazioni: una
sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da
tenere allineate a mano; adesso ce n'e' una sola, in verticale ci sta il nome
per esteso, e la sezione scelta resta leggibile mentre si lavora - cosa che in
una fila di schede in cima si perde appena l'occhio scende sul contenuto.
Accanto a ogni voce compare il numero di avvisi che la riguardano.

L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto
sempre, perche' in un programma che tratta immagini l'immagine si guarda
mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile
per giudicare e non solo per guardare. Zoom e trascinamento, perche' una
stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per
stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono
invece di essere interpolati. Confronto a tendina fra originale e corretto,
perche' l'unico modo di capire cosa una correzione stia facendo e' vedere
accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore
calcola comunque e che spiega perche' la sfocatura viene come viene,
soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso
la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una
panoramica virtuale.

Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini
e pannello degli avvisi sulla sequenza, istogramma e forma d'onda
sull'esposizione, percorso ricostruito della stabilizzazione sul movimento,
piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e
che finivano in due numeri in fondo a una riga di stato, cioe' invisibili.

Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu':
un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo
passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello
che il menu dell'orientamento usava da solo, esteso a larghezza di analisi,
finestra del deflicker, lunghezza della transizione, finestra della
stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del
deflicker viene da quattro periodi dello sfarfallio misurati
sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende
liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi.
Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce
meno decisioni e lascia il cursore dov'e'.

Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e'
l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non
toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un
ostacolo. Gli annunci si leggono da una cartella locale con un listino in
formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun
identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito
pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze
esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta
montando un time-lapse. Le campagne si aggiornano copiando file; a listino
vuoto compaiono note interne; il riquadro si spegne dalle preferenze.

Aggiunge anche le preferenze dell'applicazione, distinte da quelle del
progetto, con persistenza in un file di testo scritto a mano nello stesso
spirito del resto: una riga per voce, correggibile con un editor. Il progetto
descrive come trattare questi fotogrammi, le preferenze come si comporta il
programma.

Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche'
trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e
anteprima dei fotogrammi mentre vengono codificati, che non migliora il
risultato di un pixel ma cambia molto un'attesa di mezz'ora.

Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le
sezioni ispezionate a video sulla scena di prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 23:58:23 +02:00
co-authored by Claude Opus 5
parent 790637bc0d
commit 2e5a441938
19 changed files with 4481 additions and 347 deletions
+238 -133
View File
@@ -6,10 +6,13 @@ using Titano.Video;
namespace Titano.UI;
/// <summary>
/// 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.
/// Colonna delle impostazioni. Non ha più schede proprie: mostra la pagina della sezione
/// scelta nella barra di navigazione, così la barra governa insieme il contenuto principale
/// e i comandi che lo riguardano, e non esistono due gerarchie di schede da tenere allineate.
///
/// Ogni controllo dichiara quale parte della pipeline invalida. C'è differenza fra spostare
/// un cursore che ricalcola una curva già misurata e sceglierne uno che obbliga a rileggere
/// mille file, e il programma deve saperla.
/// </summary>
internal sealed class SettingsPanel : Panel
{
@@ -17,8 +20,9 @@ internal sealed class SettingsPanel : Panel
private static readonly int?[] OrientationValues = [null, 1, 2, 4, 6, 3, 8];
private readonly TitanoProject _project;
private readonly TabStrip _tabs;
private readonly Panel[] _pages;
private readonly AppSettings _app;
private readonly Dictionary<WorkspaceSection, Panel> _pages = [];
private readonly Dictionary<AutoKey, ParameterSlider> _autoSliders = [];
private LabeledCombo? _orientationCombo;
private Label? _analysisNote;
@@ -31,30 +35,19 @@ internal sealed class SettingsPanel : Panel
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>
public event EventHandler? AnalysisInvalidated;
/// <summary>È cambiato un parametro che modifica solo l'anteprima o l'esportazione.</summary>
public event EventHandler? PreviewInvalidated;
public event EventHandler? BrowseOutputRequested;
public event EventHandler? AppSettingsChanged;
public TextBox OutputPathBox { get; }
public SettingsPanel(TitanoProject project)
public SettingsPanel(TitanoProject project, AppSettings app)
{
_project = project;
_app = app;
BackColor = Theme.Surface;
Padding = new Padding(0);
_tabs = new TabStrip("Generale", "Immagine", "Movimento", "Tempo", "Esportazione")
{
Dock = DockStyle.Top,
};
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
OutputPathBox = new TextBox
{
@@ -70,24 +63,30 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
// 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(), BuildMotionPage(), BuildTimePage(), BuildExportPage()];
foreach (var page in _pages)
_pages[WorkspaceSection.Sequence] = BuildSequencePage();
_pages[WorkspaceSection.Exposure] = BuildExposurePage();
_pages[WorkspaceSection.Motion] = BuildMotionPage();
_pages[WorkspaceSection.Timing] = BuildTimingPage();
_pages[WorkspaceSection.Export] = BuildExportPage();
_pages[WorkspaceSection.Preferences] = BuildPreferencesPage();
foreach (var page in _pages.Values)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
Controls.Add(page);
}
Controls.Add(_tabs);
ShowPage(0);
ShowSection(WorkspaceSection.Sequence);
}
/// <summary>
/// Riporta nel menu l'esito del rilevamento, così la voce "Automatico" dice cosa ha deciso.
/// </summary>
public void ShowSection(WorkspaceSection section)
{
foreach (var (key, page) in _pages) page.Visible = key == section;
}
// ------------------------------------------------------------------ aggiornamenti dall'esterno
public void ShowDetectedOrientation(Imaging.OrientationDetection detection)
{
if (_orientationCombo is null || _orientationCombo.Combo.Items.Count == 0) return;
@@ -97,7 +96,24 @@ internal sealed class SettingsPanel : Panel
_orientationCombo.Combo.SelectedIndex = selected;
}
/// <summary>Aggiorna le voci che dipendono dall'analisi: maschera, transizioni, tremolio.</summary>
/// <summary>
/// Riporta nei cursori i valori che il direttore ha dedotto, con il motivo di ciascuno.
/// Quelli di cui l'utente ha preso il controllo restano dove sono.
/// </summary>
public void ShowAutoDecisions(IReadOnlyDictionary<AutoKey, AutoDecision> decisions)
{
foreach (var (key, slider) in _autoSliders)
{
bool manual = _project.ManualParameters.Contains(key);
slider.IsAuto = !manual;
if (!decisions.TryGetValue(key, out var decision)) continue;
slider.AutoReason = decision.Reason;
if (!manual) slider.SetValueSilently(decision.Value);
}
Invalidate(true);
}
public void ShowAnalysis()
{
if (_analysisNote is null) return;
@@ -115,16 +131,16 @@ internal sealed class SettingsPanel : Panel
{
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)."));
: $"Transizioni: {transitions.StepCount} cambi" +
(transitions.UnobservedSteps > 0 ? $", {transitions.UnobservedSteps} non recepiti" : string.Empty) +
$", il maggiore di {transitions.LargestStepStops:0.00} EV.");
}
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.#}%.");
$"ritaglio {(_project.StabilizationZoom - 1) * 100:0.#}%.");
}
_analysisNote.Text = lines.Count > 0
@@ -133,7 +149,6 @@ internal sealed class SettingsPanel : Panel
LayoutNote(_analysisNote);
}
/// <summary>Aggiorna l'editor del movimento quando cambia la sequenza caricata.</summary>
public void ShowSequenceGeometry()
{
if (_cameraEditor is null) return;
@@ -142,17 +157,9 @@ internal sealed class SettingsPanel : Panel
_cameraEditor.Invalidate();
}
/// <summary>Seleziona una delle sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
// ------------------------------------------------------------------ Sequenza
private void ShowPage(int index)
{
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
}
// ------------------------------------------------------------------ Generale
private Panel BuildGeneralPage()
private Panel BuildSequencePage()
{
var stack = NewStack();
@@ -166,13 +173,6 @@ internal sealed class SettingsPanel : Panel
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
value =>
{
_project.General.CadenceTolerance = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
_orientationCombo = Combo("Orientamento dei fotogrammi",
["Automatico", "Nessuna rotazione", "Specchia orizzontalmente", "Specchia verticalmente",
"Ruota 90° in senso orario", "Ruota 180°", "Ruota 90° in senso antiorario"],
@@ -184,13 +184,14 @@ internal sealed class SettingsPanel : Panel
});
stack.Add(_orientationCombo);
stack.Add(Note("In automatico l'orientamento viene dedotto dai metadati del primo scatto e " +
"confrontato con ciò che il decodificatore di sistema restituisce, così la " +
"trasformazione non viene applicata due volte. Se il risultato non convince, " +
"l'anteprima mostra subito l'effetto di una scelta manuale."));
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
value =>
{
_project.General.CadenceTolerance = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Qualità di elaborazione"));
stack.Add(Combo("Profilo",
["Bozza — veloce", "Standard — equilibrio", "Massima — resa migliore"],
(int)_project.General.Quality,
@@ -201,11 +202,12 @@ internal sealed class SettingsPanel : Panel
}));
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."));
"riquadri della correlazione di fase e risoluzione delle passate di analisi."));
stack.Add(new SectionHeader("Prestazioni e memoria"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
stack.Add(AutoSlider(AutoKey.AnalysisWidth, "Larghezza della passata di analisi", 256, 2048,
_project.General.AnalysisWidth, 64, "0", "px",
value =>
{
_project.General.AnalysisWidth = (int)value;
@@ -222,16 +224,16 @@ internal sealed class SettingsPanel : Panel
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",
stack.Add(AutoSlider(AutoKey.MemoryBudget, "Tetto di memoria", 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."));
"anticipo. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono " +
"ripresi una volta sola. Il file di parcheggio si cancella da sé."));
stack.Add(new SectionHeader("Esito dell'analisi"));
_analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.");
@@ -240,9 +242,9 @@ internal sealed class SettingsPanel : Panel
return stack.Panel;
}
// ------------------------------------------------------------------ Immagine
// ------------------------------------------------------------------ Esposizione
private Panel BuildImagePage()
private Panel BuildExposurePage()
{
var stack = NewStack();
@@ -254,7 +256,8 @@ internal sealed class SettingsPanel : Panel
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
stack.Add(AutoSlider(AutoKey.DeflickerWindow, "Finestra temporale", 3, 121,
_project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
@@ -275,10 +278,10 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty,
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); }));
// ---- Regioni
stack.Add(new SectionHeader("Deflicker per regioni"));
stack.Add(Combo("Divisione del fotogramma",
@@ -305,11 +308,9 @@ internal sealed class SettingsPanel : Panel
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."));
"toglie di mezzo nuvole e passanti. Impedisce che il transito di una nuvola 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 =>
@@ -318,7 +319,8 @@ internal sealed class SettingsPanel : Panel
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Lunghezza della transizione", 4, 240, _project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
stack.Add(AutoSlider(AutoKey.TransitionFrames, "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",
@@ -336,17 +338,16 @@ internal sealed class SettingsPanel : Panel
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0", "fotogrammi",
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."));
stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni salto " +
"è nota senza incertezza. Un salto che la luminanza non ha recepito — fotogramma " +
"già saturo — viene lasciato stare."));
return stack.Panel;
}
@@ -365,13 +366,15 @@ internal sealed class SettingsPanel : Panel
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra del percorso", 5, 151, _project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
stack.Add(AutoSlider(AutoKey.StabilizationWindow, "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",
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 =>
@@ -383,7 +386,6 @@ internal sealed class SettingsPanel : Panel
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);
}));
@@ -391,13 +393,6 @@ internal sealed class SettingsPanel : Panel
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 =>
@@ -406,7 +401,7 @@ internal sealed class SettingsPanel : Panel
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 210 };
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 200 };
_cameraEditor.Changed += (_, _) =>
{
SyncKeyframeControls();
@@ -416,8 +411,8 @@ internal sealed class SettingsPanel : Panel
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 };
var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 128, Height = 28 };
var removeButton = new DarkButton { Text = "Togli nodo", Width = 116, Height = 28, Left = 134 };
addButton.Click += (_, _) => { _cameraEditor.AddKeyframe(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
removeButton.Click += (_, _) => { _cameraEditor.RemoveSelected(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
keyframeButtons.Controls.Add(addButton);
@@ -447,13 +442,6 @@ internal sealed class SettingsPanel : Panel
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 =>
@@ -474,9 +462,6 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Campioni per pixel", 3, 65, _project.MotionBlur.MaxSamples, 2, "0", string.Empty,
value => { _project.MotionBlur.MaxSamples = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " +
"√(obiettivo² reale²). A 180° si ottiene la resa cinematografica."));
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
@@ -506,7 +491,6 @@ internal sealed class SettingsPanel : Panel
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;
@@ -522,7 +506,7 @@ internal sealed class SettingsPanel : Panel
// ------------------------------------------------------------------ Tempo
private Panel BuildTimePage()
private Panel BuildTimingPage()
{
var stack = NewStack();
@@ -541,7 +525,6 @@ internal sealed class SettingsPanel : Panel
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 =>
@@ -554,7 +537,7 @@ internal sealed class SettingsPanel : Panel
{
Minimum = 0.1,
Maximum = 8.0,
Height = 168,
Height = 160,
StartLabel = "primo scatto",
EndLabel = "ultimo scatto",
};
@@ -567,13 +550,9 @@ internal sealed class SettingsPanel : Panel
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."));
"La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: sopra 1 " +
"la sequenza accelera, sotto 1 rallenta e i fotogrammi mancanti vengono sintetizzati."));
// ---- Stacking
stack.Add(new SectionHeader("Accumulo temporale"));
stack.Add(Combo("Modalità",
@@ -599,11 +578,9 @@ internal sealed class SettingsPanel : Panel
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."));
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 trasforma le stelle in archi; a zero le scie non si spengono mai."));
return stack.Panel;
}
@@ -635,7 +612,7 @@ internal sealed class SettingsPanel : Panel
value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
value => _project.Export.BitrateMbps = value));
value => { _project.Export.BitrateMbps = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
value => _project.Export.KeyframeIntervalSeconds = (int)value));
@@ -652,8 +629,113 @@ internal sealed class SettingsPanel : Panel
stack.Add(browse);
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."));
"l'elaborazione può creare è il parcheggio temporaneo, che si cancella da sé."));
return stack.Panel;
}
// ------------------------------------------------------------------ Impostazioni dell'applicazione
private Panel BuildPreferencesPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("All'apertura"));
stack.Add(Check("Ricorda le impostazioni per cartella", _app.RememberPerFolder, value =>
{
_app.RememberPerFolder = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Analizza appena una sequenza è caricata", _app.AnalyzeOnLoad, value =>
{
_app.AnalyzeOnLoad = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Valori predefiniti dei nuovi progetti"));
stack.Add(Combo("Profilo di qualità", ["Bozza", "Standard", "Massima"], (int)_app.DefaultQuality,
index =>
{
_app.DefaultQuality = (QualityProfile)Math.Clamp(index, 0, 2);
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _app.DefaultDecodeParallelism, 1, "0", "thread",
value => { _app.DefaultDecodeParallelism = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Tetto di memoria", 256, 32768, _app.DefaultMemoryBudgetMiB, 256, "0", "MiB",
value => { _app.DefaultMemoryBudgetMiB = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _app.DefaultAllowDiskSpill, value =>
{
_app.DefaultAllowDiskSpill = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Durante l'esportazione"));
stack.Add(Check("Chiedi conferma prima di sovrascrivere", _app.ConfirmOverwrite, value =>
{
_app.ConfirmOverwrite = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Mostra i fotogrammi mentre vengono codificati", _app.LivePreviewDuringExport, value =>
{
_app.LivePreviewDuringExport = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Aggiorna l'anteprima ogni", 1, 60, _app.LivePreviewEvery, 1, "0", "fotogrammi",
value => { _app.LivePreviewEvery = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Apri la cartella a esportazione conclusa", _app.RevealWhenFinished, value =>
{
_app.RevealWhenFinished = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Riquadro sponsor"));
stack.Add(Check("Mostra gli sponsor durante l'attesa", _app.ShowSponsors, value =>
{
_app.ShowSponsors = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Cambia annuncio ogni", 5, 180, _app.SponsorRotationSeconds, 5, "0", "s",
value => { _app.SponsorRotationSeconds = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
var openFolder = new DarkButton { Text = "Apri la cartella delle campagne", Height = 30, Dock = DockStyle.Top };
openFolder.Click += (_, _) =>
{
try
{
SponsorCatalogue.EnsureExample(_app.ResolvedSponsorFolder);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(_app.ResolvedSponsorFolder)
{
UseShellExecute = true,
});
}
catch (Exception ex) when (ex is IOException or System.ComponentModel.Win32Exception)
{
// Cartella non apribile: non c'è nulla di sensato da fare.
}
};
stack.Add(openFolder);
stack.Add(Note("Gli annunci si leggono da una cartella locale: il programma non contatta alcun " +
"servizio, non invia identificativi e non registra clic. Le campagne si " +
"aggiornano copiando file dentro quella cartella, con un elenco chiamato " +
"campagne.txt. Il riquadro compare soltanto nella scheda Esportazione, dove per " +
"forza di cose si aspetta."));
stack.Add(new SectionHeader("Dove stanno i file"));
stack.Add(Note($"Impostazioni: {AppSettings.SettingsPath}"));
stack.Add(Note($"Campagne: {_app.ResolvedSponsorFolder}"));
return stack.Panel;
}
@@ -673,8 +755,6 @@ internal sealed class SettingsPanel : Panel
control.Width = Panel.ClientSize.Width - 34;
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right;
// 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) MeasureNote(note);
Panel.Controls.Add(control);
@@ -690,14 +770,12 @@ internal sealed class SettingsPanel : Panel
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)
{
@@ -707,13 +785,8 @@ internal sealed class SettingsPanel : Panel
private static Stack NewStack()
{
var panel = new Panel
{
BackColor = Theme.Surface,
Padding = new Padding(0, 8, 0, 12),
Width = 360,
};
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 360 };
var panel = new Panel { BackColor = Theme.Surface, Padding = new Padding(0, 8, 0, 12), Width = 372 };
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 372 };
panel.Controls.Add(host);
return new Stack(host);
}
@@ -735,6 +808,38 @@ internal sealed class SettingsPanel : Panel
return slider;
}
/// <summary>
/// Cursore che il direttore sa impostare da sé. L'indicatore «auto» dice chi comanda;
/// toccare il cursore passa il comando all'utente, e il pulsante lo restituisce.
/// </summary>
private ParameterSlider AutoSlider(AutoKey key, string caption, double min, double max, double value,
double step, string format, string unit, Action<double> onChange)
{
var slider = Slider(caption, min, max, value, step, format, unit, onChange);
slider.AutoSupported = true;
slider.IsAuto = !_project.ManualParameters.Contains(key);
slider.AutoChanged += (_, _) =>
{
if (slider.IsAuto)
{
_project.ManualParameters.Remove(key);
if (_project.AutoDecisions.TryGetValue(key, out var decision))
{
slider.SetValueSilently(decision.Value);
onChange(decision.Value);
}
}
else
{
_project.ManualParameters.Add(key);
}
};
_autoSliders[key] = slider;
return slider;
}
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
{
var box = new DarkCheckBox { Text = caption, Checked = value };