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