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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 21:40:26 +02:00
co-authored by Claude Opus 5
parent 165eff627b
commit cc1d040ac0
46 changed files with 9453 additions and 0 deletions
+488
View File
@@ -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);
}
}
}
+288
View File
@@ -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);
}
}
+549
View File
@@ -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);
}
}
}
+588
View File
@@ -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));
}
}
+272
View File
@@ -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);
}
}
+354
View File
@@ -0,0 +1,354 @@
using Titano.Pipeline;
using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Pannello di configurazione avanzata, suddiviso nelle tre sezioni richieste:
/// Generale, Elaborazione immagini (deflicker, motion blur, campo vettoriale) ed
/// Esportazione video. Ogni controllo scrive direttamente nel progetto e segnala
/// quale parte della pipeline va ricalcolata.
/// </summary>
internal sealed class SettingsPanel : Panel
{
private readonly TitanoProject _project;
private readonly TabStrip _tabs;
private readonly Panel[] _pages;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</summary>
public event EventHandler? DeflickerChanged;
/// <summary>È cambiato un parametro che invalida l'analisi già svolta.</summary>
public event EventHandler? AnalysisInvalidated;
/// <summary>È cambiato un parametro che modifica solo l'anteprima o l'esportazione.</summary>
public event EventHandler? PreviewInvalidated;
public event EventHandler? BrowseOutputRequested;
public TextBox OutputPathBox { get; }
public SettingsPanel(TitanoProject project)
{
_project = project;
BackColor = Theme.Surface;
Padding = new Padding(0);
_tabs = new TabStrip("Generale", "Elaborazione immagini", "Esportazione") { Dock = DockStyle.Top };
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
OutputPathBox = new TextBox
{
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.Body,
Dock = DockStyle.Top,
};
OutputPathBox.TextChanged += (_, _) =>
{
_project.Export.OutputPath = OutputPathBox.Text;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
// L'ordine di inserimento determina l'ordine di ancoraggio: i controlli in coda alla
// collezione vengono disposti per primi, quindi la barra delle schede va aggiunta
// dopo le pagine per riservarsi la propria fascia in alto.
_pages = [BuildGeneralPage(), BuildImagePage(), BuildExportPage()];
foreach (var page in _pages)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
Controls.Add(page);
}
Controls.Add(_tabs);
ShowPage(0);
}
/// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
private void ShowPage(int index)
{
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
}
// ------------------------------------------------------------------ pagine
private Panel BuildGeneralPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Sequenza"));
stack.Add(Combo("Risoluzione di lavoro",
["Nativa (piena risoluzione)", "3840 px (4K UHD)", "2560 px", "1920 px (Full HD)", "1280 px"],
WorkingWidthToIndex(_project.General.WorkingWidth),
index =>
{
_project.General.WorkingWidth = index switch { 1 => 3840, 2 => 2560, 3 => 1920, 4 => 1280, _ => 0 };
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
value =>
{
_project.General.CadenceTolerance = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Prestazioni"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
value =>
{
_project.General.AnalysisWidth = (int)value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _project.General.DecodeParallelism, 1, "0", "thread",
value =>
{
_project.General.DecodeParallelism = (int)value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Note("Le decodifiche simultanee determinano anche quanti fotogrammi restano " +
"contemporaneamente in memoria: l'occupazione non dipende dalla lunghezza della sequenza."));
return stack.Panel;
}
private Panel BuildImagePage()
{
var stack = NewStack();
// ---- Deflicker
stack.Add(new SectionHeader("Deflicker"));
var deflickerEnabled = Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
{
_project.Deflicker.Enabled = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
});
stack.Add(deflickerEnabled);
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
value => { _project.Deflicker.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.1, 3.0, _project.Deflicker.MaxCorrectionStops, 0.1, "0.0", "EV",
value => { _project.Deflicker.MaxCorrectionStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers, value =>
{
_project.Deflicker.RejectOutliers = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Stabilizza il bilanciamento colore", _project.Deflicker.StabilizeColor, value =>
{
_project.Deflicker.StabilizeColor = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
{
_project.Deflicker.ProtectHighlights = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty,
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
// ---- Motion blur
stack.Add(new SectionHeader("Motion blur sintetico"));
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
{
_project.MotionBlur.Enabled = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Shutter angle obiettivo", 0, 360, _project.MotionBlur.TargetShutterAngle, 5, "0", "°",
value => { _project.MotionBlur.TargetShutterAngle = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.MotionBlur.Strength, 0.05, "0.00", string.Empty,
value => { _project.MotionBlur.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza massima della scia", 4, 160, _project.MotionBlur.MaxBlurPixels, 2, "0", "px",
value => { _project.MotionBlur.MaxBlurPixels = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Campioni per pixel", 3, 65, _project.MotionBlur.MaxSamples, 2, "0", string.Empty,
value => { _project.MotionBlur.MaxSamples = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " +
"√(obiettivo² reale²). A 180° si ottiene la resa cinematografica."));
// ---- Optical flow
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
value => { _project.Flow.AnalysisWidth = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Passo della griglia", 4, 32, _project.Flow.CellSize, 1, "0", "px",
value => { _project.Flow.CellSize = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Livelli della piramide", 1, 6, _project.Flow.PyramidLevels, 1, "0", string.Empty,
value => { _project.Flow.PyramidLevels = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Raggio della finestra", 2, 12, _project.Flow.WindowRadius, 1, "0", "px",
value => { _project.Flow.WindowRadius = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
return stack.Panel;
}
private Panel BuildExportPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Formato"));
stack.Add(Combo("Codec", ["H.264 / AVC", "H.265 / HEVC"], _project.Export.Codec == VideoCodec.H264 ? 0 : 1,
index =>
{
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
index => _project.Export.Profile = index switch
{
0 => H264Profile.Baseline,
1 => H264Profile.Main,
_ => H264Profile.High,
}));
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
value => _project.Export.BitrateMbps = value));
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
value => _project.Export.KeyframeIntervalSeconds = (int)value));
stack.Add(new SectionHeader("Andamento temporale"));
stack.Add(Combo("Durata dei fotogrammi",
["Costante — un fotogramma per scatto",
"Adattiva — durata proporzionale all'intervallo",
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
(int)_project.Export.Timing,
index =>
{
_project.Export.Timing = (FrameTimingMode)index;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
value => _project.Export.MaxAdaptiveStretch = value));
stack.Add(new SectionHeader("Codifica"));
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
value => _project.Export.PreferHardware = value));
stack.Add(new SectionHeader("Destinazione"));
stack.Add(OutputPathBox);
var browse = new DarkButton { Text = "Scegli il file di destinazione…", Height = 32, Dock = DockStyle.Top };
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
stack.Add(browse);
stack.Add(Note("Il video viene scritto in un unico flusso continuo: l'elaborazione non " +
"genera alcun file temporaneo su disco."));
return stack.Panel;
}
// ------------------------------------------------------------------ costruttori di controlli
private sealed class Stack(Panel panel)
{
public Panel Panel { get; } = panel;
private int _y;
public void Add(Control control)
{
control.Dock = DockStyle.None;
control.Left = 14;
control.Top = _y;
control.Width = Panel.ClientSize.Width - 34;
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right;
Panel.Controls.Add(control);
_y += control.Height + 6;
}
}
private static Stack NewStack()
{
var panel = new Panel
{
BackColor = Theme.Surface,
Padding = new Padding(0, 8, 0, 12),
Width = 360,
};
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 360 };
panel.Controls.Add(host);
return new Stack(host);
}
private static ParameterSlider Slider(string caption, double min, double max, double value, double step,
string format, string unit, Action<double> onChange)
{
var slider = new ParameterSlider
{
Caption = caption,
Minimum = min,
Maximum = max,
Step = step,
ValueFormat = format,
Unit = unit,
};
slider.SetValueSilently(value);
slider.ValueChanged += (_, _) => onChange(slider.Value);
return slider;
}
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
{
var box = new DarkCheckBox { Text = caption, Checked = value };
box.CheckedChanged += (_, _) => onChange(box.Checked);
return box;
}
private static LabeledCombo Combo(string caption, string[] items, int selected, Action<int> onChange)
{
var row = new LabeledCombo(caption);
row.Combo.Items.AddRange(items);
row.Combo.SelectedIndex = Math.Clamp(selected, 0, items.Length - 1);
row.Combo.SelectedIndexChanged += (_, _) => onChange(row.Combo.SelectedIndex);
return row;
}
private static Label Note(string text) => new()
{
Text = text,
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Height = 52,
BackColor = Theme.Surface,
};
private static int WorkingWidthToIndex(int width) => width switch
{
3840 => 1,
2560 => 2,
1920 => 3,
1280 => 4,
_ => 0,
};
}
+110
View File
@@ -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));
}
}