diff --git a/Titano/Diagnostics/AdvancedModuleTests.cs b/Titano/Diagnostics/AdvancedModuleTests.cs
index ed1147a..995f349 100644
--- a/Titano/Diagnostics/AdvancedModuleTests.cs
+++ b/Titano/Diagnostics/AdvancedModuleTests.cs
@@ -33,6 +33,7 @@ internal static class AdvancedModuleTests
Stacking(add);
Archive(workingDirectory, add);
Integration(workingDirectory, add, output);
+ Cancellation(workingDirectory, add, output);
}
// ================================================================== 1. stabilizzazione
@@ -798,4 +799,96 @@ internal static class AdvancedModuleTests
add("Parcheggio su disco — nessun file temporaneo sopravvissuto", leftovers.Length == 0,
leftovers.Length == 0 ? "cartella temporanea pulita" : $"{leftovers.Length} file rimasti");
}
+ // ================================================================== 9. annullamento
+
+ ///
+ /// Un'esportazione fermata a metà.
+ ///
+ /// Annullare non è un caso limite: è quello che si fa appena ci si accorge di aver
+ /// sbagliato un parametro, e succede quindi molto più spesso di quanto un'esportazione
+ /// arrivi in fondo. Deve costare esattamente quanto costa smettere — l'eccezione di
+ /// annullamento e nient'altro, il file di parcheggio rimosso, il video parziale
+ /// comunque leggibile per i fotogrammi che ha fatto in tempo a contenere.
+ ///
+ /// Il tetto di memoria è volutamente stretto anche qui: annullare mentre nessuno sta
+ /// scrivendo su disco non proverebbe la parte che può rompersi, cioè la corsa fra la
+ /// chiusura della finestra dei fotogrammi e le decodifiche ancora in volo.
+ ///
+ private static void Cancellation(string workingDirectory, Report add, TextWriter output)
+ {
+ string directory = Path.Combine(workingDirectory, "scena");
+ if (!Directory.Exists(directory))
+ {
+ add("Annullamento — l'esportazione interrotta esce con l'eccezione giusta", false,
+ "scena di prova non disponibile");
+ return;
+ }
+
+ var paths = Directory.GetFiles(directory, "*.jpg").OrderBy(p => p, StringComparer.Ordinal).ToArray();
+ var project = BuildSceneProject(paths, SceneDefinition);
+
+ project.Stacking.Mode = StackingMode.Median;
+ project.Stacking.WindowFrames = 5;
+ project.Export.OutputPath = Path.Combine(workingDirectory, "titano-annullata.mp4");
+ project.Export.Width = 320;
+ project.Export.Height = 214;
+ project.Export.FrameRate = 24;
+ project.Export.BitrateMbps = 12;
+ project.Cache.PrefetchDepth = 8;
+ project.Cache.MemoryBudgetMiB = 4;
+ project.Cache.AllowDiskSpill = true;
+
+ output.WriteLine("Esportazione annullata a metà con parcheggio su disco attivo");
+
+ var pipeline = new RenderPipeline(project);
+ pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
+
+ using var source = new CancellationTokenSource();
+ Exception? raised = null;
+ int seen = 0;
+
+ // Si annulla al terzo rapporto di avanzamento: la codifica è partita davvero e la
+ // lettura in anticipo ha già riempito il file di parcheggio.
+ var progress = new Progress(_ =>
+ {
+ if (Interlocked.Increment(ref seen) == 3) source.Cancel();
+ });
+
+ try
+ {
+ pipeline.RenderAsync(progress, source.Token).GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ raised = ex;
+ }
+
+ add("Annullamento — l'esportazione interrotta esce con l'eccezione giusta",
+ raised is OperationCanceledException,
+ raised is null ? "nessuna eccezione: l'annullamento non ha avuto effetto"
+ : raised.GetType().Name + ": " + raised.Message);
+
+ // Le eccezioni dei compiti che nessuno ha atteso emergono solo alla raccolta: se una
+ // decodifica interrotta ne avesse lasciata una in giro, cadrebbe il processo.
+ Exception? unobserved = null;
+ void Catch(object? _, UnobservedTaskExceptionEventArgs e)
+ {
+ unobserved = e.Exception;
+ e.SetObserved();
+ }
+
+ TaskScheduler.UnobservedTaskException += Catch;
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+ TaskScheduler.UnobservedTaskException -= Catch;
+
+ add("Annullamento — nessuna eccezione lasciata indietro dalle decodifiche",
+ unobserved is null,
+ unobserved is null ? "tutti i compiti chiusi puliti" : unobserved.GetBaseException().Message);
+
+ var leftovers = Directory.GetFiles(Path.GetTempPath(), "titano-*.frames");
+ add("Annullamento — il file di parcheggio viene rimosso comunque", leftovers.Length == 0,
+ leftovers.Length == 0 ? "cartella temporanea pulita" : leftovers.Length + " file rimasti");
+ }
}
diff --git a/Titano/Diagnostics/CrashGuard.cs b/Titano/Diagnostics/CrashGuard.cs
new file mode 100644
index 0000000..8cf461f
--- /dev/null
+++ b/Titano/Diagnostics/CrashGuard.cs
@@ -0,0 +1,97 @@
+using System.Text;
+using Titano.Pipeline;
+
+namespace Titano.Diagnostics;
+
+///
+/// Rete di sicurezza per le eccezioni che sfuggono ai gestori.
+///
+/// Un programma a finestre che incontra un'eccezione non gestita sul thread dell'interfaccia
+/// muore mostrando la finestra di errore di sistema, e con lui muore il lavoro in corso: la
+/// sequenza caricata, l'analisi appena fatta, le impostazioni non ancora salvate. È un
+/// prezzo sproporzionato per un difetto che nella maggior parte dei casi riguarda un
+/// dettaglio del disegno o un compito che stava chiudendo.
+///
+/// Qui l'eccezione viene invece scritta in un file accanto alle impostazioni, con la data e
+/// la traccia completa, e mostrata in una finestra che dice dove è finita. Il programma
+/// resta aperto. Se il difetto è grave se ne accorgerà comunque; se non lo è, non ha portato
+/// via niente — e il file resta da leggere per capire cos'era.
+///
+public static class CrashGuard
+{
+ private static readonly object Gate = new();
+ private static bool _installed;
+
+ public static string LogPath => Path.Combine(AppSettings.DataDirectory, "errori.txt");
+
+ public static void Install()
+ {
+ if (_installed) return;
+ _installed = true;
+
+ Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
+
+ Application.ThreadException += (_, e) => Handle(e.Exception, "interfaccia");
+
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ {
+ // Qui non si può impedire la chiusura: resta da lasciarne traccia.
+ if (e.ExceptionObject is Exception exception) Record(exception, "dominio");
+ };
+
+ // Un compito il cui esito nessuno ha guardato non deve far cadere il processo, ma
+ // vale la pena saperlo: quasi sempre è una decodifica interrotta a metà.
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ e.SetObserved();
+ Record(e.Exception, "compito non osservato");
+ };
+ }
+
+ private static void Handle(Exception exception, string origin)
+ {
+ string path = Record(exception, origin);
+
+ try
+ {
+ MessageBox.Show(
+ $"Si è verificato un errore imprevisto e Titano lo ha annotato invece di chiudersi.\n\n" +
+ $"{exception.GetType().Name}: {exception.Message}\n\n" +
+ $"La traccia completa è in:\n{path}\n\n" +
+ "Il lavoro in corso è ancora aperto. Conviene comunque salvare l'esportazione " +
+ "e riavviare quando possibile.",
+ "Titano", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ catch (Exception)
+ {
+ // Se non si riesce nemmeno a mostrare un messaggio non c'è altro da tentare.
+ }
+ }
+
+ private static string Record(Exception exception, string origin)
+ {
+ string path = LogPath;
+ try
+ {
+ lock (Gate)
+ {
+ Directory.CreateDirectory(AppSettings.DataDirectory);
+
+ var text = new StringBuilder();
+ text.AppendLine(new string('-', 78));
+ text.AppendLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} origine: {origin}");
+ text.AppendLine($"versione .NET {Environment.Version}, {Environment.OSVersion}");
+ text.AppendLine();
+ text.AppendLine(exception.ToString());
+ text.AppendLine();
+
+ File.AppendAllText(path, text.ToString(), Encoding.UTF8);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Non poter annotare l'errore non è motivo per generarne un secondo.
+ }
+ return path;
+ }
+}
diff --git a/Titano/Imaging/GeometryStage.cs b/Titano/Imaging/GeometryStage.cs
index 505da2b..b208d7d 100644
--- a/Titano/Imaging/GeometryStage.cs
+++ b/Titano/Imaging/GeometryStage.cs
@@ -44,14 +44,16 @@ public static class GeometryStage
/// avrebbe dovuto essere; qui serve il percorso opposto, perché si parte dalla destinazione
/// e si va a cercare il pixel nella sorgente.
///
- public static SourceMapping Build(int sourceWidth, int sourceHeight, int outputWidth, int outputHeight,
- in CameraFraming framing, in SimilarityTransform stabilization)
+ ///
+ /// Rettangolo che finisce nel fotogramma d'uscita, in coordinate del fotogramma sorgente.
+ ///
+ /// Il ritaglio prende il rapporto dell'uscita, non quello della sorgente: altrimenti
+ /// passare da 4:3 a 16:9 stirerebbe l'immagine invece di tagliarla. A zoom uno si prende
+ /// il rettangolo più grande con quel rapporto che ci sta dentro.
+ ///
+ public static (double Left, double Top, double Width, double Height) CropRect(
+ int sourceWidth, int sourceHeight, int outputWidth, int outputHeight, in CameraFraming framing)
{
- var inverse = stabilization.Inverse;
-
- // Il ritaglio prende il rapporto dell'uscita, non quello della sorgente: altrimenti
- // passare da 4:3 a 16:9 stirerebbe l'immagine invece di tagliarla. A zoom uno si
- // prende il rettangolo più grande con quel rapporto che ci sta dentro.
double zoom = Math.Max(1e-3, framing.Zoom);
double outputAspect = outputHeight > 0 ? outputWidth / (double)outputHeight
: sourceWidth / (double)Math.Max(1, sourceHeight);
@@ -60,8 +62,17 @@ public static class GeometryStage
double cropWidth = baseWidth / zoom;
double cropHeight = baseHeight / zoom;
- double left = framing.CentreX * sourceWidth - cropWidth * 0.5;
- double top = framing.CentreY * sourceHeight - cropHeight * 0.5;
+ return (framing.CentreX * sourceWidth - cropWidth * 0.5,
+ framing.CentreY * sourceHeight - cropHeight * 0.5,
+ cropWidth, cropHeight);
+ }
+
+ public static SourceMapping Build(int sourceWidth, int sourceHeight, int outputWidth, int outputHeight,
+ in CameraFraming framing, in SimilarityTransform stabilization)
+ {
+ var inverse = stabilization.Inverse;
+ var (left, top, cropWidth, cropHeight) =
+ CropRect(sourceWidth, sourceHeight, outputWidth, outputHeight, framing);
// La catena è affine, quindi tre punti la determinano per intero: valutarla e poi
// ricavarne i coefficienti è più corto — e molto meno soggetto a errori di segno —
diff --git a/Titano/Pipeline/AppSettings.cs b/Titano/Pipeline/AppSettings.cs
index eed6e67..4ea1f77 100644
--- a/Titano/Pipeline/AppSettings.cs
+++ b/Titano/Pipeline/AppSettings.cs
@@ -47,6 +47,9 @@ public sealed class AppSettings
/// Apre la cartella di destinazione a esportazione conclusa.
public bool RevealWhenFinished { get; set; }
+ /// Barra delle sezioni ridotta ai soli simboli.
+ public bool RailCollapsed { get; set; }
+
// ---- riquadro sponsor ---------------------------------------------------
/// Mostra il riquadro degli sponsor durante l'attesa dell'esportazione.
public bool ShowSponsors { get; set; } = true;
@@ -62,12 +65,23 @@ public sealed class AppSettings
// ------------------------------------------------------------------ percorsi
- /// Cartella dei dati dell'applicazione, creata alla prima scrittura.
+ ///
+ /// Cartella dei dati dell'applicazione, creata alla prima scrittura.
+ ///
+ /// Sta in Documenti e non in AppData perché questi file sono fatti per essere aperti:
+ /// le impostazioni sono un testo leggibile e correggibile a mano, il listino degli
+ /// sponsor è una cartella in cui si mettono immagini. Nascondere in una cartella di
+ /// sistema roba che l'utente deve poter raggiungere non serve a nessuno.
+ ///
public static string DataDirectory => Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Titano");
+ Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Titano");
public static string SettingsPath => Path.Combine(DataDirectory, "impostazioni.txt");
+ /// Vecchia collocazione, letta una sola volta per non perdere le preferenze.
+ private static string LegacyDataDirectory => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Titano");
+
public string ResolvedSponsorFolder => string.IsNullOrWhiteSpace(SponsorFolder)
? Path.Combine(DataDirectory, "sponsor")
: SponsorFolder;
@@ -79,8 +93,15 @@ public sealed class AppSettings
var settings = new AppSettings();
try
{
- if (!File.Exists(SettingsPath)) return settings;
- foreach (var (key, value) in ReadPairs(File.ReadAllLines(SettingsPath))) settings.Apply(key, value);
+ string path = File.Exists(SettingsPath)
+ ? SettingsPath
+ : Path.Combine(LegacyDataDirectory, "impostazioni.txt");
+ if (!File.Exists(path)) return settings;
+ foreach (var (key, value) in ReadPairs(File.ReadAllLines(path))) settings.Apply(key, value);
+
+ // Le preferenze trovate nella vecchia collocazione vengono riscritte subito in
+ // quella nuova: la prossima apertura non deve più passare di lì.
+ if (path != SettingsPath) settings.Save();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -126,6 +147,7 @@ public sealed class AppSettings
yield return ("anteprima-durante-esportazione", Format(LivePreviewDuringExport));
yield return ("anteprima-ogni", LivePreviewEvery.ToString(CultureInfo.InvariantCulture));
yield return ("apri-cartella-a-fine", Format(RevealWhenFinished));
+ yield return ("barra-sezioni-ridotta", Format(RailCollapsed));
yield return ("mostra-sponsor", Format(ShowSponsors));
yield return ("rotazione-sponsor-secondi", SponsorRotationSeconds.ToString(CultureInfo.InvariantCulture));
@@ -158,6 +180,7 @@ public sealed class AppSettings
break;
case "anteprima-ogni": LivePreviewEvery = Math.Clamp(ParseInt(value, LivePreviewEvery), 1, 240); break;
case "apri-cartella-a-fine": RevealWhenFinished = ParseBool(value, RevealWhenFinished); break;
+ case "barra-sezioni-ridotta": RailCollapsed = ParseBool(value, RailCollapsed); break;
case "mostra-sponsor": ShowSponsors = ParseBool(value, ShowSponsors); break;
case "rotazione-sponsor-secondi":
diff --git a/Titano/Pipeline/FrameWindow.cs b/Titano/Pipeline/FrameWindow.cs
index 74e6f46..3a2f464 100644
--- a/Titano/Pipeline/FrameWindow.cs
+++ b/Titano/Pipeline/FrameWindow.cs
@@ -176,6 +176,7 @@ public sealed class FrameWindow : IDisposable
{
lock (_gate)
{
+ if (_closed) return;
int inFlight = _pending.Count;
for (int i = first; i <= last; i++)
@@ -281,6 +282,7 @@ public sealed class FrameWindow : IDisposable
try
{
var handle = OpenSpill();
+ if (handle is null) return -1;
long offset;
lock (_gate)
@@ -310,17 +312,21 @@ public sealed class FrameWindow : IDisposable
buffer.Dispose();
return offset;
}
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException
+ or ObjectDisposedException)
{
- // Disco pieno o non scrivibile: si tiene tutto in memoria e si tira avanti.
+ // Disco pieno, non scrivibile, oppure finestra chiusa mentre si scriveva perché
+ // l'operazione è stata annullata: in tutti i casi si tiene il fotogramma in
+ // memoria e si tira avanti. Un annullamento non è un errore da propagare.
return -1;
}
}
- private SafeFileHandle OpenSpill()
+ private SafeFileHandle? OpenSpill()
{
lock (_gate)
{
+ if (_closed) return null;
if (_spillHandle is not null) return _spillHandle;
string directory = string.IsNullOrWhiteSpace(_settings.SpillDirectory)
@@ -366,7 +372,7 @@ public sealed class FrameWindow : IDisposable
_spillRead += read;
}
}
- catch (IOException)
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
buffer.Dispose();
lock (_gate) _failed.Add(index);
@@ -394,14 +400,29 @@ public sealed class FrameWindow : IDisposable
}
}
+ private bool _closed;
+
public void Dispose()
{
Task[] pending;
- lock (_gate) pending = [.. _pending.Values];
+ lock (_gate)
+ {
+ // Segnato prima dell'attesa: da qui in poi nessuna decodifica ancora viva prova
+ // più a scrivere sul file di parcheggio, che sta per essere chiuso sotto di lei.
+ _closed = true;
+ pending = [.. _pending.Values];
+ }
try { Task.WaitAll(pending, TimeSpan.FromSeconds(10)); }
catch (Exception) { /* le decodifiche interrotte non hanno nulla da salvare */ }
+ // Un compito che ha comunque fallito va osservato, altrimenti la sua eccezione
+ // riemerge dal finalizzatore quando ormai nessuno sa più da dove venga.
+ foreach (var task in pending)
+ {
+ if (task.IsFaulted) _ = task.Exception;
+ }
+
lock (_gate)
{
foreach (var buffer in _resident.Values) buffer.Dispose();
diff --git a/Titano/Program.cs b/Titano/Program.cs
index 9154c8a..4926ddb 100644
--- a/Titano/Program.cs
+++ b/Titano/Program.cs
@@ -83,6 +83,7 @@ internal static class Program
}
ApplicationConfiguration.Initialize();
+ CrashGuard.Install();
Application.Run(new UI.MainForm());
return 0;
}
diff --git a/Titano/README.md b/Titano/README.md
index a54d934..a4fbaa1 100644
--- a/Titano/README.md
+++ b/Titano/README.md
@@ -56,15 +56,43 @@ Diagnostics/ sequenze sintetiche, verifica end-to-end, ispettore MP4
## L'interfaccia
-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. 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 ciascuna voce compare il numero di avvisi che la riguardano.
+La navigazione è una barra verticale sul fianco sinistro, alta quanto la finestra, e governa
+insieme il contenuto principale e la colonna delle impostazioni: una sezione, una vista, i
+suoi comandi. 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 ciascuna voce compare il numero di avvisi che la riguardano. La barra si
+riduce ai soli simboli con l'interruttore in testa, e la scelta resta salvata: su uno schermo
+stretto sono centoventi pixel che tornano al contenuto, e il nome della sezione continua a
+comparire al passaggio del puntatore, insieme a cosa contiene.
-L'anteprima invece resta 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. Ha quattro
-strumenti che la rendono utilizzabile per giudicare e non solo per guardare:
+A destra della barra non c'è nient'altro che la scheda, dal bordo superiore a quello
+inferiore. I comandi di ciascuna sezione stanno **dentro** la sezione, in una riga sopra il
+contenuto su cui agiscono: prima erano allineati accanto al marchio, in cima alla finestra,
+dove cambiavano insieme alla scheda ma restavano fuori dal suo riquadro — e per trovare il
+comando che riguardava una tabella bisognava risalire fino al bordo della finestra. Ogni
+pulsante dice a comparsa cosa fa: un comando che avvia mezz'ora di lavoro merita di
+annunciarlo, e «Analizza sequenza» da solo non dice che rilegge ogni file.
+
+Lo spazio così liberato in cima ospita l'avanzamento delle operazioni di massa, che è l'unica
+cosa a riguardare tutte le schede insieme, insieme al pulsante che le ferma. La barra è una
+sola: la scheda Esportazione ne aveva una seconda che diceva la stessa cosa più in basso, e
+due barre identiche a due altezze diverse costringono a chiedersi se stiano davvero dicendo
+la stessa cosa. Quel riquadro ora tiene i numeri — fotogrammi fatti, fotogrammi al secondo,
+tempo rimanente e trascorso — che una barra non può mostrare. La stessa frazione passa anche
+sull'icona nella barra delle applicazioni, tramite `ITaskbarList3` dichiarata a mano: una
+codifica lunga si lascia lavorare andando a fare altro, e da lì si vede a che punto è senza
+tornare sulla finestra. Un'esportazione fallita lascia l'icona in rosso.
+
+Mentre un'operazione di massa è in corso, i controlli che la disturberebbero sono spenti:
+tutta la colonna delle impostazioni, le tabelle e i grafici da cui si sceglie un fotogramma,
+gli interruttori dell'anteprima, l'elenco dei supporti. Il progetto viene letto da un altro
+thread a ogni fotogramma, e cambiare risoluzione o ritaglio a metà significa cambiare le
+regole a partita iniziata. Restano vivi il pulsante che ferma, la barra delle sezioni, e
+tutto ciò che si limita a guardare.
+
+L'anteprima resta 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. Ha gli strumenti che
+la rendono utilizzabile per giudicare e non solo per guardare:
- **zoom e trascinamento**, perché una stabilizzazione sotto il pixel o il bordo di una scia
non si vedono su un'immagine rimpicciolita per stare in un riquadro; da scala uno a uno in
@@ -75,7 +103,19 @@ strumenti che la rendono utilizzabile per giudicare e non solo per guardare:
- **campo vettoriale sovrapposto**, che il motore calcola comunque, e che spiega in un colpo
d'occhio perché la sfocatura viene come viene — soprattutto quando è sbagliata;
- **confine delle regioni**, campionato attraverso la stessa mappatura del ritaglio, così la
- linea resta al suo posto anche quando l'anteprima mostra una panoramica virtuale.
+ linea resta al suo posto anche quando l'anteprima mostra una panoramica virtuale;
+- **maschera del ritaglio**: quando il formato d'uscita taglia — un 4:3 portato a 16:9, un
+ ritaglio verticale, un movimento di macchina virtuale — l'anteprima mostra il fotogramma
+ intero e scurisce quello che resterà fuori, invece di mostrare direttamente il risultato.
+ Sono due domande diverse. *Come verrà* si risponde guardando l'uscita, e serve mentre si
+ regola l'esposizione; *cosa perdo* si risponde solo vedendo anche quello che resta fuori, e
+ serve mentre si sceglie il rapporto d'immagine. L'interruttore alterna le due letture;
+- **schermo intero**, in una finestra a parte, alla risoluzione nativa del fotogramma e non a
+ quella del riquadro: decidere se una stella è puntiforme o se il primo piano è a fuoco si
+ fa al cento per cento, e farlo con un altro visualizzatore mostrerebbe il RAW come lo
+ interpreta lui, non come Titano lo sta elaborando. I dati di scatto stanno in un pannello
+ che si chiude, perché servono spesso ma non sempre e occupano l'angolo in cui di solito c'è
+ il cielo.
Le sezioni portano ciascuna lo strumento di misura che le riguarda: la striscia dei provini e
gli avvisi sulla sequenza, istogramma e forma d'onda sull'esposizione, il percorso ricostruito
@@ -158,6 +198,26 @@ sopra. Una nota stampata occupa spazio a chi la conosce già, e per questo deve
un suggerimento che compare solo quando serve può dire l'unica cosa che conta davvero — perché
quel parametro esiste, e cosa succede a spostarlo nella direzione sbagliata.
+## Preimpostazioni di esportazione
+
+I parametri di codifica non sono indipendenti fra loro. Il bitrate che a 1080p è abbondante
+lascia gradini sul cielo a 4K; il profilo che apre su un televisore vecchio non è quello con
+cui si archivia; la cadenza cinematografica va con il rapporto da grande schermo e non con
+quello verticale. Sceglierli bene significa muoverli insieme, e finora bisognava saperlo.
+
+La scheda Esportazione si apre quindi su una preimpostazione — 4K da archivio, 4K
+compatibile, cinema 21:9, 1440p, 1080p per la condivisione, verticale per i formati a
+colonna, quadrato, prova veloce a 720p — che porta in un colpo risoluzione, rapporto, codec,
+profilo, cadenza, bitrate e distanza fra i fotogrammi chiave. I bitrate seguono all'incirca
+0,10 bit per pixel per fotogramma in H.264 e 0,07 in HEVC, che è la soglia sotto la quale una
+sfumatura notturna comincia a mostrare i gradini: un time-lapse di cielo è il contenuto più
+esigente che esista per un codec, quindi si sta larghi.
+
+Nessun valore viene bloccato: ciascuno resta modificabile sotto, e appena se ne tocca uno la
+voce torna a «Personalizzata», perché la combinazione non è più quella dichiarata e
+lasciarcela sarebbe un'etichetta falsa. Se i valori tornano a coincidere con una
+preimpostazione, quella riprende il suo nome.
+
## Il riquadro sponsor
La codifica di una sequenza lunga sono minuti o decine di minuti in cui non c'è niente da
@@ -174,6 +234,21 @@ time-lapse. Le campagne si aggiornano copiando file in una cartella. Quando il l
vuoto compaiono note interne sul funzionamento del programma, e l'intero riquadro si spegne
dalle preferenze.
+## Dove il programma tiene le sue cose
+
+`Documenti\Titano`, non `AppData`. Sono file fatti per essere aperti: `impostazioni.txt` è un
+testo leggibile e correggibile a mano, `sponsor\` è una cartella in cui si mettono immagini,
+`errori.txt` è il registro delle eccezioni. Nascondere in una cartella di sistema roba che
+l'utente deve poter raggiungere non serve a nessuno. Le preferenze trovate nella vecchia
+collocazione vengono lette una volta e riscritte in quella nuova, così niente va perso.
+
+Quel registro esiste perché un programma a finestre che incontra un'eccezione non gestita sul
+thread dell'interfaccia muore mostrando la finestra di errore di sistema, e con lui muore il
+lavoro in corso: la sequenza caricata, l'analisi appena fatta. È un prezzo sproporzionato per
+un difetto che quasi sempre riguarda un dettaglio del disegno o un compito che stava
+chiudendo. L'eccezione viene quindi annotata con data e traccia completa, mostrata in una
+finestra che dice dove è finita, e il programma resta aperto.
+
## Il piano di rendering
Il motore non percorre la sequenza sorgente: percorre un elenco di fotogrammi d'uscita, ognuno
@@ -402,7 +477,7 @@ Titano.exe --selftest [cartella]
```
Genera sequenze sintetiche dalle proprietà note e le fa attraversare l'intera pipeline,
-confrontando 60 grandezze misurate con i valori attesi. Nessuna soglia è scelta a posteriori:
+confrontando 63 grandezze misurate con i valori attesi. Nessuna soglia è scelta a posteriori:
la scena è costruita perché il valore atteso sia la conseguenza aritmetica di come è stata
generata — il tremolio ha un percorso noto, il gradino di esposizione un'ampiezza dichiarata
nei metadati e visibile nei pixel, la nuvola attraversa il solo cielo.
@@ -421,6 +496,14 @@ contenitore; e — prova conclusiva
— un rendering con tutti i moduli attivi insieme, con tetto di memoria volutamente stretto per
esercitare il parcheggio su disco, riletto poi dal lettore di sistema.
+Le ultime tre verifiche riguardano l'annullamento, che non è un caso limite: è quello che si
+fa appena ci si accorge di aver sbagliato un parametro, e succede quindi molto più spesso di
+quanto un'esportazione arrivi in fondo. Un rendering viene fermato al terzo rapporto di
+avanzamento, con il parcheggio su disco attivo perché è lì che sta la corsa fra la chiusura
+della finestra dei fotogrammi e le decodifiche ancora in volo; si controlla che esca
+l'eccezione di annullamento e nessun'altra, che nessuna decodifica interrotta lasci indietro
+un'eccezione non osservata, e che il file di parcheggio sparisca comunque.
+
```
Titano.exe --diagnose [uscita.mp4] [fotogrammi] [larghezza] [h264|hevc]
[avanzato] [luminanza] [camera] [mediana|scie] [analisi=N]
diff --git a/Titano/UI/AboutPanel.cs b/Titano/UI/AboutPanel.cs
index c19d7cf..5be433b 100644
--- a/Titano/UI/AboutPanel.cs
+++ b/Titano/UI/AboutPanel.cs
@@ -41,6 +41,8 @@ internal sealed class AboutPanel : Panel
_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(("Registro errori", Diagnostics.CrashGuard.LogPath +
+ (File.Exists(Diagnostics.CrashGuard.LogPath) ? string.Empty : " (nessuno)")));
_rows.Add(("Campagne sponsor", app.ResolvedSponsorFolder));
_rows.Add(("Parcheggio fotogrammi", string.IsNullOrWhiteSpace(app.SpillDirectory)
? Path.GetTempPath()
diff --git a/Titano/UI/CommandBar.cs b/Titano/UI/CommandBar.cs
new file mode 100644
index 0000000..953a42c
--- /dev/null
+++ b/Titano/UI/CommandBar.cs
@@ -0,0 +1,87 @@
+namespace Titano.UI;
+
+///
+/// Riga dei comandi della sezione, dentro la sezione.
+///
+/// Prima questi pulsanti stavano sulla riga del marchio, in cima alla finestra: cambiavano
+/// insieme alla scheda ma restavano fuori dal suo riquadro, e chi guardava una tabella
+/// doveva risalire fino al bordo della finestra per trovare il comando che quella tabella
+/// riguardava. Qui stanno dentro, sopra il contenuto su cui agiscono.
+///
+/// I pulsanti non appartengono alla barra: le vengono prestati dalla finestra a ogni cambio
+/// di sezione. Averne una copia per scheda avrebbe voluto dire duplicare anche i gestori, e
+/// «Ottimizza» è lo stesso comando ovunque — cambia solo su cosa lavora.
+///
+internal sealed class CommandBar : Panel
+{
+ private const int Height_ = 48;
+ private const int Gap = 8;
+
+ private readonly List _shown = [];
+ private string _caption = string.Empty;
+
+ public CommandBar()
+ {
+ Dock = DockStyle.Top;
+ Height = Height_;
+ BackColor = Theme.Background;
+ Padding = new Padding(0);
+ }
+
+ ///
+ /// Mostra i comandi indicati, nell'ordine. Un elenco vuoto fa sparire la riga: una barra
+ /// vuota è spazio tolto al contenuto in cambio di niente.
+ ///
+ public void Show(string caption, IReadOnlyList<(DarkButton Button, string Tip)> commands)
+ {
+ _caption = caption;
+
+ foreach (var button in _shown) Controls.Remove(button);
+ _shown.Clear();
+
+ foreach (var (button, tip) in commands)
+ {
+ button.Visible = true;
+ Tips.Set(button, tip);
+ Controls.Add(button);
+ _shown.Add(button);
+ }
+
+ Visible = _shown.Count > 0;
+ Height = _shown.Count > 0 ? Height_ : 0;
+ Arrange();
+ Invalidate();
+ }
+
+ private void Arrange()
+ {
+ int x = 0;
+ foreach (var button in _shown)
+ {
+ button.Bounds = new Rectangle(x, 8, button.Width, 32);
+ x += button.Width + Gap;
+ }
+ }
+
+ protected override void OnResize(EventArgs e)
+ {
+ base.OnResize(e);
+ Arrange();
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ base.OnPaint(e);
+ if (_shown.Count == 0 || string.IsNullOrEmpty(_caption)) return;
+
+ // Il titolo della sezione sta a destra dei comandi, in piccolo: serve a confermare
+ // dove ci si trova, non a fare da intestazione.
+ int left = _shown.Sum(b => b.Width + Gap) + 8;
+ if (left + 120 > Width) return;
+
+ TextRenderer.DrawText(e.Graphics, _caption, Theme.Small,
+ new Rectangle(left, 0, Width - left, Height_ - 4), Theme.TextFaint,
+ TextFormatFlags.Right | TextFormatFlags.VerticalCenter |
+ TextFormatFlags.EndEllipsis);
+ }
+}
diff --git a/Titano/UI/Controls.cs b/Titano/UI/Controls.cs
index d909beb..430b92a 100644
--- a/Titano/UI/Controls.cs
+++ b/Titano/UI/Controls.cs
@@ -456,6 +456,8 @@ internal enum ToggleGlyph
Zoom,
Motion,
Region,
+ Crop,
+ FullScreen,
}
///
@@ -476,11 +478,19 @@ internal sealed class DarkToggle : Control
public string Hint
{
get => _hint;
- set { _hint = value; _tooltip.SetToolTip(this, value); }
+ set { _hint = value; Tips.Set(this, value); }
}
private string _hint = string.Empty;
- private readonly ToolTip _tooltip = new();
+
+ ///
+ /// Interruttore che non resta premuto: fa una cosa e torna com'era. Serve ai comandi che
+ /// aprono qualcosa invece di accendere un'opzione, e che nella stessa fila di simboli
+ /// starebbero comunque bene.
+ ///
+ public bool Momentary { get; set; }
+
+ public event EventHandler? Pressed;
public bool Checked
{
@@ -504,7 +514,12 @@ internal sealed class DarkToggle : Control
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 OnClick(EventArgs e)
+ {
+ if (Momentary) Pressed?.Invoke(this, EventArgs.Empty);
+ else Checked = !Checked;
+ base.OnClick(e);
+ }
protected override void OnPaint(PaintEventArgs e)
{
@@ -565,15 +580,39 @@ internal sealed class DarkToggle : Control
new PointF(x + w, y + h * 0.58f),
]);
break;
+
+ case ToggleGlyph.Crop:
+ // Le due squadre del ritaglio, sfalsate come sui mirini.
+ g.DrawLines(pen,
+ [
+ new PointF(x + w * 0.28f, y),
+ new PointF(x + w * 0.28f, y + h * 0.78f),
+ new PointF(x + w, y + h * 0.78f),
+ ]);
+ g.DrawLines(pen,
+ [
+ new PointF(x, y + h * 0.26f),
+ new PointF(x + w * 0.72f, y + h * 0.26f),
+ new PointF(x + w * 0.72f, y + h),
+ ]);
+ break;
+
+ case ToggleGlyph.FullScreen:
+ // Quattro angoli che spingono verso fuori.
+ for (int corner = 0; corner < 4; corner++)
+ {
+ float cx = (corner & 1) == 0 ? x : x + w;
+ float cy = (corner & 2) == 0 ? y : y + h;
+ float dx = (corner & 1) == 0 ? 1 : -1;
+ float dy = (corner & 2) == 0 ? 1 : -1;
+ g.DrawLine(pen, cx, cy, cx + dx * w * 0.36f, cy);
+ g.DrawLine(pen, cx, cy, cx, cy + dy * h * 0.36f);
+ }
+ break;
}
_ = brush;
}
- protected override void Dispose(bool disposing)
- {
- if (disposing) _tooltip.Dispose();
- base.Dispose(disposing);
- }
}
/// Intestazione di sezione con filetto di separazione.
diff --git a/Titano/UI/ExportPanel.cs b/Titano/UI/ExportPanel.cs
index 8ed15fb..102ac60 100644
--- a/Titano/UI/ExportPanel.cs
+++ b/Titano/UI/ExportPanel.cs
@@ -16,7 +16,7 @@ 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 ProgressCard _progress = new() { Dock = DockStyle.Top, Height = 112 };
private readonly ResultCard _result = new() { Dock = DockStyle.Fill };
public ExportPanel(TitanoProject project)
@@ -207,18 +207,14 @@ internal sealed class ExportPanel : Panel
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);
- }
-
+ // Nessuna barra qui: quella sta in cima alla finestra ed è sempre in vista, anche
+ // spostandosi su un'altra scheda. Due barre che dicono la stessa cosa a due
+ // altezze diverse costringono a chiedersi se stiano davvero dicendo la stessa
+ // cosa; questo riquadro tiene invece i numeri, che una barra non può mostrare.
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,
+ ? $"{_progress.Completed} / {_progress.Total} fotogrammi ({_progress.Fraction * 100:0.#}%)"
+ : "in attesa";
+ TextRenderer.DrawText(g, counter, Theme.BodyBold, new Rectangle(16, 54, 320, 20), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
var elapsed = DateTime.Now - _started;
@@ -226,14 +222,14 @@ internal sealed class ExportPanel : Panel
? $"{_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,
+ TextRenderer.DrawText(g, right, Theme.Small, new Rectangle(Width - 400, 56, 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,
+ Theme.Small, new Rectangle(16, 78, Width - 32, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
}
}
diff --git a/Titano/UI/FrameViewer.cs b/Titano/UI/FrameViewer.cs
new file mode 100644
index 0000000..125a910
--- /dev/null
+++ b/Titano/UI/FrameViewer.cs
@@ -0,0 +1,302 @@
+using System.Drawing.Drawing2D;
+using Titano.Core;
+using Titano.Metadata;
+
+namespace Titano.UI;
+
+///
+/// Il fotogramma da solo, a schermo intero, con lo zoom e la scheda dei metadati.
+///
+/// L'anteprima nella finestra principale sta in un riquadro alto trecento pixel: basta per
+/// vedere se l'esposizione tiene, non per decidere se una stella è puntiforme o se il primo
+/// piano è a fuoco. Quelle sono decisioni che si prendono al cento per cento e su tutto lo
+/// schermo, e finora richiedevano di uscire dal programma e aprire un altro visualizzatore —
+/// che però mostra il RAW come lo interpreta lui, non come Titano lo sta elaborando.
+///
+/// I dati di scatto stanno in un pannello che si chiude. Servono spesso ma non sempre, e
+/// quando non servono rubano l'angolo dell'immagine in cui di solito c'è il cielo.
+///
+internal sealed class FrameViewer : Form
+{
+ private readonly Bitmap _frame;
+ private readonly FrameMetadata? _metadata;
+ private readonly string _caption;
+
+ private float _zoom; // 0 = adatta allo schermo
+ private PointF _centre = new(0.5f, 0.5f);
+ private bool _panning;
+ private Point _dragOrigin;
+ private PointF _centreOrigin;
+ private bool _showInfo = true;
+ private bool _hoverInfo;
+ private bool _hoverClose;
+
+ private const int PanelWidth = 300;
+
+ public FrameViewer(Bitmap frame, FrameMetadata? metadata, string caption)
+ {
+ _frame = frame;
+ _metadata = metadata;
+ _caption = caption;
+
+ FormBorderStyle = FormBorderStyle.None;
+ WindowState = FormWindowState.Maximized;
+ StartPosition = FormStartPosition.CenterScreen;
+ BackColor = Color.FromArgb(0x0B, 0x0C, 0x0E);
+ KeyPreview = true;
+ DoubleBuffered = true;
+ Text = caption;
+ ShowInTaskbar = false;
+
+ SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ }
+
+ // ------------------------------------------------------------------ vista
+
+ private Rectangle Viewport => new(0, 0, Width, Math.Max(20, Height - 34));
+
+ private float ScaleFor()
+ {
+ if (_zoom > 0) return _zoom;
+ var view = Viewport;
+ return (float)Math.Min(view.Width / (double)_frame.Width, view.Height / (double)_frame.Height);
+ }
+
+ private RectangleF Target()
+ {
+ var view = Viewport;
+ float scale = ScaleFor();
+ float width = _frame.Width * scale;
+ float height = _frame.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);
+ }
+
+ private Rectangle InfoButton => new(Width - 168, 12, 148, 26);
+ private Rectangle CloseButton => new(Width - 168 - 34, 12, 26, 26);
+
+ protected override void OnMouseWheel(MouseEventArgs e)
+ {
+ var target = Target();
+ 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 next = Math.Clamp(ScaleFor() * (e.Delta > 0 ? 1.25f : 0.8f), 0.02f, 16f);
+ var view = Viewport;
+ float fit = (float)Math.Min(view.Width / (double)_frame.Width, view.Height / (double)_frame.Height);
+
+ if (next <= fit * 1.02f) { _zoom = 0; _centre = new PointF(0.5f, 0.5f); Invalidate(); return; }
+
+ _zoom = next;
+ _centre = new PointF(
+ Math.Clamp(anchorX + (view.Width / 2f - e.X) / Math.Max(1f, _frame.Width * next), 0f, 1f),
+ Math.Clamp(anchorY + (view.Height / 2f - e.Y) / Math.Max(1f, _frame.Height * next), 0f, 1f));
+ Invalidate();
+ }
+
+ protected override void OnMouseDown(MouseEventArgs e)
+ {
+ if (InfoButton.Contains(e.Location)) { _showInfo = !_showInfo; Invalidate(); return; }
+ if (CloseButton.Contains(e.Location)) { Close(); return; }
+
+ if (e.Button == MouseButtons.Left && _zoom > 0)
+ {
+ _panning = true;
+ _dragOrigin = e.Location;
+ _centreOrigin = _centre;
+ }
+ base.OnMouseDown(e);
+ }
+
+ protected override void OnMouseMove(MouseEventArgs e)
+ {
+ bool info = InfoButton.Contains(e.Location);
+ bool close = CloseButton.Contains(e.Location);
+ if (info != _hoverInfo || close != _hoverClose)
+ {
+ _hoverInfo = info;
+ _hoverClose = close;
+ Cursor = info || close ? Cursors.Hand : _zoom > 0 ? Cursors.SizeAll : Cursors.Default;
+ Invalidate();
+ }
+
+ if (_panning)
+ {
+ var target = Target();
+ _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();
+ }
+ base.OnMouseMove(e);
+ }
+
+ protected override void OnMouseUp(MouseEventArgs e)
+ {
+ _panning = false;
+ base.OnMouseUp(e);
+ }
+
+ protected override void OnMouseDoubleClick(MouseEventArgs e)
+ {
+ if (InfoButton.Contains(e.Location) || CloseButton.Contains(e.Location)) return;
+
+ // Doppio clic: uno a uno se si stava guardando l'insieme, e viceversa. È il gesto
+ // che tutti provano per primo, e qui fa la cosa che ci si aspetta.
+ if (_zoom > 0) { _zoom = 0; _centre = new PointF(0.5f, 0.5f); }
+ else
+ {
+ _zoom = 1f;
+ var target = Target();
+ _centre = new PointF(
+ target.Width > 0 ? Math.Clamp((e.X - target.Left) / target.Width, 0f, 1f) : 0.5f,
+ target.Height > 0 ? Math.Clamp((e.Y - target.Top) / target.Height, 0f, 1f) : 0.5f);
+ }
+ Invalidate();
+ base.OnMouseDoubleClick(e);
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ switch (e.KeyCode)
+ {
+ case Keys.Escape: Close(); break;
+ case Keys.I: _showInfo = !_showInfo; Invalidate(); break;
+ case Keys.D0 or Keys.NumPad0: _zoom = 0; _centre = new PointF(0.5f, 0.5f); Invalidate(); break;
+ case Keys.D1 or Keys.NumPad1: _zoom = 1f; Invalidate(); break;
+ case Keys.Add or Keys.Oemplus: _zoom = Math.Clamp(ScaleFor() * 1.25f, 0.02f, 16f); Invalidate(); break;
+ case Keys.Subtract or Keys.OemMinus: _zoom = Math.Clamp(ScaleFor() * 0.8f, 0.02f, 16f); Invalidate(); break;
+ }
+ base.OnKeyDown(e);
+ }
+
+ // ------------------------------------------------------------------ disegno
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ Theme.HighQuality(g);
+ g.Clear(BackColor);
+
+ var target = Target();
+ float scale = ScaleFor();
+ g.InterpolationMode = scale >= 1f ? InterpolationMode.NearestNeighbor : InterpolationMode.HighQualityBicubic;
+ g.PixelOffsetMode = PixelOffsetMode.Half;
+ g.DrawImage(_frame, target);
+ g.InterpolationMode = InterpolationMode.HighQualityBilinear;
+ g.PixelOffsetMode = PixelOffsetMode.HighQuality;
+
+ DrawChrome(g, scale);
+ if (_showInfo) DrawInfoPanel(g);
+ DrawFooter(g, scale);
+ }
+
+ private void DrawChrome(Graphics g, float scale)
+ {
+ TextRenderer.DrawText(g, _caption, Theme.BodyBold, new Rectangle(20, 12, Width - 400, 26),
+ Color.FromArgb(230, Color.White),
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
+
+ var info = InfoButton;
+ Theme.FillAndStroke(g, info, 5f,
+ _showInfo ? Theme.AccentDim : _hoverInfo ? Theme.SurfaceAlt : Color.FromArgb(150, Theme.Surface),
+ _showInfo ? Theme.Accent : Theme.Border);
+ TextRenderer.DrawText(g, _showInfo ? "Nascondi i dati di scatto" : "Mostra i dati di scatto",
+ Theme.Small, info, Color.FromArgb(235, Color.White),
+ TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
+
+ var close = CloseButton;
+ Theme.FillAndStroke(g, close, 5f,
+ _hoverClose ? Theme.Danger : Color.FromArgb(150, Theme.Surface), Theme.Border);
+ using var pen = new Pen(Color.White, 1.6f) { StartCap = LineCap.Round, EndCap = LineCap.Round };
+ g.DrawLine(pen, close.Left + 9, close.Top + 9, close.Right - 9, close.Bottom - 9);
+ g.DrawLine(pen, close.Right - 9, close.Top + 9, close.Left + 9, close.Bottom - 9);
+
+ _ = scale;
+ }
+
+ private void DrawFooter(Graphics g, float scale)
+ {
+ var strip = new Rectangle(0, Height - 34, Width, 34);
+ using (var fill = new SolidBrush(Color.FromArgb(220, 0, 0, 0))) g.FillRectangle(fill, strip);
+
+ TextRenderer.DrawText(g, $"{_frame.Width}×{_frame.Height} · {scale * 100:0}%",
+ Theme.Small, new Rectangle(20, strip.Y, 340, strip.Height),
+ Color.FromArgb(200, Color.White),
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
+
+ TextRenderer.DrawText(g,
+ "rotella o doppio clic per ingrandire · trascina per spostare · I dati di scatto · Esc chiude",
+ Theme.Small, new Rectangle(Width - 700, strip.Y, 680, strip.Height),
+ Color.FromArgb(140, Color.White), TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
+ }
+
+ private void DrawInfoPanel(Graphics g)
+ {
+ var rows = Rows();
+ int height = 46 + rows.Count * 21;
+ var panel = new Rectangle(Width - PanelWidth - 20, 50, PanelWidth, height);
+
+ Theme.FillAndStroke(g, panel, 6f, Color.FromArgb(232, Theme.Surface), Theme.Border);
+
+ TextRenderer.DrawText(g, "DATI DI SCATTO", Theme.SmallBold,
+ new Rectangle(panel.Left + 14, panel.Top + 9, PanelWidth - 28, 16),
+ Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
+ using (var rule = new Pen(Theme.Border))
+ g.DrawLine(rule, panel.Left + 14, panel.Top + 29, panel.Right - 14, panel.Top + 29);
+
+ int y = panel.Top + 36;
+ foreach (var (label, value) in rows)
+ {
+ TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(panel.Left + 14, y, 118, 18),
+ Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
+ TextRenderer.DrawText(g, value, Theme.SmallBold,
+ new Rectangle(panel.Left + 132, y, PanelWidth - 146, 18), Theme.Text,
+ TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
+ y += 21;
+ }
+ }
+
+ private List<(string Label, string Value)> Rows()
+ {
+ var rows = new List<(string, string)>();
+ if (_metadata is not { } m)
+ {
+ rows.Add(("Dati di scatto", "non disponibili"));
+ return rows;
+ }
+
+ rows.Add(("File", m.FileName));
+ if (m.CaptureTime is { } taken) rows.Add(("Scatto", taken.ToString("dd/MM/yyyy HH:mm:ss")));
+ rows.Add(("Origine data", m.CaptureSource.ToString()));
+ if (m.PixelWidth > 0) rows.Add(("Immagine", $"{m.PixelWidth}\u00d7{m.PixelHeight}"));
+ if (m.ExposureSeconds is { } shutter && shutter > 0) rows.Add(("Tempo", FormatShutter(shutter)));
+ if (m.FNumber is { } aperture && aperture > 0) rows.Add(("Diaframma", $"f/{aperture:0.#}"));
+ if (m.Iso is { } iso && iso > 0) rows.Add(("Sensibilit\u00e0", $"ISO {iso}"));
+ if (m.FocalLength is { } focal && focal > 0) rows.Add(("Focale", $"{focal:0.#} mm"));
+ if (m.ExposureBias is { } bias && Math.Abs(bias) > 0.01) rows.Add(("Compensazione", $"{bias:+0.0;-0.0} EV"));
+ if (!string.IsNullOrWhiteSpace(m.Camera)) rows.Add(("Fotocamera", m.Camera));
+ if (!string.IsNullOrWhiteSpace(m.Lens)) rows.Add(("Obiettivo", m.Lens));
+ if (m.Orientation > 1) rows.Add(("Orientamento", m.Orientation.ToString()));
+ if (m.FileSize > 0) rows.Add(("Dimensione", $"{m.FileSize / (1024.0 * 1024.0):0.0} MiB"));
+ if (!string.IsNullOrWhiteSpace(m.Warning)) rows.Add(("Avviso", m.Warning));
+ return rows;
+ }
+
+ private static string FormatShutter(double seconds) => seconds >= 1
+ ? $"{seconds:0.###} s"
+ : $"1/{Math.Round(1 / seconds):0} s";
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) _frame.Dispose();
+ base.Dispose(disposing);
+ }
+}
diff --git a/Titano/UI/FramingEditor.cs b/Titano/UI/FramingEditor.cs
index e3af4d4..96ef53e 100644
--- a/Titano/UI/FramingEditor.cs
+++ b/Titano/UI/FramingEditor.cs
@@ -83,7 +83,8 @@ internal sealed class FramingEditor : Control
}
}
- private RectangleF Handle
+ /// Maniglia d'angolo con cui si stringe il ritaglio.
+ private RectangleF Grip
{
get
{
@@ -100,7 +101,7 @@ internal sealed class FramingEditor : Control
if (e.Button != MouseButtons.Left) return;
Focus();
- if (Handle.Contains(e.Location)) { _draggingZoom = true; return; }
+ if (Grip.Contains(e.Location)) { _draggingZoom = true; return; }
var crop = CropRect;
if (!crop.Contains(e.Location)) return;
@@ -132,7 +133,7 @@ internal sealed class FramingEditor : Control
return;
}
- Cursor = Handle.Contains(e.Location) ? Cursors.SizeNWSE
+ Cursor = Grip.Contains(e.Location) ? Cursors.SizeNWSE
: CropRect.Contains(e.Location) ? Cursors.SizeAll
: Cursors.Default;
base.OnMouseMove(e);
diff --git a/Titano/UI/ImportPanel.cs b/Titano/UI/ImportPanel.cs
index f2c5755..0b7fc25 100644
--- a/Titano/UI/ImportPanel.cs
+++ b/Titano/UI/ImportPanel.cs
@@ -36,11 +36,17 @@ internal sealed class ImportPanel : Panel
Controls.Add(_volumes);
}
+ ///
+ /// Blocca la scelta del supporto mentre una copia è in corso. Il riquadro
+ /// dell'avanzamento continua a disegnarsi: quello va guardato, non toccato.
+ ///
+ public void SetInteractive(bool value) => _volumes.Enabled = value;
+
public ImportPlan? CurrentPlan => _current;
public int CandidateCount => _candidates.Count;
/// Rilegge l'elenco dei supporti collegati.
- public void Refresh()
+ public new void Refresh()
{
_volumes.SetVolumes(MediaImporter.Volumes(), _settings.SourcePath);
_plan.Update(_settings, _candidates, _current);
diff --git a/Titano/UI/MainForm.cs b/Titano/UI/MainForm.cs
index a5dc158..349ce04 100644
--- a/Titano/UI/MainForm.cs
+++ b/Titano/UI/MainForm.cs
@@ -35,9 +35,11 @@ internal sealed class MainForm : Form
private readonly ImportSettings _importSettings = new();
private readonly Dictionary _sections = [];
+ private readonly CommandBar _commands = new();
private Panel _sectionHost = null!;
private Panel _previewHost = null!;
private Splitter _previewSplitter = null!;
+ private Panel _operationBar = null!;
private readonly DarkProgressBar _progress = new() { Dock = DockStyle.Fill };
private readonly Label _status;
@@ -56,6 +58,15 @@ internal sealed class MainForm : Form
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 readonly DarkToggle _cropToggle = new() { Glyph = ToggleGlyph.Crop, Hint = "Maschera del ritaglio d'uscita", Checked = true };
+ private readonly DarkToggle _fullScreenToggle = new() { Glyph = ToggleGlyph.FullScreen, Hint = "Apri a schermo intero" };
+
+ /// Tutti i comandi contestuali, quelli che la riga della sezione presta e ritira.
+ private IEnumerable ToolbarButtons =>
+ [
+ _importButton, _addFilesButton, _addFolderButton, _clearButton, _analyzeButton,
+ _optimizeButton, _exportButton,
+ ];
private CancellationTokenSource? _operation;
private bool _busy;
@@ -108,6 +119,7 @@ internal sealed class MainForm : Form
// 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;
+ _rail.Collapsed = _app.RailCollapsed;
WireEvents();
_export.Configure(_app);
ShowSection(_rail.Selected);
@@ -142,8 +154,7 @@ internal sealed class MainForm : Form
private void BuildLayout()
{
- var toolbar = BuildToolbar();
- var statusBar = BuildStatusBar();
+ _operationBar = BuildOperationBar();
_settingsHost = new Panel
{
@@ -185,6 +196,10 @@ internal sealed class MainForm : Form
_sectionHost.Controls.Add(section);
}
+ // Aggiunta per ultima, la riga dei comandi si ancora per prima e resta sopra le
+ // pagine, che sono tutte a riempimento.
+ _sectionHost.Controls.Add(_commands);
+
_previewHost = Card(_preview, "Anteprima", DockStyle.Top, 330, BuildPreviewTools());
_previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
@@ -192,22 +207,26 @@ internal sealed class MainForm : Form
center.Controls.Add(_sectionHost);
center.Controls.Add(_previewSplitter);
center.Controls.Add(_previewHost);
+ center.Controls.Add(_operationBar);
+ // La barra delle sezioni è aggiunta per ultima e si ancora quindi per prima: da lì
+ // prende l'altezza intera della finestra, dal bordo superiore a quello inferiore.
+ // Tutto il resto vive alla sua destra, e anche quello dall'alto al basso.
Controls.Add(center);
Controls.Add(_settingsHost);
Controls.Add(_rail);
- Controls.Add(statusBar);
- Controls.Add(toolbar);
}
/// Interruttori dell'anteprima, ospitati nell'intestazione del riquadro.
private Panel BuildPreviewTools()
{
- var host = new Panel { BackColor = Theme.Background, Width = 4 * 30 + 8, Height = 26 };
+ var toggles = new[] { _compareToggle, _zoomToggle, _motionToggle, _regionToggle, _cropToggle, _fullScreenToggle };
+ var host = new Panel { BackColor = Theme.Background, Width = toggles.Length * 30 + 8, Height = 26 };
int x = 0;
- foreach (var toggle in new[] { _compareToggle, _zoomToggle, _motionToggle, _regionToggle })
+ foreach (var toggle in toggles)
{
toggle.Bounds = new Rectangle(x, 1, 26, 24);
+ Tips.Set(toggle, toggle.Hint);
host.Controls.Add(toggle);
x += 30;
}
@@ -251,22 +270,81 @@ internal sealed class MainForm : Form
return host;
}
- private Panel BuildToolbar()
+ ///
+ /// La riga in cima al contenuto: dove si trova l'utente, a che punto è l'operazione in
+ /// corso, e come fermarla.
+ ///
+ /// Prima qui c'era il marchio con accanto i comandi della scheda, che però stavano fuori
+ /// dal riquadro della scheda a cui appartenevano. Il marchio è sceso nella barra delle
+ /// sezioni e i comandi sono entrati nella scheda; lo spazio così liberato è andato
+ /// all'unica cosa che riguarda tutte le schede insieme — l'avanzamento delle operazioni
+ /// lunghe, che prima stava in fondo e in una seconda copia dentro la scheda esportazione.
+ ///
+ private Panel BuildOperationBar()
{
- var bar = new Panel { Dock = DockStyle.Top, Height = 58, BackColor = Theme.Background };
+ var bar = new Panel { Dock = DockStyle.Top, Height = 52, BackColor = Theme.Background };
- var title = new Label
+ _status.TextAlign = ContentAlignment.MiddleLeft;
+ _status.Dock = DockStyle.None;
+ _summary.TextAlign = ContentAlignment.MiddleRight;
+ _summary.Dock = DockStyle.None;
+ _progress.Dock = DockStyle.None;
+ _cancelButton.Dock = DockStyle.None;
+
+ bar.Controls.Add(_status);
+ bar.Controls.Add(_summary);
+ bar.Controls.Add(_progress);
+ bar.Controls.Add(_cancelButton);
+
+ Tips.Set(_cancelButton, "Ferma l'operazione in corso. Quel che è già stato scritto resta: " +
+ "un video interrotto contiene i fotogrammi già codificati ed è riproducibile.");
+
+ void Arrange()
{
- Text = "TITANO",
- Font = Theme.Title,
- ForeColor = Theme.Text,
- AutoSize = false,
- Bounds = new Rectangle(16, 12, 100, 32),
- TextAlign = ContentAlignment.MiddleLeft,
- };
+ int right = bar.Width - 16;
- foreach (var button in ToolbarButtons) bar.Controls.Add(button);
- bar.Controls.Add(title);
+ if (_cancelButton.Visible)
+ {
+ _cancelButton.Bounds = new Rectangle(right - _cancelButton.Width, 11, _cancelButton.Width, 30);
+ right -= _cancelButton.Width + 12;
+ }
+
+ int barWidth = Math.Clamp(bar.Width / 3, 160, 420);
+ if (_busy)
+ {
+ _progress.Bounds = new Rectangle(right - barWidth, 15, barWidth, 22);
+ right -= barWidth + 16;
+ _progress.Visible = true;
+ }
+ else
+ {
+ _progress.Visible = false;
+ }
+
+ // Il riepilogo prende quanto gli serve e non un pixel di più: è una riga di
+ // numeri che troncata non dice niente, ma che vuota lascerebbe spazio alla riga
+ // di stato, dove invece i messaggi lunghi capitano davvero.
+ int summaryWidth = 0;
+ if (_summary.Visible && _summary.Text.Length > 0)
+ {
+ int wanted = TextRenderer.MeasureText(_summary.Text, _summary.Font).Width + 8;
+ summaryWidth = Math.Clamp(wanted, 0, Math.Max(0, right - 220));
+ }
+ if (summaryWidth > 0)
+ {
+ _summary.Bounds = new Rectangle(right - summaryWidth, 16, summaryWidth, 20);
+ right -= summaryWidth + 16;
+ }
+
+ _status.Bounds = new Rectangle(16, 16, Math.Max(40, right - 16), 20);
+ }
+
+ bar.Resize += (_, _) => Arrange();
+ _cancelButton.VisibleChanged += (_, _) => Arrange();
+ _summary.TextChanged += (_, _) => Arrange();
+ _summary.VisibleChanged += (_, _) => Arrange();
+ bar.HandleCreated += (_, _) => Arrange();
+ _arrangeOperationBar = Arrange;
bar.Paint += (_, e) =>
{
@@ -276,68 +354,7 @@ internal sealed class MainForm : Form
return bar;
}
- /// I pulsanti della barra, nell'ordine in cui compaiono quando sono visibili.
- private IEnumerable ToolbarButtons =>
- [
- _importButton, _addFilesButton, _addFolderButton, _clearButton, _analyzeButton,
- _optimizeButton, _exportButton,
- ];
-
- ///
- /// 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 Visible: 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.
- ///
- private static void LayoutToolbar(IReadOnlyList 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 };
-
- var layout = new TableLayoutPanel
- {
- Dock = DockStyle.Fill,
- ColumnCount = 3,
- RowCount = 2,
- BackColor = Theme.Background,
- Padding = new Padding(16, 6, 16, 6),
- };
- layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 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));
-
- layout.Controls.Add(_status, 0, 0);
- layout.Controls.Add(_summary, 1, 0);
- layout.Controls.Add(_progress, 0, 1);
- layout.SetColumnSpan(_progress, 2);
-
- _cancelButton.Dock = DockStyle.Fill;
- _cancelButton.Margin = new Padding(8, 4, 0, 4);
- layout.Controls.Add(_cancelButton, 2, 0);
- layout.SetRowSpan(_cancelButton, 2);
-
- bar.Controls.Add(layout);
- bar.Paint += (_, e) =>
- {
- using var pen = new Pen(Theme.Border);
- e.Graphics.DrawLine(pen, 0, 0, bar.Width, 0);
- };
- return bar;
- }
+ private Action _arrangeOperationBar = () => { };
// ------------------------------------------------------------------ eventi
@@ -348,7 +365,7 @@ internal sealed class MainForm : Form
_clearButton.Click += (_, _) => ClearSequence();
_analyzeButton.Click += async (_, _) => await AnalyzeAsync();
_exportButton.Click += async (_, _) => await ExportAsync();
- _cancelButton.Click += (_, _) => _operation?.Cancel();
+ _cancelButton.Click += (_, _) => CancelOperation();
_rail.SelectionChanged += (_, _) => ShowSection(_rail.Selected);
@@ -372,6 +389,16 @@ internal sealed class MainForm : Form
_compareToggle.CheckedChanged += (_, _) => _preview.SetCompareMode(_compareToggle.Checked);
_motionToggle.CheckedChanged += (_, _) => _preview.SetMotionField(_motionToggle.Checked);
_regionToggle.CheckedChanged += (_, _) => _preview.SetRegionOverlay(_regionToggle.Checked);
+ _cropToggle.CheckedChanged += (_, _) => _preview.SetCropOverlay(_cropToggle.Checked);
+
+ _fullScreenToggle.Momentary = true;
+ _fullScreenToggle.Pressed += (_, _) => OpenFullScreen();
+
+ _rail.CollapsedChanged += (_, _) =>
+ {
+ _app.RailCollapsed = _rail.Collapsed;
+ _app.Save();
+ };
_zoomToggle.CheckedChanged += (_, _) =>
{
if (_zoomToggle.Checked) _preview.ToggleZoom(); else _preview.ResetView();
@@ -391,10 +418,14 @@ internal sealed class MainForm : Form
DragEnter += (_, e) =>
{
- if (e.Data?.GetDataPresent(DataFormats.FileDrop) == true) e.Effect = DragDropEffects.Copy;
+ // Durante un'operazione di massa il trascinamento non viene accettato: la
+ // registrazione OLE resta invece com'è, perché accenderla e spegnerla a ogni
+ // esportazione è una chiamata di sistema che può fallire.
+ if (!_busy && e.Data?.GetDataPresent(DataFormats.FileDrop) == true) e.Effect = DragDropEffects.Copy;
};
DragDrop += (_, e) =>
{
+ if (_busy) return;
if (e.Data?.GetData(DataFormats.FileDrop) is string[] items) _ = LoadAsync(ExpandPaths(items));
};
}
@@ -411,6 +442,20 @@ internal sealed class MainForm : Form
_export.Configure(_app);
};
_settings.ImportChanged += (_, _) => _import.RebuildPlan();
+ _settings.LayoutInvalidated += (_, _) =>
+ {
+ // La richiesta arriva da dentro un controllo della colonna, e ricostruire la
+ // colonna significa distruggere quel controllo mentre il suo gestore è ancora
+ // sullo stack. Si rimanda al giro successivo del ciclo dei messaggi, quando
+ // nessuno lo sta più usando.
+ BeginInvoke(() =>
+ {
+ RebuildSettingsPanel();
+ RecomputeCurve();
+ RefreshDerived();
+ ShowPreview(_table.SelectedIndex);
+ });
+ };
}
private void ShowSection(WorkspaceSection section)
@@ -427,7 +472,7 @@ internal sealed class MainForm : Form
_previewSplitter.Visible = sequenceContext;
_summary.Visible = sequenceContext;
- UpdateToolbarFor(section);
+ UpdateCommandsFor(section);
if (section == WorkspaceSection.Export) _export.Refresh(_project);
if (section == WorkspaceSection.Archive) _import.Refresh();
@@ -441,22 +486,17 @@ internal sealed class MainForm : Form
}
///
- /// 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.
+ /// I comandi seguono la sezione, dentro 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.
+ ///
+ /// La spiegazione di ciascuno arriva a comparsa. Un pulsante che avvia mezz'ora di
+ /// lavoro merita di dire prima cosa farà, e l'etichetta da sola non basta: «Analizza
+ /// sequenza» non dice che rilegge ogni file, né che dopo l'automatismo saprà decidere.
///
- private void UpdateToolbarFor(WorkspaceSection section)
+ private void UpdateCommandsFor(WorkspaceSection section)
{
bool onSequence = section == WorkspaceSection.Sequence;
- bool onArchive = section == WorkspaceSection.Archive;
-
- var shown = new List();
- 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
{
@@ -467,7 +507,110 @@ internal sealed class MainForm : Form
_ => "Ottimizza esportazione",
};
- LayoutToolbar(shown);
+ var shown = new List<(DarkButton, string)>();
+
+ if (section == WorkspaceSection.Archive)
+ {
+ shown.Add((_importButton, "Legge il supporto scelto, cerca le immagini anche nelle sottocartelle " +
+ "e prepara il piano di copia. Non tocca niente finché il piano non viene eseguito."));
+ }
+
+ if (onSequence)
+ {
+ shown.Add((_addFilesButton, "Aggiunge singoli file alla sequenza. L'ordine viene dagli scatti, " +
+ "non da come li selezioni."));
+ shown.Add((_addFolderButton, "Aggiunge tutte le immagini riconosciute di una cartella. " +
+ "Si può anche trascinare la cartella sulla finestra."));
+ shown.Add((_clearButton, "Toglie tutti i fotogrammi. I file sul disco non vengono toccati."));
+ shown.Add((_analyzeButton, "Rilegge ogni fotogramma a bassa risoluzione e misura luminanza, " +
+ "spostamenti e transizioni. È il passo che rende possibile tutto il resto: " +
+ "senza analisi il deflicker e la stabilizzazione non hanno su cosa lavorare."));
+ }
+
+ if (section is not (WorkspaceSection.Preferences or WorkspaceSection.Archive))
+ {
+ shown.Add((_optimizeButton, "Deduce dalle misure della sequenza i valori migliori per questa sola " +
+ "sezione e li applica, dicendo cosa ha cambiato. Non tocca le altre sezioni " +
+ "e non fa nulla che non si possa correggere a mano subito dopo."));
+ }
+
+ if (section == WorkspaceSection.Export)
+ {
+ shown.Add((_exportButton, "Elabora la sequenza intera e la codifica nel file di destinazione. " +
+ "È l'operazione lunga: si può seguire dalla barra in alto e fermare da lì."));
+ }
+
+ foreach (var button in ToolbarButtons)
+ {
+ if (!shown.Any(entry => entry.Item1 == button)) button.Visible = false;
+ }
+
+ _commands.Show(SectionCaption(section), shown);
+ }
+
+ private static string SectionCaption(WorkspaceSection section) => section switch
+ {
+ WorkspaceSection.Archive => "Importazione e archiviazione",
+ WorkspaceSection.Sequence => "Fotogrammi della sequenza",
+ WorkspaceSection.Exposure => "Esposizione e deflicker",
+ WorkspaceSection.Motion => "Movimento e stabilizzazione",
+ WorkspaceSection.Timing => "Andamento temporale",
+ WorkspaceSection.Export => "Esportazione",
+ _ => "Impostazioni dell'applicazione",
+ };
+
+ ///
+ /// Apre il fotogramma selezionato in una finestra a schermo intero.
+ ///
+ /// Non riusa la bitmap dell'anteprima: quella è grande quanto il riquadro, e ingrandirla
+ /// mostrerebbe i suoi pixel invece di quelli della fotografia. Si rende da capo alla
+ /// risoluzione dello schermo, il che costa qualche secondo su un RAW da venti megapixel
+ /// ed è esattamente il motivo per cui l'anteprima non lo fa già sempre.
+ ///
+ private async void OpenFullScreen()
+ {
+ if (_busy || _project.Sequence is not { Count: > 0 } sequence) return;
+
+ int index = Math.Clamp(_table.SelectedIndex, 0, sequence.Count - 1);
+ // Si chiede la risoluzione nativa, non quella dello schermo: a schermo intero si va
+ // per guardare i dettagli, e un ingrandimento oltre il cento per cento su un
+ // fotogramma già ridotto mostrerebbe l'interpolazione invece della fotografia. Il
+ // tetto serve solo a non chiedere mezzo gigabyte a un sensore da cento megapixel.
+ var (nativeWidth, nativeHeight) = _project.ResolveNativeSize();
+ var available = nativeWidth > 0 && nativeHeight > 0
+ ? new Size(Math.Min(6000, nativeWidth), Math.Min(6000, nativeHeight))
+ : new Size(1920, 1080);
+ var project = _project;
+ bool cropOverlay = _preview.ShowCrop;
+
+ var previous = Cursor;
+ Cursor = Cursors.WaitCursor;
+ SetStatus("Preparazione del fotogramma a schermo intero\u2026");
+ try
+ {
+ var bitmap = await Task.Run(() => PreviewPanel.RenderForViewer(
+ project, sequence, index, available, cropOverlay, CancellationToken.None));
+
+ if (bitmap is null)
+ {
+ SetStatus("Fotogramma non leggibile.");
+ return;
+ }
+
+ // La finestra possiede la bitmap e la libera chiudendosi.
+ var viewer = new FrameViewer(bitmap, sequence.Frames[index].Metadata,
+ $"#{index + 1} {sequence.Frames[index].FileName}");
+ viewer.Show(this);
+ SetStatus($"Fotogramma {index + 1} a schermo intero. Esc chiude, doppio clic ingrandisce.");
+ }
+ catch (Exception ex)
+ {
+ SetStatus("Impossibile aprire il fotogramma: " + ex.Message);
+ }
+ finally
+ {
+ Cursor = previous;
+ }
}
/// Allinea tutte le viste sullo stesso fotogramma, qualunque l'abbia scelto.
@@ -769,6 +912,7 @@ internal sealed class MainForm : Form
_export.BeginExport();
BeginOperation("Elaborazione e codifica…");
+ bool failed = false;
try
{
var pipeline = new RenderPipeline(_project);
@@ -814,13 +958,14 @@ internal sealed class MainForm : Form
}
catch (Exception ex)
{
+ failed = true;
_export.ShowFailure(ex.Message);
SetStatus("Esportazione non riuscita: " + ex.Message);
}
finally
{
_preview.EndLiveFrames();
- EndOperation();
+ EndOperation(failed);
}
}
@@ -973,8 +1118,22 @@ internal sealed class MainForm : Form
(_project.IsAnalyzed ? " · analizzata" : string.Empty);
}
+ ///
+ /// La finestra c'è ancora e i suoi controlli sono vivi.
+ ///
+ /// Un'esportazione annullata continua a sbrogliarsi per qualche istante — la codifica
+ /// chiude il contenitore, la finestra dei fotogrammi aspetta le decodifiche in volo — e
+ /// in quegli istanti l'utente può benissimo chiudere il programma, che è anzi la cosa
+ /// più naturale da fare dopo aver annullato. Quando la coda dell'operazione arriva al
+ /// suo epilogo i controlli non esistono più, e scrivere sulla riga di stato o nascondere
+ /// un pulsante solleva un'eccezione dentro un gestore asincrono, dove non c'è nessuno a
+ /// raccoglierla. Non è un errore da segnalare: è lavoro che non serve più a nessuno.
+ ///
+ private bool Alive => !IsDisposed && !Disposing;
+
private void ReportProgress(PipelineProgress progress)
{
+ if (!Alive) return;
_progress.Fraction = progress.Fraction;
string detail = progress.Total > 0 ? $" {progress.Completed}/{progress.Total}" : string.Empty;
string speed = progress.FramesPerSecond > 0.01
@@ -982,41 +1141,102 @@ internal sealed class MainForm : Form
: string.Empty;
_status.Text = progress.Message + detail + speed;
_export.ReportProgress(progress);
+ TaskbarProgress.Show(this, progress.Fraction);
}
private void SetStatus(string message)
{
+ if (!Alive) return;
_status.Text = message;
_progress.Fraction = 0;
}
private void BeginOperation(string message)
{
+ if (!Alive) return;
_busy = true;
_operation?.Dispose();
_operation = new CancellationTokenSource();
_status.Text = message;
_progress.Fraction = 0;
_cancelButton.Visible = true;
+ TaskbarProgress.Show(this, 0);
UpdateCommandState();
+ _arrangeOperationBar();
}
- private void EndOperation()
+ private void EndOperation(bool failed = false)
{
_busy = false;
+ if (!Alive) return;
_cancelButton.Visible = false;
_progress.Fraction = 0;
+
+ // Un'esportazione fallita mentre si stava lavorando altrove lascia il segno
+ // sull'icona: chi torna alla scrivania lo vede senza dover riaprire la finestra.
+ if (failed) TaskbarProgress.ShowFailed(this);
+ else TaskbarProgress.Clear(this);
UpdateCommandState();
+ _arrangeOperationBar();
}
+ ///
+ /// Cosa si può toccare mentre un'operazione di massa è in corso.
+ ///
+ /// Durante un'esportazione il progetto è letto da un altro thread a ogni fotogramma:
+ /// cambiare risoluzione, ritaglio o profilo di qualità mentre succede significa
+ /// cambiare le regole a metà partita, e l'esito va dal video incoerente all'errore. Per
+ /// questo la colonna delle impostazioni, i comandi che rileggono i file e gli
+ /// interruttori dell'anteprima che ne fanno ricalcolare uno restano spenti finché non
+ /// finisce. Restano invece vivi il pulsante che ferma l'operazione, la barra delle
+ /// sezioni e tutto ciò che si limita a guardare.
+ ///
private void UpdateCommandState()
{
+ if (!Alive) return;
bool hasSequence = _project.HasSequence;
_addFilesButton.Enabled = !_busy;
_addFolderButton.Enabled = !_busy;
_clearButton.Enabled = !_busy && hasSequence;
_analyzeButton.Enabled = !_busy && hasSequence;
_exportButton.Enabled = !_busy && hasSequence;
+ _importButton.Enabled = !_busy;
+ _optimizeButton.Enabled = !_busy;
+
+ _settings.Enabled = !_busy;
+ _table.Enabled = !_busy;
+ _timeline.Enabled = !_busy;
+ _chart.Enabled = !_busy;
+ _motionPath.Enabled = !_busy;
+
+ _import.SetInteractive(!_busy);
+
+ _compareToggle.Enabled = !_busy;
+ _zoomToggle.Enabled = !_busy;
+ _motionToggle.Enabled = !_busy;
+ _regionToggle.Enabled = !_busy;
+ _cropToggle.Enabled = !_busy;
+ }
+
+ ///
+ /// Ferma l'operazione in corso.
+ ///
+ /// Cancel esegue le proprie registrazioni sul thread che lo chiama — qui quello
+ /// dell'interfaccia — e se una di esse solleva un'eccezione la rilancia da dentro il
+ /// gestore del clic, dove non c'è nessuno a raccoglierla e la finestra muore. Il lavoro
+ /// da fermare è già stato segnato come tale in ogni caso: quel che resta è al più un
+ /// compito che stava chiudendo, e non è un motivo per perdere la sessione.
+ ///
+ private void CancelOperation()
+ {
+ try
+ {
+ _operation?.Cancel();
+ }
+ catch (Exception ex) when (ex is AggregateException or ObjectDisposedException)
+ {
+ SetStatus("Operazione interrotta.");
+ }
}
protected override void OnFormClosing(FormClosingEventArgs e)
diff --git a/Titano/UI/NavigationRail.cs b/Titano/UI/NavigationRail.cs
index 88bdd68..f653c2e 100644
--- a/Titano/UI/NavigationRail.cs
+++ b/Titano/UI/NavigationRail.cs
@@ -29,7 +29,12 @@ internal sealed class NavigationRail : Control
private const int ItemHeight = 52;
private const int GlyphSize = 20;
- private const int TopPadding = 10;
+ private const int HeaderHeight = 58;
+ private const int TopPadding = HeaderHeight + 8;
+
+ /// Larghezze dei due stati. Ridotta tiene il simbolo e basta.
+ public const int ExpandedWidth = 176;
+ public const int CollapsedWidth = 56;
private readonly Item[] _items =
[
@@ -44,20 +49,41 @@ internal sealed class NavigationRail : Control
private int _selected;
private int _hovered = -1;
+ private bool _hoveredToggle;
+ private bool _collapsed;
private readonly Dictionary _badges = [];
public event EventHandler? SelectionChanged;
+ public event EventHandler? CollapsedChanged;
public NavigationRail()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
- Width = 158;
+ Width = ExpandedWidth;
Cursor = Cursors.Hand;
TabStop = true;
}
+ ///
+ /// Riduce la barra ai soli simboli. Le etichette scompaiono ma non l'informazione: passando
+ /// sopra una voce compare il suo nome insieme a cosa contiene, che è più di quanto
+ /// l'etichetta da sola dicesse.
+ ///
+ public bool Collapsed
+ {
+ get => _collapsed;
+ set
+ {
+ if (_collapsed == value) return;
+ _collapsed = value;
+ Width = value ? CollapsedWidth : ExpandedWidth;
+ Invalidate();
+ CollapsedChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
public WorkspaceSection Selected
{
get => _items[Math.Clamp(_selected, 0, _items.Length - 1)].Section;
@@ -93,20 +119,34 @@ internal sealed class NavigationRail : Control
return index >= 0 && index < _items.Length ? index : -1;
}
+ private Rectangle ToggleBounds => _collapsed
+ ? new Rectangle((Width - 26) / 2, 16, 26, 26)
+ : new Rectangle(Width - 34, 16, 26, 26);
+
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.Y);
- if (index != _hovered)
+ bool toggle = ToggleBounds.Contains(e.Location);
+
+ if (index != _hovered || toggle != _hoveredToggle)
{
_hovered = index;
+ _hoveredToggle = toggle;
Invalidate();
}
+
+ // Con la barra ridotta il nome della sezione esiste solo qui.
+ Tips.Follow(this, toggle
+ ? (_collapsed ? "Mostra i nomi delle sezioni" : "Riduci la barra ai soli simboli")
+ : index >= 0 ? $"{_items[index].Label} — {_items[index].Hint}" : string.Empty);
+
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = -1;
+ _hoveredToggle = false;
Invalidate();
base.OnMouseLeave(e);
}
@@ -114,6 +154,8 @@ internal sealed class NavigationRail : Control
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
+ if (ToggleBounds.Contains(e.Location)) { Collapsed = !Collapsed; return; }
+
int index = IndexAt(e.Y);
if (index >= 0) Selected = _items[index].Section;
base.OnMouseDown(e);
@@ -140,6 +182,10 @@ internal sealed class NavigationRail : Control
Theme.HighQuality(g);
g.Clear(Theme.Background);
+ DrawHeader(g);
+
+ int glyphLeft = _collapsed ? (Width - GlyphSize) / 2 : 20;
+
for (int i = 0; i < _items.Length; i++)
{
var item = _items[i];
@@ -162,26 +208,77 @@ internal sealed class NavigationRail : Control
}
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);
+ DrawGlyph(g, item.Section,
+ new Rectangle(glyphLeft, 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 (!_collapsed)
+ {
+ 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);
+ if (_badges.TryGetValue(item.Section, out int badge)) DrawBadge(g, bounds, badge, _collapsed);
}
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)
+ ///
+ /// Il marchio e l'interruttore di riduzione. Il nome del programma sta qui e non su una
+ /// riga sua in cima alla finestra: quella riga toglieva alla scheda l'altezza intera, che
+ /// è la cosa che serve davvero a chi guarda una tabella o un grafico.
+ ///
+ private void DrawHeader(Graphics g)
+ {
+ if (!_collapsed)
+ {
+ TextRenderer.DrawText(g, "TITANO", Theme.Title, new Rectangle(18, 14, Width - 54, 30),
+ Theme.Text, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
+ }
+
+ var box = ToggleBounds;
+ if (_hoveredToggle) Theme.FillRounded(g, box, 5f, Theme.SurfaceAlt);
+
+ using var pen = new Pen(_hoveredToggle ? Theme.Text : Theme.TextFaint, 1.6f)
+ {
+ StartCap = LineCap.Round,
+ EndCap = LineCap.Round,
+ LineJoin = LineJoin.Round,
+ };
+
+ // Due barrette verticali e una freccia: dice "colonna che si stringe" senza etichetta.
+ float cx = box.Left + box.Width / 2f;
+ float cy = box.Top + box.Height / 2f;
+ g.DrawLine(pen, cx - 6.5f, cy - 5.5f, cx - 6.5f, cy + 5.5f);
+
+ float tip = _collapsed ? 4.5f : -1.5f;
+ float tail = _collapsed ? -1.5f : 4.5f;
+ g.DrawLine(pen, cx + tail, cy, cx + tip, cy);
+ g.DrawLines(pen,
+ [
+ new PointF(cx + tip + (_collapsed ? -3.5f : 3.5f), cy - 3.5f),
+ new PointF(cx + tip, cy),
+ new PointF(cx + tip + (_collapsed ? -3.5f : 3.5f), cy + 3.5f),
+ ]);
+
+ using var separator = new Pen(Theme.Border);
+ g.DrawLine(separator, 8, HeaderHeight, Width - 8, HeaderHeight);
+ }
+
+ private static void DrawBadge(Graphics g, Rectangle bounds, int count, bool collapsed)
{
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);
+
+ // Ridotta, la pastiglia si appoggia al simbolo invece di stare sul bordo destro:
+ // lì avrebbe coperto metà del disegno.
+ var pill = collapsed
+ ? new RectangleF(bounds.Right - width - 6, bounds.Y + 8, width, 15)
+ : 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),
diff --git a/Titano/UI/PreviewPanel.cs b/Titano/UI/PreviewPanel.cs
index 2d9e138..e2bace9 100644
--- a/Titano/UI/PreviewPanel.cs
+++ b/Titano/UI/PreviewPanel.cs
@@ -28,6 +28,7 @@ internal sealed class PreviewPanel : Control
private Bitmap? _before;
private PointF[]? _regionContour;
private MotionArrows? _arrows;
+ private RectangleF? _cropRect;
private string _caption = string.Empty;
private string _status = "Nessun fotogramma selezionato";
@@ -64,6 +65,17 @@ internal sealed class PreviewPanel : Control
/// Disegna il confine fra le regioni del deflicker.
public bool ShowRegions { get; private set; } = true;
+ ///
+ /// Mostra il fotogramma intero con sopra la maschera di ciò che il formato d'uscita
+ /// taglia via, invece del solo risultato ritagliato.
+ ///
+ /// Sono due domande diverse e servono in momenti diversi. Come verrà si risponde
+ /// guardando l'uscita, ed è quello che serve mentre si regola l'esposizione. Cosa si
+ /// perde si risponde solo vedendo anche quello che resta fuori, ed è quello che serve
+ /// mentre si sceglie il rapporto d'immagine o si sposta il ritaglio.
+ ///
+ public bool ShowCrop { get; private set; } = true;
+
public bool IsZoomed => _zoom > 0;
public string ZoomText => _zoom <= 0 ? "adatta" : $"{_zoom * 100:0}%";
@@ -92,6 +104,18 @@ internal sealed class PreviewPanel : Control
Invalidate();
}
+ public void SetCropOverlay(bool value)
+ {
+ if (ShowCrop == value) return;
+ ShowCrop = value;
+ RequestRefresh();
+ }
+
+ /// Il fotogramma attualmente mostrato, per chi lo vuole aprire più in grande.
+ public Bitmap? CurrentFrame => _after;
+
+ public string CurrentCaption => _caption;
+
/// Alterna fra adattamento al riquadro e scala uno a uno.
public void ToggleZoom()
{
@@ -123,6 +147,7 @@ internal sealed class PreviewPanel : Control
SwapBitmaps(null, null);
_regionContour = null;
_arrows = null;
+ _cropRect = null;
_live = false;
_caption = string.Empty;
_status = "Nessun fotogramma selezionato";
@@ -141,6 +166,7 @@ internal sealed class PreviewPanel : Control
SwapBitmaps(frame, null);
_regionContour = null;
_arrows = null;
+ _cropRect = null;
_live = true;
_busy = false;
_caption = caption;
@@ -170,12 +196,13 @@ internal sealed class PreviewPanel : Control
var token = source.Token;
bool compare = CompareMode;
bool motion = ShowMotionField;
+ bool cropOverlay = ShowCrop;
_ = Task.Run(() =>
{
try
{
- var output = Render(project, sequence, index, PreviewSize(), compare, motion, token);
+ var output = Render(project, sequence, index, PreviewSize(), compare, motion, cropOverlay, token);
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
{
output.After?.Dispose();
@@ -195,6 +222,7 @@ internal sealed class PreviewPanel : Control
SwapBitmaps(output.After, output.Before);
_regionContour = output.Contour;
_arrows = output.Arrows;
+ _cropRect = output.Crop;
_status = output.Status;
_busy = false;
ScopesChanged?.Invoke(this, output.Scopes);
@@ -220,6 +248,21 @@ internal sealed class PreviewPanel : Control
}, token);
}
+ ///
+ /// Rende il fotogramma alla risoluzione richiesta, in modo sincrono, per chi lo vuole
+ /// aprire a schermo intero. Passa dalla stessa pipeline dell'anteprima: quello che si
+ /// guarda ingrandito è il fotogramma elaborato da Titano, non l'interpretazione del RAW
+ /// di un altro programma.
+ ///
+ public static Bitmap? RenderForViewer(TitanoProject project, TimelapseSequence sequence, int index,
+ Size available, bool cropOverlay, CancellationToken token)
+ {
+ if (index < 0 || index >= sequence.Count) return null;
+ var output = Render(project, sequence, index, available, false, false, cropOverlay, token);
+ output.Before?.Dispose();
+ return output.After;
+ }
+
private Size PreviewSize()
{
// Con lo zoom attivo si decodifica più grande del riquadro: guardare un pixel
@@ -243,10 +286,11 @@ internal sealed class PreviewPanel : Control
// ------------------------------------------------------------------ rendering
private sealed record RenderOutput(Bitmap? After, Bitmap? Before, PointF[]? Contour,
- MotionArrows? Arrows, FrameScopes? Scopes, string Status);
+ MotionArrows? Arrows, FrameScopes? Scopes, string Status,
+ RectangleF? Crop = null);
private static RenderOutput Render(TitanoProject project, TimelapseSequence sequence, int index,
- Size available, bool compare, bool motionField,
+ Size available, bool compare, bool motionField, bool cropOverlay,
CancellationToken token)
{
var record = sequence.Frames[index];
@@ -324,23 +368,50 @@ internal sealed class PreviewPanel : Control
var mapping = SourceMapping.Identity;
int framedWidth = width, framedHeight = height;
+ RectangleF? cropRect = null;
+
if (project.NeedsGeometry)
{
var (outputWidth, outputHeight) = project.ResolveWorkingSize();
if (outputWidth > 0 && outputHeight > 0)
{
- 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) ?? SimilarityTransform.Identity;
- mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization);
- framed = pool.Rent(framedWidth, framedHeight);
- GeometryStage.Resample(result, framed, mapping);
- result = framed;
+ var (cropLeft, cropTop, cropWidth, cropHeight) =
+ GeometryStage.CropRect(width, height, outputWidth, outputHeight, framing);
+ bool cuts = cropLeft > 0.5 || cropTop > 0.5 ||
+ cropLeft + cropWidth < width - 0.5 || cropTop + cropHeight < height - 0.5;
- status.Append($" inquadratura {framing.Zoom:0.00}×");
+ if (cropOverlay && cuts)
+ {
+ // Il fotogramma resta intero: si applica la sola stabilizzazione, così il
+ // rettangolo disegnato sopra cade dove cadrà davvero, e ciò che verrà
+ // tagliato si vede invece di essere già sparito.
+ if (!stabilization.IsIdentity)
+ {
+ mapping = GeometryStage.Build(width, height, width, height,
+ CameraFraming.Full, stabilization);
+ framed = pool.Rent(width, height);
+ GeometryStage.Resample(result, framed, mapping);
+ result = framed;
+ }
+
+ cropRect = new RectangleF((float)(cropLeft / width), (float)(cropTop / height),
+ (float)(cropWidth / width), (float)(cropHeight / height));
+ status.Append($" ritaglio {framing.Zoom:0.00}× su {outputWidth}×{outputHeight}");
+ }
+ else
+ {
+ framedHeight = Math.Max(2, (int)Math.Round(width * outputHeight / (double)outputWidth) & ~1);
+ mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization);
+ framed = pool.Rent(framedWidth, framedHeight);
+ GeometryStage.Resample(result, framed, mapping);
+ result = framed;
+
+ status.Append($" inquadratura {framing.Zoom:0.00}×");
+ }
}
}
@@ -354,7 +425,7 @@ internal sealed class PreviewPanel : Control
blurred?.Dispose();
framed?.Dispose();
- return new RenderOutput(bitmap, before, contour, arrows, scopes, status.ToString());
+ return new RenderOutput(bitmap, before, contour, arrows, scopes, status.ToString(), cropRect);
}
///
@@ -689,6 +760,7 @@ internal sealed class PreviewPanel : Control
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
+ if (_cropRect is { } crop) DrawCropMask(g, target, crop);
if (ShowRegions && _regionContour is { Length: >= 2 }) DrawRegionContour(g, target);
if (ShowMotionField && _arrows is not null) DrawMotionField(g, target);
@@ -735,6 +807,40 @@ internal sealed class PreviewPanel : Control
Color.FromArgb(210, Color.White), TextFormatFlags.Right);
}
+ ///
+ /// Scurisce quel che il formato scelto lascia fuori e traccia il bordo di quel che resta.
+ /// È la stessa lettura del riquadro d'inquadratura nella colonna, ma sulla fotografia
+ /// vera: il rapporto d'immagine si sceglie guardando cosa si perde, non un rettangolo
+ /// astratto.
+ ///
+ private static void DrawCropMask(Graphics g, RectangleF target, RectangleF crop)
+ {
+ var kept = new RectangleF(target.Left + crop.X * target.Width,
+ target.Top + crop.Y * target.Height,
+ crop.Width * target.Width, crop.Height * target.Height);
+
+ using (var shade = new SolidBrush(Color.FromArgb(150, 0, 0, 0)))
+ {
+ g.FillRectangle(shade, target.Left, target.Top, target.Width, kept.Top - target.Top);
+ g.FillRectangle(shade, target.Left, kept.Bottom, target.Width, target.Bottom - kept.Bottom);
+ g.FillRectangle(shade, target.Left, kept.Top, kept.Left - target.Left, kept.Height);
+ g.FillRectangle(shade, kept.Right, kept.Top, target.Right - kept.Right, kept.Height);
+ }
+
+ using var edge = new Pen(Color.FromArgb(225, Color.White), 1.4f);
+ g.DrawRectangle(edge, kept.X, kept.Y, kept.Width, kept.Height);
+
+ // I terzi aiutano a decidere dove mettere il centro molto più del solo bordo.
+ using var guide = new Pen(Color.FromArgb(70, Color.White), 1f);
+ for (int i = 1; i <= 2; i++)
+ {
+ float x = kept.Left + kept.Width * i / 3f;
+ float y = kept.Top + kept.Height * i / 3f;
+ g.DrawLine(guide, x, kept.Top, x, kept.Bottom);
+ g.DrawLine(guide, kept.Left, y, kept.Right, y);
+ }
+ }
+
private void DrawRegionContour(Graphics g, RectangleF target)
{
var contour = _regionContour!;
diff --git a/Titano/UI/SettingsPanel.cs b/Titano/UI/SettingsPanel.cs
index 3cabe11..3a72345 100644
--- a/Titano/UI/SettingsPanel.cs
+++ b/Titano/UI/SettingsPanel.cs
@@ -31,6 +31,8 @@ internal sealed class SettingsPanel : Panel
private readonly Dictionary _autoSliders = [];
private LabeledCombo? _orientationCombo;
+ private LabeledCombo? _presetCombo;
+ private bool _applyingPreset;
private Label? _analysisNote;
private TextBox? _importSourceBox;
private CameraEditor? _cameraEditor;
@@ -50,6 +52,9 @@ internal sealed class SettingsPanel : Panel
public event EventHandler? AppSettingsChanged;
public event EventHandler? ImportChanged;
+ /// La colonna va ricostruita: i valori dei controlli non sono più quelli del progetto.
+ public event EventHandler? LayoutInvalidated;
+
public TextBox OutputPathBox { get; }
public SettingsPanel(TitanoProject project, AppSettings app, ImportSettings import)
@@ -1000,10 +1005,58 @@ internal sealed class SettingsPanel : Panel
// ------------------------------------------------------------------ Esportazione
+
+ ///
+ /// Riporta la casella delle preimpostazioni su quel che le impostazioni dicono davvero.
+ ///
+ /// Toccare a mano il bitrate di «1080p — condivisione» produce una combinazione che
+ /// quella voce non descrive più, e lasciarla selezionata sarebbe un'etichetta falsa su
+ /// un contenuto diverso. Se invece i valori tornano per caso a coincidere con una
+ /// preimpostazione, quella riprende il suo nome: è la stessa cosa, e chiamarla
+ /// «Personalizzata» sarebbe altrettanto falso.
+ ///
+ private void SyncPresetSelection()
+ {
+ if (_presetCombo is null || _applyingPreset) return;
+
+ int wanted = ExportPresets.IndexOf(_project.Export) + 1;
+ if (_presetCombo.Combo.SelectedIndex == wanted) return;
+
+ _applyingPreset = true;
+ _presetCombo.Combo.SelectedIndex = wanted;
+ _applyingPreset = false;
+ }
+
private Panel BuildExportPage()
{
var stack = NewStack();
+ stack.Add(new SectionHeader("Preimpostazione"));
+
+ string[] presetNames = ["Personalizzata", .. ExportPresets.All.Select(preset => preset.Name)];
+ int currentPreset = ExportPresets.IndexOf(_project.Export) + 1;
+
+ _presetCombo = Combo("Qualità e risoluzione", presetNames, currentPreset,
+ "Sceglie insieme risoluzione, rapporto, codec, cadenza e bitrate, che insieme " +
+ "vanno scelti: il bitrate giusto a 1080p lascia gradini sul cielo a 4K, e il " +
+ "profilo che sta su un televisore vecchio non è quello da archivio. Ogni valore " +
+ "resta poi modificabile qui sotto; appena se ne tocca uno la voce torna a " +
+ "«Personalizzata», perché la combinazione non è più quella dichiarata.\n\n" +
+ string.Join("\n\n", ExportPresets.All.Select(preset => preset.Name + " — " + preset.Description)),
+ index =>
+ {
+ if (index <= 0 || _applyingPreset) return;
+ _applyingPreset = true;
+ ExportPresets.All[index - 1].ApplyTo(_project.Export);
+
+ // I controlli sottostanti hanno letto il progetto quando sono nati: senza
+ // rifarli mostrerebbero i valori di prima accanto a una preimpostazione che
+ // dice altro.
+ LayoutInvalidated?.Invoke(this, EventArgs.Empty);
+ AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
+ });
+ stack.Add(_presetCombo);
+
stack.Add(new SectionHeader("Risoluzione e inquadratura"));
stack.Add(Combo("Rapporto d'uscita",
@@ -1016,6 +1069,7 @@ internal sealed class SettingsPanel : Panel
index =>
{
_project.Export.AspectRatio = AspectValues[Math.Clamp(index, 0, AspectValues.Length - 1)];
+ SyncPresetSelection();
if (_framingEditor is not null)
{
_framingEditor.OutputAspect = _project.OutputAspect;
@@ -1033,6 +1087,7 @@ internal sealed class SettingsPanel : Panel
{
_project.Export.Width = (int)value;
_project.Export.Height = 0;
+ SyncPresetSelection();
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
@@ -1068,33 +1123,52 @@ internal sealed class SettingsPanel : Panel
index =>
{
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
+ SyncPresetSelection();
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
"High è la scelta giusta salvo vincoli di compatibilità con apparecchi vecchi.",
- index => _project.Export.Profile = index switch
+ index =>
{
- 0 => H264Profile.Baseline,
- 1 => H264Profile.Main,
- _ => H264Profile.High,
+ _project.Export.Profile = index switch
+ {
+ 0 => H264Profile.Baseline,
+ 1 => H264Profile.Main,
+ _ => H264Profile.High,
+ };
+ SyncPresetSelection();
}));
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
"Velocità del filmato finale. Non cambia quanti scatti vengono usati, solo quanto " +
"in fretta scorrono.",
- value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
+ value =>
+ {
+ _project.Export.FrameRate = value;
+ SyncPresetSelection();
+ PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
"Quanti dati al secondo. Un cielo notturno sfumato è il contenuto che ne chiede di " +
"più: è lì che un bitrate basso produce il banding.",
- value => { _project.Export.BitrateMbps = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
+ value =>
+ {
+ _project.Export.BitrateMbps = value;
+ SyncPresetSelection();
+ PreviewInvalidated?.Invoke(this, EventArgs.Empty);
+ }));
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
"Ogni quanto inserire un fotogramma indipendente. Più fitti rendono lo " +
"scorrimento più reattivo e occupano più spazio.",
- value => _project.Export.KeyframeIntervalSeconds = (int)value));
+ value =>
+ {
+ _project.Export.KeyframeIntervalSeconds = (int)value;
+ SyncPresetSelection();
+ }));
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
"Usa l'acceleratore della scheda video quando c'è: è molto più veloce. " +
diff --git a/Titano/UI/TaskbarProgress.cs b/Titano/UI/TaskbarProgress.cs
new file mode 100644
index 0000000..9c5bf4a
--- /dev/null
+++ b/Titano/UI/TaskbarProgress.cs
@@ -0,0 +1,137 @@
+using System.Runtime.InteropServices;
+
+namespace Titano.UI;
+
+///
+/// Avanzamento sull'icona della barra delle applicazioni.
+///
+/// Un'esportazione lunga si guarda una volta e poi si va a fare altro. Chi lavora altrove
+/// non ha la finestra davanti, ma l'icona nella barra sì: farci passare la stessa barra di
+/// avanzamento che sta dentro il programma costa una chiamata e toglie la necessità di
+/// tornare a controllare.
+///
+/// L'interfaccia COM è dichiarata a mano — è quella di sistema, non una libreria: si passa
+/// dal CoCreateInstance del componente TaskbarList e si chiede
+/// ITaskbarList3. Su una shell che non lo espone la creazione fallisce e tutto
+/// diventa silenziosamente niente, che è il comportamento giusto per una comodità.
+///
+internal static class TaskbarProgress
+{
+ private enum State
+ {
+ None = 0,
+ Indeterminate = 1,
+ Normal = 2,
+ Error = 4,
+ Paused = 8,
+ }
+
+ [ComImport]
+ [Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface ITaskbarList3
+ {
+ // ITaskbarList
+ void HrInit();
+ void AddTab(IntPtr hwnd);
+ void DeleteTab(IntPtr hwnd);
+ void ActivateTab(IntPtr hwnd);
+ void SetActiveAlt(IntPtr hwnd);
+
+ // ITaskbarList2
+ void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fullscreen);
+
+ // ITaskbarList3: soltanto i due metodi che servono; gli altri restano segnaposto
+ // perché la tabella dei metodi COM è posizionale e saltarli sposterebbe tutto.
+ void SetProgressValue(IntPtr hwnd, ulong completed, ulong total);
+ void SetProgressState(IntPtr hwnd, State state);
+ }
+
+ [ComImport]
+ [Guid("56fdf344-fd6d-11d0-958a-006097c9a090")]
+ [ClassInterface(ClassInterfaceType.None)]
+ private class TaskbarInstance { }
+
+ private static ITaskbarList3? _taskbar;
+ private static bool _tried;
+
+ private static ITaskbarList3? Instance
+ {
+ get
+ {
+ if (_tried) return _taskbar;
+ _tried = true;
+ try
+ {
+ var instance = (ITaskbarList3)new TaskbarInstance();
+ instance.HrInit();
+ _taskbar = instance;
+ }
+ catch (Exception ex) when (ex is COMException or InvalidCastException or NotSupportedException
+ or PlatformNotSupportedException)
+ {
+ _taskbar = null;
+ }
+ return _taskbar;
+ }
+ }
+
+ /// Porta la frazione sull'icona. Fuori da [0,1] la barra viene tolta.
+ public static void Show(Form window, double fraction)
+ {
+ var taskbar = Instance;
+ if (taskbar is null || !Usable(window)) return;
+
+ try
+ {
+ IntPtr handle = window.Handle;
+ if (handle == IntPtr.Zero) return;
+
+ if (fraction < 0)
+ {
+ taskbar.SetProgressState(handle, State.None);
+ return;
+ }
+
+ // Sotto il mezzo per cento non c'è ancora niente da mostrare, ma qualcosa sta già
+ // succedendo: la barra pulsante lo dice meglio di una barra vuota.
+ if (fraction < 0.005)
+ {
+ taskbar.SetProgressState(handle, State.Indeterminate);
+ return;
+ }
+
+ taskbar.SetProgressState(handle, State.Normal);
+ taskbar.SetProgressValue(handle, (ulong)Math.Round(Math.Min(1, fraction) * 1000), 1000);
+ }
+ catch (COMException)
+ {
+ // La barra delle applicazioni non è un canale su cui valga la pena insistere.
+ }
+ }
+
+ /// Segna l'icona come interrotta: resta visibile finché non la si toglie.
+ public static void ShowFailed(Form window)
+ {
+ var taskbar = Instance;
+ if (taskbar is null || !Usable(window)) return;
+
+ try
+ {
+ taskbar.SetProgressState(window.Handle, State.Error);
+ taskbar.SetProgressValue(window.Handle, 1000, 1000);
+ }
+ catch (COMException) { }
+ }
+
+ public static void Clear(Form window) => Show(window, -1);
+
+ ///
+ /// Una finestra chiusa non ha più un handle, e chiederglielo lo ricrea o solleva
+ /// un'eccezione a seconda del momento. Un'esportazione annullata può benissimo
+ /// finire di sbrogliarsi dopo che la finestra è sparita: qui non c'è più nessuna
+ /// icona da aggiornare, e va bene così.
+ ///
+ private static bool Usable(Form window)
+ => window is { IsDisposed: false, Disposing: false, IsHandleCreated: true };
+}
diff --git a/Titano/UI/Tips.cs b/Titano/UI/Tips.cs
index 16577a0..2c41941 100644
--- a/Titano/UI/Tips.cs
+++ b/Titano/UI/Tips.cs
@@ -69,4 +69,15 @@ internal static class Tips
// suggerimento comparirebbe solo sui pochi pixel di bordo fra i due.
foreach (Control child in control.Controls) Set(child, text);
}
+
+ ///
+ /// Cambia la spiegazione di un controllo che ne ospita più d'una — una barra disegnata a
+ /// mano con dentro molte voci — seguendo quella sotto il puntatore. Riassegnare lo stesso
+ /// testo farebbe sfarfallare il riquadro, quindi si scrive solo quando cambia davvero.
+ ///
+ public static void Follow(Control control, string text)
+ {
+ if (Instance.GetToolTip(control) == text) return;
+ Instance.SetToolTip(control, text);
+ }
}
diff --git a/Titano/Video/ExportPresets.cs b/Titano/Video/ExportPresets.cs
new file mode 100644
index 0000000..5c6e9e3
--- /dev/null
+++ b/Titano/Video/ExportPresets.cs
@@ -0,0 +1,111 @@
+namespace Titano.Video;
+
+///
+/// Una combinazione di risoluzione, formato e qualità già pronta.
+///
+/// I parametri di codifica non sono indipendenti fra loro: il bitrate che a 1080p è
+/// abbondante a 4K produce banding sul cielo, e il profilo che va bene per l'archivio non è
+/// quello che un televisore vecchio riesce ad aprire. Scegliere bene significa muoverli
+/// insieme, e finora bisognava saperlo. Una preimpostazione li muove insieme al posto
+/// dell'utente, poi ciascuno resta modificabile: appena se ne tocca uno la scelta diventa
+/// «Personalizzata», perché la combinazione non è più quella dichiarata.
+///
+public sealed record ExportPreset(
+ string Name,
+ string Description,
+ int Width,
+ double AspectRatio,
+ VideoCodec Codec,
+ H264Profile Profile,
+ double FrameRate,
+ double BitrateMbps,
+ int KeyframeIntervalSeconds)
+{
+ /// Porta i valori della preimpostazione nelle impostazioni, lasciando il resto com'è.
+ public void ApplyTo(ExportSettings export)
+ {
+ export.Width = Width;
+ export.Height = 0;
+ export.AspectRatio = AspectRatio;
+ export.Codec = Codec;
+ export.Profile = Profile;
+ export.FrameRate = FrameRate;
+ export.BitrateMbps = BitrateMbps;
+ export.KeyframeIntervalSeconds = KeyframeIntervalSeconds;
+ }
+
+ public bool Matches(ExportSettings export) =>
+ export.Width == Width &&
+ Math.Abs(export.AspectRatio - AspectRatio) < 0.001 &&
+ export.Codec == Codec &&
+ export.Profile == Profile &&
+ Math.Abs(export.FrameRate - FrameRate) < 0.01 &&
+ Math.Abs(export.BitrateMbps - BitrateMbps) < 0.01 &&
+ export.KeyframeIntervalSeconds == KeyframeIntervalSeconds;
+}
+
+public static class ExportPresets
+{
+ private const double Wide = 16.0 / 9.0;
+ private const double Scope = 21.0 / 9.0;
+ private const double Tall = 9.0 / 16.0;
+
+ ///
+ /// I bitrate non sono tirati a caso: seguono all'incirca 0,10 bit per pixel per
+ /// fotogramma in H.264 e 0,07 in HEVC, che è la soglia sotto la quale una sfumatura
+ /// notturna comincia a mostrare i gradini. Un time-lapse di cielo è il contenuto più
+ /// esigente che esista per un codec, quindi si sta larghi.
+ ///
+ public static IReadOnlyList All { get; } =
+ [
+ new("4K UHD — archivio",
+ "3840×2160 a 30 fps, HEVC a 120 Mb/s. La qualità più alta che abbia senso " +
+ "conservare: regge la gradazione di un cielo notturno senza gradini.",
+ 3840, Wide, VideoCodec.Hevc, H264Profile.High, 30, 120, 2),
+
+ new("4K UHD — compatibile",
+ "3840×2160 a 30 fps, H.264 a 160 Mb/s. Stessa risoluzione ma in un formato che " +
+ "qualunque lettore apre; costa più spazio perché H.264 comprime meno.",
+ 3840, Wide, VideoCodec.H264, H264Profile.High, 30, 160, 2),
+
+ new("Cinema 21:9 — 4K",
+ "3840×1646 a 24 fps, HEVC a 110 Mb/s. Rapporto da grande schermo e cadenza " +
+ "cinematografica; taglia due fasce sopra e sotto rispetto al 16:9.",
+ 3840, Scope, VideoCodec.Hevc, H264Profile.High, 24, 110, 2),
+
+ new("1440p — buon compromesso",
+ "2560×1440 a 30 fps, H.264 a 80 Mb/s. Metà dei dati del 4K con una perdita che " +
+ "su uno schermo normale non si vede.",
+ 2560, Wide, VideoCodec.H264, H264Profile.High, 30, 80, 2),
+
+ new("1080p — condivisione",
+ "1920×1080 a 30 fps, H.264 a 45 Mb/s. La scelta sicura per mandarlo a qualcuno: " +
+ "si apre ovunque e resta di dimensioni maneggevoli.",
+ 1920, Wide, VideoCodec.H264, H264Profile.High, 30, 45, 2),
+
+ new("1080p verticale — social",
+ "1080×1920 a 30 fps, H.264 a 40 Mb/s. Ritaglio verticale per i formati a colonna; " +
+ "conviene controllare il riquadro d'inquadratura, perché taglia molto ai lati.",
+ 1080, Tall, VideoCodec.H264, H264Profile.High, 30, 40, 1),
+
+ new("Quadrato 1:1",
+ "1440×1440 a 30 fps, H.264 a 45 Mb/s. Formato quadrato, utile quando il soggetto " +
+ "sta al centro e i bordi non aggiungono niente.",
+ 1440, 1.0, VideoCodec.H264, H264Profile.High, 30, 45, 1),
+
+ new("Prova veloce 720p",
+ "1280×720 a 30 fps, H.264 a 16 Mb/s, chiave ogni secondo. Serve a guardare come " +
+ "viene il movimento prima di impegnare mezz'ora nella versione buona.",
+ 1280, Wide, VideoCodec.H264, H264Profile.Main, 30, 16, 1),
+ ];
+
+ /// Indice della preimpostazione che corrisponde esattamente, o -1 se nessuna.
+ public static int IndexOf(ExportSettings export)
+ {
+ for (int i = 0; i < All.Count; i++)
+ {
+ if (All[i].Matches(export)) return i;
+ }
+ return -1;
+ }
+}