Titano: motore e interfaccia per time-lapse, senza dipendenze di terze parti

Applicazione desktop completa per creazione e ottimizzazione di time-lapse.
Il vincolo che ne definisce l'architettura è l'assenza totale di componenti
di terze parti: il progetto non ha alcun PackageReference e non invoca processi
esterni. Oltre alla libreria standard di .NET si usano solo API native di
Windows (WIC, Media Foundation, GDI+, DWM) richiamate via P/Invoke scritto a mano.

Sono in-house tutte le parti che di norma si delegherebbero a una libreria:
il parser binario EXIF/XMP, la misura di luminanza e la curva di deflicker,
il calcolo del campo vettoriale di movimento con il motion blur sintetico,
il multiplexer MP4 e ogni controllo dell'interfaccia.

Scelte algoritmiche che meritano una nota:
- il deflicker usa una regressione lineare locale pesata con seconda passata
  robusta, così le rampe reali di luce (alba, tramonto) sopravvivono mentre
  lo sfarfallio del diaframma viene rimosso; una media mobile semplice le
  appiattirebbe entrambe;
- la sfocatura mancante si compone in quadratura con quella già incisa nello
  scatto, perché sommarla linearmente renderebbe l'immagine troppo morbida;
- la luminanza si misura come media logaritmica troncata, invariante alla
  scala e insensibile a cieli bruciati e ombre chiuse.

L'elaborazione non produce file temporanei e mantiene un'occupazione di memoria
stazionaria: buffer poolati e canale a capacità limitata rendono i fotogrammi
vivi indipendenti dalla lunghezza della sequenza.

Verificato con "Titano.exe --selftest": 23 controlli su una sequenza sintetica
dalle proprietà note, incluse la struttura del contenitore prodotto e la sua
ri-decodifica con il lettore di sistema. Tutti superati.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 21:40:26 +02:00
co-authored by Claude Opus 5
parent 165eff627b
commit cc1d040ac0
46 changed files with 9453 additions and 0 deletions
+354
View File
@@ -0,0 +1,354 @@
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.
/// </summary>
internal sealed class SettingsPanel : Panel
{
private readonly TitanoProject _project;
private readonly TabStrip _tabs;
private readonly Panel[] _pages;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</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 TextBox OutputPathBox { get; }
public SettingsPanel(TitanoProject project)
{
_project = project;
BackColor = Theme.Surface;
Padding = new Padding(0);
_tabs = new TabStrip("Generale", "Elaborazione immagini", "Esportazione") { Dock = DockStyle.Top };
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
OutputPathBox = new TextBox
{
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.Body,
Dock = DockStyle.Top,
};
OutputPathBox.TextChanged += (_, _) =>
{
_project.Export.OutputPath = OutputPathBox.Text;
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(), BuildExportPage()];
foreach (var page in _pages)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
Controls.Add(page);
}
Controls.Add(_tabs);
ShowPage(0);
}
/// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
private void ShowPage(int index)
{
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
}
// ------------------------------------------------------------------ pagine
private Panel BuildGeneralPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Sequenza"));
stack.Add(Combo("Risoluzione di lavoro",
["Nativa (piena risoluzione)", "3840 px (4K UHD)", "2560 px", "1920 px (Full HD)", "1280 px"],
WorkingWidthToIndex(_project.General.WorkingWidth),
index =>
{
_project.General.WorkingWidth = index switch { 1 => 3840, 2 => 2560, 3 => 1920, 4 => 1280, _ => 0 };
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);
}));
stack.Add(new SectionHeader("Prestazioni"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
value =>
{
_project.General.AnalysisWidth = (int)value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _project.General.DecodeParallelism, 1, "0", "thread",
value =>
{
_project.General.DecodeParallelism = (int)value;
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."));
return stack.Panel;
}
private Panel BuildImagePage()
{
var stack = NewStack();
// ---- Deflicker
stack.Add(new SectionHeader("Deflicker"));
var deflickerEnabled = 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); }));
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
value => { _project.Deflicker.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.1, 3.0, _project.Deflicker.MaxCorrectionStops, 0.1, "0.0", "EV",
value => { _project.Deflicker.MaxCorrectionStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers, value =>
{
_project.Deflicker.RejectOutliers = value;
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;
PreviewInvalidated?.Invoke(this, EventArgs.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); }));
// ---- Motion blur
stack.Add(new SectionHeader("Motion blur sintetico"));
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
{
_project.MotionBlur.Enabled = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Shutter angle obiettivo", 0, 360, _project.MotionBlur.TargetShutterAngle, 5, "0", "°",
value => { _project.MotionBlur.TargetShutterAngle = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.MotionBlur.Strength, 0.05, "0.00", string.Empty,
value => { _project.MotionBlur.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza massima della scia", 4, 160, _project.MotionBlur.MaxBlurPixels, 2, "0", "px",
value => { _project.MotionBlur.MaxBlurPixels = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
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."));
// ---- 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",
value => { _project.Flow.AnalysisWidth = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Passo della griglia", 4, 32, _project.Flow.CellSize, 1, "0", "px",
value => { _project.Flow.CellSize = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Livelli della piramide", 1, 6, _project.Flow.PyramidLevels, 1, "0", string.Empty,
value => { _project.Flow.PyramidLevels = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Raggio della finestra", 2, 12, _project.Flow.WindowRadius, 1, "0", "px",
value => { _project.Flow.WindowRadius = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
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); }));
return stack.Panel;
}
private Panel BuildExportPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Formato"));
stack.Add(Combo("Codec", ["H.264 / AVC", "H.265 / HEVC"], _project.Export.Codec == VideoCodec.H264 ? 0 : 1,
index =>
{
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
index => _project.Export.Profile = index switch
{
0 => H264Profile.Baseline,
1 => H264Profile.Main,
_ => H264Profile.High,
}));
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
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));
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));
stack.Add(new SectionHeader("Destinazione"));
stack.Add(OutputPathBox);
var browse = new DarkButton { Text = "Scegli il file di destinazione…", Height = 32, Dock = DockStyle.Top };
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."));
return stack.Panel;
}
// ------------------------------------------------------------------ costruttori di controlli
private sealed class Stack(Panel panel)
{
public Panel Panel { get; } = panel;
private int _y;
public void Add(Control control)
{
control.Dock = DockStyle.None;
control.Left = 14;
control.Top = _y;
control.Width = Panel.ClientSize.Width - 34;
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right;
Panel.Controls.Add(control);
_y += control.Height + 6;
}
}
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 };
panel.Controls.Add(host);
return new Stack(host);
}
private static ParameterSlider Slider(string caption, double min, double max, double value, double step,
string format, string unit, Action<double> onChange)
{
var slider = new ParameterSlider
{
Caption = caption,
Minimum = min,
Maximum = max,
Step = step,
ValueFormat = format,
Unit = unit,
};
slider.SetValueSilently(value);
slider.ValueChanged += (_, _) => onChange(slider.Value);
return slider;
}
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
{
var box = new DarkCheckBox { Text = caption, Checked = value };
box.CheckedChanged += (_, _) => onChange(box.Checked);
return box;
}
private static LabeledCombo Combo(string caption, string[] items, int selected, Action<int> onChange)
{
var row = new LabeledCombo(caption);
row.Combo.Items.AddRange(items);
row.Combo.SelectedIndex = Math.Clamp(selected, 0, items.Length - 1);
row.Combo.SelectedIndexChanged += (_, _) => onChange(row.Combo.SelectedIndex);
return row;
}
private static Label Note(string text) => new()
{
Text = text,
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Height = 52,
BackColor = Theme.Surface,
};
private static int WorkingWidthToIndex(int width) => width switch
{
3840 => 1,
2560 => 2,
1920 => 3,
1280 => 4,
_ => 0,
};
}