Scheda Archivio, ottimizzazione per sezione e spiegazioni a comparsa

Aggiunge l'importazione da scheda di memoria e riordina l'interfaccia attorno
a quello che ciascuna sezione serve a fare.

L'importazione riconosce i supporti collegati, cerca nelle sottocartelle e
prima di toccare il disco mostra il piano: quali cartelle nasceranno e che nome
avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e
guardare prima costa un istante. Fa poi tre cose che a mano si sbagliano
sempre: separa le sessioni sulla pausa fra due scatti, invece di lasciare piu'
riprese mescolate in una cartella sola; costruisce i nomi da modelli con
segnaposto, perche' un archivio si consulta anni dopo e il nome e' l'unica cosa
leggibile senza aprire nulla; e rilegge quello che ha scritto confrontandone
l'impronta, perche' una scheda che si scollega a meta' copia produce file della
dimensione giusta e del contenuto troncato, e il danno si scopre mesi dopo.

Sulla conversione in DNG va detto con precisione cosa si puo' e non si puo'
fare. Un DNG vero contiene i valori del sensore prima dell'interpolazione
cromatica; ottenerlo da un formato proprietario richiede la libreria del
produttore, che il vincolo sulle dipendenze esclude. Quello che si ottiene
in-house e' un DNG lineare - la specifica lo prevede, i pixel sono gia'
interpolati, il file e' valido e apribile ovunque ma non restituisce la
liberta' del grezzo. La copia resta quindi la scelta predefinita, e l'interfaccia
lo dice invece di lasciarlo intuire. Il contenitore TIFF/DNG e' scritto a mano
come il multiplexer MP4, e la verifica lo rilegge con il parser di questo stesso
programma: due implementazioni indipendenti dello stesso formato, e al primo
confronto e' saltato fuori un errore di dodici byte per voce nel calcolo degli
scostamenti - la directory Exif finiva oltre il puntatore che la indicava.

Le spiegazioni passano dalle note stampate ai suggerimenti a comparsa. Una nota
sotto un cursore occupa spazio a chi la conosce gia', quindi deve restare corta;
un suggerimento che appare solo quando serve non ha quel vincolo e puo' dire
l'unica cosa che conta - perche' quel parametro esiste e cosa succede a
spostarlo nel verso sbagliato.

Ogni sezione guadagna un comando Ottimizza che rileva le impostazioni migliori
per quella sola parte e riferisce cosa ha cambiato e perche'. Si distingue dal
pilota automatico, che lavora di continuo sui parametri deducibili senza
ambiguita': l'ottimizzazione si chiede a mano perche' accende e spegne interi
moduli, e sostituire quelle scelte in silenzio sarebbe peggio che lasciarle
sbagliate. Dove il dato manca non tira a indovinare: lo dice.

L'uscita guadagna rapporto e ritaglio. Cambiare rapporto non deforma piu'
l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il fotogramma,
il che ha anche corretto un difetto latente dello stadio geometrico, che con
rapporti diversi fra sorgente e uscita stirava invece di tagliare. La zona da
tenere si sceglie trascinandola.

I comandi seguono la sezione: importazione e analisi compaiono solo dove
servono. Un pulsante che non ha senso dove ci si trova non va disabilitato ma
tolto, perche' disabilitato resta un ingombro che chiede perche' non funziona.
Le preferenze dell'applicazione non mostrano piu' anteprima, riepilogo della
sequenza ne' riga di stato che ne parli: non riguardano la sequenza caricata, e
tenerle accanto confondeva due piani diversi.

Via anche le descrizioni inutili: la finestra si chiama Titano e basta.

Due difetti trovati durante la verifica a video. La barra di navigazione nasceva
sulla prima voce, che ora e' Archivio, quindi il selettore usciva subito perche'
l'indice coincideva e la sezione non veniva mai mostrata: barra su una voce,
contenuto su un'altra. E il posizionamento dei pulsanti leggeva Visible, che in
WinForms resta falso finche' la finestra non e' stata mostrata perche' riporta
la visibilita' dell'intera catena: alla costruzione nessun pulsante veniva
collocato e restavano tutti impilati sull'angolo.

Verifica: da 54 a 60 controlli. I nuovi coprono i modelli di percorso, la
sostituzione dei caratteri illegali, il rifiuto dei segnaposto inventati, il
riconoscimento delle sessioni e la rilettura del DNG scritto - sia dal parser
interno sia dal decodificatore di sistema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:09:54 +02:00
co-authored by Claude Opus 5
parent 2e5a441938
commit db21a31e56
16 changed files with 3109 additions and 199 deletions
+215
View File
@@ -0,0 +1,215 @@
using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Scelta dell'area di uscita dentro il fotogramma sorgente: si trascina il rettangolo per
/// spostarlo e la maniglia d'angolo per stringerlo.
///
/// Il rettangolo ha sempre il rapporto scelto per l'uscita, non quello della sorgente. È la
/// differenza fra tagliare e deformare: passando da 4:3 a 16:9 quello che si perde sono due
/// fasce, e deciderle guardando l'immagine è l'unico modo sensato di farlo.
/// </summary>
internal sealed class FramingEditor : Control
{
private const float HandleSize = 10f;
private readonly ExportSettings _export;
private bool _dragging;
private bool _draggingZoom;
private PointF _grabOffset;
public event EventHandler? Changed;
/// <summary>Rapporto larghezza/altezza del fotogramma sorgente.</summary>
public double SourceAspect { get; set; } = 4.0 / 3.0;
/// <summary>Rapporto richiesto per l'uscita.</summary>
public double OutputAspect { get; set; } = 4.0 / 3.0;
public FramingEditor(ExportSettings export)
{
_export = export;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 172;
}
/// <summary>Riquadro che rappresenta il fotogramma sorgente, con le sue proporzioni.</summary>
private RectangleF Stage
{
get
{
var available = new RectangleF(10, 22, Math.Max(20, Width - 20), Math.Max(20, Height - 44));
float aspect = (float)Math.Max(0.1, SourceAspect);
float width = available.Width;
float height = width / aspect;
if (height > available.Height)
{
height = available.Height;
width = height * aspect;
}
return new RectangleF(available.Left + (available.Width - width) / 2,
available.Top + (available.Height - height) / 2, width, height);
}
}
/// <summary>Rettangolo di ritaglio, con il rapporto dell'uscita e vincolato dentro la sorgente.</summary>
private RectangleF CropRect
{
get
{
var stage = Stage;
double zoom = Math.Max(1.0, _export.CropZoom);
// Il più grande rettangolo con il rapporto dell'uscita che sta nella sorgente,
// poi ristretto dallo zoom: la stessa regola che applica lo stadio geometrico.
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
double baseHeight = baseWidth / Math.Max(1e-6, OutputAspect);
float width = (float)(baseWidth / zoom);
float height = (float)(baseHeight / zoom);
float halfX = width / 2f / stage.Width;
float halfY = height / 2f / stage.Height;
float centreX = (float)Math.Clamp(_export.CropCentreX, halfX, 1 - halfX);
float centreY = (float)Math.Clamp(_export.CropCentreY, halfY, 1 - halfY);
return new RectangleF(stage.Left + centreX * stage.Width - width / 2,
stage.Top + centreY * stage.Height - height / 2, width, height);
}
}
private RectangleF Handle
{
get
{
var crop = CropRect;
return new RectangleF(crop.Right - HandleSize, crop.Bottom - HandleSize,
HandleSize * 2, HandleSize * 2);
}
}
// ------------------------------------------------------------------ interazione
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
Focus();
if (Handle.Contains(e.Location)) { _draggingZoom = true; return; }
var crop = CropRect;
if (!crop.Contains(e.Location)) return;
_dragging = true;
_grabOffset = new PointF(e.X - (crop.Left + crop.Width / 2), e.Y - (crop.Top + crop.Height / 2));
}
protected override void OnMouseMove(MouseEventArgs e)
{
var stage = Stage;
if (_draggingZoom)
{
float halfWidth = Math.Max(6f, e.X - (stage.Left + (float)_export.CropCentreX * stage.Width));
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
_export.CropZoom = Math.Clamp(baseWidth / (2.0 * halfWidth), 1.0, 6.0);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
if (_dragging)
{
_export.CropCentreX = Math.Clamp((e.X - _grabOffset.X - stage.Left) / stage.Width, 0, 1);
_export.CropCentreY = Math.Clamp((e.Y - _grabOffset.Y - stage.Top) / stage.Height, 0, 1);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
Cursor = Handle.Contains(e.Location) ? Cursors.SizeNWSE
: CropRect.Contains(e.Location) ? Cursors.SizeAll
: Cursors.Default;
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_dragging = false;
_draggingZoom = false;
base.OnMouseUp(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
// Doppio clic: si torna all'inquadratura piena, che è la richiesta più frequente
// dopo aver provato un ritaglio e non esserne convinti.
_export.CropCentreX = 0.5;
_export.CropCentreY = 0.5;
_export.CropZoom = 1.0;
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
base.OnMouseDoubleClick(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var stage = Stage;
Theme.FillRounded(g, stage, 3f, Theme.SurfaceAlt);
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(stage));
var crop = CropRect;
// Le fasce escluse si scuriscono: si vede subito quanto si sta buttando via.
using (var shade = new SolidBrush(Color.FromArgb(150, Theme.Background)))
{
g.FillRectangle(shade, stage.Left, stage.Top, stage.Width, crop.Top - stage.Top);
g.FillRectangle(shade, stage.Left, crop.Bottom, stage.Width, stage.Bottom - crop.Bottom);
g.FillRectangle(shade, stage.Left, crop.Top, crop.Left - stage.Left, crop.Height);
g.FillRectangle(shade, crop.Right, crop.Top, stage.Right - crop.Right, crop.Height);
}
using (var pen = new Pen(Theme.Accent, 2f)) g.DrawRectangle(pen, crop.Left, crop.Top, crop.Width, crop.Height);
using (var handle = new SolidBrush(Theme.Accent))
{
g.FillRectangle(handle, crop.Right - HandleSize / 2, crop.Bottom - HandleSize / 2, HandleSize, HandleSize);
}
// Terzi dentro il ritaglio: servono a comporre, ed è per comporre che si sta qui.
using (var thirds = new Pen(Color.FromArgb(70, Color.White)))
{
for (int i = 1; i < 3; i++)
{
float x = crop.Left + crop.Width * i / 3f;
float y = crop.Top + crop.Height * i / 3f;
g.DrawLine(thirds, x, crop.Top, x, crop.Bottom);
g.DrawLine(thirds, crop.Left, y, crop.Right, y);
}
}
TextRenderer.DrawText(g, "AREA DI USCITA", Theme.SmallBold, new Rectangle(2, 2, 200, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
double coverage = stage.Width * stage.Height > 0
? crop.Width * crop.Height / (stage.Width * stage.Height)
: 1;
TextRenderer.DrawText(g, $"{_export.CropZoom:0.00}× · {coverage * 100:0}% dell'area sorgente",
Theme.Small, new Rectangle(Width - 260, 2, 252, 16), Theme.TextMuted,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, "trascina per spostare · angolo per stringere · doppio clic azzera",
Theme.Small, new Rectangle(10, Height - 18, Width - 20, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
+342
View File
@@ -0,0 +1,342 @@
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Pagina dell'archivio: da dove si importa, cosa verrà scritto e dove.
///
/// L'anteprima del piano non è un vezzo. Un'importazione sbagliata su mille file non si
/// annulla: o si è scritto nel posto giusto con il nome giusto, o si passa la serata a
/// rimettere in ordine. Vedere prima le cartelle che verranno create e i primi nomi che ne
/// escono costa un istante e toglie l'unico rischio serio dell'operazione.
/// </summary>
internal sealed class ImportPanel : Panel
{
private readonly ImportSettings _settings;
private readonly VolumeList _volumes = new() { Dock = DockStyle.Top, Height = 148 };
private readonly PlanView _plan = new() { Dock = DockStyle.Fill };
private readonly ProgressStrip _progress = new() { Dock = DockStyle.Bottom, Height = 78 };
private List<ImportCandidate> _candidates = [];
private ImportPlan? _current;
/// <summary>L'utente ha scelto un supporto: il chiamante aggiorna la sorgente e rilegge.</summary>
public event EventHandler<string>? VolumeChosen;
public ImportPanel(ImportSettings settings)
{
_settings = settings;
BackColor = Theme.Background;
Padding = new Padding(14, 12, 14, 12);
_volumes.VolumeChosen += (_, path) => VolumeChosen?.Invoke(this, path);
Controls.Add(_plan);
Controls.Add(_progress);
Controls.Add(_volumes);
}
public ImportPlan? CurrentPlan => _current;
public int CandidateCount => _candidates.Count;
/// <summary>Rilegge l'elenco dei supporti collegati.</summary>
public void Refresh()
{
_volumes.SetVolumes(MediaImporter.Volumes(), _settings.SourcePath);
_plan.Update(_settings, _candidates, _current);
}
public void SetScan(List<ImportCandidate> candidates, ImportPlan? plan)
{
_candidates = candidates;
_current = plan;
_plan.Update(_settings, candidates, plan);
}
public void RebuildPlan()
{
_current = _candidates.Count > 0 ? MediaImporter.Plan(_candidates, _settings) : null;
_plan.Update(_settings, _candidates, _current);
}
public void ReportProgress(PipelineProgress progress) => _progress.Report(progress);
public void ShowResult(ImportResult result) => _progress.Show(result);
public void ShowMessage(string message) => _progress.ShowMessage(message);
// ================================================================== supporti
private sealed class VolumeList : Control
{
private List<ImportVolume> _items = [];
private string _selected = string.Empty;
private int _hovered = -1;
public event EventHandler<string>? VolumeChosen;
public VolumeList()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Cursor = Cursors.Hand;
}
public void SetVolumes(List<ImportVolume> volumes, string selected)
{
_items = volumes;
_selected = selected;
Invalidate();
}
private const int RowHeight = 30;
private Rectangle Body => new(0, 26, Width, Math.Max(20, Height - 32));
private int IndexAt(int y)
{
int index = (y - Body.Top) / RowHeight;
return index >= 0 && index < _items.Count ? 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)
{
int index = IndexAt(e.Y);
if (index < 0) return;
_selected = _items[index].Path;
Invalidate();
VolumeChosen?.Invoke(this, _items[index].Path);
base.OnMouseDown(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
TextRenderer.DrawText(g, "SUPPORTI COLLEGATI", Theme.SmallBold, new Rectangle(2, 4, 300, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (_items.Count == 0)
{
TextRenderer.DrawText(g, "Nessun supporto rilevato. Indica una cartella dalle impostazioni.",
Theme.Small, Body, Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
var body = Body;
for (int i = 0; i < _items.Count; i++)
{
var volume = _items[i];
var row = new Rectangle(0, body.Top + i * RowHeight, Width, RowHeight - 2);
if (row.Bottom > body.Bottom) break;
bool chosen = string.Equals(volume.Path, _selected, StringComparison.OrdinalIgnoreCase);
if (chosen) Theme.FillRounded(g, row, 4f, Theme.SurfaceAlt);
else if (i == _hovered) Theme.FillRounded(g, row, 4f, Theme.Surface);
// Il pallino distingue a colpo d'occhio una scheda da un disco interno: sono
// due cose diverse e si sbaglia facilmente a scaricare dalla seconda.
using (var dot = new SolidBrush(volume.Removable ? Theme.Accent : Theme.TextFaint))
g.FillEllipse(dot, 8, row.Top + RowHeight / 2f - 4, 7, 7);
TextRenderer.DrawText(g, volume.Description, chosen ? Theme.BodyBold : Theme.Body,
new Rectangle(24, row.Top, Width - 200, row.Height),
chosen ? Theme.Text : Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
string free = $"{volume.FreeBytes / (1024.0 * 1024 * 1024):0.#} GB liberi";
TextRenderer.DrawText(g, free, Theme.Small, new Rectangle(Width - 190, row.Top, 182, row.Height),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
}
}
// ================================================================== piano
private sealed class PlanView : Control
{
private readonly List<string> _lines = [];
private string _headline = "Nessuna scansione eseguita";
private string _detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
private bool _warning;
public PlanView()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Update(ImportSettings settings, List<ImportCandidate> candidates, ImportPlan? plan)
{
_lines.Clear();
_warning = false;
if (candidates.Count == 0)
{
_headline = "Nessuna scansione eseguita";
_detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
Invalidate();
return;
}
if (plan is null)
{
_headline = $"{candidates.Count} file trovati";
_detail = "Imposta la destinazione per costruire il piano.";
Invalidate();
return;
}
string size = plan.TotalBytes >= 1L << 30
? $"{plan.TotalBytes / (1024.0 * 1024 * 1024):0.0} GB"
: $"{plan.TotalBytes / (1024.0 * 1024):0} MB";
_headline = $"{plan.Pending} da importare · {plan.Skipped} già presenti · {size}";
_detail = plan.SessionCount > 1
? $"{plan.SessionCount} sessioni riconosciute con pause oltre {settings.SessionGapMinutes:0} minuti"
: "Una sola sessione riconosciuta";
if (string.IsNullOrWhiteSpace(settings.DestinationRoot))
{
_warning = true;
_detail = "Manca la cartella di destinazione.";
}
foreach (string folder in plan.Folders.Take(6))
{
_lines.Add("cartella " + folder);
}
foreach (var step in plan.Steps.Where(s => !s.Skipped).Take(6))
{
_lines.Add("file " + Path.GetFileName(step.DestinationPath));
}
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 4.5f, Width - 1, Math.Max(40, Height - 12));
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _warning ? Theme.Warning : Theme.Border);
TextRenderer.DrawText(g, "COSA VERRÀ IMPORTATO", Theme.SmallBold, new Rectangle(16, 14, 300, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, _headline, Theme.BodyBold, new Rectangle(16, 34, Width - 32, 22),
_warning ? Theme.Warning : Theme.Text,
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, _detail, Theme.Small, new Rectangle(16, 58, Width - 32, 18),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
int y = 86;
foreach (string line in _lines)
{
if (y > Height - 30) break;
TextRenderer.DrawText(g, line, Theme.Small, new Rectangle(16, y, Width - 32, 18),
Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.PathEllipsis);
y += 18;
}
}
}
// ================================================================== avanzamento
private sealed class ProgressStrip : Control
{
private PipelineProgress _progress;
private string _message = string.Empty;
private bool _failed;
public ProgressStrip()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Report(PipelineProgress progress)
{
_progress = progress;
_message = string.Empty;
Invalidate();
}
public void ShowMessage(string message)
{
_message = message;
_failed = false;
Invalidate();
}
public void Show(ImportResult result)
{
_failed = result.Failed > 0;
_message = $"Importati {result.Imported} file in {result.Elapsed.TotalSeconds:0.0} s" +
(result.Skipped > 0 ? $", {result.Skipped} già presenti" : string.Empty) +
(result.Failed > 0 ? $", {result.Failed} non riusciti" : string.Empty) +
(result.Errors.Count > 0 ? " — " + result.Errors[0] : string.Empty);
_progress = default;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 6.5f, Width - 1, Height - 10);
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _failed ? Theme.Danger : Theme.Border);
if (_message.Length > 0)
{
TextRenderer.DrawText(g, _message, Theme.Body, new Rectangle(16, 6, Width - 32, Height - 12),
_failed ? Theme.Danger : Theme.Success,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
return;
}
if (_progress.Total <= 0)
{
TextRenderer.DrawText(g, "In attesa", Theme.Small, new Rectangle(16, 6, Width - 32, Height - 12),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
TextRenderer.DrawText(g, $"{_progress.Message} {_progress.Completed}/{_progress.Total}",
Theme.Body, new Rectangle(16, 14, Width - 32, 20), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
var bar = new RectangleF(16, 46, 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);
}
}
}
}
+214 -36
View File
@@ -30,10 +30,14 @@ internal sealed class MainForm : Form
private readonly MotionPathChart _motionPath = new() { Dock = DockStyle.Fill };
private readonly PlanChart _planChart = new() { Dock = DockStyle.Fill };
private readonly ExportPanel _export;
private readonly ImportPanel _import;
private readonly AboutPanel _about = new() { Dock = DockStyle.Fill };
private readonly ImportSettings _importSettings = new();
private readonly Dictionary<WorkspaceSection, Control> _sections = [];
private Panel _sectionHost = null!;
private Panel _previewHost = null!;
private Splitter _previewSplitter = null!;
private readonly DarkProgressBar _progress = new() { Dock = DockStyle.Fill };
private readonly Label _status;
@@ -45,6 +49,8 @@ internal sealed class MainForm : Form
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 DarkButton _importButton = new() { Text = "Leggi supporto", Width = 138, Primary = true };
private readonly DarkButton _optimizeButton = new() { Text = "Ottimizza", Width = 178 };
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" };
@@ -56,7 +62,7 @@ internal sealed class MainForm : Form
public MainForm()
{
Text = "Titano — time-lapse";
Text = "Titano";
MinimumSize = new Size(1280, 760);
Size = new Size(1660, 980);
StartPosition = FormStartPosition.CenterScreen;
@@ -68,9 +74,15 @@ internal sealed class MainForm : Form
_app.ApplyTo(_project);
// Una destinazione plausibile evita che la prima importazione fallisca solo perché
// nessuno ha ancora scelto dove mettere le cose.
_importSettings.DestinationRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "Titano");
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project, _app, _importSettings) { Dock = DockStyle.Fill };
_export = new ExportPanel(_project) { Dock = DockStyle.Fill };
_import = new ImportPanel(_importSettings) { Dock = DockStyle.Fill };
_status = new Label
{
@@ -91,9 +103,14 @@ internal sealed class MainForm : Form
LoadApplicationIcon();
BuildLayout();
// La barra nasce sulla prima voce, che è l'archivio: portarla su Sequenza prima di
// collegare gli eventi evita che il selettore esca subito perché l'indice coincide,
// lasciando la barra su una sezione e il contenuto su un'altra.
_rail.Selected = WorkspaceSection.Sequence;
WireEvents();
_export.Configure(_app);
ShowSection(WorkspaceSection.Sequence);
ShowSection(_rail.Selected);
UpdateCommandState();
}
@@ -156,6 +173,7 @@ internal sealed class MainForm : Form
_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.Archive] = _import;
_sections[WorkspaceSection.Export] = _export;
_sections[WorkspaceSection.Preferences] = _about;
@@ -167,13 +185,13 @@ internal sealed class MainForm : Form
_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 };
_previewHost = Card(_preview, "Anteprima", DockStyle.Top, 330, BuildPreviewTools());
_previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
var center = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
center.Controls.Add(_sectionHost);
center.Controls.Add(splitter);
center.Controls.Add(previewHost);
center.Controls.Add(_previewSplitter);
center.Controls.Add(_previewHost);
Controls.Add(center);
Controls.Add(_settingsHost);
@@ -247,33 +265,8 @@ internal sealed class MainForm : Form
TextAlign = ContentAlignment.MiddleLeft,
};
var subtitle = new Label
{
Text = "elaborazione time-lapse",
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Bounds = new Rectangle(104, 20, 152, 18),
TextAlign = ContentAlignment.MiddleLeft,
};
int x = 272;
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
{
button.Bounds = new Rectangle(x, 13, button.Width, 32);
bar.Controls.Add(button);
x += button.Width + 8;
}
_analyzeButton.Bounds = new Rectangle(x + 16, 13, _analyzeButton.Width, 32);
bar.Controls.Add(_analyzeButton);
x += _analyzeButton.Width + 24;
_exportButton.Bounds = new Rectangle(x, 13, _exportButton.Width, 32);
bar.Controls.Add(_exportButton);
foreach (var button in ToolbarButtons) bar.Controls.Add(button);
bar.Controls.Add(title);
bar.Controls.Add(subtitle);
bar.Paint += (_, e) =>
{
@@ -283,6 +276,32 @@ internal sealed class MainForm : Form
return bar;
}
/// <summary>I pulsanti della barra, nell'ordine in cui compaiono quando sono visibili.</summary>
private IEnumerable<DarkButton> ToolbarButtons =>
[
_importButton, _addFilesButton, _addFolderButton, _clearButton, _analyzeButton,
_optimizeButton, _exportButton,
];
/// <summary>
/// Ridispone i pulsanti mostrati uno dopo l'altro. Con una barra contestuale le posizioni
/// fisse lascerebbero buchi dove i pulsanti nascosti stavano prima.
///
/// L'elenco arriva dal chiamante e non si legge da <c>Visible</c>: finché la finestra non
/// è stata mostrata quella proprietà restituisce falso anche per i controlli appena
/// accesi, perché riporta la visibilità effettiva dell'intera catena. Leggendola qui,
/// alla costruzione nessun pulsante verrebbe posizionato.
/// </summary>
private static void LayoutToolbar(IReadOnlyList<DarkButton> shown)
{
int x = 132;
foreach (var button in shown)
{
button.Bounds = new Rectangle(x, 13, button.Width, 32);
x += button.Width + 8;
}
}
private Panel BuildStatusBar()
{
var bar = new Panel { Dock = DockStyle.Bottom, Height = 52, BackColor = Theme.Background };
@@ -333,6 +352,15 @@ internal sealed class MainForm : Form
_rail.SelectionChanged += (_, _) => ShowSection(_rail.Selected);
_importButton.Click += async (_, _) => await ImportAsync();
_optimizeButton.Click += (_, _) => OptimizeCurrentSection();
_import.VolumeChosen += (_, path) =>
{
_importSettings.SourcePath = path;
_settings.ShowImportSource();
_import.Refresh();
};
_chart.SelectionChanged += (_, _) => Select(_chart.SelectedIndex);
_table.SelectionChanged += (_, _) => Select(_table.SelectedIndex);
_timeline.SelectionChanged += (_, _) => Select(_timeline.SelectedIndex);
@@ -382,6 +410,7 @@ internal sealed class MainForm : Form
_app.Save();
_export.Configure(_app);
};
_settings.ImportChanged += (_, _) => _import.RebuildPlan();
}
private void ShowSection(WorkspaceSection section)
@@ -390,8 +419,55 @@ internal sealed class MainForm : Form
_settings.ShowSection(section);
_export.SetActive(section == WorkspaceSection.Export);
// Le preferenze non riguardano la sequenza caricata: mostrarle accanto all'anteprima
// e al riepilogo degli scatti confonderebbe due piani diversi, e ruberebbe alla
// pagina lo spazio che le serve per essere letta senza scorrere.
bool sequenceContext = section is not (WorkspaceSection.Preferences or WorkspaceSection.Archive);
_previewHost.Visible = sequenceContext;
_previewSplitter.Visible = sequenceContext;
_summary.Visible = sequenceContext;
UpdateToolbarFor(section);
if (section == WorkspaceSection.Export) _export.Refresh(_project);
if (section == WorkspaceSection.Preferences) _about.Update(_project, _app);
if (section == WorkspaceSection.Archive) _import.Refresh();
if (section == WorkspaceSection.Preferences)
{
_about.Update(_project, _app);
// Anche la riga di stato parla della sequenza: qui non c'entra nulla.
SetStatus("Preferenze dell'applicazione. Valgono per ogni sequenza e restano salvate.");
}
}
/// <summary>
/// I comandi seguono la sezione. Un pulsante che non ha senso dove ci si trova non va
/// disabilitato ma tolto: disabilitato resta un ingombro che chiede perché non funziona.
/// </summary>
private void UpdateToolbarFor(WorkspaceSection section)
{
bool onSequence = section == WorkspaceSection.Sequence;
bool onArchive = section == WorkspaceSection.Archive;
var shown = new List<DarkButton>();
if (onArchive) shown.Add(_importButton);
if (onSequence) { shown.Add(_addFilesButton); shown.Add(_addFolderButton); shown.Add(_clearButton); }
if (onSequence) shown.Add(_analyzeButton);
if (section is not (WorkspaceSection.Preferences or WorkspaceSection.Archive)) shown.Add(_optimizeButton);
if (section == WorkspaceSection.Export) shown.Add(_exportButton);
foreach (var button in ToolbarButtons) button.Visible = shown.Contains(button);
_optimizeButton.Text = section switch
{
WorkspaceSection.Sequence => "Ottimizza lettura",
WorkspaceSection.Exposure => "Ottimizza esposizione",
WorkspaceSection.Motion => "Ottimizza movimento",
WorkspaceSection.Timing => "Ottimizza tempo",
_ => "Ottimizza esportazione",
};
LayoutToolbar(shown);
}
/// <summary>Allinea tutte le viste sullo stesso fotogramma, qualunque l'abbia scelto.</summary>
@@ -405,6 +481,108 @@ internal sealed class MainForm : Form
ShowPreview(index);
}
/// <summary>
/// Rilegge il supporto, costruisce il piano e — se la destinazione c'è — lo esegue.
/// Sono due passi distinti apposta: il primo non tocca il disco e si può guardare.
/// </summary>
private async Task ImportAsync()
{
if (_busy) return;
if (!Directory.Exists(_importSettings.SourcePath))
{
_import.ShowMessage("Scegli un supporto o una cartella di origine.");
return;
}
bool ready = _import.CandidateCount > 0 && _import.CurrentPlan is { Pending: > 0 };
BeginOperation(ready ? "Importazione…" : "Lettura del supporto…");
try
{
var progress = new Progress<PipelineProgress>(p =>
{
ReportProgress(p);
_import.ReportProgress(p);
});
if (!ready)
{
var settings = _importSettings.Clone();
var candidates = await Task.Run(
() => MediaImporter.Scan(settings, progress, _operation!.Token), _operation!.Token);
var plan = candidates.Count > 0 && !string.IsNullOrWhiteSpace(settings.DestinationRoot)
? MediaImporter.Plan(candidates, settings)
: null;
_import.SetScan(candidates, plan);
SetStatus(candidates.Count == 0
? "Nessun file d'immagine riconosciuto nel percorso indicato."
: $"{candidates.Count} file letti dal supporto. Controlla il piano e premi di nuovo per importare.");
return;
}
var current = _import.CurrentPlan!;
var executing = _importSettings.Clone();
var result = await Task.Run(
() => MediaImporter.Execute(current, executing, progress, _operation!.Token), _operation!.Token);
_import.ShowResult(result);
SetStatus($"Importati {result.Imported} file" +
(result.Skipped > 0 ? $", {result.Skipped} già presenti" : string.Empty) +
(result.Failed > 0 ? $", {result.Failed} non riusciti" : string.Empty) +
$" in {result.Elapsed.TotalSeconds:0.0} s.");
// La cartella appena riempita è quasi sempre quella che si vuole aprire subito.
if (result.Imported > 0 && result.FirstFolder is { } folder)
{
_app.LastFolder = folder;
_app.Save();
}
}
catch (OperationCanceledException)
{
_import.ShowMessage("Importazione interrotta.");
SetStatus("Importazione interrotta.");
}
catch (Exception ex)
{
_import.ShowMessage("Importazione non riuscita: " + ex.Message);
SetStatus("Importazione non riuscita: " + ex.Message);
}
finally
{
EndOperation();
}
}
/// <summary>Rileva le impostazioni ottimali per la sezione in vista e dice cosa ha cambiato.</summary>
private void OptimizeCurrentSection()
{
if (_busy) return;
var area = _rail.Selected switch
{
WorkspaceSection.Sequence => WorkspaceArea.Sequence,
WorkspaceSection.Exposure => WorkspaceArea.Exposure,
WorkspaceSection.Motion => WorkspaceArea.Motion,
WorkspaceSection.Timing => WorkspaceArea.Timing,
_ => WorkspaceArea.Export,
};
var changes = AutoOptimizer.Optimize(_project, area);
// I controlli leggono il progetto quando vengono costruiti: dopo un'ottimizzazione
// che tocca interruttori e menu, la colonna va rifatta o mostrerebbe i valori vecchi.
RebuildSettingsPanel();
RecomputeCurve();
RefreshDerived();
ShowPreview(_table.SelectedIndex);
SetStatus(string.Join(" ", changes));
}
// ------------------------------------------------------------------ comandi
private void AddFiles()
@@ -851,7 +1029,7 @@ internal sealed class MainForm : Form
// ------------------------------------------------------------------ modalità di cattura
internal void SelectSettingsTab(int index)
=> _rail.Selected = (WorkspaceSection)Math.Clamp(index, 0, 5);
=> _rail.Selected = (WorkspaceSection)Math.Clamp(index, 0, 6);
internal async Task PrepareForCaptureAsync(IReadOnlyList<string> paths)
{
@@ -894,7 +1072,7 @@ internal sealed class MainForm : Form
_settingsHost.Controls.Remove(_settings);
_settings.Dispose();
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project, _app, _importSettings) { Dock = DockStyle.Fill };
_settingsHost.Controls.Add(_settings);
WireSettingsEvents();
+16
View File
@@ -5,6 +5,7 @@ namespace Titano.UI;
/// <summary>Le sezioni in cui è divisa la finestra, nell'ordine in cui compaiono nella barra.</summary>
internal enum WorkspaceSection
{
Archive,
Sequence,
Exposure,
Motion,
@@ -32,6 +33,7 @@ internal sealed class NavigationRail : Control
private readonly Item[] _items =
[
new(WorkspaceSection.Archive, "Archivio", "Importazione da schede e fotocamere, nomi e cartelle"),
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"),
@@ -198,6 +200,20 @@ internal sealed class NavigationRail : Control
switch (section)
{
case WorkspaceSection.Archive:
// Cartella con la linguetta: l'archivio, non un supporto specifico.
g.DrawLines(pen,
[
new PointF(x + 1, y + h - 1.5f),
new PointF(x + 1, y + 3.5f),
new PointF(x + w * 0.42f, y + 3.5f),
new PointF(x + w * 0.55f, y + 6.5f),
new PointF(x + w - 1, y + 6.5f),
new PointF(x + w - 1, y + h - 1.5f),
]);
g.DrawLine(pen, x + 1, y + h - 1.5f, x + w - 1, y + h - 1.5f);
break;
case WorkspaceSection.Sequence:
// Tre fotogrammi impilati e sfalsati.
g.DrawRectangle(pen, x + 0.5f, y + 5.5f, w - 6, h - 8);
+629 -152
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
using System.Drawing.Drawing2D;
namespace Titano.UI;
/// <summary>
/// Suggerimenti a comparsa, disegnati a mano nel tema scuro.
///
/// Le spiegazioni stanno qui invece che sotto ai controlli per un motivo pratico: una nota
/// stampata sotto un cursore deve essere corta, perché occupa spazio a chi la conosce già.
/// Un suggerimento che compare solo quando serve non ha quel vincolo, e può permettersi di
/// dire l'unica cosa che conta davvero — perché quel parametro esiste, e cosa succede se lo
/// si sposta nella direzione sbagliata.
///
/// Il suggerimento di sistema è disegnato dal tema di Windows, che qui sarebbe chiaro su
/// scuro: viene quindi disegnato per intero, con la stessa tavolozza del resto.
/// </summary>
internal static class Tips
{
private const int MaximumWidth = 380;
private static readonly ToolTip Instance = Create();
private static ToolTip Create()
{
var tip = new ToolTip
{
OwnerDraw = true,
InitialDelay = 380,
ReshowDelay = 120,
AutoPopDelay = 32000, // le spiegazioni lunghe devono restare leggibili
ShowAlways = true,
UseAnimation = false,
UseFading = false,
};
tip.Popup += (_, e) =>
{
var size = TextRenderer.MeasureText(tip.GetToolTip(e.AssociatedControl) ?? string.Empty,
Theme.Small, new Size(MaximumWidth, 0),
TextFormatFlags.WordBreak);
e.ToolTipSize = new Size(Math.Min(MaximumWidth, size.Width) + 22, size.Height + 18);
};
tip.Draw += (_, e) =>
{
var g = e.Graphics;
Theme.HighQuality(g);
g.SmoothingMode = SmoothingMode.AntiAlias;
var bounds = new RectangleF(0.5f, 0.5f, e.Bounds.Width - 1, e.Bounds.Height - 1);
Theme.FillAndStroke(g, bounds, 5f, Theme.SurfaceAlt, Theme.BorderStrong);
TextRenderer.DrawText(g, e.ToolTipText, Theme.Small,
Rectangle.Inflate(e.Bounds, -11, -9), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
};
return tip;
}
/// <summary>Associa una spiegazione a un controllo e a tutti i suoi figli.</summary>
public static void Set(Control control, string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
Instance.SetToolTip(control, text);
// Un LabeledCombo è un pannello con dentro etichetta e menu: senza propagare, il
// suggerimento comparirebbe solo sui pochi pixel di bordo fra i due.
foreach (Control child in control.Controls) Set(child, text);
}
}