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,488 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Pulsante disegnato interamente a mano, con varianti primaria e secondaria.</summary>
|
||||
internal sealed class DarkButton : Control
|
||||
{
|
||||
private bool _hover;
|
||||
private bool _pressed;
|
||||
|
||||
public bool Primary { get; set; }
|
||||
public bool Danger { get; set; }
|
||||
|
||||
public DarkButton()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 32;
|
||||
Font = Theme.Body;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hover = false; _pressed = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
protected override void OnMouseDown(MouseEventArgs e) { _pressed = true; Invalidate(); base.OnMouseDown(e); }
|
||||
protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; Invalidate(); base.OnMouseUp(e); }
|
||||
protected override void OnEnabledChanged(EventArgs e) { Invalidate(); base.OnEnabledChanged(e); }
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
|
||||
Color fill, stroke, text;
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
fill = Theme.SurfaceAlt;
|
||||
stroke = Theme.Border;
|
||||
text = Theme.TextFaint;
|
||||
}
|
||||
else if (Primary)
|
||||
{
|
||||
fill = _pressed ? Theme.AccentDim : _hover ? Theme.Mix(Theme.Accent, Color.White, 0.12) : Theme.Accent;
|
||||
stroke = fill;
|
||||
text = Color.FromArgb(0x0B, 0x12, 0x1C);
|
||||
}
|
||||
else if (Danger)
|
||||
{
|
||||
fill = _pressed ? Theme.SurfaceAlt : _hover ? Theme.Mix(Theme.Danger, Theme.Surface, 0.75) : Theme.Surface;
|
||||
stroke = Theme.Danger;
|
||||
text = Theme.Danger;
|
||||
}
|
||||
else
|
||||
{
|
||||
fill = _pressed ? Theme.Surface : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
|
||||
stroke = _hover ? Theme.BorderStrong : Theme.Border;
|
||||
text = Theme.Text;
|
||||
}
|
||||
|
||||
Theme.FillAndStroke(g, bounds, 6f, fill, stroke);
|
||||
|
||||
TextRenderer.DrawText(g, Text, Font, new Rectangle(0, 0, Width, Height), text,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Interruttore a due stati con etichetta, disegnato a mano.</summary>
|
||||
internal sealed class DarkCheckBox : Control
|
||||
{
|
||||
private bool _checked;
|
||||
private bool _hover;
|
||||
|
||||
public event EventHandler? CheckedChanged;
|
||||
|
||||
public bool Checked
|
||||
{
|
||||
get => _checked;
|
||||
set
|
||||
{
|
||||
if (_checked == value) return;
|
||||
_checked = value;
|
||||
Invalidate();
|
||||
CheckedChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public DarkCheckBox()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 26;
|
||||
Font = Theme.Body;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
protected override void OnClick(EventArgs e) { Checked = !Checked; base.OnClick(e); }
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
const int size = 17;
|
||||
int top = (Height - size) / 2;
|
||||
var box = new RectangleF(0.5f, top + 0.5f, size, size);
|
||||
|
||||
Color fill = _checked ? Theme.Accent : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
|
||||
Color stroke = _checked ? Theme.Accent : _hover ? Theme.BorderStrong : Theme.Border;
|
||||
Theme.FillAndStroke(g, box, 4f, fill, stroke);
|
||||
|
||||
if (_checked)
|
||||
{
|
||||
using var pen = new Pen(Color.FromArgb(0x0B, 0x12, 0x1C), 2f)
|
||||
{
|
||||
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
};
|
||||
g.DrawLines(pen,
|
||||
[
|
||||
new PointF(box.Left + 4f, box.Top + 8.5f),
|
||||
new PointF(box.Left + 7f, box.Top + 11.5f),
|
||||
new PointF(box.Left + 13f, box.Top + 5f),
|
||||
]);
|
||||
}
|
||||
|
||||
var textRect = new Rectangle(size + 9, 0, Width - size - 9, Height);
|
||||
TextRenderer.DrawText(g, Text, Font, textRect, Enabled ? Theme.Text : Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cursore continuo con didascalia e valore, unità di misura opzionale e tacche.
|
||||
/// Sostituisce il TrackBar di sistema, che non è tematizzabile.
|
||||
/// </summary>
|
||||
internal sealed class ParameterSlider : Control
|
||||
{
|
||||
private double _value;
|
||||
private bool _dragging;
|
||||
private bool _hover;
|
||||
|
||||
public string Caption { get; set; } = string.Empty;
|
||||
public string Unit { get; set; } = string.Empty;
|
||||
public string ValueFormat { get; set; } = "0.##";
|
||||
public double Minimum { get; set; }
|
||||
public double Maximum { get; set; } = 1;
|
||||
public double Step { get; set; }
|
||||
|
||||
public event EventHandler? ValueChanged;
|
||||
|
||||
public double Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
double clamped = Math.Clamp(value, Minimum, Maximum);
|
||||
if (Step > 0) clamped = Math.Round(clamped / Step) * Step;
|
||||
if (Math.Abs(clamped - _value) < 1e-9) return;
|
||||
_value = clamped;
|
||||
Invalidate();
|
||||
ValueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Imposta il valore senza sollevare l'evento: usata al caricamento delle impostazioni.</summary>
|
||||
public void SetValueSilently(double value)
|
||||
{
|
||||
_value = Math.Clamp(value, Minimum, Maximum);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public ParameterSlider()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 46;
|
||||
Font = Theme.Body;
|
||||
}
|
||||
|
||||
private Rectangle TrackBounds => new(2, Height - 20, Width - 4, 12);
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left || !Enabled) return;
|
||||
_dragging = true;
|
||||
UpdateFromMouse(e.X);
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
if (_dragging) UpdateFromMouse(e.X);
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
_dragging = false;
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
double increment = Step > 0 ? Step : (Maximum - Minimum) / 50.0;
|
||||
Value += Math.Sign(e.Delta) * increment;
|
||||
}
|
||||
|
||||
private void UpdateFromMouse(int x)
|
||||
{
|
||||
var track = TrackBounds;
|
||||
double fraction = Math.Clamp((x - track.Left) / (double)Math.Max(1, track.Width), 0, 1);
|
||||
Value = Minimum + fraction * (Maximum - Minimum);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
Color captionColor = Enabled ? Theme.TextMuted : Theme.TextFaint;
|
||||
Color valueColor = Enabled ? Theme.Text : Theme.TextFaint;
|
||||
|
||||
TextRenderer.DrawText(g, Caption, Theme.Small, new Rectangle(0, 2, Width - 90, 16),
|
||||
captionColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
|
||||
string display = _value.ToString(ValueFormat, CultureInfo.CurrentCulture) +
|
||||
(string.IsNullOrEmpty(Unit) ? string.Empty : " " + Unit);
|
||||
TextRenderer.DrawText(g, display, Theme.SmallBold, new Rectangle(Width - 92, 2, 92, 16),
|
||||
valueColor, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
|
||||
var track = TrackBounds;
|
||||
float centerY = track.Top + track.Height / 2f;
|
||||
var groove = new RectangleF(track.Left, centerY - 2f, track.Width, 4f);
|
||||
Theme.FillRounded(g, groove, 2f, Enabled ? Theme.SurfaceAlt : Theme.Surface);
|
||||
|
||||
double span = Maximum - Minimum;
|
||||
float fraction = span <= 0 ? 0 : (float)((_value - Minimum) / span);
|
||||
var filled = new RectangleF(track.Left, centerY - 2f, track.Width * fraction, 4f);
|
||||
if (filled.Width > 0.5f)
|
||||
Theme.FillRounded(g, filled, 2f, Enabled ? Theme.Accent : Theme.Border);
|
||||
|
||||
float knobX = track.Left + track.Width * fraction;
|
||||
float radius = _dragging ? 7.5f : _hover ? 7f : 6f;
|
||||
var knob = new RectangleF(knobX - radius, centerY - radius, radius * 2, radius * 2);
|
||||
|
||||
using (var brush = new SolidBrush(Enabled ? Theme.Text : Theme.TextFaint)) g.FillEllipse(brush, knob);
|
||||
using (var pen = new Pen(Enabled ? Theme.Accent : Theme.Border, 2f))
|
||||
g.DrawEllipse(pen, RectangleF.Inflate(knob, -1f, -1f));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Etichetta e menu a discesa affiancati, con lo stile del tema.</summary>
|
||||
internal sealed class LabeledCombo : Panel
|
||||
{
|
||||
public ComboBox Combo { get; }
|
||||
|
||||
public LabeledCombo(string caption)
|
||||
{
|
||||
Height = 46;
|
||||
BackColor = Theme.Surface;
|
||||
|
||||
var label = new Label
|
||||
{
|
||||
Text = caption,
|
||||
Font = Theme.Small,
|
||||
ForeColor = Theme.TextMuted,
|
||||
Dock = DockStyle.Top,
|
||||
Height = 16,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
Combo = new ComboBox
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.SurfaceAlt,
|
||||
ForeColor = Theme.Text,
|
||||
Font = Theme.Body,
|
||||
Dock = DockStyle.Top,
|
||||
DrawMode = DrawMode.OwnerDrawFixed,
|
||||
ItemHeight = 20,
|
||||
};
|
||||
Combo.DrawItem += DrawItem;
|
||||
|
||||
Controls.Add(Combo);
|
||||
Controls.Add(label);
|
||||
}
|
||||
|
||||
private void DrawItem(object? sender, DrawItemEventArgs e)
|
||||
{
|
||||
if (e.Index < 0) return;
|
||||
bool selected = (e.State & DrawItemState.Selected) != 0;
|
||||
e.Graphics.FillRectangle(new SolidBrush(selected ? Theme.AccentDim : Theme.SurfaceAlt), e.Bounds);
|
||||
TextRenderer.DrawText(e.Graphics, Combo.Items[e.Index]?.ToString() ?? string.Empty, Theme.Body,
|
||||
Rectangle.Inflate(e.Bounds, -4, 0), Theme.Text,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Intestazione di sezione con filetto di separazione.</summary>
|
||||
internal sealed class SectionHeader : Control
|
||||
{
|
||||
public SectionHeader(string text)
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Text = text;
|
||||
Height = 30;
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var size = TextRenderer.MeasureText(g, Text, Theme.SmallBold);
|
||||
TextRenderer.DrawText(g, Text.ToUpperInvariant(), Theme.SmallBold,
|
||||
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
|
||||
int lineStart = size.Width + 14;
|
||||
if (lineStart < Width - 4)
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
int y = Height / 2;
|
||||
g.DrawLine(pen, lineStart, y, Width - 2, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Selettore a schede orizzontali usato dal pannello di configurazione.</summary>
|
||||
internal sealed class TabStrip : Control
|
||||
{
|
||||
private readonly List<string> _tabs = [];
|
||||
private int _selected;
|
||||
private int _hovered = -1;
|
||||
|
||||
public event EventHandler? SelectedChanged;
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
int clamped = Math.Clamp(value, 0, Math.Max(0, _tabs.Count - 1));
|
||||
if (clamped == _selected) return;
|
||||
_selected = clamped;
|
||||
Invalidate();
|
||||
SelectedChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public TabStrip(params string[] tabs)
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
_tabs.AddRange(tabs);
|
||||
Height = 36;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Larghezze proporzionali al testo: le etichette lunghe non vengono troncate solo
|
||||
/// perché condividono la barra con etichette corte.
|
||||
/// </summary>
|
||||
private float[] TabWidths()
|
||||
{
|
||||
var widths = new float[_tabs.Count];
|
||||
float total = 0;
|
||||
|
||||
using (var graphics = CreateGraphics())
|
||||
{
|
||||
for (int i = 0; i < _tabs.Count; i++)
|
||||
{
|
||||
widths[i] = TextRenderer.MeasureText(graphics, _tabs[i], Theme.SmallBold).Width + 22;
|
||||
total += widths[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (total <= 0) return widths;
|
||||
float scale = Width / total;
|
||||
for (int i = 0; i < widths.Length; i++) widths[i] *= scale;
|
||||
return widths;
|
||||
}
|
||||
|
||||
private int IndexAt(int x)
|
||||
{
|
||||
if (_tabs.Count == 0) return -1;
|
||||
var widths = TabWidths();
|
||||
float cursor = 0;
|
||||
for (int i = 0; i < widths.Length; i++)
|
||||
{
|
||||
cursor += widths[i];
|
||||
if (x < cursor) return i;
|
||||
}
|
||||
return _tabs.Count - 1;
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
int index = IndexAt(e.X);
|
||||
if (index != _hovered) { _hovered = index; Invalidate(); }
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e) { _hovered = -1; Invalidate(); base.OnMouseLeave(e); }
|
||||
protected override void OnMouseDown(MouseEventArgs e) { SelectedIndex = IndexAt(e.X); base.OnMouseDown(e); }
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
if (_tabs.Count == 0) return;
|
||||
|
||||
var widths = TabWidths();
|
||||
float offset = 0;
|
||||
|
||||
for (int i = 0; i < _tabs.Count; i++)
|
||||
{
|
||||
var bounds = new RectangleF(offset, 0, widths[i], Height);
|
||||
offset += widths[i];
|
||||
bool active = i == _selected;
|
||||
|
||||
if (active) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.SurfaceAlt);
|
||||
else if (i == _hovered) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.Surface);
|
||||
|
||||
TextRenderer.DrawText(g, _tabs[i], active ? Theme.SmallBold : Theme.Small,
|
||||
Rectangle.Round(bounds), active ? Theme.Text : Theme.TextMuted,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.EndEllipsis);
|
||||
|
||||
if (active)
|
||||
{
|
||||
using var brush = new SolidBrush(Theme.Accent);
|
||||
g.FillRectangle(brush, bounds.X + bounds.Width / 2 - 12, Height - 3, 24, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Barra di avanzamento sottile con etichetta interna.</summary>
|
||||
internal sealed class DarkProgressBar : Control
|
||||
{
|
||||
private double _fraction;
|
||||
|
||||
public double Fraction
|
||||
{
|
||||
get => _fraction;
|
||||
set { _fraction = Math.Clamp(value, 0, 1); Invalidate(); }
|
||||
}
|
||||
|
||||
public DarkProgressBar()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 22;
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var bounds = new RectangleF(0, (Height - 8) / 2f, Width, 8);
|
||||
Theme.FillRounded(g, bounds, 4f, Theme.SurfaceAlt);
|
||||
|
||||
if (_fraction > 0.0005)
|
||||
{
|
||||
var filled = new RectangleF(bounds.X, bounds.Y, (float)(bounds.Width * _fraction), bounds.Height);
|
||||
Theme.FillRounded(g, filled, 4f, Theme.Accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using Titano.Core;
|
||||
using Titano.Metadata;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Tabella dei fotogrammi a rendering virtuale: disegna soltanto le righe visibili, quindi
|
||||
/// regge sequenze da decine di migliaia di scatti senza creare un controllo per riga.
|
||||
/// Barra di scorrimento, intestazioni e selezione sono disegnate a mano nel tema scuro.
|
||||
/// </summary>
|
||||
internal sealed class FrameTable : Control
|
||||
{
|
||||
private sealed record Column(string Title, int Width, bool RightAligned, Func<FrameRecord, string> Value);
|
||||
|
||||
private const int RowHeight = 24;
|
||||
private const int HeaderHeight = 30;
|
||||
private const int ScrollWidth = 12;
|
||||
|
||||
private readonly Column[] _columns;
|
||||
private TimelapseSequence? _sequence;
|
||||
private int _scroll;
|
||||
private int _hoverRow = -1;
|
||||
private int _selectedIndex = -1;
|
||||
private bool _draggingScroll;
|
||||
private int _dragOffset;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (_selectedIndex == value) return;
|
||||
_selectedIndex = value;
|
||||
EnsureVisible(value);
|
||||
Invalidate();
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public FrameTable()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
TabStop = true;
|
||||
|
||||
_columns =
|
||||
[
|
||||
new Column("#", 52, true, r => (r.Index + 1).ToString()),
|
||||
new Column("File", 178, false, r => r.FileName),
|
||||
new Column("Ora di scatto", 104, false, r => r.Metadata.CaptureTime?.ToString("HH:mm:ss.ff") ?? "—"),
|
||||
new Column("Δt", 68, true, r => r.CadenceText),
|
||||
new Column("Posa", 62, true, r => r.Metadata.ExposureText),
|
||||
new Column("Apertura", 68, true, r => r.Metadata.ApertureText),
|
||||
new Column("ISO", 52, true, r => r.Metadata.IsoText),
|
||||
new Column("Otturatore", 74, true, r => r.ShutterAngle > 0 ? $"{r.ShutterAngle:0.#}°" : "—"),
|
||||
new Column("Luminanza", 80, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.MeasuredLuminance, 1e-9)):0.00}" : "—"),
|
||||
new Column("Target", 72, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.TargetLuminance, 1e-9)):0.00}" : "—"),
|
||||
new Column("Guadagno", 76, true, r => r.LuminanceAnalyzed ? $"{r.GainStops:+0.00;-0.00;0.00}" : "—"),
|
||||
new Column("Blur", 62, true, r => r.BlurLength > 0.01 ? $"{r.BlurLength:0.0} px" : "—"),
|
||||
new Column("Movimento", 82, true, r => r.MotionMagnitude > 0.01 ? $"{r.MotionMagnitude:0.0} px" : "—"),
|
||||
];
|
||||
}
|
||||
|
||||
public void SetSequence(TimelapseSequence? sequence)
|
||||
{
|
||||
_sequence = sequence;
|
||||
_scroll = 0;
|
||||
_hoverRow = -1;
|
||||
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void Refresh(TimelapseSequence? sequence)
|
||||
{
|
||||
_sequence = sequence;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private int VisibleRows => Math.Max(1, (Height - HeaderHeight) / RowHeight);
|
||||
private int RowCount => _sequence?.Count ?? 0;
|
||||
private int MaxScroll => Math.Max(0, RowCount - VisibleRows);
|
||||
|
||||
private void EnsureVisible(int index)
|
||||
{
|
||||
if (index < 0) return;
|
||||
if (index < _scroll) _scroll = index;
|
||||
else if (index >= _scroll + VisibleRows) _scroll = index - VisibleRows + 1;
|
||||
_scroll = Math.Clamp(_scroll, 0, MaxScroll);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) => keyData is Keys.Up or Keys.Down or Keys.PageUp or Keys.PageDown;
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (RowCount == 0) return;
|
||||
switch (e.KeyCode)
|
||||
{
|
||||
case Keys.Up: SelectedIndex = Math.Max(0, _selectedIndex - 1); break;
|
||||
case Keys.Down: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + 1); break;
|
||||
case Keys.PageUp: SelectedIndex = Math.Max(0, _selectedIndex - VisibleRows); break;
|
||||
case Keys.PageDown: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + VisibleRows); break;
|
||||
case Keys.Home: SelectedIndex = 0; break;
|
||||
case Keys.End: SelectedIndex = RowCount - 1; break;
|
||||
default: base.OnKeyDown(e); return;
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
_scroll = Math.Clamp(_scroll - Math.Sign(e.Delta) * 3, 0, MaxScroll);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
|
||||
if (e.X >= Width - ScrollWidth && RowCount > VisibleRows)
|
||||
{
|
||||
var thumb = ThumbBounds();
|
||||
if (thumb.Contains(e.Location)) { _draggingScroll = true; _dragOffset = e.Y - thumb.Top; }
|
||||
else ScrollToThumb(e.Y - thumb.Height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
int row = (e.Y - HeaderHeight) / RowHeight;
|
||||
int index = _scroll + row;
|
||||
if (e.Y >= HeaderHeight && index >= 0 && index < RowCount) SelectedIndex = index;
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
if (_draggingScroll) { ScrollToThumb(e.Y - _dragOffset); return; }
|
||||
|
||||
int row = e.Y >= HeaderHeight ? (e.Y - HeaderHeight) / RowHeight : -1;
|
||||
if (row != _hoverRow) { _hoverRow = row; Invalidate(); }
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e) { _draggingScroll = false; base.OnMouseUp(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hoverRow = -1; Invalidate(); base.OnMouseLeave(e); }
|
||||
|
||||
private Rectangle ThumbBounds()
|
||||
{
|
||||
int trackHeight = Height - HeaderHeight;
|
||||
if (RowCount <= VisibleRows) return new Rectangle(Width - ScrollWidth, HeaderHeight, ScrollWidth, trackHeight);
|
||||
|
||||
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / RowCount);
|
||||
int available = trackHeight - thumbHeight;
|
||||
int offset = MaxScroll == 0 ? 0 : available * _scroll / MaxScroll;
|
||||
return new Rectangle(Width - ScrollWidth + 2, HeaderHeight + offset, ScrollWidth - 4, thumbHeight);
|
||||
}
|
||||
|
||||
private void ScrollToThumb(int top)
|
||||
{
|
||||
int trackHeight = Height - HeaderHeight;
|
||||
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / Math.Max(1, RowCount));
|
||||
int available = Math.Max(1, trackHeight - thumbHeight);
|
||||
double fraction = Math.Clamp((top - HeaderHeight) / (double)available, 0, 1);
|
||||
_scroll = (int)Math.Round(fraction * MaxScroll);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Surface);
|
||||
|
||||
int contentWidth = Width - (RowCount > VisibleRows ? ScrollWidth : 0);
|
||||
DrawHeader(g, contentWidth);
|
||||
|
||||
if (_sequence is not { Count: > 0 })
|
||||
{
|
||||
TextRenderer.DrawText(g, "Trascina qui le immagini della sequenza, oppure usa «Aggiungi cartella»",
|
||||
Theme.Body, new Rectangle(0, HeaderHeight, Width, Height - HeaderHeight),
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
int last = Math.Min(RowCount, _scroll + VisibleRows + 1);
|
||||
for (int index = _scroll; index < last; index++)
|
||||
{
|
||||
int y = HeaderHeight + (index - _scroll) * RowHeight;
|
||||
if (y > Height) break;
|
||||
DrawRow(g, _sequence.Frames[index], index, y, contentWidth);
|
||||
}
|
||||
|
||||
DrawScrollBar(g);
|
||||
}
|
||||
|
||||
private void DrawHeader(Graphics g, int contentWidth)
|
||||
{
|
||||
using (var brush = new SolidBrush(Theme.Background))
|
||||
g.FillRectangle(brush, 0, 0, Width, HeaderHeight);
|
||||
using (var pen = new Pen(Theme.Border))
|
||||
g.DrawLine(pen, 0, HeaderHeight - 1, Width, HeaderHeight - 1);
|
||||
|
||||
int x = 8;
|
||||
foreach (var column in _columns)
|
||||
{
|
||||
if (x > contentWidth) break;
|
||||
var bounds = new Rectangle(x, 0, Math.Min(column.Width - 8, contentWidth - x), HeaderHeight);
|
||||
TextRenderer.DrawText(g, column.Title, Theme.SmallBold, bounds, Theme.TextMuted,
|
||||
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
|
||||
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
x += column.Width;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRow(Graphics g, FrameRecord record, int index, int y, int contentWidth)
|
||||
{
|
||||
bool selected = index == _selectedIndex;
|
||||
bool hovered = _hoverRow == index - _scroll;
|
||||
|
||||
if (selected)
|
||||
{
|
||||
using var brush = new SolidBrush(Color.FromArgb(58, Theme.Accent));
|
||||
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
|
||||
using var edge = new SolidBrush(Theme.Accent);
|
||||
g.FillRectangle(edge, 0, y, 2, RowHeight);
|
||||
}
|
||||
else if (hovered)
|
||||
{
|
||||
using var brush = new SolidBrush(Theme.SurfaceAlt);
|
||||
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
|
||||
}
|
||||
else if ((index & 1) == 1)
|
||||
{
|
||||
using var brush = new SolidBrush(Color.FromArgb(0x1F, 0x22, 0x29));
|
||||
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
|
||||
}
|
||||
|
||||
int x = 8;
|
||||
for (int c = 0; c < _columns.Length; c++)
|
||||
{
|
||||
var column = _columns[c];
|
||||
if (x > contentWidth) break;
|
||||
|
||||
Color color = c switch
|
||||
{
|
||||
0 => Theme.TextFaint,
|
||||
1 => selected ? Theme.Text : Theme.Text,
|
||||
10 => GainColor(record),
|
||||
_ => Theme.TextMuted,
|
||||
};
|
||||
|
||||
// Un intervallo anomalo va segnalato dove si legge: sulla colonna Δt.
|
||||
if (c == 3 && record.IsCadenceAnomaly) color = Theme.Warning;
|
||||
if (c == 2 && record.Metadata.CaptureSource == TimestampSource.FileSystem) color = Theme.Warning;
|
||||
|
||||
var bounds = new Rectangle(x, y, Math.Min(column.Width - 8, Math.Max(0, contentWidth - x)), RowHeight);
|
||||
TextRenderer.DrawText(g, column.Value(record), c == 1 ? Theme.Body : Theme.Small, bounds, color,
|
||||
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
|
||||
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
x += column.Width;
|
||||
}
|
||||
}
|
||||
|
||||
private static Color GainColor(FrameRecord record)
|
||||
{
|
||||
if (!record.LuminanceAnalyzed) return Theme.TextFaint;
|
||||
double stops = Math.Abs(record.GainStops);
|
||||
if (stops < 0.02) return Theme.TextMuted;
|
||||
return record.GainStops > 0 ? Theme.Success : Theme.Danger;
|
||||
}
|
||||
|
||||
private void DrawScrollBar(Graphics g)
|
||||
{
|
||||
if (RowCount <= VisibleRows) return;
|
||||
|
||||
using (var track = new SolidBrush(Theme.Background))
|
||||
g.FillRectangle(track, Width - ScrollWidth, HeaderHeight, ScrollWidth, Height - HeaderHeight);
|
||||
|
||||
var thumb = ThumbBounds();
|
||||
Theme.FillRounded(g, thumb, 3f, _draggingScroll ? Theme.Accent : Theme.BorderStrong);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using Titano.Analysis;
|
||||
using Titano.Core;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Sistema di rendering vettoriale per la curva di esposizione.
|
||||
///
|
||||
/// Sovrappone la luminanza misurata (lo sfarfallio, in ambra) alla curva target calcolata
|
||||
/// dal deflicker (in blu), riempiendo lo scarto fra le due — che è esattamente la correzione
|
||||
/// applicata. Una corsia inferiore mostra il guadagno in stop per fotogramma e le anomalie
|
||||
/// di cadenza dell'intervallometro. Tutto è disegnato con primitive vettoriali: zoom e
|
||||
/// spostamento non degradano la resa.
|
||||
/// </summary>
|
||||
internal sealed class LuminanceChart : Control
|
||||
{
|
||||
private TimelapseSequence? _sequence;
|
||||
private DeflickerCurve? _curve;
|
||||
|
||||
private double _viewStart;
|
||||
private double _viewEnd = 1;
|
||||
private int _hoverIndex = -1;
|
||||
private int _selectedIndex = -1;
|
||||
private Point _mousePosition;
|
||||
private bool _panning;
|
||||
private double _panAnchor;
|
||||
private int _panOriginX;
|
||||
|
||||
private const int GutterLeft = 62;
|
||||
private const int GutterBottom = 22;
|
||||
private const int GutterTop = 26;
|
||||
private const int GainLaneHeight = 62;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (_selectedIndex == value) return;
|
||||
_selectedIndex = value;
|
||||
Invalidate();
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public LuminanceChart()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
}
|
||||
|
||||
public void SetData(TimelapseSequence? sequence, DeflickerCurve? curve)
|
||||
{
|
||||
_sequence = sequence;
|
||||
_curve = curve;
|
||||
_viewStart = 0;
|
||||
_viewEnd = Math.Max(1, (sequence?.Count ?? 1) - 1);
|
||||
_hoverIndex = -1;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Aggiorna la sola curva conservando zoom e selezione (cursori del deflicker).</summary>
|
||||
public void UpdateCurve(DeflickerCurve? curve)
|
||||
{
|
||||
_curve = curve;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private Rectangle PlotArea
|
||||
{
|
||||
get
|
||||
{
|
||||
int height = Math.Max(40, Height - GutterTop - GutterBottom - GainLaneHeight);
|
||||
return new Rectangle(GutterLeft, GutterTop, Math.Max(10, Width - GutterLeft - 12), height);
|
||||
}
|
||||
}
|
||||
|
||||
private Rectangle GainArea
|
||||
{
|
||||
get
|
||||
{
|
||||
var plot = PlotArea;
|
||||
return new Rectangle(plot.Left, plot.Bottom + 6, plot.Width, GainLaneHeight - 12);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
_mousePosition = e.Location;
|
||||
|
||||
if (_panning)
|
||||
{
|
||||
var plot = PlotArea;
|
||||
double span = _viewEnd - _viewStart;
|
||||
double delta = (e.X - _panOriginX) / (double)Math.Max(1, plot.Width) * span;
|
||||
double start = _panAnchor - delta;
|
||||
int max = Math.Max(0, (_sequence?.Count ?? 1) - 1);
|
||||
start = Math.Clamp(start, 0, Math.Max(0, max - span));
|
||||
_viewStart = start;
|
||||
_viewEnd = start + span;
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
int index = IndexAt(e.X);
|
||||
if (index != _hoverIndex) { _hoverIndex = index; Invalidate(); }
|
||||
else if (index >= 0) Invalidate();
|
||||
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hoverIndex = -1;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
if (e.Button == MouseButtons.Left)
|
||||
{
|
||||
int index = IndexAt(e.X);
|
||||
if (index >= 0) SelectedIndex = index;
|
||||
}
|
||||
else if (e.Button == MouseButtons.Right)
|
||||
{
|
||||
_panning = true;
|
||||
_panAnchor = _viewStart;
|
||||
_panOriginX = e.X;
|
||||
Cursor = Cursors.SizeWE;
|
||||
}
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
_panning = false;
|
||||
Cursor = Cursors.Default;
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
if (_sequence is not { Count: > 1 }) return;
|
||||
|
||||
int last = _sequence.Count - 1;
|
||||
var plot = PlotArea;
|
||||
double fraction = Math.Clamp((e.X - plot.Left) / (double)Math.Max(1, plot.Width), 0, 1);
|
||||
double focus = _viewStart + fraction * (_viewEnd - _viewStart);
|
||||
|
||||
double factor = e.Delta > 0 ? 0.8 : 1.25;
|
||||
double span = Math.Clamp((_viewEnd - _viewStart) * factor, 4, last);
|
||||
|
||||
_viewStart = Math.Clamp(focus - fraction * span, 0, Math.Max(0, last - span));
|
||||
_viewEnd = _viewStart + span;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseDoubleClick(MouseEventArgs e)
|
||||
{
|
||||
_viewStart = 0;
|
||||
_viewEnd = Math.Max(1, (_sequence?.Count ?? 1) - 1);
|
||||
Invalidate();
|
||||
base.OnMouseDoubleClick(e);
|
||||
}
|
||||
|
||||
private int IndexAt(int x)
|
||||
{
|
||||
if (_sequence is not { Count: > 0 }) return -1;
|
||||
var plot = PlotArea;
|
||||
if (x < plot.Left - 4 || x > plot.Right + 4) return -1;
|
||||
|
||||
double fraction = (x - plot.Left) / (double)Math.Max(1, plot.Width);
|
||||
double position = _viewStart + fraction * (_viewEnd - _viewStart);
|
||||
return Math.Clamp((int)Math.Round(position), 0, _sequence.Count - 1);
|
||||
}
|
||||
|
||||
private float XFor(double index, Rectangle plot)
|
||||
{
|
||||
double span = Math.Max(1e-6, _viewEnd - _viewStart);
|
||||
return plot.Left + (float)((index - _viewStart) / span * plot.Width);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Surface);
|
||||
|
||||
var plot = PlotArea;
|
||||
|
||||
if (_sequence is not { Count: > 1 } || _curve is null || _curve.Count < 2)
|
||||
{
|
||||
DrawEmptyState(g);
|
||||
return;
|
||||
}
|
||||
|
||||
// Estensione verticale: unione delle due curve con un margine costante.
|
||||
double minValue = double.MaxValue, maxValue = double.MinValue;
|
||||
int from = Math.Max(0, (int)Math.Floor(_viewStart));
|
||||
int to = Math.Min(_curve.Count - 1, (int)Math.Ceiling(_viewEnd));
|
||||
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
minValue = Math.Min(minValue, Math.Min(_curve.Measured[i], _curve.Target[i]));
|
||||
maxValue = Math.Max(maxValue, Math.Max(_curve.Measured[i], _curve.Target[i]));
|
||||
}
|
||||
if (minValue > maxValue) { minValue = -4; maxValue = -1; }
|
||||
|
||||
double padding = Math.Max(0.12, (maxValue - minValue) * 0.15);
|
||||
minValue -= padding;
|
||||
maxValue += padding;
|
||||
|
||||
DrawGrid(g, plot, minValue, maxValue);
|
||||
DrawCadenceMarkers(g, plot);
|
||||
DrawCorrectionBand(g, plot, from, to, minValue, maxValue);
|
||||
DrawCurve(g, plot, _curve.Measured, from, to, minValue, maxValue, Theme.Measured, 1.4f);
|
||||
DrawCurve(g, plot, _curve.Target, from, to, minValue, maxValue, Theme.Accent, 2.1f);
|
||||
DrawGainLane(g, from, to);
|
||||
DrawSelection(g, plot);
|
||||
DrawLegend(g);
|
||||
DrawHover(g, plot, minValue, maxValue);
|
||||
}
|
||||
|
||||
private void DrawEmptyState(Graphics g)
|
||||
{
|
||||
string message = _sequence is null
|
||||
? "Nessuna sequenza caricata"
|
||||
: "Esegui l'analisi per visualizzare la curva di esposizione";
|
||||
|
||||
TextRenderer.DrawText(g, message, Theme.Body, new Rectangle(0, 0, Width, Height),
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
private float YFor(double value, Rectangle plot, double min, double max)
|
||||
{
|
||||
double span = Math.Max(1e-6, max - min);
|
||||
return plot.Bottom - (float)((value - min) / span * plot.Height);
|
||||
}
|
||||
|
||||
private void DrawGrid(Graphics g, Rectangle plot, double min, double max)
|
||||
{
|
||||
using var gridPen = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
|
||||
using var axisPen = new Pen(Theme.BorderStrong);
|
||||
|
||||
// Linee orizzontali a passo di stop intero (o mezzo stop se l'intervallo è stretto).
|
||||
double step = (max - min) > 4 ? 1.0 : (max - min) > 1.6 ? 0.5 : 0.25;
|
||||
double first = Math.Ceiling(min / step) * step;
|
||||
|
||||
for (double value = first; value <= max; value += step)
|
||||
{
|
||||
float y = YFor(value, plot, min, max);
|
||||
g.DrawLine(gridPen, plot.Left, y, plot.Right, y);
|
||||
TextRenderer.DrawText(g, value.ToString("0.##") + " EV", Theme.Small,
|
||||
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
g.DrawLine(axisPen, plot.Left, plot.Top, plot.Left, plot.Bottom);
|
||||
g.DrawLine(axisPen, plot.Left, plot.Bottom, plot.Right, plot.Bottom);
|
||||
|
||||
// Etichette dei fotogrammi lungo l'asse orizzontale.
|
||||
int count = _sequence!.Count;
|
||||
double span = _viewEnd - _viewStart;
|
||||
double labelStep = NiceStep(span / 8.0);
|
||||
double firstLabel = Math.Ceiling(_viewStart / labelStep) * labelStep;
|
||||
|
||||
for (double index = firstLabel; index <= _viewEnd; index += labelStep)
|
||||
{
|
||||
float x = XFor(index, plot);
|
||||
if (x < plot.Left - 1 || x > plot.Right + 1) continue;
|
||||
g.DrawLine(gridPen, x, plot.Top, x, plot.Bottom);
|
||||
|
||||
int frame = Math.Clamp((int)Math.Round(index), 0, count - 1);
|
||||
TextRenderer.DrawText(g, (frame + 1).ToString(), Theme.Small,
|
||||
new Rectangle((int)x - 30, Height - GutterBottom + 2, 60, 16),
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
|
||||
private static double NiceStep(double raw)
|
||||
{
|
||||
if (raw <= 1) return 1;
|
||||
double magnitude = Math.Pow(10, Math.Floor(Math.Log10(raw)));
|
||||
double normalized = raw / magnitude;
|
||||
double nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
/// <summary>Tacche verticali sugli intervalli che si discostano dalla cadenza nominale.</summary>
|
||||
private void DrawCadenceMarkers(Graphics g, Rectangle plot)
|
||||
{
|
||||
if (_sequence is null) return;
|
||||
using var brush = new SolidBrush(Color.FromArgb(60, Theme.Warning));
|
||||
|
||||
for (int i = Math.Max(0, (int)_viewStart); i <= Math.Min(_sequence.Count - 1, (int)Math.Ceiling(_viewEnd)); i++)
|
||||
{
|
||||
if (!_sequence.Frames[i].IsCadenceAnomaly) continue;
|
||||
float x = XFor(i, plot);
|
||||
float next = XFor(i + 1, plot);
|
||||
g.FillRectangle(brush, x, plot.Top, Math.Max(1.5f, next - x), plot.Height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Area fra misurato e target: è la correzione che verrà applicata.</summary>
|
||||
private void DrawCorrectionBand(Graphics g, Rectangle plot, int from, int to, double min, double max)
|
||||
{
|
||||
if (to - from < 1) return;
|
||||
|
||||
var points = new List<PointF>((to - from + 1) * 2);
|
||||
for (int i = from; i <= to; i++) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Measured[i], plot, min, max)));
|
||||
for (int i = to; i >= from; i--) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Target[i], plot, min, max)));
|
||||
|
||||
using var path = new GraphicsPath();
|
||||
path.AddPolygon(points.ToArray());
|
||||
using var brush = new SolidBrush(Color.FromArgb(38, Theme.Accent));
|
||||
|
||||
var clip = g.Clip;
|
||||
g.SetClip(plot);
|
||||
g.FillPath(brush, path);
|
||||
g.Clip = clip;
|
||||
}
|
||||
|
||||
private void DrawCurve(Graphics g, Rectangle plot, double[] values, int from, int to,
|
||||
double min, double max, Color color, float width)
|
||||
{
|
||||
if (to - from < 1) return;
|
||||
|
||||
var clip = g.Clip;
|
||||
g.SetClip(Rectangle.Inflate(plot, 2, 2));
|
||||
|
||||
using var pen = new Pen(color, width)
|
||||
{
|
||||
LineJoin = LineJoin.Round,
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round,
|
||||
};
|
||||
|
||||
int visible = to - from + 1;
|
||||
if (visible > plot.Width * 2)
|
||||
{
|
||||
// Più campioni che pixel: si traccia l'inviluppo min/max per colonna,
|
||||
// preservando l'ampiezza reale dello sfarfallio invece di alias arbitrari.
|
||||
DrawEnvelope(g, plot, values, from, to, min, max, color);
|
||||
}
|
||||
else
|
||||
{
|
||||
var points = new PointF[visible];
|
||||
for (int i = 0; i < visible; i++)
|
||||
{
|
||||
points[i] = new PointF(XFor(from + i, plot), YFor(values[from + i], plot, min, max));
|
||||
}
|
||||
if (points.Length >= 2) g.DrawLines(pen, points);
|
||||
}
|
||||
|
||||
// Punti singoli quando lo zoom è sufficiente a distinguerli.
|
||||
if (visible <= 90)
|
||||
{
|
||||
using var dot = new SolidBrush(color);
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
float x = XFor(i, plot);
|
||||
float y = YFor(values[i], plot, min, max);
|
||||
g.FillEllipse(dot, x - 2f, y - 2f, 4f, 4f);
|
||||
}
|
||||
}
|
||||
|
||||
g.Clip = clip;
|
||||
}
|
||||
|
||||
private void DrawEnvelope(Graphics g, Rectangle plot, double[] values, int from, int to,
|
||||
double min, double max, Color color)
|
||||
{
|
||||
using var pen = new Pen(Color.FromArgb(200, color), 1f);
|
||||
double perPixel = (to - from + 1) / (double)plot.Width;
|
||||
|
||||
for (int px = 0; px < plot.Width; px++)
|
||||
{
|
||||
int start = from + (int)(px * perPixel);
|
||||
int end = Math.Min(to, from + (int)((px + 1) * perPixel));
|
||||
if (start > end) continue;
|
||||
|
||||
double lo = double.MaxValue, hi = double.MinValue;
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
lo = Math.Min(lo, values[i]);
|
||||
hi = Math.Max(hi, values[i]);
|
||||
}
|
||||
|
||||
float x = plot.Left + px;
|
||||
g.DrawLine(pen, x, YFor(hi, plot, min, max), x, YFor(lo, plot, min, max) + 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Corsia inferiore: guadagno per fotogramma in stop, con lo zero al centro.</summary>
|
||||
private void DrawGainLane(Graphics g, int from, int to)
|
||||
{
|
||||
var lane = GainArea;
|
||||
if (lane.Height < 12) return;
|
||||
|
||||
double peak = 0.05;
|
||||
for (int i = from; i <= to; i++) peak = Math.Max(peak, Math.Abs(_curve!.GainStops[i]));
|
||||
peak = Math.Max(peak, 0.05);
|
||||
|
||||
float zero = lane.Top + lane.Height / 2f;
|
||||
using (var basePen = new Pen(Theme.Border)) g.DrawLine(basePen, lane.Left, zero, lane.Right, zero);
|
||||
|
||||
TextRenderer.DrawText(g, "guadagno", Theme.Small, new Rectangle(0, (int)zero - 8, GutterLeft - 6, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
TextRenderer.DrawText(g, $"fondoscala ±{peak:0.##} EV", Theme.Small,
|
||||
new Rectangle(Width - 172, 4, 160, 16), Theme.TextFaint,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
|
||||
double perColumn = (to - from + 1) / (double)Math.Max(1, lane.Width);
|
||||
float barWidth = Math.Max(1f, (float)(lane.Width / (double)Math.Max(1, to - from + 1)) - 1f);
|
||||
|
||||
using var positive = new SolidBrush(Color.FromArgb(190, Theme.Success));
|
||||
using var negative = new SolidBrush(Color.FromArgb(190, Theme.Danger));
|
||||
|
||||
if (perColumn <= 1.0)
|
||||
{
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
double gain = _curve!.GainStops[i];
|
||||
float x = XFor(i, lane) - barWidth / 2f;
|
||||
float height = (float)(Math.Abs(gain) / peak * (lane.Height / 2f - 2));
|
||||
if (height < 0.6f) continue;
|
||||
var rect = gain >= 0
|
||||
? new RectangleF(x, zero - height, barWidth, height)
|
||||
: new RectangleF(x, zero, barWidth, height);
|
||||
g.FillRectangle(gain >= 0 ? positive : negative, rect);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int px = 0; px < lane.Width; px++)
|
||||
{
|
||||
int start = from + (int)(px * perColumn);
|
||||
int end = Math.Min(to, from + (int)((px + 1) * perColumn));
|
||||
if (start > end) continue;
|
||||
|
||||
double lo = 0, hi = 0;
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
lo = Math.Min(lo, _curve!.GainStops[i]);
|
||||
hi = Math.Max(hi, _curve!.GainStops[i]);
|
||||
}
|
||||
|
||||
float x = lane.Left + px;
|
||||
float top = zero - (float)(hi / peak * (lane.Height / 2f - 2));
|
||||
float bottom = zero - (float)(lo / peak * (lane.Height / 2f - 2));
|
||||
g.FillRectangle(hi >= -lo ? positive : negative, x, top, 1f, Math.Max(1f, bottom - top));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSelection(Graphics g, Rectangle plot)
|
||||
{
|
||||
if (_selectedIndex < 0 || _sequence is null || _selectedIndex >= _sequence.Count) return;
|
||||
float x = XFor(_selectedIndex, plot);
|
||||
if (x < plot.Left || x > plot.Right) return;
|
||||
|
||||
using var pen = new Pen(Color.FromArgb(150, Theme.Text), 1f) { DashStyle = DashStyle.Dash };
|
||||
g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
|
||||
}
|
||||
|
||||
private void DrawLegend(Graphics g)
|
||||
{
|
||||
var entries = new (Color Color, string Label)[]
|
||||
{
|
||||
(Theme.Measured, "luminanza misurata"),
|
||||
(Theme.Accent, "curva target"),
|
||||
(Theme.Warning, "cadenza anomala"),
|
||||
};
|
||||
|
||||
int x = GutterLeft;
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
var (color, label) = entries[i];
|
||||
// L'ultima voce indica una fascia di sfondo, non una curva: si disegna come tale.
|
||||
bool band = i == entries.Length - 1;
|
||||
using (var brush = new SolidBrush(band ? Color.FromArgb(90, color) : color))
|
||||
g.FillRectangle(brush, x, band ? 5 : 10, 14, band ? 12 : 3);
|
||||
var size = TextRenderer.MeasureText(g, label, Theme.Small);
|
||||
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 19, 4, size.Width + 4, 16),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
x += 19 + size.Width + 18;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawHover(Graphics g, Rectangle plot, double min, double max)
|
||||
{
|
||||
if (_hoverIndex < 0 || _sequence is null || _hoverIndex >= _sequence.Count || _curve is null) return;
|
||||
|
||||
float x = XFor(_hoverIndex, plot);
|
||||
if (x < plot.Left - 2 || x > plot.Right + 2) return;
|
||||
|
||||
using (var pen = new Pen(Color.FromArgb(90, Theme.Text), 1f)) g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
|
||||
|
||||
float measuredY = YFor(_curve.Measured[_hoverIndex], plot, min, max);
|
||||
float targetY = YFor(_curve.Target[_hoverIndex], plot, min, max);
|
||||
|
||||
using (var brush = new SolidBrush(Theme.Measured)) g.FillEllipse(brush, x - 3.5f, measuredY - 3.5f, 7, 7);
|
||||
using (var brush = new SolidBrush(Theme.Accent)) g.FillEllipse(brush, x - 3.5f, targetY - 3.5f, 7, 7);
|
||||
|
||||
var record = _sequence.Frames[_hoverIndex];
|
||||
string[] lines =
|
||||
[
|
||||
$"#{_hoverIndex + 1} {record.FileName}",
|
||||
$"misurata {_curve.Measured[_hoverIndex]:0.00} EV",
|
||||
$"target {_curve.Target[_hoverIndex]:0.00} EV",
|
||||
$"guadagno {_curve.GainStops[_hoverIndex]:+0.00;-0.00;0.00} EV",
|
||||
$"intervallo {record.CadenceText} otturatore {record.ShutterAngle:0.#}°",
|
||||
];
|
||||
|
||||
int widthNeeded = 0;
|
||||
foreach (string line in lines)
|
||||
widthNeeded = Math.Max(widthNeeded, TextRenderer.MeasureText(g, line, Theme.Small).Width);
|
||||
|
||||
int boxWidth = widthNeeded + 18;
|
||||
int boxHeight = lines.Length * 15 + 12;
|
||||
int boxX = (int)(x + 14);
|
||||
if (boxX + boxWidth > Width - 6) boxX = (int)(x - 14 - boxWidth);
|
||||
int boxY = Math.Clamp(_mousePosition.Y - boxHeight / 2, plot.Top, Math.Max(plot.Top, Height - boxHeight - 4));
|
||||
|
||||
var box = new RectangleF(boxX, boxY, boxWidth, boxHeight);
|
||||
Theme.FillAndStroke(g, box, 6f, Color.FromArgb(242, Theme.Background), Theme.BorderStrong);
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
TextRenderer.DrawText(g, lines[i], i == 0 ? Theme.SmallBold : Theme.Small,
|
||||
new Rectangle(boxX + 9, boxY + 6 + i * 15, boxWidth - 18, 15),
|
||||
i == 0 ? Theme.Text : Theme.TextMuted,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
using Titano.Core;
|
||||
using Titano.Metadata;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Finestra principale: collega i controlli disegnati a mano al motore di elaborazione.</summary>
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
private readonly TitanoProject _project = new();
|
||||
|
||||
private readonly LuminanceChart _chart;
|
||||
private readonly FrameTable _table;
|
||||
private readonly PreviewPanel _preview;
|
||||
private readonly SettingsPanel _settings;
|
||||
private readonly DarkProgressBar _progress;
|
||||
private readonly Label _status;
|
||||
private readonly Label _summary;
|
||||
|
||||
private readonly DarkButton _addFilesButton;
|
||||
private readonly DarkButton _addFolderButton;
|
||||
private readonly DarkButton _clearButton;
|
||||
private readonly DarkButton _analyzeButton;
|
||||
private readonly DarkButton _exportButton;
|
||||
private readonly DarkButton _cancelButton;
|
||||
|
||||
private CancellationTokenSource? _operation;
|
||||
private bool _busy;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "Titano — time-lapse";
|
||||
MinimumSize = new Size(1180, 720);
|
||||
Size = new Size(1560, 950);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
BackColor = Theme.Background;
|
||||
ForeColor = Theme.Text;
|
||||
Font = Theme.Body;
|
||||
AllowDrop = true;
|
||||
DoubleBuffered = true;
|
||||
|
||||
_chart = new LuminanceChart { Dock = DockStyle.Fill };
|
||||
_table = new FrameTable { Dock = DockStyle.Fill };
|
||||
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
|
||||
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
|
||||
_progress = new DarkProgressBar { Dock = DockStyle.Fill };
|
||||
|
||||
_status = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ForeColor = Theme.TextMuted,
|
||||
Font = Theme.Small,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
Text = "Pronto. Trascina qui una sequenza di immagini per iniziare.",
|
||||
};
|
||||
|
||||
_summary = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ForeColor = Theme.TextFaint,
|
||||
Font = Theme.Small,
|
||||
TextAlign = ContentAlignment.MiddleRight,
|
||||
Text = string.Empty,
|
||||
};
|
||||
|
||||
_addFilesButton = new DarkButton { Text = "Aggiungi file…", Width = 130 };
|
||||
_addFolderButton = new DarkButton { Text = "Aggiungi cartella…", Width = 150 };
|
||||
_clearButton = new DarkButton { Text = "Svuota", Width = 84 };
|
||||
_analyzeButton = new DarkButton { Text = "Analizza sequenza", Width = 160 };
|
||||
_exportButton = new DarkButton { Text = "Esporta video", Width = 140, Primary = true };
|
||||
_cancelButton = new DarkButton { Text = "Annulla", Width = 96, Danger = true, Visible = false };
|
||||
|
||||
BuildLayout();
|
||||
WireEvents();
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
Theme.ApplyDarkTitleBar(this);
|
||||
}
|
||||
|
||||
protected override void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
// La cornice viene ridisegnata dal sistema alla comparsa: l'attributo va riconfermato.
|
||||
Theme.ApplyDarkTitleBar(this);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ struttura
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var toolbar = BuildToolbar();
|
||||
var statusBar = BuildStatusBar();
|
||||
|
||||
var settingsHost = new Panel
|
||||
{
|
||||
Dock = DockStyle.Right,
|
||||
Width = 372,
|
||||
BackColor = Theme.Surface,
|
||||
Padding = new Padding(1, 0, 0, 0),
|
||||
};
|
||||
settingsHost.Controls.Add(_settings);
|
||||
settingsHost.Paint += (_, e) =>
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
e.Graphics.DrawLine(pen, 0, 0, 0, settingsHost.Height);
|
||||
};
|
||||
|
||||
var center = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
|
||||
|
||||
var tableHost = Card(_table, "Fotogrammi", DockStyle.Fill);
|
||||
var chartHost = Card(_chart, "Curva di esposizione", DockStyle.Top, 268);
|
||||
var previewHost = Card(_preview, "Anteprima", DockStyle.Top, 300);
|
||||
|
||||
var chartSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
|
||||
var previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
|
||||
|
||||
center.Controls.Add(tableHost);
|
||||
center.Controls.Add(chartSplitter);
|
||||
center.Controls.Add(chartHost);
|
||||
center.Controls.Add(previewSplitter);
|
||||
center.Controls.Add(previewHost);
|
||||
|
||||
Controls.Add(center);
|
||||
Controls.Add(settingsHost);
|
||||
Controls.Add(statusBar);
|
||||
Controls.Add(toolbar);
|
||||
}
|
||||
|
||||
/// <summary>Riquadro con intestazione: unità visiva ricorrente dell'interfaccia.</summary>
|
||||
private static Panel Card(Control content, string title, DockStyle dock, int height = 0)
|
||||
{
|
||||
var host = new Panel
|
||||
{
|
||||
Dock = dock,
|
||||
BackColor = Theme.Surface,
|
||||
Padding = new Padding(1, 30, 1, 1),
|
||||
Margin = new Padding(0),
|
||||
};
|
||||
if (height > 0) host.Height = height;
|
||||
|
||||
host.Controls.Add(content);
|
||||
host.Paint += (_, e) =>
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
using (var brush = new SolidBrush(Theme.Background))
|
||||
g.FillRectangle(brush, 0, 0, host.Width, 30);
|
||||
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(12, 0, host.Width - 24, 30),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
using var pen = new Pen(Theme.Border);
|
||||
g.DrawRectangle(pen, 0, 0, host.Width - 1, host.Height - 1);
|
||||
g.DrawLine(pen, 0, 29, host.Width, 29);
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
private Panel BuildToolbar()
|
||||
{
|
||||
var bar = new Panel { Dock = DockStyle.Top, Height = 58, BackColor = Theme.Background };
|
||||
|
||||
var title = new Label
|
||||
{
|
||||
Text = "TITANO",
|
||||
Font = Theme.Title,
|
||||
ForeColor = Theme.Text,
|
||||
AutoSize = false,
|
||||
Bounds = new Rectangle(16, 12, 110, 32),
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
var subtitle = new Label
|
||||
{
|
||||
Text = "elaborazione time-lapse",
|
||||
Font = Theme.Small,
|
||||
ForeColor = Theme.TextFaint,
|
||||
AutoSize = false,
|
||||
Bounds = new Rectangle(112, 20, 160, 18),
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
int x = 288;
|
||||
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
|
||||
{
|
||||
button.Bounds = new Rectangle(x, 13, button.Width, 32);
|
||||
bar.Controls.Add(button);
|
||||
x += button.Width + 8;
|
||||
}
|
||||
|
||||
_analyzeButton.Bounds = new Rectangle(x + 16, 13, _analyzeButton.Width, 32);
|
||||
bar.Controls.Add(_analyzeButton);
|
||||
x += _analyzeButton.Width + 24;
|
||||
|
||||
_exportButton.Bounds = new Rectangle(x, 13, _exportButton.Width, 32);
|
||||
bar.Controls.Add(_exportButton);
|
||||
|
||||
bar.Controls.Add(title);
|
||||
bar.Controls.Add(subtitle);
|
||||
|
||||
bar.Paint += (_, e) =>
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
e.Graphics.DrawLine(pen, 0, bar.Height - 1, bar.Width, bar.Height - 1);
|
||||
};
|
||||
return bar;
|
||||
}
|
||||
|
||||
private Panel BuildStatusBar()
|
||||
{
|
||||
var bar = new Panel { Dock = DockStyle.Bottom, Height = 52, BackColor = Theme.Background };
|
||||
|
||||
var layout = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 3,
|
||||
RowCount = 2,
|
||||
BackColor = Theme.Background,
|
||||
Padding = new Padding(16, 6, 16, 6),
|
||||
};
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 60));
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40));
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 104));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
|
||||
|
||||
layout.Controls.Add(_status, 0, 0);
|
||||
layout.Controls.Add(_summary, 1, 0);
|
||||
layout.Controls.Add(_progress, 0, 1);
|
||||
layout.SetColumnSpan(_progress, 2);
|
||||
|
||||
_cancelButton.Dock = DockStyle.Fill;
|
||||
_cancelButton.Margin = new Padding(8, 4, 0, 4);
|
||||
layout.Controls.Add(_cancelButton, 2, 0);
|
||||
layout.SetRowSpan(_cancelButton, 2);
|
||||
|
||||
bar.Controls.Add(layout);
|
||||
bar.Paint += (_, e) =>
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
e.Graphics.DrawLine(pen, 0, 0, bar.Width, 0);
|
||||
};
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ eventi
|
||||
|
||||
private void WireEvents()
|
||||
{
|
||||
_addFilesButton.Click += (_, _) => AddFiles();
|
||||
_addFolderButton.Click += (_, _) => AddFolder();
|
||||
_clearButton.Click += (_, _) => ClearSequence();
|
||||
_analyzeButton.Click += async (_, _) => await AnalyzeAsync();
|
||||
_exportButton.Click += async (_, _) => await ExportAsync();
|
||||
_cancelButton.Click += (_, _) => _operation?.Cancel();
|
||||
|
||||
_chart.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (_chart.SelectedIndex >= 0) _table.SelectedIndex = _chart.SelectedIndex;
|
||||
ShowPreview(_chart.SelectedIndex);
|
||||
};
|
||||
|
||||
_table.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (_table.SelectedIndex >= 0) _chart.SelectedIndex = _table.SelectedIndex;
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
};
|
||||
|
||||
_settings.DeflickerChanged += (_, _) => RecomputeCurve();
|
||||
_settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis();
|
||||
_settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex);
|
||||
_settings.BrowseOutputRequested += (_, _) => BrowseOutput();
|
||||
|
||||
DragEnter += (_, e) =>
|
||||
{
|
||||
if (e.Data?.GetDataPresent(DataFormats.FileDrop) == true) e.Effect = DragDropEffects.Copy;
|
||||
};
|
||||
DragDrop += (_, e) =>
|
||||
{
|
||||
if (e.Data?.GetData(DataFormats.FileDrop) is string[] items) _ = LoadAsync(ExpandPaths(items));
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ comandi
|
||||
|
||||
private void AddFiles()
|
||||
{
|
||||
using var dialog = new OpenFileDialog
|
||||
{
|
||||
Multiselect = true,
|
||||
Title = "Seleziona i fotogrammi della sequenza",
|
||||
Filter = "Immagini (" + string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + ")|" +
|
||||
string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + "|Tutti i file|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(dialog.FileNames);
|
||||
}
|
||||
|
||||
private void AddFolder()
|
||||
{
|
||||
using var dialog = new FolderBrowserDialog { Description = "Seleziona la cartella della sequenza" };
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(ExpandPaths([dialog.SelectedPath]));
|
||||
}
|
||||
|
||||
private static string[] ExpandPaths(IEnumerable<string> items)
|
||||
{
|
||||
var files = new List<string>();
|
||||
foreach (string item in items)
|
||||
{
|
||||
if (Directory.Exists(item))
|
||||
{
|
||||
files.AddRange(Directory.EnumerateFiles(item).Where(MetadataReader.IsSupported));
|
||||
}
|
||||
else if (File.Exists(item) && MetadataReader.IsSupported(item))
|
||||
{
|
||||
files.Add(item);
|
||||
}
|
||||
}
|
||||
return [.. files];
|
||||
}
|
||||
|
||||
private async Task LoadAsync(IReadOnlyList<string> paths)
|
||||
{
|
||||
if (_busy || paths.Count == 0)
|
||||
{
|
||||
if (paths.Count == 0) SetStatus("Nessun file d'immagine riconosciuto fra quelli indicati.");
|
||||
return;
|
||||
}
|
||||
|
||||
BeginOperation("Lettura dei metadati…");
|
||||
try
|
||||
{
|
||||
var progress = new Progress<PipelineProgress>(ReportProgress);
|
||||
var sequence = await RenderPipeline.IngestAsync(paths, _project.General.CadenceTolerance,
|
||||
progress, _operation!.Token);
|
||||
_project.Sequence = sequence;
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
|
||||
_table.SetSequence(sequence);
|
||||
_chart.SetData(sequence, null);
|
||||
SuggestOutputPath(sequence);
|
||||
UpdateSummary();
|
||||
ShowPreview(0);
|
||||
|
||||
SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " +
|
||||
$"{sequence.CadenceAnomalies} intervalli anomali.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SetStatus("Caricamento annullato.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus("Caricamento non riuscito: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOperation();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearSequence()
|
||||
{
|
||||
if (_busy) return;
|
||||
_project.Sequence = null;
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
_table.SetSequence(null);
|
||||
_chart.SetData(null, null);
|
||||
_preview.Clear();
|
||||
_summary.Text = string.Empty;
|
||||
SetStatus("Sequenza svuotata.");
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private async Task AnalyzeAsync()
|
||||
{
|
||||
if (_busy || !_project.HasSequence) return;
|
||||
|
||||
BeginOperation("Analisi della luminanza…");
|
||||
try
|
||||
{
|
||||
var pipeline = new RenderPipeline(_project);
|
||||
var progress = new Progress<PipelineProgress>(ReportProgress);
|
||||
await pipeline.AnalyzeAsync(progress, _operation!.Token);
|
||||
|
||||
_chart.SetData(_project.Sequence, _project.Curve);
|
||||
_table.Refresh(_project.Sequence);
|
||||
UpdateSummary();
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
|
||||
var curve = _project.Curve!;
|
||||
double before = Analysis.DeflickerCurve.FlickerIndex(curve.Measured);
|
||||
var corrected = new double[curve.Count];
|
||||
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
|
||||
double after = Analysis.DeflickerCurve.FlickerIndex(corrected);
|
||||
|
||||
SetStatus($"Analisi completata. Sfarfallio {before:0.000} EV → {after:0.000} EV " +
|
||||
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione).");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SetStatus("Analisi annullata.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus("Analisi non riuscita: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOperation();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
if (_busy || !_project.HasSequence) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
|
||||
{
|
||||
BrowseOutput();
|
||||
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath)) return;
|
||||
}
|
||||
|
||||
BeginOperation("Elaborazione e codifica…");
|
||||
try
|
||||
{
|
||||
var pipeline = new RenderPipeline(_project);
|
||||
var progress = new Progress<PipelineProgress>(ReportProgress);
|
||||
var result = await pipeline.RenderAsync(progress, _operation!.Token);
|
||||
|
||||
_table.Refresh(_project.Sequence);
|
||||
SetStatus($"Esportazione completata: {result.EncodedFrames} fotogrammi, " +
|
||||
$"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.Elapsed.TotalSeconds:0.0} s " +
|
||||
$"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SetStatus("Esportazione interrotta. Il file contiene i fotogrammi già codificati.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus("Esportazione non riuscita: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOperation();
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseOutput()
|
||||
{
|
||||
using var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Destinazione del video",
|
||||
Filter = "Video MP4|*.mp4",
|
||||
DefaultExt = "mp4",
|
||||
FileName = Path.GetFileName(_project.Export.OutputPath) is { Length: > 0 } name ? name : "timelapse.mp4",
|
||||
};
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return;
|
||||
|
||||
_project.Export.OutputPath = dialog.FileName;
|
||||
_settings.OutputPathBox.Text = dialog.FileName;
|
||||
}
|
||||
|
||||
private void SuggestOutputPath(TimelapseSequence sequence)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_project.Export.OutputPath) || sequence.Count == 0) return;
|
||||
|
||||
string? directory = Path.GetDirectoryName(sequence.Frames[0].FilePath);
|
||||
if (directory is null) return;
|
||||
|
||||
string suggestion = Path.Combine(directory, "timelapse.mp4");
|
||||
_project.Export.OutputPath = suggestion;
|
||||
_settings.OutputPathBox.Text = suggestion;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ aggiornamenti
|
||||
|
||||
private void RecomputeCurve()
|
||||
{
|
||||
if (!_project.IsAnalyzed) return;
|
||||
new RenderPipeline(_project).RecomputeCurve();
|
||||
_chart.UpdateCurve(_project.Curve);
|
||||
_table.Refresh(_project.Sequence);
|
||||
UpdateSummary();
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
}
|
||||
|
||||
private void InvalidateAnalysis()
|
||||
{
|
||||
if (_project.Sequence is { } sequence) sequence.RecomputeTiming(_project.General.CadenceTolerance);
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
_chart.SetData(_project.Sequence, null);
|
||||
_table.Refresh(_project.Sequence);
|
||||
UpdateSummary();
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private void ShowPreview(int index)
|
||||
{
|
||||
if (_project.Sequence is not { Count: > 0 } sequence || index < 0) { _preview.Clear(); return; }
|
||||
_preview.Show(sequence, Math.Clamp(index, 0, sequence.Count - 1));
|
||||
}
|
||||
|
||||
private void UpdateSummary()
|
||||
{
|
||||
if (_project.Sequence is not { Count: > 0 } sequence)
|
||||
{
|
||||
_summary.Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var (width, height) = _project.ResolveWorkingSize();
|
||||
double outputSeconds = sequence.Count / Math.Max(1.0, _project.Export.FrameRate);
|
||||
|
||||
_summary.Text = $"{sequence.Count} scatti · {sequence.TotalDuration:hh\\:mm\\:ss} di ripresa · " +
|
||||
$"{width}×{height} · {outputSeconds:0.0} s di video" +
|
||||
(_project.IsAnalyzed ? " · analizzata" : string.Empty);
|
||||
}
|
||||
|
||||
private void ReportProgress(PipelineProgress progress)
|
||||
{
|
||||
_progress.Fraction = progress.Fraction;
|
||||
string detail = progress.Total > 0 ? $" {progress.Completed}/{progress.Total}" : string.Empty;
|
||||
string speed = progress.FramesPerSecond > 0.01
|
||||
? $" · {progress.FramesPerSecond:0.0} fps · {progress.Remaining:hh\\:mm\\:ss} rimanenti"
|
||||
: string.Empty;
|
||||
_status.Text = progress.Message + detail + speed;
|
||||
}
|
||||
|
||||
private void SetStatus(string message)
|
||||
{
|
||||
_status.Text = message;
|
||||
_progress.Fraction = 0;
|
||||
}
|
||||
|
||||
private void BeginOperation(string message)
|
||||
{
|
||||
_busy = true;
|
||||
_operation?.Dispose();
|
||||
_operation = new CancellationTokenSource();
|
||||
_status.Text = message;
|
||||
_progress.Fraction = 0;
|
||||
_cancelButton.Visible = true;
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private void EndOperation()
|
||||
{
|
||||
_busy = false;
|
||||
_cancelButton.Visible = false;
|
||||
_progress.Fraction = 0;
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private void UpdateCommandState()
|
||||
{
|
||||
bool hasSequence = _project.HasSequence;
|
||||
_addFilesButton.Enabled = !_busy;
|
||||
_addFolderButton.Enabled = !_busy;
|
||||
_clearButton.Enabled = !_busy && hasSequence;
|
||||
_analyzeButton.Enabled = !_busy && hasSequence;
|
||||
_exportButton.Enabled = !_busy && hasSequence;
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
_operation?.Cancel();
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carica e analizza una sequenza senza interazione: usata dalla modalità di cattura
|
||||
/// dell'interfaccia, che serve a verificare la resa grafica in modo riproducibile.
|
||||
/// </summary>
|
||||
internal void SelectSettingsTab(int index) => _settings.SelectPage(index);
|
||||
|
||||
internal async Task PrepareForCaptureAsync(IReadOnlyList<string> paths)
|
||||
{
|
||||
await LoadAsync(paths);
|
||||
await AnalyzeAsync();
|
||||
_table.SelectedIndex = Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Drawing.Imaging;
|
||||
using Titano.Core;
|
||||
using Titano.Imaging;
|
||||
using Titano.Motion;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Anteprima del fotogramma selezionato, resa dallo stesso motore usato in esportazione:
|
||||
/// decodifica, correzione di esposizione e — se attivo — motion blur sintetico calcolato
|
||||
/// sul campo vettoriale verso il fotogramma successivo.
|
||||
/// </summary>
|
||||
internal sealed class PreviewPanel : Control
|
||||
{
|
||||
private readonly TitanoProject _project;
|
||||
private Bitmap? _bitmap;
|
||||
private string _caption = string.Empty;
|
||||
private string _status = "Nessun fotogramma selezionato";
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _requestId;
|
||||
private bool _busy;
|
||||
|
||||
public PreviewPanel(TitanoProject project)
|
||||
{
|
||||
_project = project;
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Background;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Interlocked.Increment(ref _requestId);
|
||||
_pending?.Cancel();
|
||||
SwapBitmap(null);
|
||||
_caption = string.Empty;
|
||||
_status = "Nessun fotogramma selezionato";
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.</summary>
|
||||
public void Show(TimelapseSequence sequence, int index)
|
||||
{
|
||||
if (index < 0 || index >= sequence.Count) { Clear(); return; }
|
||||
|
||||
int requestId = Interlocked.Increment(ref _requestId);
|
||||
_pending?.Cancel();
|
||||
var source = new CancellationTokenSource();
|
||||
_pending = source;
|
||||
|
||||
var record = sequence.Frames[index];
|
||||
_caption = $"#{index + 1} {record.FileName}";
|
||||
_busy = true;
|
||||
Invalidate();
|
||||
|
||||
var project = _project;
|
||||
var token = source.Token;
|
||||
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var (bitmap, status) = Render(project, sequence, index, PreviewSize(), token);
|
||||
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; }
|
||||
SwapBitmap(bitmap);
|
||||
_status = status;
|
||||
_busy = false;
|
||||
Invalidate();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
try
|
||||
{
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
SwapBitmap(null);
|
||||
_status = "Anteprima non disponibile: " + ex.Message;
|
||||
_busy = false;
|
||||
Invalidate();
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException) { /* finestra già chiusa */ }
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
private Size PreviewSize()
|
||||
{
|
||||
int width = Math.Clamp(Width - 24, 160, 1600);
|
||||
int height = Math.Clamp(Height - 46, 120, 1200);
|
||||
return new Size(width, height);
|
||||
}
|
||||
|
||||
private void SwapBitmap(Bitmap? bitmap)
|
||||
{
|
||||
var previous = _bitmap;
|
||||
_bitmap = bitmap;
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rendering
|
||||
|
||||
private static (Bitmap? Bitmap, string Status) Render(TitanoProject project, TimelapseSequence sequence,
|
||||
int index, Size available, CancellationToken token)
|
||||
{
|
||||
var record = sequence.Frames[index];
|
||||
var metadata = record.Metadata;
|
||||
|
||||
int sourceWidth = metadata.PixelWidth;
|
||||
int sourceHeight = metadata.PixelHeight;
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0)
|
||||
(sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation);
|
||||
else if (ImageDecoder.SwapsAxes(metadata.Orientation))
|
||||
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile");
|
||||
|
||||
double scale = Math.Min(available.Width / (double)sourceWidth, available.Height / (double)sourceHeight);
|
||||
scale = Math.Min(scale, 1.0);
|
||||
int width = Math.Max(16, (int)(sourceWidth * scale) & ~1);
|
||||
int height = Math.Max(16, (int)(sourceHeight * scale) & ~1);
|
||||
|
||||
var pool = new FrameBufferPool(4);
|
||||
using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, metadata.Orientation, pool);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var status = new System.Text.StringBuilder();
|
||||
status.Append($"{sourceWidth}×{sourceHeight}");
|
||||
|
||||
var curve = project.Curve;
|
||||
if (project.Deflicker.Enabled && curve is not null && index < curve.Count)
|
||||
{
|
||||
Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index],
|
||||
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
|
||||
status.Append($" guadagno {curve.GainStops[index]:+0.00;-0.00;0.00} EV");
|
||||
}
|
||||
|
||||
ImageBuffer result = frame;
|
||||
ImageBuffer? blurred = null;
|
||||
|
||||
if (project.MotionBlur.Enabled && index + 1 < sequence.Count)
|
||||
{
|
||||
var nextMetadata = sequence.Frames[index + 1].Metadata;
|
||||
using var next = ImageDecoder.Decode(nextMetadata.FilePath, width, height, nextMetadata.Orientation, pool);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count)
|
||||
{
|
||||
Analysis.ExposureProcessor.Apply(next, curve.ChannelGain[index + 1],
|
||||
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
|
||||
}
|
||||
|
||||
var flow = new OpticalFlowEngine(project.Flow).Compute(frame, next);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
double missing = MotionBlurRenderer.MissingBlurFactor(record.ShutterAngle,
|
||||
project.MotionBlur.TargetShutterAngle,
|
||||
project.MotionBlur.Strength);
|
||||
// La scia è proporzionale alla risoluzione: in anteprima va riscalata.
|
||||
var scaledSettings = project.MotionBlur.Clone();
|
||||
scaledSettings.MaxBlurPixels = project.MotionBlur.MaxBlurPixels * scale;
|
||||
|
||||
blurred = pool.Rent(width, height);
|
||||
double length = MotionBlurRenderer.Render(frame, blurred, flow, missing, scaledSettings);
|
||||
result = blurred;
|
||||
|
||||
status.Append($" otturatore {record.ShutterAngle:0.#}° → {project.MotionBlur.TargetShutterAngle:0}°");
|
||||
status.Append($" scia {length:0.0} px");
|
||||
}
|
||||
|
||||
var bitmap = ToBitmap(result);
|
||||
blurred?.Dispose();
|
||||
return (bitmap, status.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
|
||||
private static unsafe Bitmap ToBitmap(ImageBuffer buffer)
|
||||
{
|
||||
var bitmap = new Bitmap(buffer.Width, buffer.Height, PixelFormat.Format32bppRgb);
|
||||
var locked = bitmap.LockBits(new Rectangle(0, 0, buffer.Width, buffer.Height),
|
||||
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
|
||||
try
|
||||
{
|
||||
var data = buffer.Data;
|
||||
byte* basePtr = (byte*)locked.Scan0;
|
||||
|
||||
for (int y = 0; y < buffer.Height; y++)
|
||||
{
|
||||
byte* row = basePtr + (long)y * locked.Stride;
|
||||
int sourceIndex = y * buffer.Width * ImageBuffer.Channels;
|
||||
for (int x = 0; x < buffer.Width; x++)
|
||||
{
|
||||
int i = sourceIndex + x * ImageBuffer.Channels;
|
||||
byte* pixel = row + x * 4;
|
||||
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]);
|
||||
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]);
|
||||
pixel[2] = ColorSpace.ToSrgbByte(data[i]);
|
||||
pixel[3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(locked);
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
|
||||
var frame = new Rectangle(0, 0, Width, Height - 22);
|
||||
|
||||
if (_bitmap is null)
|
||||
{
|
||||
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, frame,
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
double scale = Math.Min(frame.Width / (double)_bitmap.Width, frame.Height / (double)_bitmap.Height);
|
||||
int width = Math.Max(1, (int)(_bitmap.Width * scale));
|
||||
int height = Math.Max(1, (int)(_bitmap.Height * scale));
|
||||
var target = new Rectangle(frame.Left + (frame.Width - width) / 2,
|
||||
frame.Top + (frame.Height - height) / 2, width, height);
|
||||
|
||||
g.DrawImage(_bitmap, target);
|
||||
using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, target);
|
||||
|
||||
if (_busy)
|
||||
{
|
||||
using var overlay = new SolidBrush(Color.FromArgb(120, Theme.Background));
|
||||
g.FillRectangle(overlay, target);
|
||||
TextRenderer.DrawText(g, "Aggiornamento…", Theme.Small, target, Theme.Text,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
var footer = new Rectangle(8, Height - 20, Width - 16, 18);
|
||||
TextRenderer.DrawText(g, _caption, Theme.SmallBold, footer, Theme.Text,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
TextRenderer.DrawText(g, _status, Theme.Small, footer, Theme.TextMuted,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_pending?.Cancel();
|
||||
_pending?.Dispose();
|
||||
_bitmap?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Tavolozza e primitive di disegno del tema scuro. Tutti i controlli dell'applicazione
|
||||
/// sono resi con GDI+ a partire da questi valori: nessun tema di terze parti.
|
||||
/// </summary>
|
||||
internal static class Theme
|
||||
{
|
||||
public static readonly Color Background = Color.FromArgb(0x14, 0x16, 0x1A);
|
||||
public static readonly Color Surface = Color.FromArgb(0x1B, 0x1E, 0x24);
|
||||
public static readonly Color SurfaceAlt = Color.FromArgb(0x22, 0x26, 0x2E);
|
||||
public static readonly Color SurfaceHover = Color.FromArgb(0x2A, 0x2F, 0x39);
|
||||
public static readonly Color Border = Color.FromArgb(0x2E, 0x33, 0x3D);
|
||||
public static readonly Color BorderStrong = Color.FromArgb(0x3C, 0x43, 0x50);
|
||||
|
||||
public static readonly Color Text = Color.FromArgb(0xE6, 0xE9, 0xEF);
|
||||
public static readonly Color TextMuted = Color.FromArgb(0x98, 0xA0, 0xAE);
|
||||
public static readonly Color TextFaint = Color.FromArgb(0x6B, 0x73, 0x82);
|
||||
|
||||
public static readonly Color Accent = Color.FromArgb(0x4C, 0x9A, 0xFF);
|
||||
public static readonly Color AccentDim = Color.FromArgb(0x2F, 0x6C, 0xC2);
|
||||
public static readonly Color Measured = Color.FromArgb(0xF5, 0xA5, 0x24);
|
||||
public static readonly Color Success = Color.FromArgb(0x35, 0xC4, 0x8F);
|
||||
public static readonly Color Warning = Color.FromArgb(0xE8, 0xB3, 0x39);
|
||||
public static readonly Color Danger = Color.FromArgb(0xF0, 0x57, 0x5A);
|
||||
|
||||
public static readonly Font Body = new("Segoe UI", 9f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font BodyBold = new("Segoe UI", 9f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Small = new("Segoe UI", 8f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font SmallBold = new("Segoe UI", 8f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Title = new("Segoe UI Semibold", 14f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
|
||||
private const int DwmUseImmersiveDarkMode = 20;
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
|
||||
|
||||
/// <summary>Estende il tema scuro alla barra del titolo, disegnata dal sistema.</summary>
|
||||
public static void ApplyDarkTitleBar(Form form)
|
||||
{
|
||||
if (!form.IsHandleCreated) return;
|
||||
int enabled = 1;
|
||||
DwmSetWindowAttribute(form.Handle, DwmUseImmersiveDarkMode, ref enabled, sizeof(int));
|
||||
}
|
||||
|
||||
/// <summary>Rettangolo con angoli arrotondati, primitiva di base dell'interfaccia.</summary>
|
||||
public static GraphicsPath RoundedRect(RectangleF bounds, float radius)
|
||||
{
|
||||
var path = new GraphicsPath();
|
||||
if (radius <= 0.5f)
|
||||
{
|
||||
path.AddRectangle(bounds);
|
||||
return path;
|
||||
}
|
||||
|
||||
float diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height));
|
||||
var arc = new RectangleF(bounds.X, bounds.Y, diameter, diameter);
|
||||
|
||||
path.AddArc(arc, 180, 90);
|
||||
arc.X = bounds.Right - diameter;
|
||||
path.AddArc(arc, 270, 90);
|
||||
arc.Y = bounds.Bottom - diameter;
|
||||
path.AddArc(arc, 0, 90);
|
||||
arc.X = bounds.X;
|
||||
path.AddArc(arc, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
public static void FillRounded(Graphics g, RectangleF bounds, float radius, Color fill)
|
||||
{
|
||||
using var path = RoundedRect(bounds, radius);
|
||||
using var brush = new SolidBrush(fill);
|
||||
g.FillPath(brush, path);
|
||||
}
|
||||
|
||||
public static void DrawRounded(Graphics g, RectangleF bounds, float radius, Color stroke, float width = 1f)
|
||||
{
|
||||
using var path = RoundedRect(bounds, radius);
|
||||
using var pen = new Pen(stroke, width);
|
||||
g.DrawPath(pen, path);
|
||||
}
|
||||
|
||||
public static void FillAndStroke(Graphics g, RectangleF bounds, float radius, Color fill, Color stroke)
|
||||
{
|
||||
FillRounded(g, bounds, radius, fill);
|
||||
DrawRounded(g, RectangleF.Inflate(bounds, -0.5f, -0.5f), radius, stroke);
|
||||
}
|
||||
|
||||
public static void HighQuality(Graphics g)
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
|
||||
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
|
||||
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
}
|
||||
|
||||
public static Color Mix(Color a, Color b, double t)
|
||||
{
|
||||
t = Math.Clamp(t, 0, 1);
|
||||
return Color.FromArgb(
|
||||
(int)(a.A + (b.A - a.A) * t),
|
||||
(int)(a.R + (b.R - a.R) * t),
|
||||
(int)(a.G + (b.G - a.G) * t),
|
||||
(int)(a.B + (b.B - a.B) * t));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user