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; + } +}