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