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