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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user