Navigazione verticale e strumenti di misura nell'interfaccia
Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che governa insieme il contenuto principale e la colonna delle impostazioni: una sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da tenere allineate a mano; adesso ce n'e' una sola, 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 ogni voce compare il numero di avvisi che la riguardano. L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto sempre, perche' in un programma che tratta immagini l'immagine si guarda mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile per giudicare e non solo per guardare. Zoom e trascinamento, perche' una stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono invece di essere interpolati. Confronto a tendina fra originale e corretto, perche' l'unico modo di capire cosa una correzione stia facendo e' vedere accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore calcola comunque e che spiega perche' la sfocatura viene come viene, soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una panoramica virtuale. Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini e pannello degli avvisi sulla sequenza, istogramma e forma d'onda sull'esposizione, percorso ricostruito della stabilizzazione sul movimento, piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e che finivano in due numeri in fondo a una riga di stato, cioe' invisibili. Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu': un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello che il menu dell'orientamento usava da solo, esteso a larghezza di analisi, finestra del deflicker, lunghezza della transizione, finestra della stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del deflicker viene da quattro periodi dello sfarfallio misurati sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi. Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce meno decisioni e lascia il cursore dov'e'. Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e' l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un ostacolo. Gli annunci si leggono da una cartella locale con un listino in formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta montando un time-lapse. Le campagne si aggiornano copiando file; a listino vuoto compaiono note interne; il riquadro si spegne dalle preferenze. Aggiunge anche le preferenze dell'applicazione, distinte da quelle del progetto, con persistenza in un file di testo scritto a mano nello stesso spirito del resto: una riga per voce, correggibile con un editor. Il progetto descrive come trattare questi fotogrammi, le preferenze come si comporta il programma. Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche' trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e anteprima dei fotogrammi mentre vengono codificati, che non migliora il risultato di un pixel ma cambia molto un'attesa di mezz'ora. Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le sezioni ispezionate a video sulla scena di prova. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Titano.Pipeline;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class AppSettings
|
||||
{
|
||||
// ---- avvio -------------------------------------------------------------
|
||||
/// <summary>Cartella proposta dalle finestre di scelta file.</summary>
|
||||
public string LastFolder { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Ricarica le impostazioni salvate quando si riapre la stessa cartella.</summary>
|
||||
public bool RememberPerFolder { get; set; } = true;
|
||||
|
||||
/// <summary>Avvia l'analisi appena una sequenza viene caricata.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Cartella del parcheggio su disco; vuota significa quella temporanea di sistema.</summary>
|
||||
public string SpillDirectory { get; set; } = string.Empty;
|
||||
|
||||
// ---- comportamento ------------------------------------------------------
|
||||
/// <summary>Chiede conferma prima di sovrascrivere un file di uscita esistente.</summary>
|
||||
public bool ConfirmOverwrite { get; set; } = true;
|
||||
|
||||
/// <summary>Mostra l'anteprima dei fotogrammi mentre vengono codificati.</summary>
|
||||
public bool LivePreviewDuringExport { get; set; } = true;
|
||||
|
||||
/// <summary>Ogni quanti fotogrammi aggiornare l'anteprima durante l'esportazione.</summary>
|
||||
public int LivePreviewEvery { get; set; } = 8;
|
||||
|
||||
/// <summary>Apre la cartella di destinazione a esportazione conclusa.</summary>
|
||||
public bool RevealWhenFinished { get; set; }
|
||||
|
||||
// ---- riquadro sponsor ---------------------------------------------------
|
||||
/// <summary>Mostra il riquadro degli sponsor durante l'attesa dell'esportazione.</summary>
|
||||
public bool ShowSponsors { get; set; } = true;
|
||||
|
||||
/// <summary>Secondi fra un annuncio e il successivo.</summary>
|
||||
public int SponsorRotationSeconds { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Cartella del listino delle campagne; vuota significa quella predefinita accanto alle
|
||||
/// impostazioni. È una cartella locale: il programma non contatta nessun servizio.
|
||||
/// </summary>
|
||||
public string SponsorFolder { get; set; } = string.Empty;
|
||||
|
||||
// ------------------------------------------------------------------ percorsi
|
||||
|
||||
/// <summary>Cartella dei dati dell'applicazione, creata alla prima scrittura.</summary>
|
||||
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<QualityProfile>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applica i valori predefiniti a un progetto appena creato.</summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static IEnumerable<(string Key, string Value)> ReadPairs(IEnumerable<string> 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;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using Titano.Analysis;
|
||||
using Titano.Core;
|
||||
|
||||
namespace Titano.Pipeline;
|
||||
|
||||
/// <summary>Un parametro che il programma sa dedurre da sé.</summary>
|
||||
public enum AutoKey
|
||||
{
|
||||
AnalysisWidth,
|
||||
DeflickerWindow,
|
||||
TransitionFrames,
|
||||
StabilizationWindow,
|
||||
MemoryBudget,
|
||||
RegionMode,
|
||||
}
|
||||
|
||||
/// <summary>Valore scelto per un parametro, con il motivo per cui è stato scelto.</summary>
|
||||
public sealed record AutoDecision(AutoKey Key, double Value, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// 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'è.
|
||||
/// </summary>
|
||||
public static class AutoDirector
|
||||
{
|
||||
/// <summary>Finestre candidate per la lisciatura del percorso, dalla più corta.</summary>
|
||||
private static readonly int[] StabilizationCandidates = [7, 15, 31, 61, 91, 121];
|
||||
|
||||
public static Dictionary<AutoKey, AutoDecision> Derive(TitanoProject project)
|
||||
{
|
||||
var decisions = new Dictionary<AutoKey, AutoDecision>();
|
||||
|
||||
DeriveAnalysisWidth(project, decisions);
|
||||
DeriveMemoryBudget(decisions);
|
||||
DeriveDeflickerWindow(project, decisions);
|
||||
DeriveTransition(project, decisions);
|
||||
DeriveStabilizationWindow(project, decisions);
|
||||
|
||||
return decisions;
|
||||
}
|
||||
|
||||
/// <summary>Scrive nel progetto le decisioni che l'utente non ha preso a mano.</summary>
|
||||
public static void Apply(TitanoProject project, IReadOnlyDictionary<AutoKey, AutoDecision> decisions,
|
||||
IReadOnlySet<AutoKey> 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<AutoKey, AutoDecision> 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<AutoKey, AutoDecision> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void DeriveDeflickerWindow(TitanoProject project, Dictionary<AutoKey, AutoDecision> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void DeriveTransition(TitanoProject project, Dictionary<AutoKey, AutoDecision> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void DeriveStabilizationWindow(TitanoProject project, Dictionary<AutoKey, AutoDecision> 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;
|
||||
}
|
||||
|
||||
/// <summary>Energia delle differenze seconde: zero su una rampa, alta su un percorso a scatti.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,16 @@ public sealed class RenderPipeline(TitanoProject project)
|
||||
{
|
||||
private readonly TitanoProject _project = project;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Action<ImageBuffer, int, int>? FrameEncoded { get; set; }
|
||||
|
||||
/// <summary>Ogni quanti fotogrammi invocare <see cref="FrameEncoded"/>.</summary>
|
||||
public int PreviewInterval { get; set; } = 8;
|
||||
|
||||
// ------------------------------------------------------------------ ingestion
|
||||
|
||||
/// <summary>Legge i metadati dei file indicati e costruisce la sequenza ordinata.</summary>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using Titano.Analysis;
|
||||
using Titano.Motion;
|
||||
|
||||
namespace Titano.Pipeline;
|
||||
|
||||
public enum WarningSeverity
|
||||
{
|
||||
/// <summary>Vale la pena saperlo, non c'è niente da correggere.</summary>
|
||||
Note,
|
||||
|
||||
/// <summary>Il risultato ne risentirà, ma l'elaborazione va avanti.</summary>
|
||||
Caution,
|
||||
|
||||
/// <summary>Va sistemato prima di esportare.</summary>
|
||||
Problem,
|
||||
}
|
||||
|
||||
/// <summary>Una cosa che il motore ha notato, con dove si corregge.</summary>
|
||||
public sealed record SequenceWarning(
|
||||
WarningSeverity Severity,
|
||||
string Area,
|
||||
string Title,
|
||||
string Detail);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class SequenceWarnings
|
||||
{
|
||||
public static List<SequenceWarning> Collect(TitanoProject project, AppSettings settings)
|
||||
{
|
||||
var warnings = new List<SequenceWarning>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Sezione della barra a cui l'avviso appartiene, per il contatore.</summary>
|
||||
public static WorkspaceArea AreaOf(SequenceWarning warning) => warning.Area switch
|
||||
{
|
||||
"Sequenza" => WorkspaceArea.Sequence,
|
||||
"Esposizione" => WorkspaceArea.Exposure,
|
||||
"Movimento" => WorkspaceArea.Motion,
|
||||
"Tempo" => WorkspaceArea.Timing,
|
||||
_ => WorkspaceArea.Export,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Sezioni note al modello, senza dipendere dai tipi dell'interfaccia.</summary>
|
||||
public enum WorkspaceArea
|
||||
{
|
||||
Sequence,
|
||||
Exposure,
|
||||
Motion,
|
||||
Timing,
|
||||
Export,
|
||||
}
|
||||
@@ -70,6 +70,16 @@ public sealed class TitanoProject
|
||||
ApplyQualityProfile(General.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public HashSet<AutoKey> ManualParameters { get; } = [];
|
||||
|
||||
/// <summary>Ultime decisioni automatiche, con il motivo di ciascuna.</summary>
|
||||
public IReadOnlyDictionary<AutoKey, AutoDecision> AutoDecisions { get; set; }
|
||||
= new Dictionary<AutoKey, AutoDecision>();
|
||||
|
||||
public TimelapseSequence? Sequence { get; set; }
|
||||
|
||||
/// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary>
|
||||
|
||||
+64
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Reflection;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class AboutPanel : Panel
|
||||
{
|
||||
private readonly List<(string Label, string Value)> _rows = [];
|
||||
private readonly List<string> _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<float>.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;
|
||||
}
|
||||
}
|
||||
+280
-13
@@ -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; }
|
||||
|
||||
/// <summary>Il valore di questo parametro il programma sa dedurlo: compare l'indicatore automatico.</summary>
|
||||
public bool AutoSupported { get; set; }
|
||||
|
||||
/// <summary>Motivo della scelta automatica, mostrato come suggerimento.</summary>
|
||||
public string AutoReason { get; set; } = string.Empty;
|
||||
|
||||
public event EventHandler? ValueChanged;
|
||||
|
||||
/// <summary>L'utente ha preso o restituito il controllo di questo parametro.</summary>
|
||||
public event EventHandler? AutoChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Etichetta e menu a discesa affiancati, con lo stile del tema.</summary>
|
||||
@@ -309,6 +449,133 @@ internal sealed class LabeledCombo : Panel
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Simboli degli interruttori dell'anteprima.</summary>
|
||||
internal enum ToggleGlyph
|
||||
{
|
||||
Compare,
|
||||
Zoom,
|
||||
Motion,
|
||||
Region,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class DarkToggle : Control
|
||||
{
|
||||
private bool _checked;
|
||||
private bool _hover;
|
||||
|
||||
public event EventHandler? CheckedChanged;
|
||||
|
||||
public ToggleGlyph Glyph { get; set; }
|
||||
|
||||
/// <summary>Testo del suggerimento; compare come descrizione al passaggio del puntatore.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Intestazione di sezione con filetto di separazione.</summary>
|
||||
internal sealed class SectionHeader : Control
|
||||
{
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+333
-130
@@ -4,25 +4,52 @@ using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Finestra principale: collega i controlli disegnati a mano al motore di elaborazione.</summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<WorkspaceSection, Control> _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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Riprende l'icona dalle risorse dell'eseguibile: è la stessa che Esplora risorse
|
||||
/// mostra sul file, quindi finestra, barra delle applicazioni e cartella restano coerenti.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Interruttori dell'anteprima, ospitati nell'intestazione del riquadro.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Riquadro con intestazione: unità visiva ricorrente dell'interfaccia.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Allinea tutte le viste sullo stesso fotogramma, qualunque l'abbia scelto.</summary>
|
||||
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<PipelineProgress>(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<PipelineProgress>(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
|
||||
|
||||
/// <summary>Chiede al direttore i valori deducibili e li porta nei controlli.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private string DescribeAdvancedAnalysis()
|
||||
/// <summary>Ricalcola quello che dipende dalle impostazioni senza rileggere i file.</summary>
|
||||
private void RefreshDerived()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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<WorkspaceSection, int>();
|
||||
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<WorkspaceSection>())
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
/// <summary>Avverte quando la conformità del codec impone una risoluzione inferiore alla sorgente.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carica e analizza una sequenza senza interazione: usata dalla modalità di cattura
|
||||
/// dell'interfaccia, che serve a verificare la resa grafica in modo riproducibile.
|
||||
/// </summary>
|
||||
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<string> 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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using Titano.Motion;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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(); }
|
||||
}
|
||||
|
||||
/// <summary>Il percorso e la larghezza sorgente, che converte le unità normalizzate in pixel.</summary>
|
||||
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<PointF>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Le sezioni in cui è divisa la finestra, nell'ordine in cui compaiono nella barra.</summary>
|
||||
internal enum WorkspaceSection
|
||||
{
|
||||
Sequence,
|
||||
Exposure,
|
||||
Motion,
|
||||
Timing,
|
||||
Export,
|
||||
Preferences,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<WorkspaceSection, int> _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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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ì.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<PointF>(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<PointF>(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);
|
||||
}
|
||||
|
||||
/// <summary>Fascia in basso: la velocità istantanea, che è la pendenza resa esplicita.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+542
-69
@@ -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;
|
||||
|
||||
/// <summary>Frecce del campo vettoriale, in coordinate normalizzate sull'immagine mostrata.</summary>
|
||||
internal sealed record MotionArrows(PointF[] Origins, PointF[] Vectors, double MedianMagnitude);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Contorno delle regioni in coordinate normalizzate dell'anteprima.</summary>
|
||||
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<FrameScopes?>? ScopesChanged;
|
||||
|
||||
public PreviewPanel(TitanoProject project)
|
||||
{
|
||||
@@ -32,16 +55,101 @@ internal sealed class PreviewPanel : Control
|
||||
BackColor = Theme.Background;
|
||||
}
|
||||
|
||||
/// <summary>Mostra la tendina di confronto fra fotogramma corretto e originale.</summary>
|
||||
public bool CompareMode { get; private set; }
|
||||
|
||||
/// <summary>Disegna il campo vettoriale sopra il fotogramma.</summary>
|
||||
public bool ShowMotionField { get; private set; }
|
||||
|
||||
/// <summary>Disegna il confine fra le regioni del deflicker.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Alterna fra adattamento al riquadro e scala uno a uno.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Rende disponibile al chiamante la richiesta di rigenerare il fotogramma.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mostra un fotogramma appena codificato durante l'esportazione. Non passa dalla
|
||||
/// pipeline dell'anteprima: è già il risultato, arrivato dall'encoder.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.</summary>
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Misure di distribuzione di un fotogramma, calcolate una volta sola dal buffer
|
||||
/// dell'anteprima e riusate dai due strumenti che le mostrano.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Densità della forma d'onda: colonne dell'immagine per livelli di luminanza.</summary>
|
||||
public required byte[] Waveform { get; init; }
|
||||
|
||||
public required double ClippedFraction { get; init; }
|
||||
public required double BlackFraction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Alterna istogramma e forma d'onda: dicono la stessa cosa in due modi diversi.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+238
-133
@@ -6,10 +6,13 @@ using Titano.Video;
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<WorkspaceSection, Panel> _pages = [];
|
||||
private readonly Dictionary<AutoKey, ParameterSlider> _autoSliders = [];
|
||||
|
||||
private LabeledCombo? _orientationCombo;
|
||||
private Label? _analysisNote;
|
||||
@@ -31,30 +35,19 @@ internal sealed class SettingsPanel : Panel
|
||||
private ParameterSlider? _keyframeEaseIn;
|
||||
private bool _syncingKeyframe;
|
||||
|
||||
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare le curve già misurate.</summary>
|
||||
public event EventHandler? DeflickerChanged;
|
||||
|
||||
/// <summary>È cambiato un parametro che invalida l'analisi già svolta.</summary>
|
||||
public event EventHandler? AnalysisInvalidated;
|
||||
|
||||
/// <summary>È cambiato un parametro che modifica solo l'anteprima o l'esportazione.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Riporta nel menu l'esito del rilevamento, così la voce "Automatico" dice cosa ha deciso.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Aggiorna le voci che dipendono dall'analisi: maschera, transizioni, tremolio.</summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void ShowAutoDecisions(IReadOnlyDictionary<AutoKey, AutoDecision> 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);
|
||||
}
|
||||
|
||||
/// <summary>Aggiorna l'editor del movimento quando cambia la sequenza caricata.</summary>
|
||||
public void ShowSequenceGeometry()
|
||||
{
|
||||
if (_cameraEditor is null) return;
|
||||
@@ -142,17 +157,9 @@ internal sealed class SettingsPanel : Panel
|
||||
_cameraEditor.Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Seleziona una delle sezioni; usata anche dalla modalità di cattura.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Riporta nei cursori i valori del nodo scelto, senza farli reagire.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Rimisura una nota il cui testo è cambiato dopo la costruzione della pagina.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private ParameterSlider AutoSlider(AutoKey key, string caption, double min, double max, double value,
|
||||
double step, string format, string unit, Action<double> 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<bool> onChange)
|
||||
{
|
||||
var box = new DarkCheckBox { Text = caption, Checked = value };
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Un annuncio del listino locale.</summary>
|
||||
internal sealed record Sponsor(string Title, string Body, string? ImagePath, string? Link, int Weight)
|
||||
{
|
||||
public bool HasLink => !string.IsNullOrWhiteSpace(Link);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class SponsorCatalogue
|
||||
{
|
||||
public const string ManifestName = "campagne.txt";
|
||||
|
||||
public static List<Sponsor> Load(string folder)
|
||||
{
|
||||
var sponsors = new List<Sponsor>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static List<Sponsor> 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),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class SponsorPanel : Control
|
||||
{
|
||||
private readonly System.Windows.Forms.Timer _rotation = new();
|
||||
private List<Sponsor> _sponsors = [];
|
||||
private readonly List<int> _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;
|
||||
|
||||
/// <summary>Carica il listino e avvia la rotazione secondo le preferenze.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Ferma la rotazione quando la scheda non è in vista: non serve a nessuno girare a vuoto.</summary>
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (!Visible) return;
|
||||
if (active) _rotation.Start(); else _rotation.Stop();
|
||||
}
|
||||
|
||||
private static void Shuffle(List<int> 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),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>Disegna l'immagine dentro il riquadro conservandone le proporzioni.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Drawing.Imaging;
|
||||
using Titano.Core;
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class TimelineStrip : Control
|
||||
{
|
||||
private const int ThumbHeight = 54;
|
||||
private const int Inset = 5;
|
||||
|
||||
private readonly ConcurrentDictionary<int, Bitmap> _cache = [];
|
||||
private readonly object _queueGate = new();
|
||||
private readonly Queue<int> _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);
|
||||
}
|
||||
|
||||
/// <summary>Sceglie quali fotogrammi mostrare: tanti quanti ne stanno, distribuiti sull'intera sequenza.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
internal sealed class WarningsPanel : Control
|
||||
{
|
||||
private IReadOnlyList<SequenceWarning> _warnings = [];
|
||||
private int _scroll;
|
||||
private int _hovered = -1;
|
||||
|
||||
public event EventHandler<string>? 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<SequenceWarning> warnings)
|
||||
{
|
||||
_warnings = warnings;
|
||||
_scroll = 0;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ misura e interazione
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user