Navigazione verticale e strumenti di misura nell'interfaccia

Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che
governa insieme il contenuto principale e la colonna delle impostazioni: una
sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da
tenere allineate a mano; adesso ce n'e' una sola, in verticale ci sta il nome
per esteso, e la sezione scelta resta leggibile mentre si lavora - cosa che in
una fila di schede in cima si perde appena l'occhio scende sul contenuto.
Accanto a ogni voce compare il numero di avvisi che la riguardano.

L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto
sempre, perche' in un programma che tratta immagini l'immagine si guarda
mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile
per giudicare e non solo per guardare. Zoom e trascinamento, perche' una
stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per
stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono
invece di essere interpolati. Confronto a tendina fra originale e corretto,
perche' l'unico modo di capire cosa una correzione stia facendo e' vedere
accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore
calcola comunque e che spiega perche' la sfocatura viene come viene,
soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso
la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una
panoramica virtuale.

Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini
e pannello degli avvisi sulla sequenza, istogramma e forma d'onda
sull'esposizione, percorso ricostruito della stabilizzazione sul movimento,
piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e
che finivano in due numeri in fondo a una riga di stato, cioe' invisibili.

Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu':
un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo
passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello
che il menu dell'orientamento usava da solo, esteso a larghezza di analisi,
finestra del deflicker, lunghezza della transizione, finestra della
stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del
deflicker viene da quattro periodi dello sfarfallio misurati
sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende
liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi.
Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce
meno decisioni e lascia il cursore dov'e'.

Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e'
l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non
toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un
ostacolo. Gli annunci si leggono da una cartella locale con un listino in
formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun
identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito
pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze
esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta
montando un time-lapse. Le campagne si aggiornano copiando file; a listino
vuoto compaiono note interne; il riquadro si spegne dalle preferenze.

Aggiunge anche le preferenze dell'applicazione, distinte da quelle del
progetto, con persistenza in un file di testo scritto a mano nello stesso
spirito del resto: una riga per voce, correggibile con un editor. Il progetto
descrive come trattare questi fotogrammi, le preferenze come si comporta il
programma.

Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche'
trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e
anteprima dei fotogrammi mentre vengono codificati, che non migliora il
risultato di un pixel ma cambia molto un'attesa di mezz'ora.

Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le
sezioni ispezionate a video sulla scena di prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 23:58:23 +02:00
co-authored by Claude Opus 5
parent 790637bc0d
commit 2e5a441938
19 changed files with 4481 additions and 347 deletions
+138
View File
@@ -0,0 +1,138 @@
using System.Reflection;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Pagina della sezione Impostazioni: mentre la colonna di destra contiene le preferenze,
/// qui sta ciò che serve sapere sul programma e sullo stato in cui si trova — versione,
/// dove tiene i propri file, cosa il sistema mette a disposizione, quali moduli sono accesi.
///
/// È anche il posto dove il vincolo che definisce il progetto viene detto per esteso, perché
/// è la cosa che spiega tutte le altre scelte.
/// </summary>
internal sealed class AboutPanel : Panel
{
private readonly List<(string Label, string Value)> _rows = [];
private readonly List<string> _modules = [];
public AboutPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Padding = new Padding(16);
AutoScroll = true;
}
public void Update(TitanoProject project, AppSettings app)
{
_rows.Clear();
_modules.Clear();
var version = Assembly.GetExecutingAssembly().GetName().Version;
var memory = GC.GetGCMemoryInfo();
_rows.Add(("Versione", version?.ToString(3) ?? "1.0.0"));
_rows.Add(("Runtime", $".NET {Environment.Version}"));
_rows.Add(("Sistema", $"{Environment.OSVersion.VersionString} ({(Environment.Is64BitProcess ? "x64" : "x86")})"));
_rows.Add(("Processori", $"{Environment.ProcessorCount} logici"));
_rows.Add(("Vettori SIMD", $"{System.Numerics.Vector<float>.Count} float per registro"));
_rows.Add(("Memoria disponibile", $"{memory.TotalAvailableMemoryBytes / (1024.0 * 1024 * 1024):0.0} GiB"));
_rows.Add(("Memoria in uso", $"{GC.GetTotalMemory(false) / (1024.0 * 1024):0} MiB"));
_rows.Add(("Impostazioni", AppSettings.SettingsPath));
_rows.Add(("Campagne sponsor", app.ResolvedSponsorFolder));
_rows.Add(("Parcheggio fotogrammi", string.IsNullOrWhiteSpace(app.SpillDirectory)
? Path.GetTempPath()
: app.SpillDirectory));
if (project.Deflicker.Enabled) _modules.Add("Deflicker");
if (project.Regions.Mode != Analysis.RegionMode.Off) _modules.Add("Deflicker per regioni");
if (project.HolyGrail.Enabled) _modules.Add("Transizioni giorno-notte");
if (project.Stabilization.Enabled) _modules.Add("Stabilizzazione sub-pixel");
if (project.Camera.Enabled) _modules.Add("Camera virtuale");
if (project.TimeRamp.Enabled) _modules.Add("Rimappatura del tempo");
if (project.Stacking.Mode != Motion.StackingMode.Off) _modules.Add("Accumulo temporale");
if (project.MotionBlur.Enabled) _modules.Add("Motion blur sintetico");
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
int width = Math.Min(760, Math.Max(320, Width - 32));
int y = 16;
TextRenderer.DrawText(g, "Titano", Theme.Title, new Rectangle(16, y, width, 34), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
y += 36;
TextRenderer.DrawText(g,
"Elaborazione di time-lapse di livello professionale, sviluppata interamente in-house.",
Theme.Body, new Rectangle(16, y, width, 22), Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
y += 34;
y = DrawCard(g, 16, y, width, "IL VINCOLO CHE DEFINISCE IL PROGETTO",
"Nessuna libreria di terze parti. Il file di progetto non contiene un solo pacchetto: " +
"parser dei metadati, analisi fotometrica, campo vettoriale, trasformata di Fourier, " +
"multiplexer MP4 e ogni controllo di questa finestra sono scritti qui dentro, oppure " +
"ottenuti tramite chiamate dirette a componenti del sistema operativo. Non viene avviato " +
"nessun processo esterno.");
y += 12;
y = DrawRows(g, 16, y, width, "STATO", _rows);
y += 12;
DrawCard(g, 16, y, width, "MODULI ATTIVI SU QUESTA SEQUENZA",
_modules.Count > 0
? string.Join(" · ", _modules)
: "Nessun modulo avanzato attivo: la sequenza verrà elaborata con il solo percorso di base.");
}
private static int DrawCard(Graphics g, int x, int y, int width, string title, string body)
{
int textHeight = TextRenderer.MeasureText(body, Theme.Small, new Size(width - 32, 0),
TextFormatFlags.WordBreak).Height;
int height = 34 + textHeight + 14;
Theme.FillAndStroke(g, new RectangleF(x + 0.5f, y + 0.5f, width - 1, height - 1), 6f,
Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(x + 16, y + 10, width - 32, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, body, Theme.Small, new Rectangle(x + 16, y + 30, width - 32, textHeight + 4),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
return y + height;
}
private static int DrawRows(Graphics g, int x, int y, int width, string title,
List<(string Label, string Value)> rows)
{
int height = 34 + rows.Count * 20 + 10;
Theme.FillAndStroke(g, new RectangleF(x + 0.5f, y + 0.5f, width - 1, height - 1), 6f,
Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(x + 16, y + 10, width - 32, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
int row = y + 32;
foreach (var (label, value) in rows)
{
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 16, row, 180, 18), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, value, Theme.Small, new Rectangle(x + 200, row, width - 216, 18),
Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.PathEllipsis);
row += 20;
}
return y + height;
}
}
+280 -13
View File
@@ -145,6 +145,9 @@ internal sealed class ParameterSlider : Control
private double _value;
private bool _dragging;
private bool _hover;
private bool _hoverAuto;
private bool _isAuto;
private TextBox? _entry;
public string Caption { get; set; } = string.Empty;
public string Unit { get; set; } = string.Empty;
@@ -153,8 +156,33 @@ internal sealed class ParameterSlider : Control
public double Maximum { get; set; } = 1;
public double Step { get; set; }
/// <summary>Il valore di questo parametro il programma sa dedurlo: compare l'indicatore automatico.</summary>
public bool AutoSupported { get; set; }
/// <summary>Motivo della scelta automatica, mostrato come suggerimento.</summary>
public string AutoReason { get; set; } = string.Empty;
public event EventHandler? ValueChanged;
/// <summary>L'utente ha preso o restituito il controllo di questo parametro.</summary>
public event EventHandler? AutoChanged;
/// <summary>
/// Vero quando il valore lo sceglie il programma. Toccare il cursore lo disattiva: chi
/// mette le mani su un parametro se lo prende, senza dover prima dichiarare l'intenzione.
/// </summary>
public bool IsAuto
{
get => _isAuto;
set
{
if (_isAuto == value) return;
_isAuto = value;
Invalidate();
AutoChanged?.Invoke(this, EventArgs.Empty);
}
}
public double Value
{
get => _value;
@@ -185,13 +213,32 @@ internal sealed class ParameterSlider : Control
}
private Rectangle TrackBounds => new(2, Height - 20, Width - 4, 12);
private Rectangle ValueBounds => new(Width - 92, 2, 92, 16);
private Rectangle AutoBounds => new(Width - 92 - 46, 1, 42, 17);
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 OnMouseLeave(EventArgs e)
{
_hover = false;
_hoverAuto = false;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left || !Enabled) return;
if (AutoSupported && AutoBounds.Contains(e.Location))
{
IsAuto = !IsAuto;
return;
}
// Toccare il cursore significa prenderne il controllo.
if (IsAuto) IsAuto = false;
_dragging = true;
UpdateFromMouse(e.X);
base.OnMouseDown(e);
@@ -199,7 +246,12 @@ internal sealed class ParameterSlider : Control
protected override void OnMouseMove(MouseEventArgs e)
{
if (_dragging) UpdateFromMouse(e.X);
if (_dragging) { UpdateFromMouse(e.X); return; }
bool overAuto = AutoSupported && AutoBounds.Contains(e.Location);
if (overAuto != _hoverAuto) { _hoverAuto = overAuto; Invalidate(); }
Cursor = overAuto || ValueBounds.Contains(e.Location) ? Cursors.Hand : Cursors.Default;
base.OnMouseMove(e);
}
@@ -209,9 +261,16 @@ internal sealed class ParameterSlider : Control
base.OnMouseUp(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
if (Enabled && ValueBounds.Contains(e.Location)) BeginEdit();
base.OnMouseDoubleClick(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (!Enabled) return;
if (IsAuto) IsAuto = false;
double increment = Step > 0 ? Step : (Maximum - Minimum) / 50.0;
Value += Math.Sign(e.Delta) * increment;
}
@@ -223,6 +282,59 @@ internal sealed class ParameterSlider : Control
Value = Minimum + fraction * (Maximum - Minimum);
}
/// <summary>
/// Immissione diretta del valore. Per una tolleranza di cadenza o un angolo di otturatore
/// si vuole scrivere il numero: trascinare fino a 0,35 è un esercizio di mira, non una
/// regolazione.
/// </summary>
private void BeginEdit()
{
if (_entry is not null) return;
_entry = new TextBox
{
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.Body,
TextAlign = HorizontalAlignment.Right,
Bounds = ValueBounds,
Text = _value.ToString(ValueFormat, CultureInfo.CurrentCulture),
};
_entry.KeyDown += (_, args) =>
{
if (args.KeyCode == Keys.Enter) { CommitEdit(true); args.Handled = args.SuppressKeyPress = true; }
else if (args.KeyCode == Keys.Escape) { CommitEdit(false); args.Handled = args.SuppressKeyPress = true; }
};
_entry.LostFocus += (_, _) => CommitEdit(true);
Controls.Add(_entry);
_entry.Focus();
_entry.SelectAll();
}
private void CommitEdit(bool accept)
{
var entry = _entry;
if (entry is null) return;
_entry = null;
if (accept)
{
string text = entry.Text.Trim().Replace(',', '.');
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed))
{
if (IsAuto) IsAuto = false;
Value = parsed;
}
}
Controls.Remove(entry);
entry.Dispose();
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
@@ -232,13 +344,21 @@ internal sealed class ParameterSlider : Control
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);
int captionWidth = Width - 92 - (AutoSupported ? 50 : 4);
TextRenderer.DrawText(g, Caption, Theme.Small, new Rectangle(0, 2, Math.Max(40, captionWidth), 16),
captionColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis);
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);
if (AutoSupported) DrawAutoPill(g);
if (_entry is null)
{
string display = _value.ToString(ValueFormat, CultureInfo.CurrentCulture) +
(string.IsNullOrEmpty(Unit) ? string.Empty : " " + Unit);
TextRenderer.DrawText(g, display, Theme.SmallBold, ValueBounds,
_isAuto ? Theme.Accent : valueColor,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
var track = TrackBounds;
float centerY = track.Top + track.Height / 2f;
@@ -246,19 +366,39 @@ internal sealed class ParameterSlider : Control
Theme.FillRounded(g, groove, 2f, Enabled ? Theme.SurfaceAlt : Theme.Surface);
double span = Maximum - Minimum;
float fraction = span <= 0 ? 0 : (float)((_value - Minimum) / span);
float fraction = span <= 0 ? 0 : (float)Math.Clamp((_value - Minimum) / span, 0, 1);
// In automatico il cursore resta leggibile ma smorzato: dice qual è il valore senza
// sembrare qualcosa che è stato impostato a mano.
Color fill = !Enabled ? Theme.Border
: _isAuto ? Theme.AccentDim
: Theme.Accent;
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);
if (filled.Width > 0.5f) Theme.FillRounded(g, filled, 2f, fill);
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))
using (var brush = new SolidBrush(Enabled ? (_isAuto ? Theme.TextMuted : Theme.Text) : Theme.TextFaint))
g.FillEllipse(brush, knob);
using (var pen = new Pen(Enabled ? fill : Theme.Border, 2f))
g.DrawEllipse(pen, RectangleF.Inflate(knob, -1f, -1f));
}
private void DrawAutoPill(Graphics g)
{
var pill = AutoBounds;
Color border = _isAuto ? Theme.Accent : _hoverAuto ? Theme.BorderStrong : Theme.Border;
Color background = _isAuto ? Theme.AccentDim : _hoverAuto ? Theme.SurfaceHover : Theme.SurfaceAlt;
Color text = _isAuto ? Color.White : Theme.TextFaint;
Theme.FillAndStroke(g, new RectangleF(pill.X + 0.5f, pill.Y + 0.5f, pill.Width - 1, pill.Height - 1),
8f, background, border);
TextRenderer.DrawText(g, "AUTO", Theme.SmallBold, pill, text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
/// <summary>Etichetta e menu a discesa affiancati, con lo stile del tema.</summary>
@@ -309,6 +449,133 @@ internal sealed class LabeledCombo : Panel
}
}
/// <summary>Simboli degli interruttori dell'anteprima.</summary>
internal enum ToggleGlyph
{
Compare,
Zoom,
Motion,
Region,
}
/// <summary>
/// Interruttore compatto a simbolo, per i comandi che vivono nell'intestazione di un riquadro
/// e devono occupare poco. Il simbolo è disegnato con primitive e resta nitido a qualunque
/// risoluzione; lo stato acceso si legge dal fondo, non da una spia accanto.
/// </summary>
internal sealed class DarkToggle : Control
{
private bool _checked;
private bool _hover;
public event EventHandler? CheckedChanged;
public ToggleGlyph Glyph { get; set; }
/// <summary>Testo del suggerimento; compare come descrizione al passaggio del puntatore.</summary>
public string Hint
{
get => _hint;
set { _hint = value; _tooltip.SetToolTip(this, value); }
}
private string _hint = string.Empty;
private readonly ToolTip _tooltip = new();
public bool Checked
{
get => _checked;
set
{
if (_checked == value) return;
_checked = value;
Invalidate();
CheckedChanged?.Invoke(this, EventArgs.Empty);
}
}
public DarkToggle()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Size = new Size(26, 24);
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.Background);
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
Color fill = _checked ? Theme.AccentDim : _hover ? Theme.SurfaceAlt : Theme.Surface;
Color stroke = _checked ? Theme.Accent : _hover ? Theme.BorderStrong : Theme.Border;
Theme.FillAndStroke(g, bounds, 4f, fill, stroke);
Color tint = _checked ? Color.White : _hover ? Theme.Text : Theme.TextMuted;
DrawGlyph(g, new RectangleF(6, 5, Width - 12, Height - 10), tint);
}
private void DrawGlyph(Graphics g, RectangleF box, Color tint)
{
using var pen = new Pen(tint, 1.4f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
};
using var brush = new SolidBrush(tint);
float x = box.X, y = box.Y, w = box.Width, h = box.Height;
switch (Glyph)
{
case ToggleGlyph.Compare:
// Riquadro diviso a metà, con una metà piena: prima e dopo.
g.DrawRectangle(pen, x, y, w, h);
using (var half = new SolidBrush(Color.FromArgb(150, tint)))
g.FillRectangle(half, x + w / 2, y, w / 2, h);
break;
case ToggleGlyph.Zoom:
g.DrawEllipse(pen, x, y, w * 0.72f, h * 0.72f);
g.DrawLine(pen, x + w * 0.62f, y + h * 0.62f, x + w, y + h);
break;
case ToggleGlyph.Motion:
g.DrawLine(pen, x, y + h, x + w * 0.75f, y + h * 0.25f);
g.DrawLines(pen,
[
new PointF(x + w * 0.42f, y + h * 0.18f),
new PointF(x + w * 0.80f, y + h * 0.14f),
new PointF(x + w * 0.76f, y + h * 0.52f),
]);
break;
case ToggleGlyph.Region:
// Riquadro tagliato da una linea d'orizzonte.
g.DrawRectangle(pen, x, y, w, h);
g.DrawCurve(pen,
[
new PointF(x, y + h * 0.62f),
new PointF(x + w * 0.45f, y + h * 0.44f),
new PointF(x + w, y + h * 0.58f),
]);
break;
}
_ = brush;
}
protected override void Dispose(bool disposing)
{
if (disposing) _tooltip.Dispose();
base.Dispose(disposing);
}
}
/// <summary>Intestazione di sezione con filetto di separazione.</summary>
internal sealed class SectionHeader : Control
{
+321
View File
@@ -0,0 +1,321 @@
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Pagina dell'esportazione: cosa verrà prodotto, a che punto è, e — mentre si aspetta —
/// il riquadro degli sponsor.
///
/// La codifica di una sequenza lunga sono minuti o decine di minuti in cui non c'è niente da
/// fare. È l'unico momento del programma in cui uno spazio pubblicitario non toglie niente a
/// nessuno, ed è per questo che sta soltanto qui: accanto a un cursore che si sta regolando
/// sarebbe un ostacolo, accanto a una barra di avanzamento è qualcosa da guardare.
/// </summary>
internal sealed class ExportPanel : Panel
{
private readonly TitanoProject _project;
private readonly SponsorPanel _sponsors = new() { Dock = DockStyle.Bottom };
private readonly SummaryCard _summary = new() { Dock = DockStyle.Top, Height = 168 };
private readonly ProgressCard _progress = new() { Dock = DockStyle.Top, Height = 132 };
private readonly ResultCard _result = new() { Dock = DockStyle.Fill };
public ExportPanel(TitanoProject project)
{
_project = project;
BackColor = Theme.Background;
Padding = new Padding(14, 12, 14, 12);
// Ordine di ancoraggio: il riepilogo in cima, l'avanzamento sotto, l'esito riempie,
// gli sponsor restano ancorati in basso e non si muovono mai.
Controls.Add(_result);
Controls.Add(_progress);
Controls.Add(_summary);
Controls.Add(_sponsors);
}
public void Configure(AppSettings settings) => _sponsors.Configure(settings);
public void SetActive(bool active) => _sponsors.SetActive(active);
public void Refresh(TitanoProject project)
{
_summary.Update(project);
Invalidate(true);
}
public void ReportProgress(PipelineProgress progress)
{
_progress.Report(progress);
}
public void BeginExport()
{
_progress.Begin();
_result.Clear();
}
public void ShowResult(RenderResult result)
{
_progress.Finish();
_result.Show(result);
}
public void ShowFailure(string message)
{
_progress.Finish();
_result.ShowFailure(message);
}
// ================================================================== riepilogo
private sealed class SummaryCard : Control
{
private readonly List<(string Label, string Value)> _rows = [];
private string _title = "Nessuna sequenza caricata";
public SummaryCard()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Update(TitanoProject project)
{
_rows.Clear();
if (project.Sequence is not { Count: > 0 } sequence)
{
_title = "Nessuna sequenza caricata";
Invalidate();
return;
}
var (width, height) = project.ResolveWorkingSize();
uint baseUnits = (uint)Math.Max(1, Math.Round(project.Export.Timescale /
Math.Max(1.0, project.Export.FrameRate)));
var plan = RenderPlanner.Build(sequence, project.Export, project.TimeRamp, baseUnits);
double seconds = plan.Count / Math.Max(1.0, project.Export.FrameRate);
double megabytes = project.Export.BitrateMbps * seconds / 8.0;
_title = Path.GetFileName(project.Export.OutputPath) is { Length: > 0 } name
? name
: "destinazione non impostata";
_rows.Add(("Fotogrammi in uscita", $"{plan.Count} da {sequence.Count} scatti"));
_rows.Add(("Piano temporale", plan.Description));
_rows.Add(("Risoluzione", $"{width}×{height}"));
_rows.Add(("Durata", $"{TimeSpan.FromSeconds(seconds):mm\\:ss} a {project.Export.FrameRate:0} fps"));
_rows.Add(("Formato", $"{(project.Export.Codec == Video.VideoCodec.H264 ? "H.264" : "HEVC")} " +
$"a {project.Export.BitrateMbps:0} Mb/s · circa {megabytes:0} MiB"));
var active = new List<string>();
if (project.Deflicker.Enabled) active.Add("deflicker");
if (project.Regions.Mode != Analysis.RegionMode.Off) active.Add("regioni");
if (project.HolyGrail.Enabled) active.Add("transizioni");
if (project.Stabilization.Enabled) active.Add("stabilizzazione");
if (project.Camera.Enabled) active.Add("camera virtuale");
if (project.TimeRamp.Enabled) active.Add("rimappatura");
if (project.Stacking.Mode != Motion.StackingMode.Off) active.Add("accumulo");
if (project.MotionBlur.Enabled) active.Add("sfocatura");
_rows.Add(("Moduli attivi", active.Count > 0 ? string.Join(" · ", active) : "nessuno"));
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 0.5f, Width - 1, Height - 7);
Theme.FillAndStroke(g, card, 6f, Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, "VERRÀ PRODOTTO", Theme.SmallBold, new Rectangle(16, 10, 240, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, _title, Theme.BodyBold, new Rectangle(16, 28, Width - 32, 20),
Theme.Text, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
int y = 54;
foreach (var (label, value) in _rows)
{
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(16, y, 160, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, value, Theme.Small, new Rectangle(180, y, Width - 196, 16),
Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
y += 18;
}
}
}
// ================================================================== avanzamento
private sealed class ProgressCard : Control
{
private PipelineProgress _progress;
private bool _running;
private DateTime _started = DateTime.Now;
public ProgressCard()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Begin()
{
_running = true;
_started = DateTime.Now;
Invalidate();
}
public void Finish()
{
_running = false;
Invalidate();
}
public void Report(PipelineProgress progress)
{
_progress = progress;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 0.5f, Width - 1, Height - 7);
Theme.FillAndStroke(g, card, 6f, Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, "AVANZAMENTO", Theme.SmallBold, new Rectangle(16, 10, 240, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (!_running && _progress.Total == 0)
{
TextRenderer.DrawText(g, "In attesa di un'esportazione", Theme.Body,
new Rectangle(16, 34, Width - 32, 40), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
TextRenderer.DrawText(g, _progress.Message, Theme.Body, new Rectangle(16, 30, Width - 32, 20),
Theme.Text, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
var bar = new RectangleF(16, 58, Width - 32, 8);
Theme.FillRounded(g, bar, 4f, Theme.SurfaceAlt);
if (_progress.Fraction > 0.0005)
{
Theme.FillRounded(g, new RectangleF(bar.X, bar.Y, (float)(bar.Width * _progress.Fraction), bar.Height),
4f, Theme.Accent);
}
string counter = _progress.Total > 0
? $"{_progress.Completed} / {_progress.Total} ({_progress.Fraction * 100:0.#}%)"
: string.Empty;
TextRenderer.DrawText(g, counter, Theme.SmallBold, new Rectangle(16, 74, 260, 16), Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
var elapsed = DateTime.Now - _started;
string right = _progress.FramesPerSecond > 0.01
? $"{_progress.FramesPerSecond:0.0} fps · {_progress.Remaining:hh\\:mm\\:ss} rimanenti" +
$" · {elapsed:hh\\:mm\\:ss} trascorsi"
: $"{elapsed:hh\\:mm\\:ss} trascorsi";
TextRenderer.DrawText(g, right, Theme.Small, new Rectangle(Width - 400, 74, 384, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
if (_progress.FramesPerSecond is > 0.001 and < 2 && _running)
{
TextRenderer.DrawText(g,
"Meno di due fotogrammi al secondo: è il costo dei moduli attivi al profilo scelto.",
Theme.Small, new Rectangle(16, 96, Width - 32, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
}
}
}
// ================================================================== esito
private sealed class ResultCard : Control
{
private readonly List<(string Label, string Value)> _rows = [];
private string _title = string.Empty;
private bool _failed;
public ResultCard()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Clear()
{
_rows.Clear();
_title = string.Empty;
_failed = false;
Invalidate();
}
public void Show(RenderResult result)
{
_failed = false;
_title = "Esportazione completata";
_rows.Clear();
_rows.Add(("File", result.OutputPath));
_rows.Add(("Fotogrammi", $"{result.EncodedFrames}"));
_rows.Add(("Dimensione", $"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB"));
_rows.Add(("Tempo", $"{result.Elapsed.TotalSeconds:0.0} s " +
$"({result.EncodedFrames / Math.Max(0.001, result.Elapsed.TotalSeconds):0.00} fps)"));
_rows.Add(("Encoder", result.EncoderName + (result.HardwareAccelerated ? " (hardware)" : " (software)")));
_rows.Add(("Memoria", $"{result.PeakPixelMemoryBytes / (1024.0 * 1024.0):0} MiB di picco"));
if (result.UsedDisk)
{
_rows.Add(("Parcheggio su disco", $"{result.SpilledFrames} fotogrammi, " +
$"{result.SpillBytes / (1024.0 * 1024.0):0} MiB"));
}
Invalidate();
}
public void ShowFailure(string message)
{
_failed = true;
_title = "Esportazione non riuscita";
_rows.Clear();
_rows.Add(("Motivo", message));
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
if (_title.Length == 0) return;
var card = new RectangleF(0.5f, 0.5f, Width - 1, Math.Min(Height - 8, 40 + _rows.Count * 20));
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _failed ? Theme.Danger : Theme.Border);
TextRenderer.DrawText(g, _title, Theme.BodyBold, new Rectangle(16, 12, Width - 32, 20),
_failed ? Theme.Danger : Theme.Success,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
int y = 38;
foreach (var (label, value) in _rows)
{
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(16, y, 160, 18), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, value, Theme.Small, new Rectangle(180, y, Width - 196, 18),
Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
y += 20;
}
}
}
}
+333 -130
View File
@@ -4,25 +4,52 @@ using Titano.Pipeline;
namespace Titano.UI;
/// <summary>Finestra principale: collega i controlli disegnati a mano al motore di elaborazione.</summary>
/// <summary>
/// Finestra principale.
///
/// La navigazione è una barra verticale sul fianco sinistro, e governa insieme il contenuto
/// principale e la colonna delle impostazioni: una sezione, una vista, i suoi comandi.
/// L'anteprima resta invece sempre in alto, qualunque sezione sia scelta, perché in un
/// programma che tratta immagini l'immagine non è il contenuto di una scheda fra le altre.
/// </summary>
internal sealed class MainForm : Form
{
private readonly AppSettings _app = AppSettings.Load();
private readonly TitanoProject _project = new();
private readonly LuminanceChart _chart;
private readonly FrameTable _table;
private readonly NavigationRail _rail = new() { Dock = DockStyle.Left };
private readonly PreviewPanel _preview;
private SettingsPanel _settings;
private readonly DarkProgressBar _progress;
private Panel _settingsHost = null!;
private readonly ScopesPanel _scopes = new() { Dock = DockStyle.Bottom };
private readonly LuminanceChart _chart = new() { Dock = DockStyle.Fill };
private readonly FrameTable _table = new() { Dock = DockStyle.Fill };
private readonly TimelineStrip _timeline = new() { Dock = DockStyle.Bottom };
private readonly WarningsPanel _warnings = new() { Dock = DockStyle.Bottom, Height = 150 };
private readonly MotionPathChart _motionPath = new() { Dock = DockStyle.Fill };
private readonly PlanChart _planChart = new() { Dock = DockStyle.Fill };
private readonly ExportPanel _export;
private readonly AboutPanel _about = new() { Dock = DockStyle.Fill };
private readonly Dictionary<WorkspaceSection, Control> _sections = [];
private Panel _sectionHost = null!;
private readonly DarkProgressBar _progress = new() { Dock = DockStyle.Fill };
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 readonly DarkButton _addFilesButton = new() { Text = "Aggiungi file…", Width = 128 };
private readonly DarkButton _addFolderButton = new() { Text = "Aggiungi cartella…", Width = 148 };
private readonly DarkButton _clearButton = new() { Text = "Svuota", Width = 80 };
private readonly DarkButton _analyzeButton = new() { Text = "Analizza sequenza", Width = 158 };
private readonly DarkButton _exportButton = new() { Text = "Esporta video", Width = 136, Primary = true };
private readonly DarkButton _cancelButton = new() { Text = "Annulla", Width = 92, Danger = true, Visible = false };
private readonly DarkToggle _compareToggle = new() { Glyph = ToggleGlyph.Compare, Hint = "Confronto prima/dopo" };
private readonly DarkToggle _zoomToggle = new() { Glyph = ToggleGlyph.Zoom, Hint = "Scala uno a uno" };
private readonly DarkToggle _motionToggle = new() { Glyph = ToggleGlyph.Motion, Hint = "Campo vettoriale" };
private readonly DarkToggle _regionToggle = new() { Glyph = ToggleGlyph.Region, Hint = "Confine delle regioni", Checked = true };
private CancellationTokenSource? _operation;
private bool _busy;
@@ -30,8 +57,8 @@ internal sealed class MainForm : Form
public MainForm()
{
Text = "Titano — time-lapse";
MinimumSize = new Size(1180, 720);
Size = new Size(1560, 950);
MinimumSize = new Size(1280, 760);
Size = new Size(1660, 980);
StartPosition = FormStartPosition.CenterScreen;
BackColor = Theme.Background;
ForeColor = Theme.Text;
@@ -39,11 +66,11 @@ internal sealed class MainForm : Form
AllowDrop = true;
DoubleBuffered = true;
_chart = new LuminanceChart { Dock = DockStyle.Fill };
_table = new FrameTable { Dock = DockStyle.Fill };
_app.ApplyTo(_project);
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
_progress = new DarkProgressBar { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
_export = new ExportPanel(_project) { Dock = DockStyle.Fill };
_status = new Label
{
@@ -60,26 +87,16 @@ internal sealed class MainForm : Form
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 };
LoadApplicationIcon();
BuildLayout();
WireEvents();
_export.Configure(_app);
ShowSection(WorkspaceSection.Sequence);
UpdateCommandState();
}
/// <summary>
/// Riprende l'icona dalle risorse dell'eseguibile: è la stessa che Esplora risorse
/// mostra sul file, quindi finestra, barra delle applicazioni e cartella restano coerenti.
/// </summary>
private void LoadApplicationIcon()
{
try
@@ -101,7 +118,6 @@ internal sealed class MainForm : Form
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
// La cornice viene ridisegnata dal sistema alla comparsa: l'attributo va riconfermato.
Theme.ApplyDarkTitleBar(this);
}
@@ -112,43 +128,76 @@ internal sealed class MainForm : Form
var toolbar = BuildToolbar();
var statusBar = BuildStatusBar();
var settingsHost = new Panel
_settingsHost = new Panel
{
Dock = DockStyle.Right,
Width = 372,
Width = 384,
BackColor = Theme.Surface,
Padding = new Padding(1, 0, 0, 0),
};
settingsHost.Controls.Add(_settings);
settingsHost.Paint += (_, e) =>
_settingsHost.Controls.Add(_settings);
_settingsHost.Paint += (_, e) =>
{
using var pen = new Pen(Theme.Border);
e.Graphics.DrawLine(pen, 0, 0, 0, settingsHost.Height);
e.Graphics.DrawLine(pen, 0, 0, 0, _settingsHost.Height);
};
// ---- sezioni
var sequencePage = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
sequencePage.Controls.Add(Card(_table, "Fotogrammi", DockStyle.Fill));
sequencePage.Controls.Add(Card(_warnings, "Avvisi", DockStyle.Bottom, 172));
sequencePage.Controls.Add(Card(_timeline, "Provini", DockStyle.Bottom, 118));
var exposurePage = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
exposurePage.Controls.Add(Card(_chart, "Curva di esposizione", DockStyle.Fill));
exposurePage.Controls.Add(Card(_scopes, "Distribuzione", DockStyle.Bottom, 162));
_sections[WorkspaceSection.Sequence] = sequencePage;
_sections[WorkspaceSection.Exposure] = exposurePage;
_sections[WorkspaceSection.Motion] = Card(_motionPath, "Percorso della stabilizzazione", DockStyle.Fill);
_sections[WorkspaceSection.Timing] = Card(_planChart, "Piano temporale", DockStyle.Fill);
_sections[WorkspaceSection.Export] = _export;
_sections[WorkspaceSection.Preferences] = _about;
_sectionHost = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
foreach (var section in _sections.Values)
{
section.Dock = DockStyle.Fill;
section.Visible = false;
_sectionHost.Controls.Add(section);
}
var previewHost = Card(_preview, "Anteprima", DockStyle.Top, 330, BuildPreviewTools());
var splitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
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(_sectionHost);
center.Controls.Add(splitter);
center.Controls.Add(previewHost);
Controls.Add(center);
Controls.Add(settingsHost);
Controls.Add(_settingsHost);
Controls.Add(_rail);
Controls.Add(statusBar);
Controls.Add(toolbar);
}
/// <summary>Interruttori dell'anteprima, ospitati nell'intestazione del riquadro.</summary>
private Panel BuildPreviewTools()
{
var host = new Panel { BackColor = Theme.Background, Width = 4 * 30 + 8, Height = 26 };
int x = 0;
foreach (var toggle in new[] { _compareToggle, _zoomToggle, _motionToggle, _regionToggle })
{
toggle.Bounds = new Rectangle(x, 1, 26, 24);
host.Controls.Add(toggle);
x += 30;
}
return host;
}
/// <summary>Riquadro con intestazione: unità visiva ricorrente dell'interfaccia.</summary>
private static Panel Card(Control content, string title, DockStyle dock, int height = 0)
private static Panel Card(Control content, string title, DockStyle dock, int height = 0, Control? tools = null)
{
var host = new Panel
{
@@ -160,6 +209,15 @@ internal sealed class MainForm : Form
if (height > 0) host.Height = height;
host.Controls.Add(content);
if (tools is not null)
{
tools.Anchor = AnchorStyles.Top | AnchorStyles.Right;
tools.Location = new Point(host.Width - tools.Width - 8, 2);
host.Controls.Add(tools);
tools.BringToFront();
}
host.Paint += (_, e) =>
{
var g = e.Graphics;
@@ -185,7 +243,7 @@ internal sealed class MainForm : Form
Font = Theme.Title,
ForeColor = Theme.Text,
AutoSize = false,
Bounds = new Rectangle(16, 12, 110, 32),
Bounds = new Rectangle(16, 12, 100, 32),
TextAlign = ContentAlignment.MiddleLeft,
};
@@ -195,11 +253,11 @@ internal sealed class MainForm : Form
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Bounds = new Rectangle(112, 20, 160, 18),
Bounds = new Rectangle(104, 20, 152, 18),
TextAlign = ContentAlignment.MiddleLeft,
};
int x = 288;
int x = 272;
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
{
button.Bounds = new Rectangle(x, 13, button.Width, 32);
@@ -237,9 +295,9 @@ internal sealed class MainForm : Form
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.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 58));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 42));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 100));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
@@ -273,16 +331,32 @@ internal sealed class MainForm : Form
_exportButton.Click += async (_, _) => await ExportAsync();
_cancelButton.Click += (_, _) => _operation?.Cancel();
_chart.SelectionChanged += (_, _) =>
_rail.SelectionChanged += (_, _) => ShowSection(_rail.Selected);
_chart.SelectionChanged += (_, _) => Select(_chart.SelectedIndex);
_table.SelectionChanged += (_, _) => Select(_table.SelectedIndex);
_timeline.SelectionChanged += (_, _) => Select(_timeline.SelectedIndex);
_motionPath.SelectionChanged += (_, _) => Select(_motionPath.SelectedIndex);
_preview.ScopesChanged += (_, scopes) => _scopes.SetScopes(scopes);
_preview.OnRefreshNeeded((_, _) => ShowPreview(_table.SelectedIndex));
_compareToggle.CheckedChanged += (_, _) => _preview.SetCompareMode(_compareToggle.Checked);
_motionToggle.CheckedChanged += (_, _) => _preview.SetMotionField(_motionToggle.Checked);
_regionToggle.CheckedChanged += (_, _) => _preview.SetRegionOverlay(_regionToggle.Checked);
_zoomToggle.CheckedChanged += (_, _) =>
{
if (_chart.SelectedIndex >= 0) _table.SelectedIndex = _chart.SelectedIndex;
ShowPreview(_chart.SelectedIndex);
if (_zoomToggle.Checked) _preview.ToggleZoom(); else _preview.ResetView();
ShowPreview(_table.SelectedIndex);
};
_table.SelectionChanged += (_, _) =>
_warnings.AreaActivated += (_, area) => _rail.Selected = area switch
{
if (_table.SelectedIndex >= 0) _chart.SelectedIndex = _table.SelectedIndex;
ShowPreview(_table.SelectedIndex);
"Sequenza" => WorkspaceSection.Sequence,
"Esposizione" => WorkspaceSection.Exposure,
"Movimento" => WorkspaceSection.Motion,
"Tempo" => WorkspaceSection.Timing,
_ => WorkspaceSection.Export,
};
WireSettingsEvents();
@@ -301,8 +375,34 @@ internal sealed class MainForm : Form
{
_settings.DeflickerChanged += (_, _) => RecomputeCurve();
_settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis();
_settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex);
_settings.PreviewInvalidated += (_, _) => { ShowPreview(_table.SelectedIndex); RefreshDerived(); };
_settings.BrowseOutputRequested += (_, _) => BrowseOutput();
_settings.AppSettingsChanged += (_, _) =>
{
_app.Save();
_export.Configure(_app);
};
}
private void ShowSection(WorkspaceSection section)
{
foreach (var (key, control) in _sections) control.Visible = key == section;
_settings.ShowSection(section);
_export.SetActive(section == WorkspaceSection.Export);
if (section == WorkspaceSection.Export) _export.Refresh(_project);
if (section == WorkspaceSection.Preferences) _about.Update(_project, _app);
}
/// <summary>Allinea tutte le viste sullo stesso fotogramma, qualunque l'abbia scelto.</summary>
private void Select(int index)
{
if (index < 0) return;
_chart.SelectedIndex = index;
_table.SelectedIndex = index;
_timeline.SelectedIndex = index;
_motionPath.SelectedIndex = index;
ShowPreview(index);
}
// ------------------------------------------------------------------ comandi
@@ -313,6 +413,7 @@ internal sealed class MainForm : Form
{
Multiselect = true,
Title = "Seleziona i fotogrammi della sequenza",
InitialDirectory = Directory.Exists(_app.LastFolder) ? _app.LastFolder : string.Empty,
Filter = "Immagini (" + string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + ")|" +
string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + "|Tutti i file|*.*",
};
@@ -321,7 +422,11 @@ internal sealed class MainForm : Form
private void AddFolder()
{
using var dialog = new FolderBrowserDialog { Description = "Seleziona la cartella della sequenza" };
using var dialog = new FolderBrowserDialog
{
Description = "Seleziona la cartella della sequenza",
SelectedPath = Directory.Exists(_app.LastFolder) ? _app.LastFolder : string.Empty,
};
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(ExpandPaths([dialog.SelectedPath]));
}
@@ -359,20 +464,30 @@ internal sealed class MainForm : Form
_project.Sequence = sequence;
_project.InvalidateAnalysis();
_project.DetectOrientation();
if (Path.GetDirectoryName(paths[0]) is { Length: > 0 } folder)
{
_app.LastFolder = folder;
_app.Save();
}
_settings.ShowDetectedOrientation(_project.Orientation);
_settings.ShowSequenceGeometry();
_settings.ShowAnalysis();
_table.SetSequence(sequence);
_chart.SetData(sequence, null);
_timeline.SetSequence(sequence, _project.EffectiveOrientation);
_motionPath.SetPath(null, 0);
ApplyAutoDecisions();
SuggestOutputPath(sequence);
UpdateSummary();
ShowPreview(0);
RefreshDerived();
Select(0);
SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " +
$"{sequence.CadenceAnomalies} intervalli anomali. " +
$"Orientamento {_project.Orientation.Description} ({_project.Orientation.Reason})." +
DescribeResolutionClamp());
$"Orientamento {_project.Orientation.Description}.");
}
catch (OperationCanceledException)
{
@@ -386,6 +501,8 @@ internal sealed class MainForm : Form
{
EndOperation();
}
if (_app.AnalyzeOnLoad && _project.HasSequence) await AnalyzeAsync();
}
private void ClearSequence()
@@ -393,11 +510,16 @@ internal sealed class MainForm : Form
if (_busy) return;
_project.Sequence = null;
_project.InvalidateAnalysis();
_settings.ShowAnalysis();
_table.SetSequence(null);
_chart.SetData(null, null);
_timeline.SetSequence(null, 1);
_motionPath.SetPath(null, 0);
_planChart.SetPlan(null, 0, 30);
_warnings.SetWarnings([]);
_preview.Clear();
_settings.ShowAnalysis();
_summary.Text = string.Empty;
UpdateBadges();
SetStatus("Sequenza svuotata.");
UpdateCommandState();
}
@@ -413,10 +535,14 @@ internal sealed class MainForm : Form
var progress = new Progress<PipelineProgress>(ReportProgress);
await pipeline.AnalyzeAsync(progress, _operation!.Token);
ApplyAutoDecisions();
_chart.SetData(_project.Sequence, _project.Curve);
_table.Refresh(_project.Sequence);
var (sourceWidth, _) = _project.ResolveSourceSize();
_motionPath.SetPath(_project.Motion, sourceWidth);
_settings.ShowAnalysis();
UpdateSummary();
RefreshDerived();
ShowPreview(_table.SelectedIndex);
var curve = _project.Curve!;
@@ -426,8 +552,7 @@ internal sealed class MainForm : Form
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)." +
DescribeAdvancedAnalysis());
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione).");
}
catch (OperationCanceledException)
{
@@ -453,37 +578,87 @@ internal sealed class MainForm : Form
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath)) return;
}
if (_app.ConfirmOverwrite && File.Exists(_project.Export.OutputPath))
{
var answer = MessageBox.Show(this,
$"{Path.GetFileName(_project.Export.OutputPath)} esiste già. Sovrascriverlo?",
"Titano", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (answer != DialogResult.Yes) return;
}
_rail.Selected = WorkspaceSection.Export;
_export.Refresh(_project);
_export.BeginExport();
BeginOperation("Elaborazione e codifica…");
try
{
var pipeline = new RenderPipeline(_project);
var progress = new Progress<PipelineProgress>(ReportProgress);
if (_app.LivePreviewDuringExport)
{
pipeline.PreviewInterval = _app.LivePreviewEvery;
pipeline.FrameEncoded = (buffer, index, total) =>
{
// La conversione avviene sul thread di rendering, perché il buffer vive
// solo lì: si riduce subito a una miniatura, che è l'unica cosa che
// sopravvive al ritorno.
var thumbnail = PreviewPanel.ToThumbnail(buffer, 720);
try
{
BeginInvoke(() => _preview.ShowLiveFrame(thumbnail,
$"fotogramma {index + 1} di {total}", "in codifica"));
}
catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
{
thumbnail.Dispose();
}
};
}
var result = await pipeline.RenderAsync(progress, _operation!.Token);
_table.Refresh(_project.Sequence);
_export.ShowResult(result);
_preview.EndLiveFrames();
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)")}. " +
char.ToUpper(result.PlanDescription[0]) + result.PlanDescription[1..] + "." +
(result.UsedDisk
? $" {result.SpilledFrames} fotogrammi sono passati dal parcheggio su disco " +
$"({result.SpillBytes / (1024.0 * 1024.0):0} MiB)."
: string.Empty));
$"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}.");
if (_app.RevealWhenFinished) Reveal(result.OutputPath);
}
catch (OperationCanceledException)
{
SetStatus("Esportazione interrotta. Il file contiene i fotogrammi già codificati.");
_export.ShowFailure("Interrotta su richiesta. Il file contiene i fotogrammi già codificati.");
SetStatus("Esportazione interrotta.");
}
catch (Exception ex)
{
_export.ShowFailure(ex.Message);
SetStatus("Esportazione non riuscita: " + ex.Message);
}
finally
{
_preview.EndLiveFrames();
EndOperation();
}
}
private static void Reveal(string path)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe",
$"/select,\"{path}\"") { UseShellExecute = true });
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException)
{
// Non poter aprire la cartella non è un errore dell'esportazione.
}
}
private void BrowseOutput()
{
using var dialog = new SaveFileDialog
@@ -497,6 +672,7 @@ internal sealed class MainForm : Form
_project.Export.OutputPath = dialog.FileName;
_settings.OutputPathBox.Text = dialog.FileName;
RefreshDerived();
}
private void SuggestOutputPath(TimelapseSequence sequence)
@@ -513,14 +689,25 @@ internal sealed class MainForm : Form
// ------------------------------------------------------------------ aggiornamenti
/// <summary>Chiede al direttore i valori deducibili e li porta nei controlli.</summary>
private void ApplyAutoDecisions()
{
var decisions = AutoDirector.Derive(_project);
_project.AutoDecisions = decisions;
AutoDirector.Apply(_project, decisions, _project.ManualParameters);
_settings.ShowAutoDecisions(decisions);
}
private void RecomputeCurve()
{
if (!_project.IsAnalyzed) return;
new RenderPipeline(_project).RecomputeCurve();
_chart.UpdateCurve(_project.Curve);
_table.Refresh(_project.Sequence);
var (sourceWidth, _) = _project.ResolveSourceSize();
_motionPath.SetPath(_project.Motion, sourceWidth);
_settings.ShowAnalysis();
UpdateSummary();
RefreshDerived();
ShowPreview(_table.SelectedIndex);
}
@@ -530,37 +717,55 @@ internal sealed class MainForm : Form
_project.InvalidateAnalysis();
_chart.SetData(_project.Sequence, null);
_table.Refresh(_project.Sequence);
_motionPath.SetPath(null, 0);
_settings.ShowSequenceGeometry();
_settings.ShowAnalysis();
UpdateSummary();
RefreshDerived();
UpdateCommandState();
}
/// <summary>
/// Cenno compatto a quello che i moduli avanzati hanno trovato. Il dettaglio vive nella
/// sezione "Esito dell'analisi" del pannello: la barra di stato ha una riga sola, e
/// riempirla di numeri significa troncarli tutti.
/// </summary>
private string DescribeAdvancedAnalysis()
/// <summary>Ricalcola quello che dipende dalle impostazioni senza rileggere i file.</summary>
private void RefreshDerived()
{
var parts = new List<string>();
UpdateSummary();
if (_project.Mask is { Coverage: var coverage }) parts.Add($"cielo {coverage * 100:0}%");
if (_project.Transitions is { StepCount: > 0 } transitions)
if (_project.Sequence is { Count: > 0 } sequence)
{
parts.Add(transitions.StepCount == 1
? "1 cambio di esposizione"
: $"{transitions.StepCount} cambi di esposizione");
uint baseUnits = (uint)Math.Max(1, Math.Round(_project.Export.Timescale /
Math.Max(1.0, _project.Export.FrameRate)));
var plan = RenderPlanner.Build(sequence, _project.Export, _project.TimeRamp, baseUnits);
_planChart.SetPlan(plan, sequence.Count, _project.Export.FrameRate);
}
else
{
_planChart.SetPlan(null, 0, _project.Export.FrameRate);
}
if (_project.Motion is { } motion)
_warnings.SetWarnings(SequenceWarnings.Collect(_project, _app));
UpdateBadges();
_export.Refresh(_project);
}
private void UpdateBadges()
{
var counts = new Dictionary<WorkspaceSection, int>();
foreach (var warning in SequenceWarnings.Collect(_project, _app))
{
var (width, _) = _project.ResolveSourceSize();
parts.Add($"tremolio {motion.MeanShake * Math.Max(1, width):0.0} px");
var section = warning.Area switch
{
"Sequenza" => WorkspaceSection.Sequence,
"Esposizione" => WorkspaceSection.Exposure,
"Movimento" => WorkspaceSection.Motion,
"Tempo" => WorkspaceSection.Timing,
_ => WorkspaceSection.Export,
};
counts[section] = counts.GetValueOrDefault(section) + 1;
}
return parts.Count == 0 ? string.Empty : " " + string.Join(" · ", parts) + ".";
foreach (WorkspaceSection section in Enum.GetValues<WorkspaceSection>())
{
_rail.SetBadge(section, counts.GetValueOrDefault(section));
}
}
private void ShowPreview(int index)
@@ -581,8 +786,6 @@ internal sealed class MainForm : Form
var (requestedWidth, requestedHeight) = _project.ResolveRequestedSize();
double outputSeconds = sequence.Count / Math.Max(1.0, _project.Export.FrameRate);
// Una riduzione imposta dal codec va detta: chi esporta deve sapere che il video
// non esce alla risoluzione della sorgente.
string resolution = width == requestedWidth && height == requestedHeight
? $"{width}×{height}"
: $"{width}×{height} (da {requestedWidth}×{requestedHeight})";
@@ -592,20 +795,6 @@ internal sealed class MainForm : Form
(_project.IsAnalyzed ? " · analizzata" : string.Empty);
}
/// <summary>Avverte quando la conformità del codec impone una risoluzione inferiore alla sorgente.</summary>
private string DescribeResolutionClamp()
{
if (!_project.HasSequence) return string.Empty;
var (width, height) = _project.ResolveWorkingSize();
var (requestedWidth, requestedHeight) = _project.ResolveRequestedSize();
if (width == requestedWidth && height == requestedHeight) return string.Empty;
string codec = _project.Export.Codec == Video.VideoCodec.H264 ? "H.264" : "HEVC";
return $" Risoluzione ridotta a {width}×{height}: {requestedWidth}×{requestedHeight} eccede " +
$"il livello {codec} che i lettori supportano.";
}
private void ReportProgress(PipelineProgress progress)
{
_progress.Fraction = progress.Fraction;
@@ -614,6 +803,7 @@ internal sealed class MainForm : Form
? $" · {progress.FramesPerSecond:0.0} fps · {progress.Remaining:hh\\:mm\\:ss} rimanenti"
: string.Empty;
_status.Text = progress.Message + detail + speed;
_export.ReportProgress(progress);
}
private void SetStatus(string message)
@@ -654,31 +844,26 @@ internal sealed class MainForm : Form
protected override void OnFormClosing(FormClosingEventArgs e)
{
_operation?.Cancel();
_app.Save();
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);
// ------------------------------------------------------------------ modalità di cattura
internal void SelectSettingsTab(int index)
=> _rail.Selected = (WorkspaceSection)Math.Clamp(index, 0, 5);
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));
Select(Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1)));
}
/// <summary>
/// Accende i moduli avanzati prima di una cattura, così l'immagine mostra i controlli con
/// dati veri invece che a riposo. Serve solo alla verifica riproducibile dell'interfaccia.
/// </summary>
internal void EnableAdvancedModulesForCapture()
{
_project.Regions.Mode = Analysis.RegionMode.SkyGround;
_project.HolyGrail.Enabled = true;
_project.HolyGrail.TransitionFrames = 24;
_project.HolyGrail.SmoothColor = true;
_project.Stabilization.Enabled = true;
_project.Camera.Enabled = true;
@@ -692,14 +877,32 @@ internal sealed class MainForm : Form
_project.TimeRamp.Speed = [new(0.0, 2.5), new(0.45, 0.4), new(1.0, 2.0)];
_project.Stacking.Mode = Motion.StackingMode.Median;
// I pannelli leggono il progetto alla costruzione: qui vanno rifatti da capo.
var host = _settings.Parent;
int page = 0;
host?.Controls.Remove(_settings);
// I controlli leggono il progetto quando vengono costruiti: cambiarlo dopo li
// lascerebbe a mostrare i valori di prima. Qui la colonna si rifà da capo.
RebuildSettingsPanel();
_compareToggle.Checked = true;
_motionToggle.Checked = true;
_preview.SetCompareMode(true);
_preview.SetMotionField(true);
}
private void RebuildSettingsPanel()
{
var section = _rail.Selected;
_settingsHost.Controls.Remove(_settings);
_settings.Dispose();
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
host?.Controls.Add(_settings);
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
_settingsHost.Controls.Add(_settings);
WireSettingsEvents();
_settings.SelectPage(page);
_settings.ShowSection(section);
_settings.ShowDetectedOrientation(_project.Orientation);
_settings.ShowSequenceGeometry();
_settings.ShowAnalysis();
_settings.ShowAutoDecisions(_project.AutoDecisions);
_settings.OutputPathBox.Text = _project.Export.OutputPath;
}
}
+249
View File
@@ -0,0 +1,249 @@
using System.Drawing.Drawing2D;
using Titano.Motion;
namespace Titano.UI;
/// <summary>
/// Percorso della camera ricostruito dalla stabilizzazione: dove il fotogramma si trovava
/// davvero e dove la lisciatura ha deciso che dovesse stare.
///
/// I due vettori per fotogramma erano già calcolati e conservati nel progetto, e finivano in
/// due numeri nel riepilogo. Disegnati, dicono la cosa che i numeri non dicono: quanta parte
/// del movimento la stabilizzazione ha giudicato voluta — e lì le due curve coincidono — e
/// quanta ha giudicato tremolio, che è la distanza fra loro.
/// </summary>
internal sealed class MotionPathChart : Control
{
private StabilizationPath? _path;
private double _pixelsPerUnit = 1;
private int _selectedIndex = -1;
private int _hoverIndex = -1;
private const int GutterLeft = 46;
private const int GutterTop = 22;
private const int GutterBottom = 18;
public event EventHandler? SelectionChanged;
public MotionPathChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public int SelectedIndex
{
get => _selectedIndex;
set { _selectedIndex = value; Invalidate(); }
}
/// <summary>Il percorso e la larghezza sorgente, che converte le unità normalizzate in pixel.</summary>
public void SetPath(StabilizationPath? path, int sourceWidth)
{
_path = path;
_pixelsPerUnit = Math.Max(1, sourceWidth);
Invalidate();
}
private Rectangle Plot => new(GutterLeft, GutterTop,
Math.Max(20, Width - GutterLeft - 12),
Math.Max(20, Height - GutterTop - GutterBottom));
private int IndexAt(int x)
{
if (_path is not { Count: > 1 }) return -1;
var plot = Plot;
double fraction = Math.Clamp((x - plot.Left) / (double)plot.Width, 0, 1);
return (int)Math.Round(fraction * (_path.Count - 1));
}
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.X);
if (index != _hoverIndex) { _hoverIndex = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hoverIndex = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
int index = IndexAt(e.X);
if (index >= 0)
{
_selectedIndex = index;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
base.OnMouseDown(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = Plot;
if (_path is not { Count: > 2 })
{
TextRenderer.DrawText(g,
_path is null
? "Attiva la stabilizzazione e analizza la sequenza per vedere il percorso"
: "Sequenza troppo corta per ricostruire un percorso",
Theme.Body, new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
int count = _path.Count;
var measuredX = new double[count];
var measuredY = new double[count];
var targetX = new double[count];
var targetY = new double[count];
for (int i = 0; i < count; i++)
{
var measured = _path.Measured[i];
// La posizione corretta è quella misurata portata dov'è finita dopo la correzione.
var corrected = SimilarityTransform.Compose(_path.Correction[i], measured);
measuredX[i] = measured.Tx * _pixelsPerUnit;
measuredY[i] = measured.Ty * _pixelsPerUnit;
targetX[i] = corrected.Tx * _pixelsPerUnit;
targetY[i] = corrected.Ty * _pixelsPerUnit;
}
double lo = double.MaxValue, hi = double.MinValue;
foreach (var series in (double[][])[measuredX, measuredY, targetX, targetY])
{
foreach (double value in series)
{
lo = Math.Min(lo, value);
hi = Math.Max(hi, value);
}
}
if (hi - lo < 2) { double mid = (hi + lo) / 2; lo = mid - 1; hi = mid + 1; }
double padding = (hi - lo) * 0.12;
lo -= padding;
hi += padding;
DrawGrid(g, plot, lo, hi);
// Prima il misurato, sottile, poi il liscio sopra: la distanza fra i due è la
// grandezza che interessa, e va letta come uno scarto dal secondo.
DrawSeries(g, plot, measuredX, lo, hi, Color.FromArgb(110, Theme.Measured), 1.1f);
DrawSeries(g, plot, measuredY, lo, hi, Color.FromArgb(110, Theme.RegionLow), 1.1f);
DrawSeries(g, plot, targetX, lo, hi, Theme.Measured, 1.9f);
DrawSeries(g, plot, targetY, lo, hi, Theme.RegionLow, 1.9f);
DrawLegend(g);
DrawMarker(g, plot, _selectedIndex, count, Color.FromArgb(150, Theme.Text), DashStyle.Dash);
DrawMarker(g, plot, _hoverIndex, count, Color.FromArgb(90, Theme.Text), DashStyle.Solid);
DrawReadout(g, plot, measuredX, measuredY, targetX, targetY);
}
private void DrawGrid(Graphics g, Rectangle plot, double lo, double hi)
{
using var grid = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
using var axis = new Pen(Theme.BorderStrong);
double range = hi - lo;
double step = range > 200 ? 50 : range > 80 ? 20 : range > 30 ? 10 : range > 12 ? 5 : range > 4 ? 2 : 1;
for (double value = Math.Ceiling(lo / step) * step; value <= hi; value += step)
{
float y = YFor(value, plot, lo, hi);
g.DrawLine(grid, plot.Left, y, plot.Right, y);
TextRenderer.DrawText(g, value.ToString("0"), Theme.Small,
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
g.DrawLine(axis, plot.Left, plot.Top, plot.Left, plot.Bottom);
TextRenderer.DrawText(g, "px", Theme.Small, new Rectangle(0, plot.Top - 18, GutterLeft - 6, 16),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
private static float YFor(double value, Rectangle plot, double lo, double hi)
=> plot.Bottom - (float)((value - lo) / Math.Max(1e-9, hi - lo) * plot.Height);
private static void DrawSeries(Graphics g, Rectangle plot, double[] values, double lo, double hi,
Color color, float width)
{
int count = values.Length;
// Con più campioni che pixel si disegna un punto per colonna: tracciare centomila
// segmenti dentro mille pixel costa e non aggiunge un'informazione leggibile.
int stride = Math.Max(1, count / Math.Max(1, plot.Width));
var points = new List<PointF>(count / stride + 2);
for (int i = 0; i < count; i += stride)
{
float x = plot.Left + plot.Width * i / (float)(count - 1);
points.Add(new PointF(x, YFor(values[i], plot, lo, hi)));
}
if (points.Count < 2) return;
using var pen = new Pen(color, width) { LineJoin = LineJoin.Round };
var clip = g.Clip;
g.SetClip(Rectangle.Inflate(plot, 1, 1));
g.DrawLines(pen, [.. points]);
g.Clip = clip;
}
private void DrawLegend(Graphics g)
{
var entries = new (Color Color, string Label)[]
{
(Theme.Measured, "orizzontale"),
(Theme.RegionLow, "verticale"),
};
int x = GutterLeft;
foreach (var (color, label) in entries)
{
using (var faint = new SolidBrush(Color.FromArgb(110, color))) g.FillRectangle(faint, x, 8, 10, 2);
using (var solid = new SolidBrush(color)) g.FillRectangle(solid, x + 12, 8, 10, 2);
var size = TextRenderer.MeasureText(g, label, Theme.Small);
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 27, 1, size.Width + 4, 16),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
x += 27 + size.Width + 18;
}
TextRenderer.DrawText(g, "tenue: misurato · pieno: dopo la stabilizzazione", Theme.Small,
new Rectangle(x, 1, Math.Max(10, Width - x - 10), 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
private static void DrawMarker(Graphics g, Rectangle plot, int index, int count, Color color, DashStyle dash)
{
if (index < 0 || index >= count) return;
float x = plot.Left + plot.Width * index / (float)(count - 1);
using var pen = new Pen(color) { DashStyle = dash };
g.DrawLine(pen, x, plot.Top, x, plot.Bottom);
}
private void DrawReadout(Graphics g, Rectangle plot, double[] mx, double[] my, double[] tx, double[] ty)
{
int index = _hoverIndex >= 0 ? _hoverIndex : _selectedIndex;
if (index < 0 || index >= mx.Length) return;
double shiftX = tx[index] - mx[index];
double shiftY = ty[index] - my[index];
string text = $"#{index + 1} correzione {Math.Sqrt(shiftX * shiftX + shiftY * shiftY):0.00} px";
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
var box = new RectangleF(plot.Right - size.Width - 16, plot.Top + 6, size.Width + 12, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(232, Theme.Background), Theme.BorderStrong);
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
+269
View File
@@ -0,0 +1,269 @@
using System.Drawing.Drawing2D;
namespace Titano.UI;
/// <summary>Le sezioni in cui è divisa la finestra, nell'ordine in cui compaiono nella barra.</summary>
internal enum WorkspaceSection
{
Sequence,
Exposure,
Motion,
Timing,
Export,
Preferences,
}
/// <summary>
/// Barra di navigazione verticale sul fianco sinistro: una voce per sezione, ciascuna con il
/// proprio simbolo disegnato a mano e la propria etichetta.
///
/// In verticale ci sta il nome per esteso, che in una barra orizzontale avrebbe dovuto essere
/// abbreviato o troncato; e la voce selezionata resta leggibile mentre si lavora, cosa che in
/// una fila di schede in cima si perde appena l'occhio scende sul contenuto. Il segno di
/// selezione è una barretta sul bordo interno, dove l'occhio la ritrova senza cercarla.
/// </summary>
internal sealed class NavigationRail : Control
{
private sealed record Item(WorkspaceSection Section, string Label, string Hint);
private const int ItemHeight = 52;
private const int GlyphSize = 20;
private const int TopPadding = 10;
private readonly Item[] _items =
[
new(WorkspaceSection.Sequence, "Sequenza", "Fotogrammi, cadenza e lettura dei file"),
new(WorkspaceSection.Exposure, "Esposizione", "Deflicker, regioni e transizioni giorno-notte"),
new(WorkspaceSection.Motion, "Movimento", "Stabilizzazione, camera virtuale e sfocatura"),
new(WorkspaceSection.Timing, "Tempo", "Andamento, rimappatura e accumulo temporale"),
new(WorkspaceSection.Export, "Esportazione", "Codifica, destinazione e avanzamento"),
new(WorkspaceSection.Preferences, "Impostazioni", "Preferenze dell'applicazione"),
];
private int _selected;
private int _hovered = -1;
private readonly Dictionary<WorkspaceSection, int> _badges = [];
public event EventHandler? SelectionChanged;
public NavigationRail()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Width = 158;
Cursor = Cursors.Hand;
TabStop = true;
}
public WorkspaceSection Selected
{
get => _items[Math.Clamp(_selected, 0, _items.Length - 1)].Section;
set
{
int index = Array.FindIndex(_items, i => i.Section == value);
if (index < 0 || index == _selected) return;
_selected = index;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// Numero da mostrare accanto a una sezione: gli avvisi che la riguardano. Zero lo toglie.
/// Serve a far sapere che c'è qualcosa da guardare senza obbligare a passare di lì.
/// </summary>
public void SetBadge(WorkspaceSection section, int count)
{
int previous = _badges.TryGetValue(section, out int existing) ? existing : 0;
if (previous == count) return;
if (count > 0) _badges[section] = count;
else _badges.Remove(section);
Invalidate();
}
// ------------------------------------------------------------------ interazione
private int IndexAt(int y)
{
int index = (y - TopPadding) / ItemHeight;
return index >= 0 && index < _items.Length ? index : -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.Y);
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)
{
Focus();
int index = IndexAt(e.Y);
if (index >= 0) Selected = _items[index].Section;
base.OnMouseDown(e);
}
protected override bool IsInputKey(Keys keyData) => keyData is Keys.Up or Keys.Down || base.IsInputKey(keyData);
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode is Keys.Up or Keys.Down)
{
int step = e.KeyCode == Keys.Down ? 1 : -1;
Selected = _items[Math.Clamp(_selected + step, 0, _items.Length - 1)].Section;
e.Handled = true;
}
base.OnKeyDown(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
for (int i = 0; i < _items.Length; i++)
{
var item = _items[i];
var bounds = new Rectangle(0, TopPadding + i * ItemHeight, Width, ItemHeight);
bool active = i == _selected;
bool hover = i == _hovered;
if (active)
{
using var fill = new SolidBrush(Theme.SurfaceAlt);
g.FillRectangle(fill, 6, bounds.Y + 3, Width - 6, ItemHeight - 6);
using var mark = new SolidBrush(Theme.Accent);
g.FillRectangle(mark, 0, bounds.Y + 11, 3, ItemHeight - 22);
}
else if (hover)
{
using var fill = new SolidBrush(Theme.Surface);
g.FillRectangle(fill, 6, bounds.Y + 3, Width - 6, ItemHeight - 6);
}
Color tint = active ? Theme.Accent : hover ? Theme.Text : Theme.TextMuted;
DrawGlyph(g, item.Section, new Rectangle(20, bounds.Y + (ItemHeight - GlyphSize) / 2, GlyphSize, GlyphSize), tint);
var textBounds = new Rectangle(52, bounds.Y, Width - 62, ItemHeight);
TextRenderer.DrawText(g, item.Label, active ? Theme.BodyBold : Theme.Body, textBounds,
active ? Theme.Text : Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
if (_badges.TryGetValue(item.Section, out int badge)) DrawBadge(g, bounds, badge);
}
using var border = new Pen(Theme.Border);
g.DrawLine(border, Width - 1, 0, Width - 1, Height);
}
private static void DrawBadge(Graphics g, Rectangle bounds, int count)
{
string text = count > 9 ? "9+" : count.ToString();
var size = TextRenderer.MeasureText(text, Theme.Small);
int width = Math.Max(17, size.Width + 8);
var pill = new RectangleF(bounds.Right - width - 12, bounds.Y + (ItemHeight - 16) / 2f, width, 16);
Theme.FillRounded(g, pill, 8f, Theme.Warning);
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(pill), Color.FromArgb(0x1B, 0x14, 0x02),
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
/// <summary>
/// Simboli disegnati con primitive, non caratteri di un font di icone: restano nitidi a
/// qualunque risoluzione dello schermo e non dipendono da nulla di installato.
/// </summary>
private static void DrawGlyph(Graphics g, WorkspaceSection section, Rectangle box, Color tint)
{
using var pen = new Pen(tint, 1.6f) { LineJoin = LineJoin.Round, StartCap = LineCap.Round, EndCap = LineCap.Round };
using var brush = new SolidBrush(tint);
float x = box.X, y = box.Y, w = box.Width, h = box.Height;
switch (section)
{
case WorkspaceSection.Sequence:
// Tre fotogrammi impilati e sfalsati.
g.DrawRectangle(pen, x + 0.5f, y + 5.5f, w - 6, h - 8);
g.DrawLine(pen, x + 3.5f, y + 3.5f, x + w - 2.5f, y + 3.5f);
g.DrawLine(pen, x + 6.5f, y + 0.5f, x + w - 0.5f, y + 0.5f);
break;
case WorkspaceSection.Exposure:
// La curva di luminanza che sale, con il punto misurato.
g.DrawCurve(pen,
[
new PointF(x, y + h - 2),
new PointF(x + w * 0.35f, y + h * 0.62f),
new PointF(x + w * 0.62f, y + h * 0.30f),
new PointF(x + w, y + 2),
]);
g.FillEllipse(brush, x + w * 0.62f - 2.4f, y + h * 0.30f - 2.4f, 4.8f, 4.8f);
break;
case WorkspaceSection.Motion:
// Vettore di spostamento con la sua origine.
g.DrawEllipse(pen, x + 1, y + h - 7, 6, 6);
g.DrawLine(pen, x + 6.5f, y + h - 5.5f, x + w - 3, y + 3);
g.DrawLines(pen,
[
new PointF(x + w - 7.5f, y + 3.5f),
new PointF(x + w - 2.5f, y + 2.5f),
new PointF(x + w - 3.5f, y + 7.5f),
]);
break;
case WorkspaceSection.Timing:
// Quadrante con le lancette.
g.DrawEllipse(pen, x + 1.5f, y + 1.5f, w - 3, h - 3);
g.DrawLine(pen, x + w / 2, y + h / 2, x + w / 2, y + 4.5f);
g.DrawLine(pen, x + w / 2, y + h / 2, x + w - 5.5f, y + h / 2);
break;
case WorkspaceSection.Export:
// Contenitore aperto con la freccia che ne esce.
g.DrawLines(pen,
[
new PointF(x + 1.5f, y + h * 0.55f),
new PointF(x + 1.5f, y + h - 1.5f),
new PointF(x + w - 1.5f, y + h - 1.5f),
new PointF(x + w - 1.5f, y + h * 0.55f),
]);
g.DrawLine(pen, x + w / 2, y + h - 6, x + w / 2, y + 1.5f);
g.DrawLines(pen,
[
new PointF(x + w / 2 - 3.5f, y + 5),
new PointF(x + w / 2, y + 1.5f),
new PointF(x + w / 2 + 3.5f, y + 5),
]);
break;
case WorkspaceSection.Preferences:
// Tre cursori: dice "regolazioni" molto meglio di un ingranaggio a questa scala.
for (int row = 0; row < 3; row++)
{
float ly = y + 3.5f + row * ((h - 7) / 2f);
g.DrawLine(pen, x + 1, ly, x + w - 1, ly);
float knob = x + 3 + (row == 1 ? w - 10 : row * (w - 10) / 2f + 3);
g.FillEllipse(brush, knob - 2.6f, ly - 2.6f, 5.2f, 5.2f);
}
break;
}
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Drawing.Drawing2D;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Il piano temporale disegnato: a ogni fotogramma del video finale corrisponde una posizione
/// nella sequenza sorgente, e la curva che le lega è tutto ciò che c'è da sapere sul ritmo.
///
/// Una pendenza di uno significa un fotogramma per scatto. Più ripida, la sequenza corre e
/// salta scatti; più dolce, rallenta e i fotogrammi mancanti vengono sintetizzati dal campo
/// vettoriale. Sono le stesse informazioni della curva di velocità, ma viste dall'altro capo:
/// lì si regola quanto andare veloce, qui si vede dove si è arrivati.
/// </summary>
internal sealed class PlanChart : Control
{
private const int GutterLeft = 56;
private const int GutterBottom = 26;
private const int GutterTop = 26;
private RenderPlan? _plan;
private int _sourceCount;
private double _frameRate = 30;
private int _hover = -1;
public PlanChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public void SetPlan(RenderPlan? plan, int sourceCount, double frameRate)
{
_plan = plan;
_sourceCount = Math.Max(1, sourceCount);
_frameRate = Math.Max(1, frameRate);
Invalidate();
}
private Rectangle Plot => new(GutterLeft, GutterTop,
Math.Max(20, Width - GutterLeft - 16),
Math.Max(20, Height - GutterTop - GutterBottom));
protected override void OnMouseMove(MouseEventArgs e)
{
if (_plan is not { Count: > 1 }) return;
var plot = Plot;
double fraction = Math.Clamp((e.X - plot.Left) / (double)plot.Width, 0, 1);
int index = (int)Math.Round(fraction * (_plan.Count - 1));
if (index != _hover) { _hover = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hover = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = Plot;
if (_plan is not { Count: > 1 } plan)
{
TextRenderer.DrawText(g, "Carica una sequenza per vedere il piano temporale", Theme.Body,
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
DrawGrid(g, plot, plan);
// Riferimento: la diagonale è il rapporto uno a uno fra scatti e fotogrammi d'uscita.
// Serve a leggere a colpo d'occhio dove il piano accelera e dove rallenta.
using (var reference = new Pen(Color.FromArgb(90, Theme.Text)) { DashStyle = DashStyle.Dash })
{
double slope = Math.Min(1.0, (_sourceCount - 1.0) / Math.Max(1, plan.Count - 1));
float endX = plot.Left + (float)(plot.Width * Math.Min(1.0, (plan.Count - 1.0) * slope / Math.Max(1, _sourceCount - 1)));
g.DrawLine(reference, plot.Left, plot.Bottom, endX, plot.Top);
}
int stride = Math.Max(1, plan.Count / Math.Max(1, plot.Width));
var points = new List<PointF>(plan.Count / stride + 2);
for (int i = 0; i < plan.Count; i += stride)
{
points.Add(new PointF(
plot.Left + plot.Width * i / (float)(plan.Count - 1),
plot.Bottom - (float)(plan.Frames[i].SourcePosition / Math.Max(1, _sourceCount - 1) * plot.Height)));
}
if (points.Count >= 2)
{
using var area = new GraphicsPath();
var polygon = new List<PointF>(points) { new(points[^1].X, plot.Bottom), new(points[0].X, plot.Bottom) };
area.AddPolygon([.. polygon]);
using (var fill = new SolidBrush(Color.FromArgb(30, Theme.Accent))) g.FillPath(fill, area);
using var pen = new Pen(Theme.Accent, 2f) { LineJoin = LineJoin.Round };
g.DrawLines(pen, [.. points]);
}
DrawSpeedLane(g, plot, plan, stride);
DrawReadout(g, plot, plan);
}
private void DrawGrid(Graphics g, Rectangle plot, RenderPlan plan)
{
using var grid = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
using var axis = new Pen(Theme.BorderStrong);
for (int i = 0; i <= 4; i++)
{
float y = plot.Bottom - plot.Height * i / 4f;
g.DrawLine(grid, plot.Left, y, plot.Right, y);
TextRenderer.DrawText(g, (_sourceCount * i / 4).ToString(), Theme.Small,
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
g.DrawLine(axis, plot.Left, plot.Top, plot.Left, plot.Bottom);
g.DrawLine(axis, plot.Left, plot.Bottom, plot.Right, plot.Bottom);
TextRenderer.DrawText(g, "scatto", Theme.Small, new Rectangle(0, plot.Top - 18, GutterLeft - 6, 16),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
double seconds = plan.Count / _frameRate;
TextRenderer.DrawText(g,
$"{plan.Count} fotogrammi d'uscita · {TimeSpan.FromSeconds(seconds):mm\\:ss} · {plan.Description}",
Theme.Small, new Rectangle(GutterLeft, 4, Math.Max(20, Width - GutterLeft - 12), 16),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, "fotogramma del video", Theme.Small,
new Rectangle(GutterLeft, Height - GutterBottom + 4, 200, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
/// <summary>Fascia in basso: la velocità istantanea, che è la pendenza resa esplicita.</summary>
private void DrawSpeedLane(Graphics g, Rectangle plot, RenderPlan plan, int stride)
{
double fastest = 0.1;
foreach (var frame in plan.Frames) fastest = Math.Max(fastest, frame.Speed);
int laneHeight = Math.Min(28, plot.Height / 5);
var lane = new Rectangle(plot.Left, plot.Bottom - laneHeight, plot.Width, laneHeight);
using var brush = new SolidBrush(Color.FromArgb(120, Theme.Warning));
for (int i = 0; i < plan.Count; i += stride)
{
float x = lane.Left + lane.Width * i / (float)(plan.Count - 1);
float height = (float)(plan.Frames[i].Speed / fastest * lane.Height);
g.FillRectangle(brush, x, lane.Bottom - height, Math.Max(1f, lane.Width / (float)(plan.Count / stride + 1)), height);
}
TextRenderer.DrawText(g, $"velocità · fondoscala {fastest:0.##}× ", Theme.Small,
new Rectangle(lane.Right - 200, lane.Top - 15, 200, 14), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
private void DrawReadout(Graphics g, Rectangle plot, RenderPlan plan)
{
if (_hover < 0 || _hover >= plan.Count) return;
var frame = plan.Frames[_hover];
float x = plot.Left + plot.Width * _hover / (float)(plan.Count - 1);
float y = plot.Bottom - (float)(frame.SourcePosition / Math.Max(1, _sourceCount - 1) * plot.Height);
using (var pen = new Pen(Color.FromArgb(90, Theme.Text))) g.DrawLine(pen, x, plot.Top, x, plot.Bottom);
using (var dot = new SolidBrush(Theme.Accent)) g.FillEllipse(dot, x - 3.5f, y - 3.5f, 7, 7);
string text = $"uscita #{_hover + 1} scatto {frame.SourcePosition:0.00} {frame.Speed:0.00}×";
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
int left = (int)Math.Min(x + 12, plot.Right - size.Width - 14);
var box = new RectangleF(left, plot.Top + 6, size.Width + 12, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(232, Theme.Background), Theme.BorderStrong);
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
+542 -69
View File
@@ -1,3 +1,4 @@
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using Titano.Core;
using Titano.Imaging;
@@ -6,23 +7,45 @@ using Titano.Pipeline;
namespace Titano.UI;
/// <summary>Frecce del campo vettoriale, in coordinate normalizzate sull'immagine mostrata.</summary>
internal sealed record MotionArrows(PointF[] Origins, PointF[] Vectors, double MedianMagnitude);
/// <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.
/// Anteprima del fotogramma selezionato, resa dallo stesso motore usato in esportazione.
///
/// Tre cose la rendono utilizzabile per giudicare, e non solo per guardare. Lo zoom, perché
/// una stabilizzazione sotto il pixel o il bordo di una scia non si vedono su un'immagine
/// rimpicciolita per stare in un riquadro. Il confronto a tendina, perché l'unico modo di
/// capire cosa una correzione stia facendo è vedere accanto ciò che c'era prima. E la
/// sovrapposizione del campo vettoriale, che il motore calcola comunque e che spiega in un
/// colpo d'occhio perché la sfocatura viene come viene — soprattutto quando è sbagliata.
/// </summary>
internal sealed class PreviewPanel : Control
{
private readonly TitanoProject _project;
private Bitmap? _bitmap;
private Bitmap? _after;
private Bitmap? _before;
private PointF[]? _regionContour;
private MotionArrows? _arrows;
private string _caption = string.Empty;
private string _status = "Nessun fotogramma selezionato";
private CancellationTokenSource? _pending;
private int _requestId;
private bool _busy;
private bool _live;
/// <summary>Contorno delle regioni in coordinate normalizzate dell'anteprima.</summary>
private PointF[]? _regionContour;
// ---- stato della vista
private float _zoom; // 0 = adatta al riquadro
private PointF _centre = new(0.5f, 0.5f); // punto dell'immagine al centro della vista
private float _wipe = 1f; // 1 = tutto "dopo", 0 = tutto "prima"
private bool _panning;
private bool _draggingWipe;
private Point _dragOrigin;
private PointF _centreOrigin;
public event EventHandler<FrameScopes?>? ScopesChanged;
public PreviewPanel(TitanoProject project)
{
@@ -32,16 +55,101 @@ internal sealed class PreviewPanel : Control
BackColor = Theme.Background;
}
/// <summary>Mostra la tendina di confronto fra fotogramma corretto e originale.</summary>
public bool CompareMode { get; private set; }
/// <summary>Disegna il campo vettoriale sopra il fotogramma.</summary>
public bool ShowMotionField { get; private set; }
/// <summary>Disegna il confine fra le regioni del deflicker.</summary>
public bool ShowRegions { get; private set; } = true;
public bool IsZoomed => _zoom > 0;
public string ZoomText => _zoom <= 0 ? "adatta" : $"{_zoom * 100:0}%";
// ------------------------------------------------------------------ comandi della vista
public void SetCompareMode(bool value)
{
if (CompareMode == value) return;
CompareMode = value;
_wipe = value ? 0.5f : 1f;
Invalidate();
RequestRefresh();
}
public void SetMotionField(bool value)
{
if (ShowMotionField == value) return;
ShowMotionField = value;
RequestRefresh();
}
public void SetRegionOverlay(bool value)
{
ShowRegions = value;
Invalidate();
}
/// <summary>Alterna fra adattamento al riquadro e scala uno a uno.</summary>
public void ToggleZoom()
{
_zoom = _zoom > 0 ? 0 : 1f;
_centre = new PointF(0.5f, 0.5f);
Invalidate();
}
public void ResetView()
{
_zoom = 0;
_centre = new PointF(0.5f, 0.5f);
Invalidate();
}
private event EventHandler? RefreshRequested;
/// <summary>Rende disponibile al chiamante la richiesta di rigenerare il fotogramma.</summary>
public void OnRefreshNeeded(EventHandler handler) => RefreshRequested += handler;
private void RequestRefresh() => RefreshRequested?.Invoke(this, EventArgs.Empty);
// ------------------------------------------------------------------ contenuto
public void Clear()
{
Interlocked.Increment(ref _requestId);
_pending?.Cancel();
SwapBitmap(null);
SwapBitmaps(null, null);
_regionContour = null;
_arrows = null;
_live = false;
_caption = string.Empty;
_status = "Nessun fotogramma selezionato";
ScopesChanged?.Invoke(this, null);
Invalidate();
}
/// <summary>
/// Mostra un fotogramma appena codificato durante l'esportazione. Non passa dalla
/// pipeline dell'anteprima: è già il risultato, arrivato dall'encoder.
/// </summary>
public void ShowLiveFrame(Bitmap frame, string caption, string status)
{
Interlocked.Increment(ref _requestId);
_pending?.Cancel();
SwapBitmaps(frame, null);
_regionContour = null;
_arrows = null;
_live = true;
_busy = false;
_caption = caption;
_status = status;
Invalidate();
}
public void EndLiveFrames() => _live = false;
/// <summary>Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.</summary>
public void Show(TimelapseSequence sequence, int index)
{
@@ -54,30 +162,42 @@ internal sealed class PreviewPanel : Control
var record = sequence.Frames[index];
_caption = $"#{index + 1} {record.FileName}";
_live = false;
_busy = true;
Invalidate();
var project = _project;
var token = source.Token;
bool compare = CompareMode;
bool motion = ShowMotionField;
_ = Task.Run(() =>
{
try
{
var (bitmap, status, contour) = Render(project, sequence, index, PreviewSize(), token);
var output = Render(project, sequence, index, PreviewSize(), compare, motion, token);
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
{
bitmap?.Dispose();
output.After?.Dispose();
output.Before?.Dispose();
return;
}
BeginInvoke(() =>
{
if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; }
SwapBitmap(bitmap);
_regionContour = contour;
_status = status;
if (requestId != Volatile.Read(ref _requestId))
{
output.After?.Dispose();
output.Before?.Dispose();
return;
}
SwapBitmaps(output.After, output.Before);
_regionContour = output.Contour;
_arrows = output.Arrows;
_status = output.Status;
_busy = false;
ScopesChanged?.Invoke(this, output.Scopes);
Invalidate();
});
}
@@ -88,9 +208,10 @@ internal sealed class PreviewPanel : Control
{
BeginInvoke(() =>
{
SwapBitmap(null);
SwapBitmaps(null, null);
_status = "Anteprima non disponibile: " + ex.Message;
_busy = false;
ScopesChanged?.Invoke(this, null);
Invalidate();
});
}
@@ -101,45 +222,52 @@ internal sealed class PreviewPanel : Control
private Size PreviewSize()
{
int width = Math.Clamp(Width - 24, 160, 1600);
int height = Math.Clamp(Height - 46, 120, 1200);
// Con lo zoom attivo si decodifica più grande del riquadro: guardare un pixel
// richiede che quel pixel esista, non che venga interpolato dall'anteprima.
int factor = _zoom > 0 ? 2 : 1;
int width = Math.Clamp((Width - 24) * factor, 160, 2600);
int height = Math.Clamp((Height - 46) * factor, 120, 2000);
return new Size(width, height);
}
private void SwapBitmap(Bitmap? bitmap)
private void SwapBitmaps(Bitmap? after, Bitmap? before)
{
var previous = _bitmap;
_bitmap = bitmap;
previous?.Dispose();
var previousAfter = _after;
var previousBefore = _before;
_after = after;
_before = before;
previousAfter?.Dispose();
previousBefore?.Dispose();
}
// ------------------------------------------------------------------ rendering
private static (Bitmap? Bitmap, string Status, PointF[]? Contour) Render(
TitanoProject project, TimelapseSequence sequence, int index, Size available, CancellationToken token)
private sealed record RenderOutput(Bitmap? After, Bitmap? Before, PointF[]? Contour,
MotionArrows? Arrows, FrameScopes? Scopes, string Status);
private static RenderOutput Render(TitanoProject project, TimelapseSequence sequence, int index,
Size available, bool compare, bool motionField,
CancellationToken token)
{
var record = sequence.Frames[index];
var metadata = record.Metadata;
int orientation = project.EffectiveOrientation;
int sourceWidth = metadata.PixelWidth;
int sourceHeight = metadata.PixelHeight;
var (sourceWidth, sourceHeight) = project.ResolveNativeSize();
if (sourceWidth <= 0 || sourceHeight <= 0)
(sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, orientation);
else if (ImageDecoder.SwapsAxes(orientation))
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile", null);
return new RenderOutput(null, null, null, null, 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);
var pool = new FrameBufferPool(6);
using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, orientation, pool);
token.ThrowIfCancellationRequested();
Bitmap? before = compare ? ToBitmap(frame) : null;
var status = new System.Text.StringBuilder();
status.Append($"{sourceWidth}×{sourceHeight}");
@@ -152,8 +280,10 @@ internal sealed class PreviewPanel : Control
ImageBuffer result = frame;
ImageBuffer? blurred = null;
MotionArrows? arrows = null;
if (project.MotionBlur.Enabled && index + 1 < sequence.Count)
bool wantsFlow = motionField || project.MotionBlur.Enabled;
if (wantsFlow && index + 1 < sequence.Count)
{
var nextMetadata = sequence.Frames[index + 1].Metadata;
using var next = ImageDecoder.Decode(nextMetadata.FilePath, width, height, orientation, pool);
@@ -167,19 +297,24 @@ internal sealed class PreviewPanel : Control
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;
if (motionField) arrows = SampleArrows(flow, width, height);
blurred = pool.Rent(width, height);
double length = MotionBlurRenderer.Render(frame, blurred, flow, missing, scaledSettings);
result = blurred;
if (project.MotionBlur.Enabled)
{
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;
status.Append($" otturatore {record.ShutterAngle:0.#}° → {project.MotionBlur.TargetShutterAngle:0}°");
status.Append($" scia {length:0.0} px");
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");
}
}
// Inquadratura virtuale e stabilizzazione: l'anteprima deve mostrare il fotogramma
@@ -194,12 +329,11 @@ internal sealed class PreviewPanel : Control
var (outputWidth, outputHeight) = project.ResolveWorkingSize();
if (outputWidth > 0 && outputHeight > 0)
{
framedWidth = width;
framedHeight = Math.Max(2, (int)Math.Round(width * outputHeight / (double)outputWidth) & ~1);
double normalized = sequence.Count > 1 ? index / (double)(sequence.Count - 1) : 0;
var framing = project.FramingAt(normalized);
var stabilization = project.Motion?.At(index) ?? Motion.SimilarityTransform.Identity;
var stabilization = project.Motion?.At(index) ?? SimilarityTransform.Identity;
mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization);
framed = pool.Rent(framedWidth, framedHeight);
@@ -215,10 +349,44 @@ internal sealed class PreviewPanel : Control
contour = TraceRegionBoundary(mask, mapping, framedWidth, framedHeight, width, height);
}
var scopes = FrameScopes.Compute(result);
var bitmap = ToBitmap(result);
blurred?.Dispose();
framed?.Dispose();
return (bitmap, status.ToString(), contour);
return new RenderOutput(bitmap, before, contour, arrows, scopes, status.ToString());
}
/// <summary>
/// Estrae dal campo una griglia rada di frecce. Disegnarle tutte sarebbe illeggibile:
/// se ne prende una ogni tot pixel dell'immagine mostrata, che è la densità a cui la
/// direzione del movimento si legge senza che le frecce si accavallino.
/// </summary>
private static MotionArrows SampleArrows(MotionField field, int width, int height)
{
const int spacing = 46;
int columns = Math.Max(2, width / spacing);
int rows = Math.Max(2, height / spacing);
var origins = new PointF[columns * rows];
var vectors = new PointF[columns * rows];
int n = 0;
for (int row = 0; row < rows; row++)
{
float y = (row + 0.5f) * height / rows;
for (int column = 0; column < columns; column++)
{
float x = (column + 0.5f) * width / columns;
field.Sample(x, y, out float vx, out float vy);
origins[n] = new PointF(x / width, y / height);
vectors[n] = new PointF(vx / width, vy / width); // stessa scala sui due assi
n++;
}
}
return new MotionArrows(origins[..n], vectors[..n], field.MedianMagnitude());
}
private static void ApplyExposure(TitanoProject project, Analysis.DeflickerCurve curve,
@@ -270,6 +438,48 @@ internal sealed class PreviewPanel : Control
return points.Count >= 2 ? [.. points] : null;
}
/// <summary>
/// Riduzione veloce a bitmap per l'anteprima dal vivo durante l'esportazione: campiona a
/// passo costante invece di mediare. Converte un fotogramma da dodici megapixel in pochi
/// millisecondi, e siccome gira sul thread che sta codificando, quei millisecondi sono
/// tolti al lavoro vero.
/// </summary>
internal static unsafe Bitmap ToThumbnail(ImageBuffer buffer, int maxWidth)
{
int step = Math.Max(1, (int)Math.Ceiling(buffer.Width / (double)Math.Max(16, maxWidth)));
int width = Math.Max(1, buffer.Width / step);
int height = Math.Max(1, buffer.Height / step);
var bitmap = new Bitmap(width, height, PixelFormat.Format32bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, width, height),
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
try
{
var data = buffer.Data;
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
int sourceRow = y * step * buffer.Width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int i = sourceRow + x * step * 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;
}
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
private static unsafe Bitmap ToBitmap(ImageBuffer buffer)
{
@@ -303,6 +513,132 @@ internal sealed class PreviewPanel : Control
return bitmap;
}
// ------------------------------------------------------------------ vista: zoom e trascinamento
private Rectangle Viewport => new(0, 0, Width, Math.Max(20, Height - 22));
private float ScaleFor(Bitmap bitmap)
{
if (_zoom > 0) return _zoom;
var view = Viewport;
return (float)Math.Min(view.Width / (double)bitmap.Width, view.Height / (double)bitmap.Height);
}
private RectangleF TargetFor(Bitmap bitmap)
{
var view = Viewport;
float scale = ScaleFor(bitmap);
float width = bitmap.Width * scale;
float height = bitmap.Height * scale;
if (_zoom <= 0)
{
return new RectangleF(view.Left + (view.Width - width) / 2f,
view.Top + (view.Height - height) / 2f, width, height);
}
return new RectangleF(view.Left + view.Width / 2f - _centre.X * width,
view.Top + view.Height / 2f - _centre.Y * height, width, height);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (_after is null) return;
var target = TargetFor(_after);
// Punto dell'immagine sotto il puntatore, che deve restare fermo mentre si ingrandisce.
float anchorX = target.Width > 0 ? (e.X - target.Left) / target.Width : 0.5f;
float anchorY = target.Height > 0 ? (e.Y - target.Top) / target.Height : 0.5f;
float current = ScaleFor(_after);
float next = Math.Clamp(current * (e.Delta > 0 ? 1.25f : 0.8f), 0.05f, 8f);
var view = Viewport;
float fit = (float)Math.Min(view.Width / (double)_after.Width, view.Height / (double)_after.Height);
if (next <= fit * 1.02f)
{
ResetView();
return;
}
_zoom = next;
float width = _after.Width * next;
float height = _after.Height * next;
_centre = new PointF(
Math.Clamp(anchorX + (view.Width / 2f - e.X) / Math.Max(1f, width), 0f, 1f),
Math.Clamp(anchorY + (view.Height / 2f - e.Y) / Math.Max(1f, height), 0f, 1f));
Invalidate();
}
private bool OverWipeHandle(Point location)
{
if (!CompareMode || _before is null || _after is null) return false;
var target = TargetFor(_after);
float x = target.Left + target.Width * _wipe;
return Math.Abs(location.X - x) <= 7 && location.Y >= target.Top && location.Y <= target.Bottom;
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.Button != MouseButtons.Left) { base.OnMouseDown(e); return; }
if (OverWipeHandle(e.Location))
{
_draggingWipe = true;
return;
}
if (_zoom > 0)
{
_panning = true;
_dragOrigin = e.Location;
_centreOrigin = _centre;
}
base.OnMouseDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_draggingWipe && _after is not null)
{
var target = TargetFor(_after);
_wipe = target.Width > 0 ? Math.Clamp((e.X - target.Left) / target.Width, 0f, 1f) : 0.5f;
Invalidate();
return;
}
if (_panning && _after is not null)
{
var target = TargetFor(_after);
_centre = new PointF(
Math.Clamp(_centreOrigin.X - (e.X - _dragOrigin.X) / Math.Max(1f, target.Width), 0f, 1f),
Math.Clamp(_centreOrigin.Y - (e.Y - _dragOrigin.Y) / Math.Max(1f, target.Height), 0f, 1f));
Invalidate();
return;
}
Cursor = OverWipeHandle(e.Location) ? Cursors.SizeWE
: _zoom > 0 ? Cursors.SizeAll
: Cursors.Default;
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_panning = false;
_draggingWipe = false;
base.OnMouseUp(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
if (!OverWipeHandle(e.Location)) ToggleZoom();
base.OnMouseDoubleClick(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
@@ -311,48 +647,184 @@ internal sealed class PreviewPanel : Control
Theme.HighQuality(g);
g.Clear(Theme.Background);
var frame = new Rectangle(0, 0, Width, Height - 22);
var view = Viewport;
if (_bitmap is null)
if (_after is null)
{
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, frame,
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, view,
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
DrawFooter(g);
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);
var target = TargetFor(_after);
g.DrawImage(_bitmap, target);
using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, target);
// A scala uno a uno l'interpolazione morbida nasconderebbe proprio ciò che si vuole
// guardare: da lì in su si mostrano i pixel come sono.
g.InterpolationMode = ScaleFor(_after) >= 1f ? InterpolationMode.NearestNeighbor
: InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.Half;
if (_regionContour is { Length: >= 2 } contour)
var clip = g.Clip;
g.SetClip(view);
if (CompareMode && _before is not null)
{
var line = new PointF[contour.Length];
for (int i = 0; i < contour.Length; i++)
{
line[i] = new PointF(target.Left + contour[i].X * target.Width,
target.Top + contour[i].Y * target.Height);
}
g.DrawImage(_before, target);
using var shadow = new Pen(Color.FromArgb(140, Color.Black), 3f);
using var boundary = new Pen(Color.FromArgb(210, Theme.Success), 1.6f);
g.DrawLines(shadow, line);
g.DrawLines(boundary, line);
float split = target.Left + target.Width * _wipe;
g.SetClip(new RectangleF(split, target.Top, Math.Max(0, target.Right - split), target.Height),
CombineMode.Intersect);
g.DrawImage(_after, target);
g.SetClip(view, CombineMode.Replace);
DrawWipeHandle(g, target, split);
}
else
{
g.DrawImage(_after, target);
}
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
if (ShowRegions && _regionContour is { Length: >= 2 }) DrawRegionContour(g, target);
if (ShowMotionField && _arrows is not null) DrawMotionField(g, target);
g.Clip = clip;
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(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,
g.FillRectangle(overlay, view);
TextRenderer.DrawText(g, "Aggiornamento…", Theme.Small, view, Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
if (_zoom > 0) DrawZoomBadge(g, view);
if (_live) DrawLiveBadge(g, view);
DrawFooter(g);
}
private static void DrawWipeHandle(Graphics g, RectangleF target, float x)
{
using (var line = new Pen(Color.FromArgb(220, Color.White), 1.6f))
g.DrawLine(line, x, target.Top, x, target.Bottom);
float cy = target.Top + target.Height / 2f;
var grip = new RectangleF(x - 11, cy - 15, 22, 30);
Theme.FillAndStroke(g, grip, 5f, Color.FromArgb(225, Theme.Background), Color.FromArgb(220, Color.White));
using var arrows = new Pen(Color.White, 1.5f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
};
g.DrawLines(arrows, [new PointF(x - 3.5f, cy - 4), new PointF(x - 6.5f, cy), new PointF(x - 3.5f, cy + 4)]);
g.DrawLines(arrows, [new PointF(x + 3.5f, cy - 4), new PointF(x + 6.5f, cy), new PointF(x + 3.5f, cy + 4)]);
TextRenderer.DrawText(g, "originale", Theme.SmallBold,
new Rectangle((int)target.Left + 8, (int)target.Top + 6, 90, 16),
Color.FromArgb(210, Color.White), TextFormatFlags.Left);
TextRenderer.DrawText(g, "corretto", Theme.SmallBold,
new Rectangle((int)target.Right - 98, (int)target.Top + 6, 90, 16),
Color.FromArgb(210, Color.White), TextFormatFlags.Right);
}
private void DrawRegionContour(Graphics g, RectangleF target)
{
var contour = _regionContour!;
var line = new PointF[contour.Length];
for (int i = 0; i < contour.Length; i++)
{
line[i] = new PointF(target.Left + contour[i].X * target.Width,
target.Top + contour[i].Y * target.Height);
}
using var shadow = new Pen(Color.FromArgb(140, Color.Black), 3f);
using var boundary = new Pen(Color.FromArgb(210, Theme.RegionHigh), 1.6f);
g.DrawLines(shadow, line);
g.DrawLines(boundary, line);
}
private void DrawMotionField(Graphics g, RectangleF target)
{
var arrows = _arrows!;
if (arrows.Origins.Length == 0) return;
// La lunghezza si normalizza sul vettore mediano: su una ripresa notturna gli
// spostamenti sono di pochi pixel e a scala reale le frecce sarebbero invisibili.
// Il limite inferiore però conta: su una scena ferma il mediano tende a zero, e senza
// di esso il rumore del campo verrebbe amplificato fino a sembrare movimento vero.
// Sotto un terzo di pixel non c'è movimento da mostrare, solo il rumore del campo:
// si smette di normalizzare e si usa una scala fissa modesta, così le frecce restano
// visibili senza raccontare uno spostamento che non c'è.
float gain = arrows.MedianMagnitude < 0.35
? 8f
: (float)Math.Min(30.0, 26.0 / arrows.MedianMagnitude);
using var pen = new Pen(Color.FromArgb(200, Theme.Accent), 1.4f)
{
EndCap = LineCap.ArrowAnchor,
StartCap = LineCap.Round,
};
using var dot = new SolidBrush(Color.FromArgb(150, Theme.Accent));
for (int i = 0; i < arrows.Origins.Length; i++)
{
float ox = target.Left + arrows.Origins[i].X * target.Width;
float oy = target.Top + arrows.Origins[i].Y * target.Height;
float dx = arrows.Vectors[i].X * target.Width * gain;
float dy = arrows.Vectors[i].Y * target.Width * gain;
float length = MathF.Sqrt(dx * dx + dy * dy);
if (length < 1.5f)
{
g.FillEllipse(dot, ox - 1.4f, oy - 1.4f, 2.8f, 2.8f);
continue;
}
// Un limite alla lunghezza disegnata: un vettore sbagliato non deve attraversare
// tutta l'immagine e coprire quelli giusti.
if (length > 44f) { dx *= 44f / length; dy *= 44f / length; }
g.DrawLine(pen, ox, oy, ox + dx, oy + dy);
}
string caption = arrows.MedianMagnitude < 0.35
? $"campo vettoriale · mediana {arrows.MedianMagnitude:0.00} px, movimento trascurabile"
: $"campo vettoriale · mediana {arrows.MedianMagnitude:0.0} px";
TextRenderer.DrawText(g, caption, Theme.Small,
new Rectangle((int)target.Left + 8, (int)target.Bottom - 20,
Math.Max(120, (int)target.Width - 16), 16),
Color.FromArgb(200, Theme.Accent), TextFormatFlags.Left);
}
private void DrawZoomBadge(Graphics g, Rectangle view)
{
string text = $"{ZoomText} · doppio clic per adattare";
var size = TextRenderer.MeasureText(g, text, Theme.Small);
var box = new RectangleF(view.Right - size.Width - 24, view.Top + 8, size.Width + 14, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(220, Theme.Background), Theme.Border);
TextRenderer.DrawText(g, text, Theme.Small, Rectangle.Round(box), Theme.TextMuted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
private static void DrawLiveBadge(Graphics g, Rectangle view)
{
const string text = "IN CODIFICA";
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
var box = new RectangleF(view.Left + 10, view.Top + 8, size.Width + 16, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(230, Theme.AccentDim), Theme.Accent);
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Color.White,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
private void DrawFooter(Graphics g)
{
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);
@@ -366,7 +838,8 @@ internal sealed class PreviewPanel : Control
{
_pending?.Cancel();
_pending?.Dispose();
_bitmap?.Dispose();
_after?.Dispose();
_before?.Dispose();
}
base.Dispose(disposing);
}
+253
View File
@@ -0,0 +1,253 @@
using Titano.Imaging;
namespace Titano.UI;
/// <summary>
/// Misure di distribuzione di un fotogramma, calcolate una volta sola dal buffer
/// dell'anteprima e riusate dai due strumenti che le mostrano.
/// </summary>
internal sealed class FrameScopes
{
public const int Levels = 256;
public const int WaveformColumns = 320;
public const int WaveformRows = 128;
public required int[] Red { get; init; }
public required int[] Green { get; init; }
public required int[] Blue { get; init; }
public required int Peak { get; init; }
/// <summary>Densità della forma d'onda: colonne dell'immagine per livelli di luminanza.</summary>
public required byte[] Waveform { get; init; }
public required double ClippedFraction { get; init; }
public required double BlackFraction { get; init; }
/// <summary>
/// Calcola istogramma e forma d'onda dal buffer in luce lineare, convertendo in sRGB:
/// è lo spazio in cui il fotogramma verrà mostrato e codificato, e quindi l'unico in cui
/// "saturo" e "nero" vogliono dire quello che l'occhio vede.
/// </summary>
public static FrameScopes Compute(ImageBuffer frame)
{
var red = new int[Levels];
var green = new int[Levels];
var blue = new int[Levels];
var waveform = new byte[WaveformColumns * WaveformRows];
var data = frame.Data;
int width = frame.Width;
int height = frame.Height;
// Un campionamento a griglia tiene il costo costante anche su un fotogramma da 12 MP:
// per una distribuzione bastano poche centinaia di migliaia di campioni.
int step = Math.Max(1, (int)Math.Sqrt(frame.PixelCount / 240_000.0));
long clipped = 0, black = 0, counted = 0;
var columnCounts = new int[WaveformColumns * WaveformRows];
for (int y = 0; y < height; y += step)
{
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x += step)
{
int i = rowBase + x * ImageBuffer.Channels;
byte r = ColorSpace.ToSrgbByte(data[i]);
byte g = ColorSpace.ToSrgbByte(data[i + 1]);
byte b = ColorSpace.ToSrgbByte(data[i + 2]);
red[r]++;
green[g]++;
blue[b]++;
counted++;
int luma = (r * 54 + g * 183 + b * 19) >> 8;
if (luma >= 253) clipped++;
else if (luma <= 2) black++;
int column = x * WaveformColumns / width;
int row = (Levels - 1 - luma) * WaveformRows / Levels;
columnCounts[row * WaveformColumns + Math.Min(column, WaveformColumns - 1)]++;
}
}
// Normalizzazione logaritmica: senza, la forma d'onda di un cielo notturno sarebbe
// una riga sola in basso e tutto il resto invisibile.
int densest = 1;
foreach (int value in columnCounts) densest = Math.Max(densest, value);
double scale = 255.0 / Math.Log(1 + densest);
for (int i = 0; i < columnCounts.Length; i++)
{
waveform[i] = columnCounts[i] == 0 ? (byte)0 : (byte)Math.Clamp(Math.Log(1 + columnCounts[i]) * scale, 0, 255);
}
int peak = 1;
for (int i = 1; i < Levels - 1; i++)
{
peak = Math.Max(peak, Math.Max(red[i], Math.Max(green[i], blue[i])));
}
return new FrameScopes
{
Red = red,
Green = green,
Blue = blue,
Peak = peak,
Waveform = waveform,
ClippedFraction = counted > 0 ? clipped / (double)counted : 0,
BlackFraction = counted > 0 ? black / (double)counted : 0,
};
}
}
/// <summary>
/// Istogramma e forma d'onda del fotogramma in anteprima.
///
/// Servono a una cosa sola ma importante: rendere ovvio il clipping. È la condizione che
/// rovina più decisioni a valle — sui file reali ha guastato insieme stabilizzazione,
/// segmentazione e riconoscimento dei cambi di esposizione — e finora si leggeva soltanto
/// come una percentuale in una colonna della tabella, dove nessuno la guarda.
/// </summary>
internal sealed class ScopesPanel : Control
{
private FrameScopes? _scopes;
private bool _waveform;
public ScopesPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 132;
Cursor = Cursors.Hand;
}
/// <summary>Alterna istogramma e forma d'onda: dicono la stessa cosa in due modi diversi.</summary>
public bool ShowWaveform
{
get => _waveform;
set { _waveform = value; Invalidate(); }
}
public void SetScopes(FrameScopes? scopes)
{
_scopes = scopes;
Invalidate();
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button == MouseButtons.Left) ShowWaveform = !ShowWaveform;
base.OnMouseDown(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = new Rectangle(8, 20, Math.Max(20, Width - 16), Math.Max(20, Height - 28));
TextRenderer.DrawText(g, _waveform ? "FORMA D'ONDA" : "ISTOGRAMMA", Theme.SmallBold,
new Rectangle(10, 2, 200, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (_scopes is null)
{
TextRenderer.DrawText(g, "nessun fotogramma", Theme.Small, plot, Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
TextRenderer.DrawText(g,
$"saturi {_scopes.ClippedFraction * 100:0.00}% neri {_scopes.BlackFraction * 100:0.00}% " +
$"(clic per cambiare)",
Theme.Small, new Rectangle(Width - 320, 2, 312, 16),
_scopes.ClippedFraction > 0.02 ? Theme.Warning : Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
using (var frame = new Pen(Theme.Border)) g.DrawRectangle(frame, plot);
if (_waveform) DrawWaveform(g, plot, _scopes);
else DrawHistogram(g, plot, _scopes);
}
private static void DrawHistogram(Graphics g, Rectangle plot, FrameScopes scopes)
{
var clip = g.Clip;
g.SetClip(plot);
// I tre canali sovrapposti in additiva: dove coincidono si legge il grigio, dove
// divergono si vede subito quale canale sta andando a fondo scala.
DrawChannel(g, plot, scopes.Red, scopes.Peak, Color.FromArgb(150, 0xF0, 0x5D, 0x5D));
DrawChannel(g, plot, scopes.Green, scopes.Peak, Color.FromArgb(150, 0x5D, 0xE0, 0x8A));
DrawChannel(g, plot, scopes.Blue, scopes.Peak, Color.FromArgb(150, 0x5D, 0x9D, 0xF0));
g.Clip = clip;
using var guide = new Pen(Color.FromArgb(70, Theme.Text)) { DashStyle = System.Drawing.Drawing2D.DashStyle.Dot };
for (int i = 1; i < 4; i++)
{
float x = plot.Left + plot.Width * i / 4f;
g.DrawLine(guide, x, plot.Top, x, plot.Bottom);
}
}
private static void DrawChannel(Graphics g, Rectangle plot, int[] values, int peak, Color color)
{
var points = new PointF[FrameScopes.Levels + 2];
points[0] = new PointF(plot.Left, plot.Bottom);
for (int i = 0; i < FrameScopes.Levels; i++)
{
float x = plot.Left + plot.Width * i / (float)(FrameScopes.Levels - 1);
float height = Math.Min(1f, values[i] / (float)peak) * (plot.Height - 3);
points[i + 1] = new PointF(x, plot.Bottom - height);
}
points[^1] = new PointF(plot.Right, plot.Bottom);
using var brush = new SolidBrush(color);
g.FillPolygon(brush, points);
}
private static unsafe void DrawWaveform(Graphics g, Rectangle plot, FrameScopes scopes)
{
using var bitmap = new Bitmap(FrameScopes.WaveformColumns, FrameScopes.WaveformRows,
System.Drawing.Imaging.PixelFormat.Format32bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height),
System.Drawing.Imaging.ImageLockMode.WriteOnly,
System.Drawing.Imaging.PixelFormat.Format32bppRgb);
try
{
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < bitmap.Height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
for (int x = 0; x < bitmap.Width; x++)
{
byte density = scopes.Waveform[y * FrameScopes.WaveformColumns + x];
byte* pixel = row + x * 4;
pixel[0] = (byte)(density * 0.55f + 0x14); // B
pixel[1] = (byte)(density * 0.90f + 0x16); // G
pixel[2] = (byte)(density * 0.70f + 0x14); // R
pixel[3] = 255;
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
var previous = g.InterpolationMode;
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBilinear;
g.DrawImage(bitmap, Rectangle.Inflate(plot, -1, -1));
g.InterpolationMode = previous;
// Le due soglie che contano: il bianco e il nero. Una forma d'onda che le tocca
// dice che l'informazione oltre quel punto non c'è più.
using var limit = new Pen(Color.FromArgb(120, Theme.Danger));
g.DrawLine(limit, plot.Left, plot.Top + 1, plot.Right, plot.Top + 1);
g.DrawLine(limit, plot.Left, plot.Bottom - 1, plot.Right, plot.Bottom - 1);
}
}
+238 -133
View File
@@ -6,10 +6,13 @@ using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Pannello di configurazione avanzata, diviso nelle sezioni Generale, Immagine, Movimento,
/// Tempo ed Esportazione. Ogni controllo scrive direttamente nel progetto e dichiara quale
/// parte della pipeline va rifatta: c'è differenza fra spostare un cursore che cambia solo
/// una curva già calcolata e sceglierne uno che obbliga a rileggere ogni file.
/// Colonna delle impostazioni. Non ha più schede proprie: mostra la pagina della sezione
/// scelta nella barra di navigazione, così la barra governa insieme il contenuto principale
/// e i comandi che lo riguardano, e non esistono due gerarchie di schede da tenere allineate.
///
/// Ogni controllo dichiara quale parte della pipeline invalida. C'è differenza fra spostare
/// un cursore che ricalcola una curva già misurata e sceglierne uno che obbliga a rileggere
/// mille file, e il programma deve saperla.
/// </summary>
internal sealed class SettingsPanel : Panel
{
@@ -17,8 +20,9 @@ internal sealed class SettingsPanel : Panel
private static readonly int?[] OrientationValues = [null, 1, 2, 4, 6, 3, 8];
private readonly TitanoProject _project;
private readonly TabStrip _tabs;
private readonly Panel[] _pages;
private readonly AppSettings _app;
private readonly Dictionary<WorkspaceSection, Panel> _pages = [];
private readonly Dictionary<AutoKey, ParameterSlider> _autoSliders = [];
private LabeledCombo? _orientationCombo;
private Label? _analysisNote;
@@ -31,30 +35,19 @@ internal sealed class SettingsPanel : Panel
private ParameterSlider? _keyframeEaseIn;
private bool _syncingKeyframe;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare le curve già misurate.</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 event EventHandler? AppSettingsChanged;
public TextBox OutputPathBox { get; }
public SettingsPanel(TitanoProject project)
public SettingsPanel(TitanoProject project, AppSettings app)
{
_project = project;
_app = app;
BackColor = Theme.Surface;
Padding = new Padding(0);
_tabs = new TabStrip("Generale", "Immagine", "Movimento", "Tempo", "Esportazione")
{
Dock = DockStyle.Top,
};
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
OutputPathBox = new TextBox
{
@@ -70,24 +63,30 @@ internal sealed class SettingsPanel : Panel
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(), BuildMotionPage(), BuildTimePage(), BuildExportPage()];
foreach (var page in _pages)
_pages[WorkspaceSection.Sequence] = BuildSequencePage();
_pages[WorkspaceSection.Exposure] = BuildExposurePage();
_pages[WorkspaceSection.Motion] = BuildMotionPage();
_pages[WorkspaceSection.Timing] = BuildTimingPage();
_pages[WorkspaceSection.Export] = BuildExportPage();
_pages[WorkspaceSection.Preferences] = BuildPreferencesPage();
foreach (var page in _pages.Values)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
Controls.Add(page);
}
Controls.Add(_tabs);
ShowPage(0);
ShowSection(WorkspaceSection.Sequence);
}
/// <summary>
/// Riporta nel menu l'esito del rilevamento, così la voce "Automatico" dice cosa ha deciso.
/// </summary>
public void ShowSection(WorkspaceSection section)
{
foreach (var (key, page) in _pages) page.Visible = key == section;
}
// ------------------------------------------------------------------ aggiornamenti dall'esterno
public void ShowDetectedOrientation(Imaging.OrientationDetection detection)
{
if (_orientationCombo is null || _orientationCombo.Combo.Items.Count == 0) return;
@@ -97,7 +96,24 @@ internal sealed class SettingsPanel : Panel
_orientationCombo.Combo.SelectedIndex = selected;
}
/// <summary>Aggiorna le voci che dipendono dall'analisi: maschera, transizioni, tremolio.</summary>
/// <summary>
/// Riporta nei cursori i valori che il direttore ha dedotto, con il motivo di ciascuno.
/// Quelli di cui l'utente ha preso il controllo restano dove sono.
/// </summary>
public void ShowAutoDecisions(IReadOnlyDictionary<AutoKey, AutoDecision> decisions)
{
foreach (var (key, slider) in _autoSliders)
{
bool manual = _project.ManualParameters.Contains(key);
slider.IsAuto = !manual;
if (!decisions.TryGetValue(key, out var decision)) continue;
slider.AutoReason = decision.Reason;
if (!manual) slider.SetValueSilently(decision.Value);
}
Invalidate(true);
}
public void ShowAnalysis()
{
if (_analysisNote is null) return;
@@ -115,16 +131,16 @@ internal sealed class SettingsPanel : Panel
{
lines.Add(transitions.StepCount == 0
? "Transizioni: nessun cambio di impostazione rilevato."
: $"Transizioni: {transitions.StepCount} cambi di impostazione, " +
$"il maggiore di {transitions.LargestStepStops:0.00} EV" +
(transitions.MetadataUsable ? " (dai metadati)." : " (dedotti dalla luminanza)."));
: $"Transizioni: {transitions.StepCount} cambi" +
(transitions.UnobservedSteps > 0 ? $", {transitions.UnobservedSteps} non recepiti" : string.Empty) +
$", il maggiore di {transitions.LargestStepStops:0.00} EV.");
}
if (_project.Stabilization.Enabled && _project.Motion is { } motion)
{
var (width, _) = _project.ResolveSourceSize();
lines.Add($"Stabilizzazione: tremolio medio {motion.MeanShake * Math.Max(1, width):0.0} px, " +
$"ritaglio necessario {(_project.StabilizationZoom - 1) * 100:0.#}%.");
$"ritaglio {(_project.StabilizationZoom - 1) * 100:0.#}%.");
}
_analysisNote.Text = lines.Count > 0
@@ -133,7 +149,6 @@ internal sealed class SettingsPanel : Panel
LayoutNote(_analysisNote);
}
/// <summary>Aggiorna l'editor del movimento quando cambia la sequenza caricata.</summary>
public void ShowSequenceGeometry()
{
if (_cameraEditor is null) return;
@@ -142,17 +157,9 @@ internal sealed class SettingsPanel : Panel
_cameraEditor.Invalidate();
}
/// <summary>Seleziona una delle sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
// ------------------------------------------------------------------ Sequenza
private void ShowPage(int index)
{
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
}
// ------------------------------------------------------------------ Generale
private Panel BuildGeneralPage()
private Panel BuildSequencePage()
{
var stack = NewStack();
@@ -166,13 +173,6 @@ internal sealed class SettingsPanel : Panel
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);
}));
_orientationCombo = Combo("Orientamento dei fotogrammi",
["Automatico", "Nessuna rotazione", "Specchia orizzontalmente", "Specchia verticalmente",
"Ruota 90° in senso orario", "Ruota 180°", "Ruota 90° in senso antiorario"],
@@ -184,13 +184,14 @@ internal sealed class SettingsPanel : Panel
});
stack.Add(_orientationCombo);
stack.Add(Note("In automatico l'orientamento viene dedotto dai metadati del primo scatto e " +
"confrontato con ciò che il decodificatore di sistema restituisce, così la " +
"trasformazione non viene applicata due volte. Se il risultato non convince, " +
"l'anteprima mostra subito l'effetto di una scelta manuale."));
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("Qualità di elaborazione"));
stack.Add(Combo("Profilo",
["Bozza — veloce", "Standard — equilibrio", "Massima — resa migliore"],
(int)_project.General.Quality,
@@ -201,11 +202,12 @@ internal sealed class SettingsPanel : Panel
}));
stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura, " +
"riquadri della correlazione di fase e risoluzione delle passate di analisi, " +
"e sovrascrive i cursori delle sezioni avanzate."));
"riquadri della correlazione di fase e risoluzione delle passate di analisi."));
stack.Add(new SectionHeader("Prestazioni e memoria"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
stack.Add(AutoSlider(AutoKey.AnalysisWidth, "Larghezza della passata di analisi", 256, 2048,
_project.General.AnalysisWidth, 64, "0", "px",
value =>
{
_project.General.AnalysisWidth = (int)value;
@@ -222,16 +224,16 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Lettura in anticipo", 0, 32, _project.Cache.PrefetchDepth, 1, "0", "fotogrammi",
value => _project.Cache.PrefetchDepth = (int)value));
stack.Add(Slider("Tetto di memoria per i fotogrammi", 256, 32768, _project.Cache.MemoryBudgetMiB, 256, "0", "MiB",
stack.Add(AutoSlider(AutoKey.MemoryBudget, "Tetto di memoria", 256, 32768,
_project.Cache.MemoryBudgetMiB, 256, "0", "MiB",
value => _project.Cache.MemoryBudgetMiB = (int)value));
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _project.Cache.AllowDiskSpill,
value => _project.Cache.AllowDiskSpill = value));
stack.Add(Note("La finestra attiva sta sempre in memoria; il tetto governa la lettura in " +
"anticipo, che serve a tenere occupati tutti i processori sulla decodifica dei " +
"RAW. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono ripresi " +
"una volta sola. Il file di parcheggio si cancella da sé alla chiusura."));
"anticipo. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono " +
"ripresi una volta sola. Il file di parcheggio si cancella da sé."));
stack.Add(new SectionHeader("Esito dell'analisi"));
_analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.");
@@ -240,9 +242,9 @@ internal sealed class SettingsPanel : Panel
return stack.Panel;
}
// ------------------------------------------------------------------ Immagine
// ------------------------------------------------------------------ Esposizione
private Panel BuildImagePage()
private Panel BuildExposurePage()
{
var stack = NewStack();
@@ -254,7 +256,8 @@ internal sealed class SettingsPanel : Panel
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
stack.Add(AutoSlider(AutoKey.DeflickerWindow, "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,
@@ -275,10 +278,10 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.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); }));
// ---- Regioni
stack.Add(new SectionHeader("Deflicker per regioni"));
stack.Add(Combo("Divisione del fotogramma",
@@ -305,11 +308,9 @@ internal sealed class SettingsPanel : Panel
value => { _project.Regions.SampleFrames = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La maschera nasce dalla mediana temporale di un campione di fotogrammi, che " +
"toglie di mezzo nuvole e passanti e lascia la struttura fissa della scena. " +
"Serve a impedire che il transito di una nuvola densa sul cielo faccia " +
"schiarire anche il paesaggio, che invece non è cambiato."));
"toglie di mezzo nuvole e passanti. Impedisce che il transito di una nuvola sul " +
"cielo faccia schiarire anche il paesaggio, che invece non è cambiato."));
// ---- Holy Grail
stack.Add(new SectionHeader("Transizioni giorno-notte"));
stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled, value =>
@@ -318,7 +319,8 @@ internal sealed class SettingsPanel : Panel
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Lunghezza della transizione", 4, 240, _project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
stack.Add(AutoSlider(AutoKey.TransitionFrames, "Lunghezza della transizione", 4, 240,
_project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
value => { _project.HolyGrail.TransitionFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Soglia di riconoscimento", 0.05, 1.0, _project.HolyGrail.StepThresholdStops, 0.05, "0.00", "EV",
@@ -336,17 +338,16 @@ internal sealed class SettingsPanel : Panel
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0", "fotogrammi",
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0",
"fotogrammi",
value => { _project.HolyGrail.ColorWindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità cromatica", 0, 1, _project.HolyGrail.ColorStrength, 0.05, "0.00", string.Empty,
value => { _project.HolyGrail.ColorStrength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni " +
"salto è nota senza incertezza e viene ridistribuita su una transizione a " +
"derivata nulla agli estremi. La lisciatura cromatica agisce sul rapporto fra " +
"i canali: toglie il tremolio del bilanciamento automatico e lascia intatto " +
"il viaggio verso il caldo del tramonto."));
stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni salto " +
"è nota senza incertezza. Un salto che la luminanza non ha recepito — fotogramma " +
"già saturo — viene lasciato stare."));
return stack.Panel;
}
@@ -365,13 +366,15 @@ internal sealed class SettingsPanel : Panel
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Finestra del percorso", 5, 151, _project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
stack.Add(AutoSlider(AutoKey.StabilizationWindow, "Finestra del percorso", 5, 151,
_project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
value => { _project.Stabilization.SmoothingFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.Stabilization.Strength, 0.05, "0.00", string.Empty,
value => { _project.Stabilization.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005, "0.000", "×L",
stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005,
"0.000", "×L",
value => { _project.Stabilization.MaxCorrectionFraction = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation, value =>
@@ -383,7 +386,6 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Lato dei riquadri di correlazione", 64, 512, _project.Stabilization.PatchSize, 64, "0", "px",
value =>
{
// La trasformata vuole una potenza di due: il cursore si muove per gradini validi.
_project.Stabilization.PatchSize = Fourier.FloorPowerOfTwo((int)value);
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
@@ -391,13 +393,6 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Riquadri per lato", 1, 5, _project.Stabilization.Grid, 1, "0", string.Empty,
value => { _project.Stabilization.Grid = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("Lo spostamento fra fotogrammi adiacenti si misura con la correlazione di " +
"fase, che ignora le differenze di luminosità e reagisce alla sola geometria. " +
"Il percorso ricostruito viene lisciato: quello che resta fra percorso vero e " +
"percorso liscio è il tremolio, e la sua inversa è la correzione. Una " +
"panoramica voluta sopravvive perché è già liscia."));
// ---- Virtual camera
stack.Add(new SectionHeader("Movimento di macchina virtuale"));
stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled, value =>
@@ -406,7 +401,7 @@ internal sealed class SettingsPanel : Panel
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 210 };
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 200 };
_cameraEditor.Changed += (_, _) =>
{
SyncKeyframeControls();
@@ -416,8 +411,8 @@ internal sealed class SettingsPanel : Panel
stack.Add(_cameraEditor);
var keyframeButtons = new Panel { Height = 32, BackColor = Theme.Surface };
var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 130, Height = 28, Left = 0, Top = 0 };
var removeButton = new DarkButton { Text = "Togli nodo", Width = 118, Height = 28, Left = 138, Top = 0 };
var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 128, Height = 28 };
var removeButton = new DarkButton { Text = "Togli nodo", Width = 116, Height = 28, Left = 134 };
addButton.Click += (_, _) => { _cameraEditor.AddKeyframe(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
removeButton.Click += (_, _) => { _cameraEditor.RemoveSelected(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
keyframeButtons.Controls.Add(addButton);
@@ -447,13 +442,6 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Note("Le inquadrature si trascinano dentro il riquadro e si stringono dalla " +
"maniglia d'angolo; la striscia in basso è la linea del tempo. Le tacche " +
"lungo il percorso sono equispaziate nel tempo: dove si addensano il " +
"movimento rallenta. Con il movimento attivo i fotogrammi vengono letti a " +
"risoluzione nativa, perché il ritaglio deve avere pixel da cui attingere."));
// ---- Motion blur e campo vettoriale
stack.Add(new SectionHeader("Motion blur sintetico"));
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
@@ -474,9 +462,6 @@ internal sealed class SettingsPanel : Panel
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."));
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
@@ -506,7 +491,6 @@ internal sealed class SettingsPanel : Panel
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}
/// <summary>Riporta nei cursori i valori del nodo scelto, senza farli reagire.</summary>
private void SyncKeyframeControls()
{
if (_cameraEditor?.Selected is not { } keyframe) return;
@@ -522,7 +506,7 @@ internal sealed class SettingsPanel : Panel
// ------------------------------------------------------------------ Tempo
private Panel BuildTimePage()
private Panel BuildTimingPage()
{
var stack = NewStack();
@@ -541,7 +525,6 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
value => _project.Export.MaxAdaptiveStretch = value));
// ---- Time ramping
stack.Add(new SectionHeader("Rimappatura non lineare"));
stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled, value =>
@@ -554,7 +537,7 @@ internal sealed class SettingsPanel : Panel
{
Minimum = 0.1,
Maximum = 8.0,
Height = 168,
Height = 160,
StartLabel = "primo scatto",
EndLabel = "ultimo scatto",
};
@@ -567,13 +550,9 @@ internal sealed class SettingsPanel : Panel
stack.Add(_rampEditor);
stack.Add(Note("Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro. " +
"La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: " +
"sopra 1 la sequenza accelera saltando scatti, sotto 1 rallenta e i fotogrammi " +
"mancanti vengono sintetizzati dal campo vettoriale. La velocità del filmato " +
"resta fissa. La spline è monotona per costruzione, quindi il tempo non può " +
"tornare indietro fra due nodi."));
"La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: sopra 1 " +
"la sequenza accelera, sotto 1 rallenta e i fotogrammi mancanti vengono sintetizzati."));
// ---- Stacking
stack.Add(new SectionHeader("Accumulo temporale"));
stack.Add(Combo("Modalità",
@@ -599,11 +578,9 @@ internal sealed class SettingsPanel : Panel
stack.Add(Slider("Lunghezza delle scie", 0, 400, _project.Stacking.TrailFrames, 5, "0", "fotogrammi",
value => { _project.Stacking.TrailFrames = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la " +
"scena stabile resta se stessa, chi attraversa l'inquadratura una volta sola " +
"sparisce. Il massimo conserva il valore più alto incontrato e trasforma le " +
"stelle in archi continui; a zero le scie non si spengono mai. La finestra " +
"della mediana tiene occupata memoria: sono tutti fotogrammi vivi insieme."));
stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la scena " +
"stabile resta se stessa, chi attraversa l'inquadratura una volta sola sparisce. " +
"Il massimo trasforma le stelle in archi; a zero le scie non si spengono mai."));
return stack.Panel;
}
@@ -635,7 +612,7 @@ internal sealed class SettingsPanel : Panel
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));
value => { _project.Export.BitrateMbps = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
value => _project.Export.KeyframeIntervalSeconds = (int)value));
@@ -652,8 +629,113 @@ internal sealed class SettingsPanel : Panel
stack.Add(browse);
stack.Add(Note("Il video viene scritto in un unico flusso continuo. L'unico altro file che " +
"l'elaborazione può creare è il parcheggio temporaneo dei fotogrammi, che si " +
"cancella da sé e non contiene nulla di riutilizzabile."));
"l'elaborazione può creare è il parcheggio temporaneo, che si cancella da sé."));
return stack.Panel;
}
// ------------------------------------------------------------------ Impostazioni dell'applicazione
private Panel BuildPreferencesPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("All'apertura"));
stack.Add(Check("Ricorda le impostazioni per cartella", _app.RememberPerFolder, value =>
{
_app.RememberPerFolder = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Analizza appena una sequenza è caricata", _app.AnalyzeOnLoad, value =>
{
_app.AnalyzeOnLoad = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Valori predefiniti dei nuovi progetti"));
stack.Add(Combo("Profilo di qualità", ["Bozza", "Standard", "Massima"], (int)_app.DefaultQuality,
index =>
{
_app.DefaultQuality = (QualityProfile)Math.Clamp(index, 0, 2);
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _app.DefaultDecodeParallelism, 1, "0", "thread",
value => { _app.DefaultDecodeParallelism = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Tetto di memoria", 256, 32768, _app.DefaultMemoryBudgetMiB, 256, "0", "MiB",
value => { _app.DefaultMemoryBudgetMiB = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _app.DefaultAllowDiskSpill, value =>
{
_app.DefaultAllowDiskSpill = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Durante l'esportazione"));
stack.Add(Check("Chiedi conferma prima di sovrascrivere", _app.ConfirmOverwrite, value =>
{
_app.ConfirmOverwrite = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Mostra i fotogrammi mentre vengono codificati", _app.LivePreviewDuringExport, value =>
{
_app.LivePreviewDuringExport = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Aggiorna l'anteprima ogni", 1, 60, _app.LivePreviewEvery, 1, "0", "fotogrammi",
value => { _app.LivePreviewEvery = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Apri la cartella a esportazione conclusa", _app.RevealWhenFinished, value =>
{
_app.RevealWhenFinished = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Riquadro sponsor"));
stack.Add(Check("Mostra gli sponsor durante l'attesa", _app.ShowSponsors, value =>
{
_app.ShowSponsors = value;
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Cambia annuncio ogni", 5, 180, _app.SponsorRotationSeconds, 5, "0", "s",
value => { _app.SponsorRotationSeconds = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
var openFolder = new DarkButton { Text = "Apri la cartella delle campagne", Height = 30, Dock = DockStyle.Top };
openFolder.Click += (_, _) =>
{
try
{
SponsorCatalogue.EnsureExample(_app.ResolvedSponsorFolder);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(_app.ResolvedSponsorFolder)
{
UseShellExecute = true,
});
}
catch (Exception ex) when (ex is IOException or System.ComponentModel.Win32Exception)
{
// Cartella non apribile: non c'è nulla di sensato da fare.
}
};
stack.Add(openFolder);
stack.Add(Note("Gli annunci si leggono da una cartella locale: il programma non contatta alcun " +
"servizio, non invia identificativi e non registra clic. Le campagne si " +
"aggiornano copiando file dentro quella cartella, con un elenco chiamato " +
"campagne.txt. Il riquadro compare soltanto nella scheda Esportazione, dove per " +
"forza di cose si aspetta."));
stack.Add(new SectionHeader("Dove stanno i file"));
stack.Add(Note($"Impostazioni: {AppSettings.SettingsPath}"));
stack.Add(Note($"Campagne: {_app.ResolvedSponsorFolder}"));
return stack.Panel;
}
@@ -673,8 +755,6 @@ internal sealed class SettingsPanel : Panel
control.Width = Panel.ClientSize.Width - 34;
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right;
// Le note esplicative variano in lunghezza: si misurano sulla larghezza reale
// della colonna, altrimenti le più lunghe finirebbero tagliate a metà frase.
if (control is Label note && !note.AutoSize) MeasureNote(note);
Panel.Controls.Add(control);
@@ -690,14 +770,12 @@ internal sealed class SettingsPanel : Panel
note.Height = measured.Height + 8;
}
/// <summary>Rimisura una nota il cui testo è cambiato dopo la costruzione della pagina.</summary>
private static void LayoutNote(Label note)
{
int before = note.Height;
MeasureNote(note);
if (note.Height == before || note.Parent is null) return;
// Le note sotto vanno fatte scorrere: la colonna è posizionata a coordinate assolute.
int delta = note.Height - before;
foreach (Control sibling in note.Parent.Controls)
{
@@ -707,13 +785,8 @@ internal sealed class SettingsPanel : Panel
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 };
var panel = new Panel { BackColor = Theme.Surface, Padding = new Padding(0, 8, 0, 12), Width = 372 };
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 372 };
panel.Controls.Add(host);
return new Stack(host);
}
@@ -735,6 +808,38 @@ internal sealed class SettingsPanel : Panel
return slider;
}
/// <summary>
/// Cursore che il direttore sa impostare da sé. L'indicatore «auto» dice chi comanda;
/// toccare il cursore passa il comando all'utente, e il pulsante lo restituisce.
/// </summary>
private ParameterSlider AutoSlider(AutoKey key, string caption, double min, double max, double value,
double step, string format, string unit, Action<double> onChange)
{
var slider = Slider(caption, min, max, value, step, format, unit, onChange);
slider.AutoSupported = true;
slider.IsAuto = !_project.ManualParameters.Contains(key);
slider.AutoChanged += (_, _) =>
{
if (slider.IsAuto)
{
_project.ManualParameters.Remove(key);
if (_project.AutoDecisions.TryGetValue(key, out var decision))
{
slider.SetValueSilently(decision.Value);
onChange(decision.Value);
}
}
else
{
_project.ManualParameters.Add(key);
}
};
_autoSliders[key] = slider;
return slider;
}
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
{
var box = new DarkCheckBox { Text = caption, Checked = value };
+419
View File
@@ -0,0 +1,419 @@
using System.Diagnostics;
using System.Text;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>Un annuncio del listino locale.</summary>
internal sealed record Sponsor(string Title, string Body, string? ImagePath, string? Link, int Weight)
{
public bool HasLink => !string.IsNullOrWhiteSpace(Link);
}
/// <summary>
/// Listino degli sponsor, letto da una cartella locale.
///
/// Nessuna rete: il programma non contatta alcun servizio, non invia identificativi e non
/// registra clic. È una scelta, non una mancanza — un circuito pubblicitario vero
/// richiederebbe di integrarne l'SDK, che il vincolo sulle dipendenze esclude, e comunque
/// significherebbe far uscire dati dalla macchina di chi sta soltanto montando un
/// time-lapse. Le campagne si aggiornano copiando file in una cartella.
///
/// Il formato è lo stesso delle impostazioni: blocchi introdotti da [campagna], una riga per
/// campo. Le immagini stanno accanto al listino.
/// </summary>
internal static class SponsorCatalogue
{
public const string ManifestName = "campagne.txt";
public static List<Sponsor> Load(string folder)
{
var sponsors = new List<Sponsor>();
try
{
string manifest = Path.Combine(folder, ManifestName);
if (!File.Exists(manifest)) return sponsors;
string? title = null, body = null, image = null, link = null;
int weight = 1;
void Flush()
{
if (string.IsNullOrWhiteSpace(title)) return;
string? resolved = string.IsNullOrWhiteSpace(image) ? null : Path.Combine(folder, image);
if (resolved is not null && !File.Exists(resolved)) resolved = null;
sponsors.Add(new Sponsor(title!, body ?? string.Empty, resolved, Sanitize(link), Math.Clamp(weight, 1, 10)));
}
foreach (string raw in File.ReadAllLines(manifest, Encoding.UTF8))
{
string line = raw.Trim();
if (line.Length == 0 || line[0] == '#') continue;
if (line.StartsWith('[') && line.EndsWith(']'))
{
Flush();
title = body = image = link = null;
weight = 1;
continue;
}
int separator = line.IndexOf('=');
if (separator <= 0) continue;
string key = line[..separator].Trim().ToLowerInvariant();
string value = line[(separator + 1)..].Trim();
switch (key)
{
case "titolo": title = value; break;
case "testo": body = value; break;
case "immagine": image = value; break;
case "collegamento": link = value; break;
case "peso": weight = AppSettings.ParseInt(value, 1); break;
}
}
Flush();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
// Listino illeggibile: restano gli annunci interni, che non mancano mai.
}
return sponsors;
}
/// <summary>
/// Crea la cartella e, se è vuota, vi scrive un listino di esempio commentato. Il formato
/// si impara guardando un file che c'è già, non cercando dove sia documentato.
/// </summary>
public static void EnsureExample(string folder)
{
try
{
Directory.CreateDirectory(folder);
string manifest = Path.Combine(folder, ManifestName);
if (File.Exists(manifest)) return;
File.WriteAllText(manifest, """
# Listino delle campagne mostrate durante l'esportazione.
#
# Un blocco per annuncio, introdotto da [campagna]. Le immagini stanno in
# questa stessa cartella; sono facoltative, e vengono ridimensionate
# conservando le proporzioni. Il collegamento deve essere http o https:
# qualunque altra cosa viene ignorata.
#
# Il peso governa la frequenza: peso 3 compare tre volte più spesso di peso 1.
#
# Titano non contatta nessun servizio e non registra i clic: questo file è
# l'unica sorgente degli annunci.
[campagna]
titolo = Titolo dell'annuncio
testo = Una o due righe di testo. Chi legge sta aspettando la fine di una codifica, quindi può leggere una frase intera.
immagine = esempio.jpg
collegamento = https://esempio.it
peso = 1
""", Encoding.UTF8);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Cartella non scrivibile: restano gli annunci interni.
}
}
/// <summary>
/// Accetta solo indirizzi web. Il listino è un file locale, ma aprire alla cieca ciò che
/// vi si trova scritto significherebbe eseguire quello che qualcuno vi ha messo dentro.
/// </summary>
private static string? Sanitize(string? link)
{
if (string.IsNullOrWhiteSpace(link)) return null;
return Uri.TryCreate(link, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? uri.AbsoluteUri
: null;
}
/// <summary>
/// Contenuti interni, mostrati quando il listino è vuoto. Non sono un riempitivo: sono
/// le cose che un'attesa di mezz'ora è il momento giusto per leggere.
/// </summary>
public static List<Sponsor> House() =>
[
new("Il profilo di qualità governa il tempo",
"Massima infittisce il campo vettoriale e i campioni della scia. Su una sequenza senza " +
"movimento, Standard dà lo stesso risultato in un terzo del tempo.", null, null, 1),
new("La finestra della mediana vive in memoria",
"I fotogrammi della finestra sono vivi tutti insieme, perché la mediana li vuole " +
"simultaneamente. Il tetto di memoria governa la lettura in anticipo, non la finestra.", null, null, 1),
new("Un cielo più scuro del terreno è normale",
"Di notte, con un primo piano illuminato, la regione superiore è la più scura. " +
"La divisione per orizzonte se ne accorge e non la scambia per un errore.", null, null, 1),
new("I cambi di esposizione invisibili restano tali",
"Se il fotogramma è già saturo, cambiare sensibilità non lo scurisce. Titano lo verifica " +
"prima di correggere: togliere un gradino che non c'è significa introdurlo.", null, null, 1),
];
}
/// <summary>
/// Riquadro degli sponsor, mostrato durante l'attesa dell'esportazione.
///
/// Sta soltanto qui, nella scheda dove per forza di cose si aspetta, e non compare mai
/// altrove: un annuncio accanto a un cursore che si sta regolando sarebbe un ostacolo, uno
/// accanto a una barra di avanzamento è qualcosa da guardare mentre passa il tempo. È
/// dichiarato come tale, non si muove da solo se il sistema chiede di ridurre le animazioni,
/// e si spegne dalle impostazioni.
/// </summary>
internal sealed class SponsorPanel : Control
{
private readonly System.Windows.Forms.Timer _rotation = new();
private List<Sponsor> _sponsors = [];
private readonly List<int> _order = [];
private int _position;
private Image? _image;
private string? _imagePath;
private bool _hoverLink;
private bool _hoverNext;
public SponsorPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 132;
_rotation.Tick += (_, _) => Advance();
}
public Sponsor? Current => _order.Count > 0 && _position < _order.Count
? _sponsors[_order[_position]]
: null;
/// <summary>Carica il listino e avvia la rotazione secondo le preferenze.</summary>
public void Configure(AppSettings settings)
{
Visible = settings.ShowSponsors;
if (!settings.ShowSponsors)
{
_rotation.Stop();
return;
}
var loaded = SponsorCatalogue.Load(settings.ResolvedSponsorFolder);
_sponsors = loaded.Count > 0 ? loaded : SponsorCatalogue.House();
// L'ordine tiene conto del peso ripetendo le voci: una campagna di peso tre compare
// tre volte nel giro, senza bisogno di estrazioni casuali che potrebbero saltarla.
_order.Clear();
for (int i = 0; i < _sponsors.Count; i++)
{
for (int repeat = 0; repeat < _sponsors[i].Weight; repeat++) _order.Add(i);
}
Shuffle(_order);
_position = 0;
LoadImage();
_rotation.Interval = Math.Clamp(settings.SponsorRotationSeconds, 5, 600) * 1000;
_rotation.Start();
Invalidate();
}
/// <summary>Ferma la rotazione quando la scheda non è in vista: non serve a nessuno girare a vuoto.</summary>
public void SetActive(bool active)
{
if (!Visible) return;
if (active) _rotation.Start(); else _rotation.Stop();
}
private static void Shuffle(List<int> values)
{
for (int i = values.Count - 1; i > 0; i--)
{
int j = Random.Shared.Next(i + 1);
(values[i], values[j]) = (values[j], values[i]);
}
}
private void Advance()
{
if (_order.Count == 0) return;
_position = (_position + 1) % _order.Count;
LoadImage();
Invalidate();
}
private void LoadImage()
{
string? path = Current?.ImagePath;
if (path == _imagePath) return;
_image?.Dispose();
_image = null;
_imagePath = path;
if (path is null) return;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
_image = Image.FromStream(stream);
}
catch (Exception ex) when (ex is IOException or ArgumentException or OutOfMemoryException)
{
_image = null;
}
}
// ------------------------------------------------------------------ interazione
private Rectangle NextButton => new(Width - 26, 6, 18, 18);
private Rectangle TextArea
{
get
{
int left = _image is null ? 14 : 14 + ThumbWidth + 14;
return new Rectangle(left, 26, Math.Max(40, Width - left - 16), Math.Max(20, Height - 36));
}
}
private int ThumbWidth => Math.Min(168, Math.Max(80, (Height - 28) * 16 / 9));
protected override void OnMouseMove(MouseEventArgs e)
{
bool overNext = NextButton.Contains(e.Location);
bool overLink = Current is { HasLink: true } && TextArea.Contains(e.Location);
if (overNext != _hoverNext || overLink != _hoverLink)
{
_hoverNext = overNext;
_hoverLink = overLink;
Cursor = overNext || overLink ? Cursors.Hand : Cursors.Default;
Invalidate();
}
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hoverNext = _hoverLink = false;
Cursor = Cursors.Default;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
if (NextButton.Contains(e.Location)) { Advance(); return; }
if (Current is { HasLink: true, Link: { } link } && TextArea.Contains(e.Location))
{
try
{
Process.Start(new ProcessStartInfo(link) { UseShellExecute = true });
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException)
{
// Nessun browser predefinito: non c'è nulla di sensato da fare.
}
}
base.OnMouseDown(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Background);
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
Theme.FillAndStroke(g, bounds, 6f, Theme.Surface, Theme.Border);
if (Current is not { } sponsor)
{
TextRenderer.DrawText(g, "Nessuna campagna nel listino", Theme.Small,
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
// L'etichetta è la prima cosa che si legge: chi guarda deve sapere subito che cos'è.
TextRenderer.DrawText(g, "SPONSOR", Theme.SmallBold, new Rectangle(14, 5, 120, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (_order.Count > 1) DrawNextButton(g);
if (_image is not null)
{
var box = new Rectangle(14, 26, ThumbWidth, Height - 40);
DrawImageFitted(g, _image, box);
}
var text = TextArea;
TextRenderer.DrawText(g, sponsor.Title, Theme.BodyBold,
new Rectangle(text.Left, text.Top, text.Width, 20),
_hoverLink ? Theme.Accent : Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, sponsor.Body, Theme.Small,
new Rectangle(text.Left, text.Top + 22, text.Width, text.Height - 40),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
if (sponsor.HasLink)
{
TextRenderer.DrawText(g, _hoverLink ? "Apri nel browser →" : "Clic per saperne di più",
Theme.Small, new Rectangle(text.Left, Height - 20, text.Width, 16),
_hoverLink ? Theme.Accent : Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
private void DrawNextButton(Graphics g)
{
var box = NextButton;
if (_hoverNext) Theme.FillRounded(g, box, 4f, Theme.SurfaceAlt);
using var pen = new Pen(_hoverNext ? Theme.Text : Theme.TextFaint, 1.5f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
};
float cx = box.Left + box.Width / 2f;
float cy = box.Top + box.Height / 2f;
g.DrawLines(pen,
[
new PointF(cx - 2.5f, cy - 4f),
new PointF(cx + 2.5f, cy),
new PointF(cx - 2.5f, cy + 4f),
]);
}
/// <summary>Disegna l'immagine dentro il riquadro conservandone le proporzioni.</summary>
private static void DrawImageFitted(Graphics g, Image image, Rectangle box)
{
double scale = Math.Min(box.Width / (double)image.Width, box.Height / (double)image.Height);
int width = Math.Max(1, (int)(image.Width * scale));
int height = Math.Max(1, (int)(image.Height * scale));
var target = new Rectangle(box.Left + (box.Width - width) / 2,
box.Top + (box.Height - height) / 2, width, height);
g.DrawImage(image, target);
using var border = new Pen(Theme.Border);
g.DrawRectangle(border, target);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_rotation.Stop();
_rotation.Dispose();
_image?.Dispose();
}
base.Dispose(disposing);
}
}
+327
View File
@@ -0,0 +1,327 @@
using System.Collections.Concurrent;
using System.Drawing.Imaging;
using Titano.Core;
using Titano.Imaging;
namespace Titano.UI;
/// <summary>
/// Striscia di provini che copre l'intera sequenza: ogni riquadro è il fotogramma che sta in
/// quella posizione, non il fotogramma successivo. Scorrere mille scatti in una tabella dice
/// quando sono stati fatti; questa dice come cambiano, che è la domanda vera.
///
/// I provini si decodificano su un thread di servizio e si accumulano in una cache: la
/// striscia si riempie mentre si guarda, senza mai bloccare l'interfaccia, e i riquadri non
/// ancora pronti restano segnati come tali invece di apparire vuoti.
/// </summary>
internal sealed class TimelineStrip : Control
{
private const int ThumbHeight = 54;
private const int Inset = 5;
private readonly ConcurrentDictionary<int, Bitmap> _cache = [];
private readonly object _queueGate = new();
private readonly Queue<int> _pending = new();
private TimelapseSequence? _sequence;
private int _orientation = 1;
private int _thumbWidth = 72;
private int _selectedIndex = -1;
private int _hovered = -1;
private CancellationTokenSource? _loader;
private int[] _slots = [];
public event EventHandler? SelectionChanged;
public TimelineStrip()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Height = ThumbHeight + 2 * Inset + 14;
Cursor = Cursors.Hand;
}
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectedIndex == value) return;
_selectedIndex = value;
Invalidate();
}
}
public void SetSequence(TimelapseSequence? sequence, int orientation)
{
StopLoading();
foreach (var bitmap in _cache.Values) bitmap.Dispose();
_cache.Clear();
_sequence = sequence;
_orientation = orientation;
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
if (sequence is { Count: > 0 })
{
var first = sequence.Frames[0].Metadata;
double aspect = first.PixelWidth > 0 && first.PixelHeight > 0
? first.PixelWidth / (double)first.PixelHeight
: 4.0 / 3.0;
if (ImageDecoder.SwapsAxes(orientation)) aspect = 1 / aspect;
_thumbWidth = Math.Clamp((int)Math.Round(ThumbHeight * aspect), 32, 160);
}
RebuildSlots();
Invalidate();
}
protected override void OnResize(EventArgs e)
{
RebuildSlots();
base.OnResize(e);
}
/// <summary>Sceglie quali fotogrammi mostrare: tanti quanti ne stanno, distribuiti sull'intera sequenza.</summary>
private void RebuildSlots()
{
if (_sequence is not { Count: > 0 } sequence) { _slots = []; return; }
int usable = Math.Max(_thumbWidth, Width - 2 * Inset);
int count = Math.Clamp(usable / (_thumbWidth + 3), 1, sequence.Count);
var slots = new int[count];
for (int i = 0; i < count; i++)
{
slots[i] = count == 1 ? 0 : (int)Math.Round(i * (sequence.Count - 1.0) / (count - 1));
}
_slots = slots;
RequestMissing();
}
// ------------------------------------------------------------------ caricamento
private void RequestMissing()
{
if (_sequence is null) return;
lock (_queueGate)
{
_pending.Clear();
foreach (int index in _slots)
{
if (!_cache.ContainsKey(index)) _pending.Enqueue(index);
}
if (_pending.Count == 0) return;
}
if (_loader is not null) return;
var source = new CancellationTokenSource();
_loader = source;
var token = source.Token;
var sequence = _sequence;
int orientation = _orientation;
int width = _thumbWidth;
_ = Task.Run(() =>
{
var pool = new FrameBufferPool(4);
try
{
while (!token.IsCancellationRequested)
{
int index;
lock (_queueGate)
{
if (_pending.Count == 0) break;
index = _pending.Dequeue();
}
if (index < 0 || index >= sequence.Count) continue;
Bitmap? thumb = null;
try
{
using var buffer = ImageDecoder.Decode(sequence.Frames[index].FilePath,
width, ThumbHeight, orientation, pool);
thumb = ToBitmap(buffer);
}
catch (Exception)
{
// Un provino che non si legge resta assente: il riquadro lo dichiara.
}
if (thumb is null) continue;
if (token.IsCancellationRequested || !_cache.TryAdd(index, thumb)) { thumb.Dispose(); continue; }
// Un solo ridisegno ogni pochi provini: ridipingere a ogni arrivo su una
// sequenza lunga costerebbe più della decodifica stessa.
if (_cache.Count % 4 == 0) RequestRedraw();
}
}
finally
{
RequestRedraw();
_loader = null;
}
}, token);
}
private void RequestRedraw()
{
try
{
if (IsHandleCreated && !IsDisposed) BeginInvoke(Invalidate);
}
catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
{
// Finestra già chiusa: non c'è più niente da ridisegnare.
}
}
private void StopLoading()
{
_loader?.Cancel();
_loader?.Dispose();
_loader = null;
lock (_queueGate) _pending.Clear();
}
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;
}
// ------------------------------------------------------------------ interazione
private int SlotAt(int x)
{
if (_slots.Length == 0) return -1;
int relative = x - Inset;
int slot = relative / (_thumbWidth + 3);
return slot >= 0 && slot < _slots.Length ? slot : -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
int slot = SlotAt(e.X);
if (slot != _hovered) { _hovered = slot; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
int slot = SlotAt(e.X);
if (slot < 0) return;
_selectedIndex = _slots[slot];
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
base.OnMouseDown(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
if (_sequence is not { Count: > 0 } sequence || _slots.Length == 0)
{
TextRenderer.DrawText(g, "La striscia dei provini compare quando una sequenza è caricata",
Theme.Small, new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
for (int slot = 0; slot < _slots.Length; slot++)
{
int index = _slots[slot];
var box = new Rectangle(Inset + slot * (_thumbWidth + 3), Inset, _thumbWidth, ThumbHeight);
if (_cache.TryGetValue(index, out var thumb))
{
g.DrawImage(thumb, box);
}
else
{
using var placeholder = new SolidBrush(Theme.SurfaceAlt);
g.FillRectangle(placeholder, box);
TextRenderer.DrawText(g, "…", Theme.Small, box, Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
bool selected = index == _selectedIndex;
bool hovered = slot == _hovered;
using var border = new Pen(selected ? Theme.Accent : hovered ? Theme.BorderStrong : Theme.Border,
selected ? 2f : 1f);
g.DrawRectangle(border, selected ? Rectangle.Inflate(box, -1, -1) : box);
// Il numero solo dove c'è spazio: su una striscia fitta diventerebbe rumore.
if (_thumbWidth >= 56)
{
var label = new Rectangle(box.Left, box.Bottom + 1, box.Width, 12);
TextRenderer.DrawText(g, (index + 1).ToString(), Theme.Small, label,
selected ? Theme.Text : Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
int loaded = _cache.Count;
if (loaded < _slots.Length)
{
TextRenderer.DrawText(g, $"{loaded}/{_slots.Length}", Theme.Small,
new Rectangle(Width - 60, Height - 14, 52, 12), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
StopLoading();
foreach (var bitmap in _cache.Values) bitmap.Dispose();
_cache.Clear();
}
base.Dispose(disposing);
}
}
+153
View File
@@ -0,0 +1,153 @@
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Elenco degli avvisi raccolti dal motore, ordinati per gravità. Ogni voce dice cosa è
/// stato notato e cosa comporta; nessuna chiede di essere risolta prima di andare avanti,
/// perché quasi sempre la risposta giusta è "va bene così, volevo saperlo".
/// </summary>
internal sealed class WarningsPanel : Control
{
private IReadOnlyList<SequenceWarning> _warnings = [];
private int _scroll;
private int _hovered = -1;
public event EventHandler<string>? AreaActivated;
public WarningsPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public int Count => _warnings.Count;
public void SetWarnings(IReadOnlyList<SequenceWarning> warnings)
{
_warnings = warnings;
_scroll = 0;
Invalidate();
}
// ------------------------------------------------------------------ misura e interazione
/// <summary>
/// Altezza di una voce: dipende dal testo, che va misurato sulla larghezza reale della
/// colonna. A larghezza fissa le spiegazioni lunghe finirebbero tagliate a metà frase.
/// </summary>
private int HeightOf(SequenceWarning warning, Graphics g)
{
int textWidth = Math.Max(80, Width - 66);
int detail = TextRenderer.MeasureText(g, warning.Detail, Theme.Small,
new Size(textWidth, 0), TextFormatFlags.WordBreak).Height;
return 26 + detail + 14;
}
private int IndexAt(int y, Graphics g)
{
int cursor = 8 - _scroll;
for (int i = 0; i < _warnings.Count; i++)
{
int height = HeightOf(_warnings[i], g);
if (y >= cursor && y < cursor + height) return i;
cursor += height;
}
return -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
using var g = CreateGraphics();
int index = IndexAt(e.Y, g);
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)
{
using var g = CreateGraphics();
int index = IndexAt(e.Y, g);
if (index >= 0) AreaActivated?.Invoke(this, _warnings[index].Area);
base.OnMouseDown(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
using var g = CreateGraphics();
int total = 16;
foreach (var warning in _warnings) total += HeightOf(warning, g);
int maximum = Math.Max(0, total - Height);
_scroll = Math.Clamp(_scroll - Math.Sign(e.Delta) * 48, 0, maximum);
Invalidate();
base.OnMouseWheel(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
if (_warnings.Count == 0)
{
TextRenderer.DrawText(g, "Nessun avviso: la sequenza non presenta anomalie note.",
Theme.Body, new Rectangle(12, 0, Width - 24, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.WordBreak);
return;
}
int y = 8 - _scroll;
for (int i = 0; i < _warnings.Count; i++)
{
var warning = _warnings[i];
int height = HeightOf(warning, g);
if (y + height > 0 && y < Height) DrawEntry(g, warning, y, height, i == _hovered);
y += height;
}
}
private void DrawEntry(Graphics g, SequenceWarning warning, int top, int height, bool hover)
{
var bounds = new Rectangle(8, top, Math.Max(20, Width - 16), height - 6);
if (hover) Theme.FillRounded(g, bounds, 5f, Theme.SurfaceAlt);
Color accent = warning.Severity switch
{
WarningSeverity.Problem => Theme.Danger,
WarningSeverity.Caution => Theme.Warning,
_ => Theme.TextFaint,
};
// Filetto verticale colorato: la gravità si legge prima del testo.
using (var stripe = new SolidBrush(accent))
g.FillRectangle(stripe, bounds.Left + 2, bounds.Top + 3, 3, bounds.Height - 6);
var titleBounds = new Rectangle(bounds.Left + 14, bounds.Top + 2, bounds.Width - 22, 18);
TextRenderer.DrawText(g, warning.Title, Theme.BodyBold, titleBounds, Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
var areaSize = TextRenderer.MeasureText(g, warning.Area.ToUpperInvariant(), Theme.Small);
TextRenderer.DrawText(g, warning.Area.ToUpperInvariant(), Theme.SmallBold,
new Rectangle(bounds.Right - areaSize.Width - 10, bounds.Top + 3, areaSize.Width + 6, 16),
accent, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
var detailBounds = new Rectangle(bounds.Left + 14, bounds.Top + 22, bounds.Width - 26, bounds.Height - 26);
TextRenderer.DrawText(g, warning.Detail, Theme.Small, detailBounds, Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
}
}