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>
289 lines
11 KiB
C#
289 lines
11 KiB
C#
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);
|
|
}
|
|
}
|