Compare commits

...
4 Commits
Author SHA1 Message Date
Alby96andClaude Opus 5 15735a7b11 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>
2026-09-07 17:09:40 +02:00
Alby96andClaude Opus 5 db21a31e56 Scheda Archivio, ottimizzazione per sezione e spiegazioni a comparsa
Aggiunge l'importazione da scheda di memoria e riordina l'interfaccia attorno
a quello che ciascuna sezione serve a fare.

L'importazione riconosce i supporti collegati, cerca nelle sottocartelle e
prima di toccare il disco mostra il piano: quali cartelle nasceranno e che nome
avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e
guardare prima costa un istante. Fa poi tre cose che a mano si sbagliano
sempre: separa le sessioni sulla pausa fra due scatti, invece di lasciare piu'
riprese mescolate in una cartella sola; costruisce i nomi da modelli con
segnaposto, perche' un archivio si consulta anni dopo e il nome e' l'unica cosa
leggibile senza aprire nulla; e rilegge quello che ha scritto confrontandone
l'impronta, perche' una scheda che si scollega a meta' copia produce file della
dimensione giusta e del contenuto troncato, e il danno si scopre mesi dopo.

Sulla conversione in DNG va detto con precisione cosa si puo' e non si puo'
fare. Un DNG vero contiene i valori del sensore prima dell'interpolazione
cromatica; ottenerlo da un formato proprietario richiede la libreria del
produttore, che il vincolo sulle dipendenze esclude. Quello che si ottiene
in-house e' un DNG lineare - la specifica lo prevede, i pixel sono gia'
interpolati, il file e' valido e apribile ovunque ma non restituisce la
liberta' del grezzo. La copia resta quindi la scelta predefinita, e l'interfaccia
lo dice invece di lasciarlo intuire. Il contenitore TIFF/DNG e' scritto a mano
come il multiplexer MP4, e la verifica lo rilegge con il parser di questo stesso
programma: due implementazioni indipendenti dello stesso formato, e al primo
confronto e' saltato fuori un errore di dodici byte per voce nel calcolo degli
scostamenti - la directory Exif finiva oltre il puntatore che la indicava.

Le spiegazioni passano dalle note stampate ai suggerimenti a comparsa. Una nota
sotto un cursore occupa spazio a chi la conosce gia', quindi deve restare corta;
un suggerimento che appare solo quando serve non ha quel vincolo e puo' dire
l'unica cosa che conta - perche' quel parametro esiste e cosa succede a
spostarlo nel verso sbagliato.

Ogni sezione guadagna un comando Ottimizza che rileva le impostazioni migliori
per quella sola parte e riferisce cosa ha cambiato e perche'. Si distingue dal
pilota automatico, che lavora di continuo sui parametri deducibili senza
ambiguita': l'ottimizzazione si chiede a mano perche' accende e spegne interi
moduli, e sostituire quelle scelte in silenzio sarebbe peggio che lasciarle
sbagliate. Dove il dato manca non tira a indovinare: lo dice.

L'uscita guadagna rapporto e ritaglio. Cambiare rapporto non deforma piu'
l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il fotogramma,
il che ha anche corretto un difetto latente dello stadio geometrico, che con
rapporti diversi fra sorgente e uscita stirava invece di tagliare. La zona da
tenere si sceglie trascinandola.

I comandi seguono la sezione: importazione e analisi compaiono solo dove
servono. Un pulsante che non ha senso dove ci si trova non va disabilitato ma
tolto, perche' disabilitato resta un ingombro che chiede perche' non funziona.
Le preferenze dell'applicazione non mostrano piu' anteprima, riepilogo della
sequenza ne' riga di stato che ne parli: non riguardano la sequenza caricata, e
tenerle accanto confondeva due piani diversi.

Via anche le descrizioni inutili: la finestra si chiama Titano e basta.

Due difetti trovati durante la verifica a video. La barra di navigazione nasceva
sulla prima voce, che ora e' Archivio, quindi il selettore usciva subito perche'
l'indice coincideva e la sezione non veniva mai mostrata: barra su una voce,
contenuto su un'altra. E il posizionamento dei pulsanti leggeva Visible, che in
WinForms resta falso finche' la finestra non e' stata mostrata perche' riporta
la visibilita' dell'intera catena: alla costruzione nessun pulsante veniva
collocato e restavano tutti impilati sull'angolo.

Verifica: da 54 a 60 controlli. I nuovi coprono i modelli di percorso, la
sostituzione dei caratteri illegali, il rifiuto dei segnaposto inventati, il
riconoscimento delle sessioni e la rilettura del DNG scritto - sia dal parser
interno sia dal decodificatore di sistema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:09:54 +02:00
Alby96andClaude Opus 5 2e5a441938 Navigazione verticale e strumenti di misura nell'interfaccia
Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che
governa insieme il contenuto principale e la colonna delle impostazioni: una
sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da
tenere allineate a mano; adesso ce n'e' una sola, in verticale ci sta il nome
per esteso, e la sezione scelta resta leggibile mentre si lavora - cosa che in
una fila di schede in cima si perde appena l'occhio scende sul contenuto.
Accanto a ogni voce compare il numero di avvisi che la riguardano.

L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto
sempre, perche' in un programma che tratta immagini l'immagine si guarda
mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile
per giudicare e non solo per guardare. Zoom e trascinamento, perche' una
stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per
stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono
invece di essere interpolati. Confronto a tendina fra originale e corretto,
perche' l'unico modo di capire cosa una correzione stia facendo e' vedere
accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore
calcola comunque e che spiega perche' la sfocatura viene come viene,
soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso
la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una
panoramica virtuale.

Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini
e pannello degli avvisi sulla sequenza, istogramma e forma d'onda
sull'esposizione, percorso ricostruito della stabilizzazione sul movimento,
piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e
che finivano in due numeri in fondo a una riga di stato, cioe' invisibili.

Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu':
un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo
passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello
che il menu dell'orientamento usava da solo, esteso a larghezza di analisi,
finestra del deflicker, lunghezza della transizione, finestra della
stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del
deflicker viene da quattro periodi dello sfarfallio misurati
sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende
liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi.
Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce
meno decisioni e lascia il cursore dov'e'.

Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e'
l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non
toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un
ostacolo. Gli annunci si leggono da una cartella locale con un listino in
formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun
identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito
pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze
esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta
montando un time-lapse. Le campagne si aggiornano copiando file; a listino
vuoto compaiono note interne; il riquadro si spegne dalle preferenze.

Aggiunge anche le preferenze dell'applicazione, distinte da quelle del
progetto, con persistenza in un file di testo scritto a mano nello stesso
spirito del resto: una riga per voce, correggibile con un editor. Il progetto
descrive come trattare questi fotogrammi, le preferenze come si comporta il
programma.

Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche'
trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e
anteprima dei fotogrammi mentre vengono codificati, che non migliora il
risultato di un pixel ma cambia molto un'attesa di mezz'ora.

Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le
sezioni ispezionate a video sulla scena di prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:58:23 +02:00
Alby96andClaude Opus 5 790637bc0d Correzioni emerse provando i moduli avanzati sui DNG reali
I sei moduli passavano cinquantuno controlli su scene sintetiche. Le scene
sintetiche pero' sono costruite perche' la risposta sia nota, e per questo non
possono sorprendere. Provati su quattro sequenze vere - da 554 a 1005 scatti,
riprese notturne di comete e aurore con una GoPro - hanno mostrato cinque
difetti, nessuno dei quali visibile prima. Uno sta nel nucleo del deflicker e
c'era da molto.

La curva obiettivo poteva essere piu' a scatti del segnale che lisciava. La
robustezza di Tukey presuppone anomalie sparse; dove invece un tratto contiguo
si discosta - il crollo di luce del crepuscolo - azzera l'intera finestra, e la
stima commutava fra l'usare i soli sopravvissuti e l'usare tutto. Nel punto di
commutazione si apriva uno scalino di 2,3 stop, piu' grande di qualunque salto
presente nel segnale che doveva lisciare. Il rimedio non e' scegliere meglio
fra le due stime ma non scegliere affatto: ora si mescolano con continuita'
secondo quanta finestra e' sopravvissuta, e dove sopravvive per intero il
risultato resta identico a prima. Sulla ripresa di sedici ore la riduzione
dello sfarfallio passa dal 17% al 67% e il salto massimo da 1,73 a 0,21 stop.

La correlazione di fase inseguiva spostamenti inventati. Una ripresa attraversa
il giorno con pose da trenta secondi, quindi esce bruciata: su un riquadro
uniforme la normalizzazione al modulo unitario amplifica il solo rumore
numerico e l'antitrasformata da' un picco qualunque. Con un controllo di
tessitura il tremolio misurato scende da 9,8 a 0,07 px e il ritaglio richiesto
dal 15% allo 0,1%.

La ricerca dell'orizzonte presupponeva un cielo chiaro e sgombro sopra la
testa. Sotto un pergolato agganciava il bordo del tetto e chiamava cielo le
travi. Ora la linea e' il gradino piu' marcato del profilo di luminanza per
riga, che del verso non si cura: cielo dal 29% al 91% dell'inquadratura, con
1,9 EV di separazione dove prima erano zero, e sfarfallio trasmesso al
paesaggio ridotto del 79% invece che del 57%.

I gradini dichiarati nei metadati non sempre si vedono. Se il fotogramma e'
gia' saturo dimezzare la sensibilita' non lo scurisce, e se l'esposizione
automatica insegue l'alba il salto e' compensato dalla scena. In entrambi i
casi sottrarlo introduceva il gradino invece di toglierlo. Ora ogni cambio
viene ridotto alla quota che la luminanza ha davvero recepito.

La temperatura di colore inventava numeri. Su un cielo stellato il colore medio
non somiglia a nessun corpo nero, McCamy diverge, e uscivano decine di migliaia
di kelvin troncate a un estremo. Ora fuori dall'intorno del luogo di Planck la
misura si dichiara inapplicabile - e il controllo che lo verifica ha trovato
subito un buco nel primo filtro, perche' un riquadro sulle coordinate
cromatiche sa dire in quale zona si e' ma non quanto si e' vicini a una curva.

La diagnostica impara a fare queste domande: --diagnose accetta ora parole
chiave che accendono i moduli e riporta cosa ciascuno ha trovato sul materiale
vero. Le scie stellari si verificano sull'uscita con la proprieta' che le
definisce - la luminanza non puo' calare, perche' ogni pixel trattiene il
valore piu' alto incontrato - e su 150 fotogrammi risulta non decrescente sul
99,3% dei passi.

Verifica: da 51 a 54 controlli. I tre nuovi coprono proprio cio' che era
sfuggito, a partire dalla garanzia che una curva lisciata non sia mai piu' a
scatti dell'originale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:58:57 +02:00
42 changed files with 9850 additions and 605 deletions
+32 -4
View File
@@ -34,20 +34,46 @@ public static class ColorScience
/// <summary>
/// Temperatura di colore correlata con l'approssimazione di McCamy: il punto di
/// convergenza delle rette isotermiche sta molto vicino a (0,3320; 0,1858) e l'angolo
/// visto da lì determina la temperatura. Vale con buona precisione fra 2000 e 25000 K,
/// che copre tutto ciò che si incontra fra una candela e un cielo di mezzogiorno.
/// visto da lì determina la temperatura.
///
/// Restituisce NaN quando il colore medio non sta nell'intorno del luogo di Planck in cui
/// la formula è stata costruita. Non è prudenza teorica: su una ripresa notturna la
/// cromaticità media cade lontanissimo dal luogo dei corpi neri, il polinomio diverge e
/// produce valori di decine di migliaia di kelvin. Troncarli a un estremo darebbe un
/// numero dall'aria plausibile che non descrive nulla, ed è peggio che non darne uno.
/// </summary>
public static double CorrelatedColorTemperature(double r, double g, double b)
{
var (x, y) = Chromaticity(r, g, b);
if (!IsNearPlanckianLocus(x, y)) return double.NaN;
// Il riquadro sulle coordinate cromatiche sa dire "in quale zona", non "quanto vicino
// alla curva": un rosso saturo ci cade dentro di misura e riceverebbe una temperatura
// dall'aria sensata. Il luogo dei corpi neri percorre l'asse caldo-freddo e si scosta
// pochissimo da quello verde-magenta, quindi è lo scostamento su quell'asse — la
// stessa grandezza che il motore corregge — a dire se il colore somiglia a un
// illuminante o a una tinta qualsiasi.
if (Math.Abs(GreenTintStops(Log2Safe(r), Log2Safe(g), Log2Safe(b))) > 0.45) return double.NaN;
double denominator = 0.1858 - y;
if (Math.Abs(denominator) < 1e-9) return double.NaN;
if (Math.Abs(denominator) < 1e-6) return double.NaN;
double n = (x - 0.3320) / denominator;
double cct = 449 * n * n * n + 3525 * n * n + 6823.3 * n + 5520.33;
return double.IsFinite(cct) ? Math.Clamp(cct, 1000, 40000) : double.NaN;
// Oltre questi estremi il polinomio non è più un'approssimazione di niente:
// sotto sta la brace, sopra un blu che nessun illuminante produce.
return double.IsFinite(cct) && cct is >= 1500 and <= 15000 ? cct : double.NaN;
}
/// <summary>
/// Vero se la cromaticità cade nella regione in cui l'approssimazione è stata ricavata.
/// Il riquadro è generoso: contiene tutti gli illuminanti reali, dalla candela al cielo
/// coperto, ed esclude i colori saturi che di temperatura non ne hanno una.
/// </summary>
public static bool IsNearPlanckianLocus(double x, double y)
=> x is >= 0.20 and <= 0.60 && y is >= 0.18 and <= 0.55;
/// <summary>
/// Deviazione verde-magenta, in stop: eccesso di verde rispetto alla media di rosso e blu.
/// È la grandezza che il motore corregge davvero, quindi viene riportata così com'è invece
@@ -56,6 +82,8 @@ public static class ColorScience
public static double GreenTintStops(double log2R, double log2G, double log2B)
=> log2G - (log2R + log2B) * 0.5;
private static double Log2Safe(double value) => Math.Log2(Math.Max(value, 1.0 / 65536.0));
/// <summary>Descrizione compatta di una temperatura, per l'interfaccia e la diagnostica.</summary>
public static string Describe(double kelvin)
=> double.IsNaN(kelvin) ? "—" : $"{kelvin:0} K";
+63 -1
View File
@@ -62,6 +62,14 @@ public sealed class HolyGrailAnalysis
public double LargestStepStops { get; init; }
public int StepCount => StepFrames.Length;
/// <summary>
/// Cambi dichiarati dai metadati che la luminanza non ha recepito, e che sono stati
/// quindi lasciati stare. Le cause sono due, entrambe legittime: il fotogramma era già
/// saturo e non poteva scurirsi oltre, oppure la scena è cambiata di altrettanto nel
/// verso opposto — l'esposizione automatica che insegue l'alba — e il salto non si vede.
/// </summary>
public int UnobservedSteps { get; init; }
public static HolyGrailAnalysis Empty(int count) => new()
{
ExposureValue = Filled(count, double.NaN),
@@ -149,8 +157,9 @@ public static class HolyGrailEngine
double threshold = Math.Max(0.02, settings.StepThresholdStops);
bool metadataUsable = settings.UseMetadata && usable > n / 2;
int unobserved = 0;
var jumps = metadataUsable
? StepsFromMetadata(exposureValue, threshold)
? GateByObservability(StepsFromMetadata(exposureValue, threshold), stats, out unobserved)
: StepsFromLuminance(stats, threshold);
var staircase = new double[n];
@@ -199,9 +208,62 @@ public static class HolyGrailEngine
TintStops = tint,
MetadataUsable = metadataUsable,
LargestStepStops = largest,
UnobservedSteps = unobserved,
};
}
/// <summary>
/// Riduce ogni gradino alla quota che la luminanza ha davvero recepito.
///
/// I metadati dicono cosa la macchina ha cambiato, non cosa si vede: se il fotogramma è
/// già saturo, dimezzare la sensibilità non lo scurisce affatto, perché sopra il bianco
/// non c'è niente da togliere. Sottrarre comunque il gradino dichiarato introdurrebbe nel
/// segnale un salto che nell'immagine non esisteva — e la correzione, invece di
/// ammorbidire uno scalino, ne creerebbe uno. Il confronto è fatto su mediane di pochi
/// fotogrammi per lato, per non farsi ingannare dal rumore del singolo scatto.
/// </summary>
private static double[] GateByObservability(double[] jumps, IReadOnlyList<LuminanceStats> stats,
out int unobserved)
{
const int span = 3;
int n = jumps.Length;
var gated = new double[n];
unobserved = 0;
for (int i = 0; i < n; i++)
{
double predicted = jumps[i];
if (Math.Abs(predicted) < 1e-9) continue;
double before = MedianLuminance(stats, i - span, i - 1);
double after = MedianLuminance(stats, i, i + span - 1);
// Quota recepita: 1 se la luminanza si è mossa quanto previsto, 0 se non si è
// mossa affatto o si è mossa nel verso opposto.
double share = Math.Clamp((after - before) / predicted, 0, 1);
gated[i] = predicted * share;
if (share < 0.25) unobserved++;
}
return gated;
}
private static double MedianLuminance(IReadOnlyList<LuminanceStats> stats, int from, int to)
{
int count = stats.Count;
from = Math.Clamp(from, 0, count - 1);
to = Math.Clamp(to, 0, count - 1);
if (to < from) (from, to) = (to, from);
Span<double> values = stackalloc double[8];
int n = 0;
for (int i = from; i <= to && n < values.Length; i++) values[n++] = stats[i].Log2Average;
var slice = values[..n];
slice.Sort();
return slice[n / 2];
}
/// <summary>
/// Valore di esposizione dello scatto. Diaframma e sensibilità mancanti si considerano
/// costanti — quasi sempre lo sono in un time-lapse — mentre senza il tempo di posa il
+88 -30
View File
@@ -134,10 +134,6 @@ public static class RegionSegmenter
double log2High = weightHigh > 1e-6 ? sumHigh / weightHigh : 0;
double log2Low = weightLow > 1e-6 ? sumLow / weightLow : 0;
string description = settings.Mode == RegionMode.SkyGround
? $"cielo {coverage * 100:0}% dell'inquadratura, {log2High - log2Low:0.0} EV sopra il paesaggio"
: $"regione chiara {coverage * 100:0}% dell'inquadratura, {log2High - log2Low:0.0} EV sopra la scura";
return new RegionMask
{
Width = width,
@@ -146,10 +142,48 @@ public static class RegionSegmenter
Coverage = coverage,
Log2High = log2High,
Log2Low = log2Low,
Description = description,
Description = Describe(settings.Mode, coverage, log2High, log2Low),
};
}
/// <summary>
/// Descrizione della divisione trovata, riportando quello che si è misurato invece di
/// quello che ci si aspettava.
///
/// La divisione per orizzonte assegna alla prima regione ciò che sta sopra la linea, e
/// nulla garantisce che sia il cielo. Un cielo più scuro del terreno però non basta a
/// dire che la divisione ha sbagliato: di notte, con un primo piano illuminato, è la
/// norma. Il sospetto nasce quando la regione superiore è insieme scura e piccola —
/// una tettoia, un ramo, un cornicione che coprono l'alto dell'inquadratura — perché un
/// cielo vero, per quanto scuro, occupa una porzione ampia del fotogramma.
/// </summary>
private static string Describe(RegionMode mode, double coverage, double log2High, double log2Low)
{
double separation = log2High - log2Low;
if (mode == RegionMode.Luminance)
{
return $"regione chiara {coverage * 100:0}% dell'inquadratura, " +
$"{Math.Abs(separation):0.0} EV sopra la scura";
}
if (separation >= 0)
{
return $"cielo {coverage * 100:0}% dell'inquadratura, {separation:0.0} EV sopra il paesaggio";
}
if (coverage >= 0.35)
{
return $"cielo {coverage * 100:0}% dell'inquadratura, {-separation:0.0} EV sotto il paesaggio " +
$"(scena notturna con primo piano illuminato)";
}
return $"regione superiore {coverage * 100:0}% dell'inquadratura, e {-separation:0.0} EV più " +
$"scura di quella inferiore: stretta e scura insieme, sopra la linea è probabile ci sia " +
$"un primo piano che copre l'alto e non il cielo. Per questa scena conviene la " +
$"divisione per luminanza.";
}
// ------------------------------------------------------------------ campionamento
private static List<GrayPlane> CollectSamples(IReadOnlyList<string> paths, int orientation, int width,
@@ -182,13 +216,20 @@ public static class RegionSegmenter
var plane = new GrayPlane(width, height);
var data = buffer.Data;
int saturated = 0;
for (int p = 0; p < plane.Data.Length; p++)
{
int s = p * ImageBuffer.Channels;
float luma = ColorSpace.Luminance(data[s], data[s + 1], data[s + 2]);
if (luma >= 0.99f) saturated++;
plane.Data[p] = (float)Math.Log2(Math.Max(luma, 1.0 / 65536.0));
}
results[i] = plane;
// Un fotogramma quasi interamente bruciato non ha struttura da votare: la sua
// mediana sarebbe bianco ovunque e cancellerebbe l'orizzonte visto dagli altri.
// Capita davvero in una ripresa che attraversa il giorno con pose lunghe.
if (saturated < plane.Data.Length * 0.9) results[i] = plane;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -285,40 +326,57 @@ public static class RegionSegmenter
}
/// <summary>
/// Maschera per linea d'orizzonte. La soglia dà una prima ipotesi colonna per colonna;
/// il gradiente verticale la corregge, perché il passaggio cielo-terra è il contrasto
/// più forte della colonna e cade esattamente sul bordo, non dove capita la soglia.
/// Il profilo risultante viene poi lisciato: un orizzonte reale non fa scalini.
/// Maschera per linea d'orizzonte.
///
/// L'ipotesi iniziale viene dal profilo di luminanza per riga: la linea d'orizzonte è il
/// gradino più marcato del profilo, qualunque ne sia il verso. La versione precedente
/// cercava invece la prima riga sotto soglia scendendo dall'alto, il che presuppone un
/// cielo chiaro e sgombro sopra la testa; su una ripresa notturna fatta da sotto un
/// pergolato quella regola aggancia il bordo del tetto e chiama "cielo" le travi.
/// Il gradino del profilo non fa questa assunzione e vale anche quando il cielo è più
/// scuro del primo piano, cosa normalissima di notte.
///
/// Il gradiente verticale raffina poi l'ipotesi colonna per colonna, perché il passaggio
/// cade esattamente sul bordo e non dove capita la soglia, e il profilo risultante viene
/// lisciato: un orizzonte reale non fa scalini.
/// </summary>
private static float[] BuildFromHorizon(float[] median, int width, int height, double threshold,
RegionSettings settings)
{
_ = threshold;
var rowMean = new double[height];
for (int y = 0; y < height; y++)
{
double sum = 0;
int rowBase = y * width;
for (int x = 0; x < width; x++) sum += median[rowBase + x];
rowMean[y] = sum / width;
}
var smoothRows = SmoothProfile(rowMean, Math.Max(1, height / 48));
int span = Math.Max(1, height / 32);
double strongest = -1;
int horizon = height / 2;
for (int y = span; y < height - span; y++)
{
double step = Math.Abs(smoothRows[y + span] - smoothRows[y - span]);
if (step <= strongest) continue;
strongest = step;
horizon = y;
}
var boundary = new double[width];
int band = Math.Max(2, height / 8);
for (int x = 0; x < width; x++)
{
// Prima riga, dall'alto, che apre una serie stabile di pixel sotto soglia.
int guess = height;
int run = 0;
int required = Math.Max(2, height / 40);
for (int y = 0; y < height; y++)
{
if (median[y * width + x] < threshold)
{
if (++run >= required) { guess = y - required + 1; break; }
}
else
{
run = 0;
}
}
if (guess >= height) guess = height - 1;
int from = Math.Max(1, guess - band);
int to = Math.Min(height - 2, guess + band);
int from = Math.Max(1, horizon - band);
int to = Math.Min(height - 2, horizon + band);
double bestGradient = -1;
int bestRow = guess;
int bestRow = horizon;
for (int y = from; y <= to; y++)
{
+54 -26
View File
@@ -56,43 +56,71 @@ public static class LocalRegression
int n = y.Length;
double twoSigmaSq = 2.0 * sigma * sigma;
for (int i = 0; i < n; i++)
for (int i = 0; i < n; i++) output[i] = Fit(i);
// Stima nel punto indicato, mescolando la retta robusta e quella semplice secondo
// quanta parte della finestra i pesi di robustezza hanno lasciato in vita.
//
// La robustezza di Tukey presuppone anomalie sparse. Quando invece un tratto contiguo
// si discosta — il crollo di luce del crepuscolo, una serie di fotogrammi bruciati —
// può azzerare l'intera finestra, e allora l'unica cosa che conta è cosa si fa dopo.
// Commutare fra "uso i soli sopravvissuti" e "uso tutto" produce uno scalino nel punto
// di commutazione: sulla ripresa reale di sedici ore la curva obiettivo arrivava così
// ad avere un salto di 2,3 stop, più grande di qualunque salto presente nel segnale
// che doveva lisciare. Il rimedio non è scegliere meglio fra le due stime ma non
// scegliere affatto: si passa dall'una all'altra con continuità, e dove la finestra
// sopravvive per intero il risultato resta identico a prima.
double Fit(int centre)
{
int from = Math.Max(0, i - radius);
int to = Math.Min(n - 1, i + radius);
int from = Math.Max(0, centre - radius);
int to = Math.Min(n - 1, centre + radius);
double pw = 0, pwt = 0, pwt2 = 0, pwy = 0, pwty = 0; // retta semplice
double rw = 0, rwt = 0, rwt2 = 0, rwy = 0, rwty = 0; // retta robusta
// Sistema normale della retta pesata y = a + b·t, con t = j - i.
double sw = 0, swt = 0, swt2 = 0, swy = 0, swty = 0;
for (int j = from; j <= to; j++)
{
double t = j - i;
double w = Math.Exp(-(t * t) / twoSigmaSq) * robust[j];
if (w <= 1e-9) continue;
sw += w;
swt += w * t;
swt2 += w * t * t;
swy += w * y[j];
swty += w * t * y[j];
double t = j - centre;
double plain = Math.Exp(-(t * t) / twoSigmaSq);
if (plain <= 1e-12) continue;
pw += plain; pwt += plain * t; pwt2 += plain * t * t;
pwy += plain * y[j]; pwty += plain * t * y[j];
double weighted = plain * robust[j];
if (weighted <= 1e-12) continue;
rw += weighted; rwt += weighted * t; rwt2 += weighted * t * t;
rwy += weighted * y[j]; rwty += weighted * t * y[j];
}
if (sw <= 1e-9)
{
output[i] = y[i];
continue;
}
if (pw <= 1e-9) return y[centre];
double det = sw * swt2 - swt * swt;
if (Math.Abs(det) < 1e-12)
{
output[i] = swy / sw; // finestra degenere: media pesata
continue;
}
double simple = Solve(pw, pwt, pwt2, pwy, pwty, y[centre]);
double survival = rw / pw;
if (survival <= 1e-6) return simple;
double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0
output[i] = a;
double sturdy = Solve(rw, rwt, rwt2, rwy, rwty, y[centre]);
// Sopra metà finestra sopravvissuta la stima è quella robusta e basta; sotto,
// si scivola verso quella semplice senza gradini.
double blend = Math.Clamp(survival / 0.5, 0, 1);
blend = blend * blend * (3 - 2 * blend);
return simple + (sturdy - simple) * blend;
}
}
/// <summary>Intercetta della retta pesata, ossia il valore stimato nel centro della finestra.</summary>
private static double Solve(double sw, double swt, double swt2, double swy, double swty, double fallback)
{
if (sw <= 1e-9) return fallback;
double det = sw * swt2 - swt * swt;
if (Math.Abs(det) < 1e-12) return swy / sw; // finestra degenere: media pesata
return (swt2 * swy - swt * swty) / det;
}
private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust)
{
int n = y.Length;
+273
View File
@@ -25,12 +25,15 @@ internal static class AdvancedModuleTests
{
Stabilization(add);
PhotometricRegions(workingDirectory, add, output);
Smoothing(add);
ColourDrift(add);
Easing(add);
Geometry(add);
Ramp(add);
Stacking(add);
Archive(workingDirectory, add);
Integration(workingDirectory, add, output);
Cancellation(workingDirectory, add, output);
}
// ================================================================== 1. stabilizzazione
@@ -295,6 +298,59 @@ internal static class AdvancedModuleTests
double temperature = ColorScience.CorrelatedColorTemperature(1.0, 1.0, 1.0);
add("Temperatura di colore — bianco di riferimento",
Math.Abs(temperature - 6504) < 120, $"{ColorScience.Describe(temperature)} per un grigio neutro");
// Su una ripresa notturna il colore medio cade lontanissimo dal luogo dei corpi neri,
// dove l'approssimazione diverge. Deve dichiararsi inapplicabile invece di restituire
// un numero troncato a un estremo, che avrebbe l'aria di essere una misura.
double nightSky = ColorScience.CorrelatedColorTemperature(0.004, 0.006, 0.020);
double deepRed = ColorScience.CorrelatedColorTemperature(0.90, 0.05, 0.02);
add("Temperatura di colore — dichiarata non significativa fuori dal luogo di Planck",
double.IsNaN(nightSky) && double.IsNaN(deepRed),
$"cielo notturno {ColorScience.Describe(nightSky)}, rosso saturo {ColorScience.Describe(deepRed)}");
}
/// <summary>
/// La curva obiettivo non deve mai essere più a scatti del segnale che liscia.
///
/// Sembra ovvio e invece è la proprietà che si è rotta per prima su materiale vero: dove i
/// residui restano grandi per un tratto intero, i pesi di robustezza azzerano l'intera
/// finestra, e ripiegando sul campione grezzo la curva obiettivo apre uno scalino proprio
/// dove doveva esserci la massima continuità. La scena qui riproduce quelle condizioni:
/// un crollo di luce ripido con sopra una serie contigua di fotogrammi anomali.
/// </summary>
private static void Smoothing(Report add)
{
const int count = 240;
var series = new double[count];
for (int i = 0; i < count; i++)
{
// Crepuscolo: quasi piatto, poi un crollo ripido, poi di nuovo piatto.
double t = (i - 100) / 30.0;
series[i] = -2.0 - 7.0 / (1.0 + Math.Exp(-t));
// Un tratto contiguo di fotogrammi fuori scala, non un singolo scatto isolato:
// è la condizione che fa collassare i pesi su tutta la finestra.
if (i is >= 118 and <= 132) series[i] += 1.6;
}
var smoothed = LocalRegression.Smooth(series, 15, true);
double worstInput = 0, worstOutput = 0;
for (int i = 1; i < count; i++)
{
worstInput = Math.Max(worstInput, Math.Abs(series[i] - series[i - 1]));
worstOutput = Math.Max(worstOutput, Math.Abs(smoothed[i] - smoothed[i - 1]));
}
add("Regressione locale — la curva lisciata non è mai più a scatti dell'originale",
worstOutput <= worstInput,
$"salto massimo {worstInput:0.000} stop in ingresso, {worstOutput:0.000} in uscita");
// E deve comunque seguire il crollo: una curva piatta sarebbe continua ma inutile.
double range = smoothed.Max() - smoothed.Min();
add("Regressione locale — il crollo di luce viene seguito", range > 6.0,
$"escursione {range:0.00} EV su {series.Max() - series.Min():0.00} EV del segnale");
}
// ================================================================== 4. accelerazione
@@ -531,6 +587,131 @@ internal static class AdvancedModuleTests
foreach (var frame in window) frame.Dispose();
}
// ================================================================== 7-bis. archivio
/// <summary>
/// Verifiche dell'importazione: i nomi che i modelli producono, il riconoscimento delle
/// sessioni, e la prova che il contenitore scritto dallo scrittore di immagini viene
/// riletto dal parser di questo stesso programma. Quest'ultima è la più severa: sono due
/// implementazioni indipendenti dello stesso formato, e se una delle due sbaglia si vede.
/// </summary>
private static void Archive(string workingDirectory, Report add)
{
var capture = new DateTime(2026, 8, 15, 21, 30, 45, DateTimeKind.Unspecified);
var metadata = new FrameMetadata
{
FilePath = @"K:\DCIM@GOPRO\G0011051.dng",
FileName = "G0011051.dng",
CaptureTime = capture,
ExposureSeconds = 10,
FNumber = 2.8,
Iso = 800,
PixelWidth = 4000,
PixelHeight = 3000,
Camera = "GoPro HERO8 Black",
};
var context = new NamingContext(metadata, 41, 2, capture.AddMinutes(-20), 7);
string folder = PathTemplate.Expand("{anno}/{data} {fotocamera}", context, allowSeparators: true);
string file = PathTemplate.Expand("{data}_{ora}_{n:0000}", context, allowSeparators: false);
string expectedFolder = Path.Combine("2026", "2026-08-15 GoPro HERO8 Black");
add("Modelli di percorso — cartella e nome file",
folder == expectedFolder && file == "2026-08-15_21-30-45_0042",
$"«{folder}» e «{file}»");
// Un tempo di posa contiene una barra: dentro un nome file non deve diventare un livello.
var fast = Variant(metadata, metadata.FileName, capture, 1.0 / 125);
string safe = PathTemplate.Expand("{posa}", new NamingContext(fast, 0, 0, capture, 0), false);
add("Modelli di percorso — caratteri illegali sostituiti",
safe.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && safe.Length > 0,
$"posa «{safe}»");
add("Modelli di percorso — segnaposto sconosciuto respinto",
PathTemplate.Validate("{anno}/{inesistente}", true) is not null &&
PathTemplate.Validate("{anno}/{data}", true) is null,
"il modello valido passa, quello con un segnaposto inventato no");
// ---- sessioni: due gruppi separati da una pausa lunga
var settings = new ImportSettings { GroupIntoSessions = true, SessionGapMinutes = 45, MinimumSessionFrames = 3 };
var files = new List<string>();
var stamps = new List<DateTime>();
for (int i = 0; i < 12; i++) stamps.Add(capture.AddMinutes(i));
for (int i = 0; i < 12; i++) stamps.Add(capture.AddMinutes(200 + i));
var candidates = new List<ImportCandidate>();
for (int i = 0; i < stamps.Count; i++)
{
candidates.Add(new ImportCandidate($"s{i}.dng",
Variant(metadata, $"s{i}.dng", stamps[i], metadata.ExposureSeconds), 1024, 0));
}
var grouped = MediaImporter.Regroup(candidates, settings);
int sessions = grouped.Select(c => c.SessionIndex).Distinct().Count();
add("Importazione — sessioni riconosciute sulle pause", sessions == 2,
$"{sessions} sessioni da 24 scatti con una pausa di tre ore nel mezzo");
_ = files;
// ---- scrittura e rilettura del contenitore
string path = Path.Combine(workingDirectory, "prova-lineare.dng");
var pool = new FrameBufferPool(4);
using (var frame = pool.Rent(64, 48))
{
for (int y = 0; y < 48; y++)
{
for (int x = 0; x < 64; x++)
{
int i = frame.Offset(x, y);
frame.Data[i] = x / 63f;
frame.Data[i + 1] = y / 47f;
frame.Data[i + 2] = 0.25f;
}
}
RasterWriter.Write(frame, path, metadata, RasterFormat.LinearDng, 92);
}
var reread = MetadataReader.Read(path);
bool sizeOk = reread.PixelWidth == 64 && reread.PixelHeight == 48;
bool exifOk = reread.Iso == 800 && reread.FNumber is { } f && Math.Abs(f - 2.8) < 0.01 &&
reread.CaptureTime == capture;
add("DNG lineare — riletto dal parser di Titano", sizeOk && exifOk,
$"{reread.PixelWidth}×{reread.PixelHeight}, ISO {reread.IsoText}, {reread.ApertureText}, " +
$"scatto {reread.CaptureTime:HH:mm:ss}");
// E il decodificatore di sistema deve saperlo aprire: è la prova che il file non è
// valido soltanto secondo chi lo ha scritto.
try
{
var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(path, 1);
add("DNG lineare — aperto dal decodificatore di sistema",
probeWidth == 64 && probeHeight == 48, $"{probeWidth}×{probeHeight}");
}
catch (Exception ex)
{
add("DNG lineare — aperto dal decodificatore di sistema", false, ex.Message);
}
}
/// <summary>
/// Copia di metadati con qualche campo cambiato. FrameMetadata è una classe con proprietà
/// di sola inizializzazione, quindi non ha la copia con modifica dei record.
/// </summary>
private static FrameMetadata Variant(FrameMetadata source, string fileName, DateTime capture,
double? exposure) => new()
{
FilePath = source.FilePath,
FileName = fileName,
CaptureTime = capture,
ExposureSeconds = exposure,
FNumber = source.FNumber,
Iso = source.Iso,
PixelWidth = source.PixelWidth,
PixelHeight = source.PixelHeight,
Camera = source.Camera,
};
// ================================================================== 8. integrazione
/// <summary>
@@ -618,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");
}
}
+97
View File
@@ -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;
}
}
+315 -3
View File
@@ -14,9 +14,24 @@ namespace Titano.Diagnostics;
/// </summary>
public static class SequenceDiagnostics
{
/// <summary>Moduli opzionali da accendere durante la diagnosi di una cartella reale.</summary>
[Flags]
public enum AdvancedModules
{
None = 0,
Analysis = 1, // stabilizzazione, regioni, transizioni giorno-notte
Camera = 2, // movimento di macchina virtuale e rimappatura del tempo
MedianStack = 4,
StarTrails = 8,
/// <summary>Divide per luminanza invece che per linea d'orizzonte.</summary>
RegionsByLuminance = 16,
}
public static int Run(string directory, TextWriter output, int sampleCount = 6,
string? renderPath = null, int renderFrames = 24,
int renderWidth = 0, Video.VideoCodec renderCodec = Video.VideoCodec.H264)
int renderWidth = 0, Video.VideoCodec renderCodec = Video.VideoCodec.H264,
AdvancedModules advanced = AdvancedModules.None, int analysisFrames = 0)
{
if (!Directory.Exists(directory))
{
@@ -147,18 +162,262 @@ public static class SequenceDiagnostics
var distinct = all2.Select(m => (m.PixelWidth, m.PixelHeight)).Distinct().ToList();
output.WriteLine($" dimensioni dichiarate distinte: {string.Join(", ", distinct.Select(d => $"{d.PixelWidth}×{d.PixelHeight}"))}");
if (advanced != AdvancedModules.None)
RunAdvancedAnalysis(all2, advanced, analysisFrames, output);
if (renderPath is not null)
RunTrialRender(all2, renderPath, renderFrames, renderWidth, renderCodec, output);
RunTrialRender(all2, renderPath, renderFrames, renderWidth, renderCodec, advanced, output);
return decoded == sampleMetadata.Count ? 0 : 1;
}
/// <summary>
/// Fa girare i moduli avanzati sulla sequenza reale e riporta che cosa hanno trovato.
///
/// Non è un rendering: è la fase di analisi, l'unica che può dire se su materiale vero la
/// correlazione di fase aggancia qualcosa, se la scena si lascia dividere in cielo e
/// paesaggio, se la macchina ha davvero cambiato impostazioni lungo la ripresa. Sono le
/// domande a cui una scena sintetica, costruita perché la risposta sia nota, non risponde.
/// </summary>
private static void RunAdvancedAnalysis(List<FrameMetadata> metadata, AdvancedModules modules,
int analysisFrames, TextWriter output)
{
output.WriteLine();
output.WriteLine("MODULI AVANZATI SULLA SEQUENZA REALE");
output.WriteLine(new string('-', 74));
var ordered = metadata.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
.ToList();
// La stabilizzazione confronta ogni fotogramma con il precedente: il campione deve
// essere contiguo, non sparso sulla sequenza.
var subset = analysisFrames > 0 && analysisFrames < ordered.Count
? ordered.Take(analysisFrames).ToList()
: ordered;
var project = new Pipeline.TitanoProject();
project.Sequence = TimelapseSequence.Build(subset);
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
project.DetectOrientation();
ConfigureAdvanced(project, modules);
output.WriteLine($" fotogrammi analizzati {subset.Count}" +
(subset.Count < ordered.Count ? $" di {ordered.Count} (sottoinsieme contiguo)" : string.Empty));
output.WriteLine($" larghezza di analisi {project.General.AnalysisWidth} px");
var stopwatch = Stopwatch.StartNew();
try
{
new Pipeline.RenderPipeline(project).AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
output.WriteLine($" ANALISI NON RIUSCITA: {ex.GetType().Name} — {ex.Message}");
return;
}
stopwatch.Stop();
output.WriteLine($" tempo di analisi {stopwatch.Elapsed.TotalSeconds:0.0} s " +
$"({subset.Count / Math.Max(0.001, stopwatch.Elapsed.TotalSeconds):0.0} fotogrammi/s)");
output.WriteLine();
ReportStabilization(project, output);
ReportRegions(project, output);
ReportTransitions(project, output);
ReportDeflicker(project, output);
}
private static void ConfigureAdvanced(Pipeline.TitanoProject project, AdvancedModules modules)
{
if (modules.HasFlag(AdvancedModules.Analysis))
{
project.Stabilization.Enabled = true;
project.Regions.Mode = modules.HasFlag(AdvancedModules.RegionsByLuminance)
? RegionMode.Luminance
: RegionMode.SkyGround;
project.HolyGrail.Enabled = true;
project.HolyGrail.SmoothColor = true;
}
if (modules.HasFlag(AdvancedModules.Camera))
{
project.Camera.Enabled = true;
project.Camera.Keyframes =
[
new() { Time = 0.0, CentreX = 0.42, CentreY = 0.46, Zoom = 1.10 },
new() { Time = 1.0, CentreX = 0.58, CentreY = 0.54, Zoom = 1.45 },
];
project.TimeRamp.Enabled = true;
project.TimeRamp.Speed = [new(0.0, 1.6), new(0.5, 0.5), new(1.0, 1.6)];
}
if (modules.HasFlag(AdvancedModules.MedianStack))
{
project.Stacking.Mode = Motion.StackingMode.Median;
project.Stacking.WindowFrames = 5;
}
else if (modules.HasFlag(AdvancedModules.StarTrails))
{
project.Stacking.Mode = Motion.StackingMode.Maximum;
project.Stacking.TrailFrames = 0; // scie che non si spengono
}
}
private static void ReportStabilization(Pipeline.TitanoProject project, TextWriter output)
{
if (project.Motion is not { } motion) return;
var (width, height) = project.ResolveSourceSize();
double toPixels = Math.Max(1, width);
double maxRotation = 0;
double maxShift = 0;
foreach (var correction in motion.Correction)
{
maxRotation = Math.Max(maxRotation, Math.Abs(correction.Rotation) * 180 / Math.PI);
maxShift = Math.Max(maxShift, Math.Sqrt(correction.Tx * correction.Tx + correction.Ty * correction.Ty));
}
var last = motion.Measured[^1];
double drift = Math.Sqrt(last.Tx * last.Tx + last.Ty * last.Ty) * toPixels;
output.WriteLine(" STABILIZZAZIONE");
output.WriteLine($" correlazione debole {motion.UnreliableFraction * 100:0.0}% delle coppie");
output.WriteLine($" tremolio rimosso {motion.MeanShake * toPixels:0.00} px in media, " +
$"{maxShift * toPixels:0.00} px al massimo");
output.WriteLine($" rotazione compensata {maxRotation:0.000}° al massimo");
output.WriteLine($" deriva complessiva {drift:0.0} px dal primo all'ultimo fotogramma");
output.WriteLine($" ritaglio necessario {(motion.RequiredZoom(height / (double)width) - 1) * 100:0.0}%");
output.WriteLine();
}
private static void ReportRegions(Pipeline.TitanoProject project, TextWriter output)
{
output.WriteLine(" SEGMENTAZIONE");
if (project.Mask is not { } mask)
{
output.WriteLine(" esito la scena non si divide in modo utile: resta la curva unica");
output.WriteLine();
return;
}
output.WriteLine($" esito {mask.Description}");
output.WriteLine($" maschera {mask.Width}×{mask.Height}, copertura {mask.Coverage * 100:0.0}%");
var curve = project.Curve;
if (curve is { HasRegions: true, MeasuredLow: { } measuredLow, TargetLow: { } targetLow })
{
var underGlobal = new double[measuredLow.Length];
for (int i = 0; i < measuredLow.Length; i++) underGlobal[i] = measuredLow[i] + curve.GainStops[i];
double globalNoise = DeflickerCurve.FlickerIndex(underGlobal);
double regionalNoise = DeflickerCurve.FlickerIndex(targetLow);
double gain = globalNoise > 1e-9 ? 100 * (1 - regionalNoise / globalNoise) : 0;
output.WriteLine($" effetto sul paesaggio sfarfallio {globalNoise:0.0000} stop con la curva globale, " +
$"{regionalNoise:0.0000} con quella di regione ({gain:0.#}% meglio)");
}
output.WriteLine();
}
private static void ReportTransitions(Pipeline.TitanoProject project, TextWriter output)
{
if (project.Transitions is not { } transitions) return;
int withExposureValue = transitions.ExposureValue.Count(v => !double.IsNaN(v));
var temperatures = transitions.TemperatureKelvin.Where(double.IsFinite).ToList();
var tints = transitions.TintStops.Where(double.IsFinite).ToList();
output.WriteLine(" TRANSIZIONI GIORNO-NOTTE");
output.WriteLine($" valore di esposizione ricostruito su {withExposureValue}/{transitions.ExposureValue.Length} fotogrammi");
output.WriteLine($" cambi di impostazione {transitions.StepCount}" +
(transitions.StepCount > 0
? $", il maggiore di {transitions.LargestStepStops:0.00} EV " +
$"({(transitions.MetadataUsable ? "dai metadati" : "dedotti dalla luminanza")})"
: string.Empty));
if (transitions.StepCount is > 0 and <= 12)
{
output.WriteLine($" ai fotogrammi {string.Join(", ", transitions.StepFrames.Select(f => f + 1))}");
}
if (transitions.UnobservedSteps > 0)
{
// Due cause, entrambe legittime: il fotogramma era già saturo e non poteva
// scurirsi oltre, oppure la scena è cambiata di altrettanto nel verso opposto e
// il salto, semplicemente, non si vede. In nessuno dei due casi c'è qualcosa da
// ammorbidire, e correggere comunque introdurrebbe il gradino invece di toglierlo.
output.WriteLine($" non recepiti {transitions.UnobservedSteps} cambi dichiarati che la " +
$"luminanza non ha recepito (fotogramma saturo, o esposizione " +
$"compensata dalla scena): lasciati stare");
}
int total = transitions.TemperatureKelvin.Length;
if (temperatures.Count > 0)
{
output.WriteLine($" temperatura di colore da {temperatures[0]:0} K a {temperatures[^1]:0} K " +
$"(intervallo {temperatures.Min():0}{temperatures.Max():0} K)" +
(temperatures.Count < total
? $", significativa su {temperatures.Count}/{total} fotogrammi"
: string.Empty));
}
else
{
// Su una ripresa notturna è il caso normale, non un guasto: il colore medio di un
// cielo stellato non sta da nessuna parte vicino al luogo dei corpi neri.
output.WriteLine($" temperatura di colore non significativa su nessuno dei {total} fotogrammi: " +
$"il colore medio è troppo lontano dal luogo di Planck perché una " +
$"temperatura voglia dire qualcosa");
}
if (tints.Count > 0)
{
output.WriteLine($" tinta verde-magenta da {tints[0]:+0.000;-0.000;0.000} a {tints[^1]:+0.000;-0.000;0.000} EV " +
$"(escursione {tints.Max() - tints.Min():0.000} EV)");
}
output.WriteLine();
}
private static void ReportDeflicker(Pipeline.TitanoProject project, TextWriter output)
{
if (project.Curve is not { } curve || curve.Count < 3) return;
double before = DeflickerCurve.FlickerIndex(curve.Measured);
var corrected = new double[curve.Count];
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
double after = DeflickerCurve.FlickerIndex(corrected);
double maxStepBefore = 0, maxStepAfter = 0;
int worstBefore = 0, worstAfter = 0;
for (int i = 1; i < curve.Count; i++)
{
double stepBefore = Math.Abs(curve.Measured[i] - curve.Measured[i - 1]);
if (stepBefore > maxStepBefore) { maxStepBefore = stepBefore; worstBefore = i; }
double stepAfter = Math.Abs(curve.Target[i] - curve.Target[i - 1]);
if (stepAfter > maxStepAfter) { maxStepAfter = stepAfter; worstAfter = i; }
}
output.WriteLine(" DEFLICKER");
output.WriteLine($" sfarfallio {before:0.0000} → {after:0.0000} stop RMS " +
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione)");
output.WriteLine($" salto massimo {maxStepBefore:0.000} stop al fotogramma {worstBefore + 1} " +
$"→ {maxStepAfter:0.000} al fotogramma {worstAfter + 1}" +
(maxStepAfter > maxStepBefore ? " ← LA CORREZIONE LO PEGGIORA" : string.Empty));
output.WriteLine($" escursione della luce {curve.Measured.Max() - curve.Measured.Min():0.00} EV sull'intera sequenza");
output.WriteLine();
}
/// <summary>
/// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero
/// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file.
/// </summary>
private static void RunTrialRender(List<FrameMetadata> metadata, string outputPath, int frames,
int forcedWidth, Video.VideoCodec codec, TextWriter output)
int forcedWidth, Video.VideoCodec codec,
AdvancedModules advanced, TextWriter output)
{
output.WriteLine();
output.WriteLine("RENDER DI PROVA");
@@ -178,6 +437,7 @@ public static class SequenceDiagnostics
project.Export.BitrateMbps = 80;
project.Export.Codec = codec;
project.General.WorkingWidth = forcedWidth;
ConfigureAdvanced(project, advanced);
var (width, height) = project.ResolveWorkingSize();
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
@@ -191,6 +451,14 @@ public static class SequenceDiagnostics
: string.Empty));
output.WriteLine($" memoria per buffer {perFrame / (1024.0 * 1024.0):0.0} MiB");
output.WriteLine($" codec {codec}");
if (advanced != AdvancedModules.None) output.WriteLine($" moduli attivi {advanced}");
if (project.NeedsGeometry)
{
var (sourceWidth, sourceHeight) = project.ResolveSourceSize();
output.WriteLine($" lettura sorgente {sourceWidth}×{sourceHeight} " +
$"({(long)sourceWidth * sourceHeight * 3 * sizeof(float) / (1024.0 * 1024.0):0.0} MiB per fotogramma)");
}
var pipeline = new Pipeline.RenderPipeline(project);
var stopwatch = Stopwatch.StartNew();
@@ -213,6 +481,8 @@ public static class SequenceDiagnostics
var playback = Mp4Playback.Read(result.OutputPath);
output.WriteLine($" rilettura {playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati"}");
ReportStackingEvidence(advanced, playback, output);
}
catch (Exception ex)
{
@@ -220,6 +490,48 @@ public static class SequenceDiagnostics
}
}
/// <summary>
/// Verifica sull'uscita vera la proprietà che definisce ciascuna modalità di accumulo.
///
/// Che il rendering non si inceppi non prova che l'effetto ci sia. Il massimo progressivo
/// ha una proprietà controllabile senza guardare l'immagine: la luminanza non può mai
/// calare, perché ogni pixel trattiene il valore più alto incontrato. Se cala, qualcosa
/// nell'accumulatore non tiene.
/// </summary>
private static void ReportStackingEvidence(AdvancedModules advanced, Mp4Playback.Playback playback,
TextWriter output)
{
var luma = playback.MeanLuma;
if (luma.Count < 4) return;
if (advanced.HasFlag(AdvancedModules.StarTrails))
{
int rising = 0;
double worstDrop = 0;
for (int i = 1; i < luma.Count; i++)
{
if (luma[i] >= luma[i - 1] - 1e-4) rising++;
else worstDrop = Math.Max(worstDrop, luma[i - 1] - luma[i]);
}
double share = 100.0 * rising / (luma.Count - 1);
output.WriteLine($" scie stellari luminanza da {luma[0]:0.0000} a {luma[^1]:0.0000} " +
$"(×{luma[^1] / Math.Max(1e-6, luma[0]):0.0}), " +
$"non decrescente sul {share:0.#}% dei passi" +
(worstDrop > 1e-3 ? $", calo massimo {worstDrop:0.0000}" : string.Empty));
}
if (advanced.HasFlag(AdvancedModules.MedianStack))
{
// La mediana temporale toglie ciò che passa una volta sola: la luminanza deve
// risultare più regolare di quella della sequenza sorgente.
double roughness = 0;
for (int i = 1; i < luma.Count; i++) roughness += Math.Abs(luma[i] - luma[i - 1]);
output.WriteLine($" mediana temporale variazione media fra fotogrammi " +
$"{roughness / (luma.Count - 1):0.00000}");
}
}
private static List<string> PickSamples(List<string> files, int count)
{
if (files.Count <= count) return files;
+25 -6
View File
@@ -44,16 +44,35 @@ 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>
/// <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)
{
double zoom = Math.Max(1e-3, framing.Zoom);
double outputAspect = outputHeight > 0 ? outputWidth / (double)outputHeight
: sourceWidth / (double)Math.Max(1, sourceHeight);
double baseWidth = Math.Min(sourceWidth, sourceHeight * outputAspect);
double baseHeight = baseWidth / Math.Max(1e-6, outputAspect);
double cropWidth = baseWidth / zoom;
double cropHeight = baseHeight / zoom;
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;
double zoom = Math.Max(1e-3, framing.Zoom);
double cropWidth = sourceWidth / zoom;
double cropHeight = sourceHeight / zoom;
double left = framing.CentreX * sourceWidth - cropWidth * 0.5;
double top = framing.CentreY * sourceHeight - cropHeight * 0.5;
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 —
+381
View File
@@ -0,0 +1,381 @@
using System.Buffers.Binary;
using System.Drawing.Imaging;
using System.Globalization;
using System.Text;
using Titano.Metadata;
namespace Titano.Imaging;
/// <summary>Formato in cui l'importazione scrive i file convertiti.</summary>
public enum RasterFormat
{
/// <summary>TIFF a 16 bit per canale, codificato in sRGB: il formato di scambio classico.</summary>
Tiff16,
/// <summary>DNG lineare a 16 bit: stessi pixel, in luce lineare e con i tag che lo rendono un DNG.</summary>
LinearDng,
/// <summary>JPEG, tramite il codificatore di sistema.</summary>
Jpeg,
}
/// <summary>
/// Scrittura di immagini su file, con il contenitore costruito a mano.
///
/// <b>Cosa è, e cosa non è, il DNG che questo produce.</b> Un DNG "vero" contiene i valori
/// grezzi del sensore, prima dell'interpolazione cromatica, ed è quello che permette a un
/// programma di sviluppo di rifare da capo il bilanciamento del bianco e la demosaicizzazione.
/// Per ottenerlo da un formato proprietario servirebbe interpretarne il sensore modello per
/// modello — cioè la libreria del produttore, che il vincolo sulle dipendenze esclude.
///
/// Quello che si può fare in-house è un <i>DNG lineare</i>: la specifica lo prevede
/// (PhotometricInterpretation 34892) e contiene pixel già interpolati, in luce lineare, a 16
/// bit. È un DNG valido e apribile ovunque, ma non restituisce la libertà di sviluppo del
/// grezzo. Chi vuole conservare quella libertà copia il file com'è, e infatti la copia resta
/// l'impostazione predefinita dell'importazione.
/// </summary>
public static class RasterWriter
{
private const ushort TypeByte = 1;
private const ushort TypeAscii = 2;
private const ushort TypeShort = 3;
private const ushort TypeLong = 4;
private const ushort TypeRational = 5;
private const ushort TypeSRational = 10;
public static string ExtensionFor(RasterFormat format) => format switch
{
RasterFormat.Tiff16 => ".tif",
RasterFormat.LinearDng => ".dng",
_ => ".jpg",
};
public static void Write(ImageBuffer frame, string path, FrameMetadata metadata,
RasterFormat format, int jpegQuality)
{
switch (format)
{
case RasterFormat.Jpeg:
WriteJpeg(frame, path, jpegQuality);
break;
default:
WriteTiff(frame, path, metadata, format == RasterFormat.LinearDng);
break;
}
}
// ------------------------------------------------------------------ JPEG
private static unsafe void WriteJpeg(ImageBuffer frame, string path, int quality)
{
using var bitmap = new Bitmap(frame.Width, frame.Height, PixelFormat.Format24bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, frame.Width, frame.Height),
ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
try
{
var data = frame.Data;
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < frame.Height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
int source = y * frame.Width * ImageBuffer.Channels;
for (int x = 0; x < frame.Width; x++)
{
int i = source + x * ImageBuffer.Channels;
byte* pixel = row + x * 3;
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]); // B
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]); // G
pixel[2] = ColorSpace.ToSrgbByte(data[i]); // R
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
using var parameters = new EncoderParameters(1);
using var setting = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality,
(long)Math.Clamp(quality, 40, 100));
parameters.Param[0] = setting;
bitmap.Save(path, codec, parameters);
}
// ------------------------------------------------------------------ TIFF e DNG
/// <summary>Voce di una directory TIFF, con il valore già ridotto a byte.</summary>
private sealed record Entry(ushort Tag, ushort Type, uint Count, byte[] Payload)
{
public bool Inline => Payload.Length <= 4;
}
private static void WriteTiff(ImageBuffer frame, string path, FrameMetadata metadata, bool asDng)
{
int width = frame.Width;
int height = frame.Height;
long pixelBytes = (long)width * height * 3 * sizeof(ushort);
var exif = BuildExifEntries(metadata, width, height);
var main = BuildMainEntries(metadata, width, height, asDng);
// Disposizione: intestazione, directory principale, directory Exif, valori fuori
// linea, pixel. Le dimensioni si conoscono tutte in anticipo, quindi gli scostamenti
// si calcolano senza dover tornare indietro a correggerli.
// Tre voci vengono aggiunte in coda — puntatore alla Exif, posizione e lunghezza dei
// pixel — e vanno contate qui: sbagliare di una voce sposta tutto ciò che segue di
// dodici byte, e il file resta apribile ma con i valori presi dal posto sbagliato.
const int appended = 3;
long ifdOffset = 8;
long ifdSize = 2 + 12L * (main.Count + appended) + 4;
long exifOffset = ifdOffset + ifdSize;
long exifSize = 2 + 12L * exif.Count + 4;
long valuesOffset = exifOffset + exifSize;
long cursor = valuesOffset;
var placement = new Dictionary<Entry, long>();
foreach (var entry in main.Concat(exif))
{
if (entry.Inline) continue;
placement[entry] = cursor;
cursor += entry.Payload.Length;
if ((cursor & 1) != 0) cursor++; // le voci partono su indirizzo pari
}
long stripOffset = cursor;
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None,
1 << 20, FileOptions.SequentialScan);
Span<byte> header = stackalloc byte[8];
header[0] = (byte)'I';
header[1] = (byte)'I';
BinaryPrimitives.WriteUInt16LittleEndian(header[2..], 42);
BinaryPrimitives.WriteUInt32LittleEndian(header[4..], (uint)ifdOffset);
stream.Write(header);
// Le voci vanno in ordine di tag crescente: è un requisito del formato, non un vezzo.
var mainOrdered = main
.Append(new Entry(34665, TypeLong, 1, BitConverter.GetBytes((uint)exifOffset)))
.Append(new Entry(273, TypeLong, 1, BitConverter.GetBytes((uint)stripOffset)))
.Append(new Entry(279, TypeLong, 1, BitConverter.GetBytes((uint)pixelBytes)))
.OrderBy(e => e.Tag)
.ToList();
WriteDirectory(stream, mainOrdered, placement, 0);
WriteDirectory(stream, [.. exif.OrderBy(e => e.Tag)], placement, 0);
foreach (var entry in mainOrdered.Concat(exif))
{
if (entry.Inline) continue;
stream.Position = placement[entry];
stream.Write(entry.Payload);
}
stream.Position = stripOffset;
WritePixels(stream, frame, linear: asDng);
}
private static void WriteDirectory(Stream stream, List<Entry> entries,
Dictionary<Entry, long> placement, uint nextIfd)
{
Span<byte> count = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(count, (ushort)entries.Count);
stream.Write(count);
Span<byte> record = stackalloc byte[12];
foreach (var entry in entries)
{
record.Clear();
BinaryPrimitives.WriteUInt16LittleEndian(record, entry.Tag);
BinaryPrimitives.WriteUInt16LittleEndian(record[2..], entry.Type);
BinaryPrimitives.WriteUInt32LittleEndian(record[4..], entry.Count);
if (entry.Inline) entry.Payload.CopyTo(record[8..]);
else BinaryPrimitives.WriteUInt32LittleEndian(record[8..], (uint)placement[entry]);
stream.Write(record);
}
Span<byte> next = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(next, nextIfd);
stream.Write(next);
}
/// <summary>
/// Pixel a 16 bit. Il DNG lineare vuole luce lineare, che è ciò che il buffer contiene;
/// il TIFF va invece codificato in sRGB, perché è come qualunque visualizzatore lo
/// interpreterà. Scrivere gli stessi numeri in entrambi darebbe un TIFF molto scuro.
/// </summary>
private static void WritePixels(Stream stream, ImageBuffer frame, bool linear)
{
int width = frame.Width;
var data = frame.Data;
var row = new byte[width * 3 * sizeof(ushort)];
for (int y = 0; y < frame.Height; y++)
{
int source = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int i = source + x * ImageBuffer.Channels;
for (int channel = 0; channel < 3; channel++)
{
float value = data[i + channel];
float encoded = linear ? Math.Clamp(value, 0f, 1f) : ColorSpace.ToSrgb(value);
ushort level = (ushort)Math.Clamp((int)(encoded * 65535f + 0.5f), 0, 65535);
BinaryPrimitives.WriteUInt16LittleEndian(row.AsSpan((x * 3 + channel) * 2), level);
}
}
stream.Write(row);
}
}
private static List<Entry> BuildMainEntries(FrameMetadata metadata, int width, int height, bool asDng)
{
var entries = new List<Entry>
{
Long(254, 0),
Long(256, (uint)width),
Long(257, (uint)height),
Shorts(258, [16, 16, 16]),
Short(259, 1), // nessuna compressione
Short(262, asDng ? (ushort)34892 : (ushort)2), // LinearRaw oppure RGB
Short(274, 1),
Short(277, 3),
Long(278, (uint)height), // una striscia sola
Rational(282, 72, 1),
Rational(283, 72, 1),
Short(296, 2),
Ascii(305, "Titano"),
Shorts(339, [1, 1, 1]), // interi senza segno
};
if (metadata.Camera is { Length: > 0 } camera)
{
entries.Add(Ascii(271, camera));
entries.Add(Ascii(272, camera));
}
if (metadata.CaptureTime is { } capture)
{
entries.Add(Ascii(306, capture.ToString("yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture)));
}
if (!asDng) return entries;
entries.Add(Bytes(50706, [1, 4, 0, 0])); // DNGVersion
entries.Add(Bytes(50707, [1, 1, 0, 0])); // DNGBackwardVersion
entries.Add(Ascii(50708, metadata.Camera ?? "Titano linear"));
entries.Add(Rationals(50714, [(0, 1)])); // BlackLevel
entries.Add(Long(50717, 65535)); // WhiteLevel
entries.Add(Short(50778, 21)); // illuminante di calibrazione: D65
// Lo spazio "della fotocamera" qui è l'sRGB lineare, perché è da lì che arrivano i
// pixel: la matrice dichiarata è quindi la XYZ verso sRGB, non una risposta misurata
// su un sensore. Dichiararne una inventata sarebbe peggio che dichiarare questa.
entries.Add(SRationals(50721,
[
(3204454, 1000000), (-1537139, 1000000), (-498531, 1000000),
(-969266, 1000000), (1876011, 1000000), (41556, 1000000),
(55643, 1000000), (-204026, 1000000), (1057225, 1000000),
]));
entries.Add(Rationals(50728, [(1, 1), (1, 1), (1, 1)])); // AsShotNeutral
return entries;
}
private static List<Entry> BuildExifEntries(FrameMetadata metadata, int width, int height)
{
var entries = new List<Entry>
{
Long(40962, (uint)width),
Long(40963, (uint)height),
};
if (metadata.ExposureSeconds is { } exposure && exposure > 0)
{
var (numerator, denominator) = ToRational(exposure);
entries.Add(Rational(33434, numerator, denominator));
}
if (metadata.FNumber is { } aperture && aperture > 0)
{
entries.Add(Rational(33437, (uint)Math.Round(aperture * 100), 100));
}
if (metadata.Iso is { } iso && iso > 0)
{
entries.Add(Short(34855, (ushort)Math.Min(iso, ushort.MaxValue)));
}
if (metadata.FocalLength is { } focal && focal > 0)
{
entries.Add(Rational(37386, (uint)Math.Round(focal * 100), 100));
}
if (metadata.CaptureTime is { } capture)
{
entries.Add(Ascii(36867, capture.ToString("yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture)));
}
return entries;
}
/// <summary>Frazione che rappresenta un tempo di posa senza perdere i valori tipici (1/125, 1/8000).</summary>
private static (uint Numerator, uint Denominator) ToRational(double value)
{
if (value >= 1) return ((uint)Math.Round(value * 1000), 1000);
double inverse = 1.0 / value;
return (1, (uint)Math.Clamp(Math.Round(inverse), 1, uint.MaxValue));
}
// ---- costruttori di voce
private static Entry Short(ushort tag, ushort value)
{
var payload = new byte[4];
BinaryPrimitives.WriteUInt16LittleEndian(payload, value);
return new Entry(tag, TypeShort, 1, payload);
}
private static Entry Shorts(ushort tag, ushort[] values)
{
var payload = new byte[values.Length * 2];
for (int i = 0; i < values.Length; i++)
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(i * 2), values[i]);
return new Entry(tag, TypeShort, (uint)values.Length, payload);
}
private static Entry Long(ushort tag, uint value)
=> new(tag, TypeLong, 1, BitConverter.GetBytes(value));
private static Entry Bytes(ushort tag, byte[] values)
=> new(tag, TypeByte, (uint)values.Length, values);
private static Entry Ascii(ushort tag, string value)
{
var payload = Encoding.ASCII.GetBytes(value + "\0");
return new Entry(tag, TypeAscii, (uint)payload.Length, payload);
}
private static Entry Rational(ushort tag, uint numerator, uint denominator)
=> Rationals(tag, [(numerator, denominator)]);
private static Entry Rationals(ushort tag, (uint Numerator, uint Denominator)[] values)
{
var payload = new byte[values.Length * 8];
for (int i = 0; i < values.Length; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(i * 8), values[i].Numerator);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(i * 8 + 4), values[i].Denominator);
}
return new Entry(tag, TypeRational, (uint)values.Length, payload);
}
private static Entry SRationals(ushort tag, (int Numerator, int Denominator)[] values)
{
var payload = new byte[values.Length * 8];
for (int i = 0; i < values.Length; i++)
{
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(i * 8), values[i].Numerator);
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(i * 8 + 4), values[i].Denominator);
}
return new Entry(tag, TypeSRational, (uint)values.Length, payload);
}
}
+29 -5
View File
@@ -118,10 +118,23 @@ public sealed class PhaseCorrelator
/// Misura lo spostamento del contenuto del riquadro fra <paramref name="a"/> e
/// <paramref name="b"/>: il risultato è il vettore d per cui b(p) ≈ a(p d).
/// </summary>
/// <summary>
/// Deviazione standard sotto la quale un riquadro si considera privo di tessitura.
/// Corrisponde a mezzo livello su 255: sotto, non c'è nulla di cui misurare lo spostamento.
/// </summary>
private const float TextureFloor = 0.002f;
public PhaseShift Correlate(GrayImage a, GrayImage b, int originX, int originY)
{
Load(a, originX, originY, _aRe, _aIm);
Load(b, originX, originY, _bRe, _bIm);
float deviationA = Load(a, originX, originY, _aRe, _aIm);
float deviationB = Load(b, originX, originY, _bRe, _bIm);
// Un riquadro uniforme non ha spostamento misurabile: la normalizzazione al modulo
// unitario amplificherebbe il solo rumore numerico e l'antitrasformata darebbe un
// picco qualunque, indistinguibile da uno vero. Succede davvero — su una ripresa che
// attraversa il giorno con pose da trenta secondi il cielo esce bruciato e piatto —
// e senza questo controllo la stabilizzazione inseguiva spostamenti inventati.
if (deviationA < TextureFloor || deviationB < TextureFloor) return PhaseShift.None;
Fourier.Transform2D(_aRe, _aIm, _size, false);
Fourier.Transform2D(_bRe, _bIm, _size, false);
@@ -273,10 +286,15 @@ public sealed class PhaseCorrelator
return MathF.Abs(offset) > 0.5f ? 0 : offset;
}
/// <summary>Estrae il riquadro, ne toglie la media e vi applica la finestra.</summary>
private void Load(GrayImage image, int originX, int originY, float[] re, float[] im)
/// <summary>
/// Estrae il riquadro, ne toglie la media e vi applica la finestra. Restituisce la
/// deviazione standard del riquadro, che dice se c'era qualcosa da misurare.
/// </summary>
private float Load(GrayImage image, int originX, int originY, float[] re, float[] im)
{
double sum = 0;
double sumSquares = 0;
for (int y = 0; y < _size; y++)
{
int rowBase = y * _size;
@@ -285,14 +303,20 @@ public sealed class PhaseCorrelator
float value = image.At(originX + x, originY + y);
re[rowBase + x] = value;
sum += value;
sumSquares += (double)value * value;
}
}
float mean = (float)(sum / re.Length);
double count = re.Length;
float mean = (float)(sum / count);
double variance = Math.Max(0, sumSquares / count - (double)mean * mean);
for (int i = 0; i < re.Length; i++)
{
re[i] = (re[i] - mean) * _window[i];
im[i] = 0f;
}
return (float)Math.Sqrt(variance);
}
}
+240
View File
@@ -0,0 +1,240 @@
using System.Globalization;
using System.Text;
namespace Titano.Pipeline;
/// <summary>
/// Impostazioni dell'applicazione, distinte da quelle del progetto: valgono per ogni
/// sequenza e sopravvivono alla chiusura. Il progetto descrive come trattare *questi*
/// fotogrammi, queste descrivono come si comporta il programma.
///
/// Il file è scritto e riletto a mano, nello stesso spirito del resto: una riga per
/// impostazione, chiave e valore separati da uguale, commenti con il cancelletto. Un
/// formato che si apre con un editor di testo e si corregge a occhio vale, per un file di
/// una trentina di righe, più di qualunque serializzatore.
/// </summary>
public sealed class AppSettings
{
// ---- avvio -------------------------------------------------------------
/// <summary>Cartella proposta dalle finestre di scelta file.</summary>
public string LastFolder { get; set; } = string.Empty;
/// <summary>Ricarica le impostazioni salvate quando si riapre la stessa cartella.</summary>
public bool RememberPerFolder { get; set; } = true;
/// <summary>Avvia l'analisi appena una sequenza viene caricata.</summary>
public bool AnalyzeOnLoad { get; set; }
// ---- valori predefiniti del progetto ------------------------------------
public QualityProfile DefaultQuality { get; set; } = QualityProfile.Massima;
public int DefaultDecodeParallelism { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 2, 8);
public int DefaultMemoryBudgetMiB { get; set; } = 3072;
public bool DefaultAllowDiskSpill { get; set; } = true;
/// <summary>Cartella del parcheggio su disco; vuota significa quella temporanea di sistema.</summary>
public string SpillDirectory { get; set; } = string.Empty;
// ---- comportamento ------------------------------------------------------
/// <summary>Chiede conferma prima di sovrascrivere un file di uscita esistente.</summary>
public bool ConfirmOverwrite { get; set; } = true;
/// <summary>Mostra l'anteprima dei fotogrammi mentre vengono codificati.</summary>
public bool LivePreviewDuringExport { get; set; } = true;
/// <summary>Ogni quanti fotogrammi aggiornare l'anteprima durante l'esportazione.</summary>
public int LivePreviewEvery { get; set; } = 8;
/// <summary>Apre la cartella di destinazione a esportazione conclusa.</summary>
public bool RevealWhenFinished { get; set; }
/// <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;
/// <summary>Secondi fra un annuncio e il successivo.</summary>
public int SponsorRotationSeconds { get; set; } = 20;
/// <summary>
/// Cartella del listino delle campagne; vuota significa quella predefinita accanto alle
/// impostazioni. È una cartella locale: il programma non contatta nessun servizio.
/// </summary>
public string SponsorFolder { get; set; } = string.Empty;
// ------------------------------------------------------------------ percorsi
/// <summary>
/// Cartella dei dati dell'applicazione, creata alla prima scrittura.
///
/// 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.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;
// ------------------------------------------------------------------ lettura e scrittura
public static AppSettings Load()
{
var settings = new AppSettings();
try
{
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)
{
// Impostazioni illeggibili: si riparte dai valori predefiniti, che sono validi.
}
return settings;
}
public void Save()
{
try
{
Directory.CreateDirectory(DataDirectory);
var text = new StringBuilder();
text.AppendLine("# Impostazioni di Titano. Una riga per voce, chiave = valore.");
text.AppendLine("# Il file si può correggere a mano: i valori fuori intervallo vengono riportati dentro.");
text.AppendLine();
foreach (var (key, value) in Pairs()) text.AppendLine($"{key} = {value}");
File.WriteAllText(SettingsPath, text.ToString(), Encoding.UTF8);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Non poter salvare le preferenze non deve impedire di lavorare.
}
}
private IEnumerable<(string Key, string Value)> Pairs()
{
yield return ("cartella-recente", LastFolder);
yield return ("ricorda-per-cartella", Format(RememberPerFolder));
yield return ("analizza-al-caricamento", Format(AnalyzeOnLoad));
yield return ("qualita-predefinita", DefaultQuality.ToString());
yield return ("decodifiche-simultanee", DefaultDecodeParallelism.ToString(CultureInfo.InvariantCulture));
yield return ("tetto-memoria-mib", DefaultMemoryBudgetMiB.ToString(CultureInfo.InvariantCulture));
yield return ("parcheggio-su-disco", Format(DefaultAllowDiskSpill));
yield return ("cartella-parcheggio", SpillDirectory);
yield return ("conferma-sovrascrittura", Format(ConfirmOverwrite));
yield return ("anteprima-durante-esportazione", Format(LivePreviewDuringExport));
yield return ("anteprima-ogni", LivePreviewEvery.ToString(CultureInfo.InvariantCulture));
yield return ("apri-cartella-a-fine", Format(RevealWhenFinished));
yield return ("barra-sezioni-ridotta", Format(RailCollapsed));
yield return ("mostra-sponsor", Format(ShowSponsors));
yield return ("rotazione-sponsor-secondi", SponsorRotationSeconds.ToString(CultureInfo.InvariantCulture));
yield return ("cartella-sponsor", SponsorFolder);
}
private void Apply(string key, string value)
{
switch (key)
{
case "cartella-recente": LastFolder = value; break;
case "ricorda-per-cartella": RememberPerFolder = ParseBool(value, RememberPerFolder); break;
case "analizza-al-caricamento": AnalyzeOnLoad = ParseBool(value, AnalyzeOnLoad); break;
case "qualita-predefinita":
if (Enum.TryParse<QualityProfile>(value, true, out var quality)) DefaultQuality = quality;
break;
case "decodifiche-simultanee":
DefaultDecodeParallelism = Math.Clamp(ParseInt(value, DefaultDecodeParallelism), 1, 16);
break;
case "tetto-memoria-mib":
DefaultMemoryBudgetMiB = Math.Clamp(ParseInt(value, DefaultMemoryBudgetMiB), 0, 131072);
break;
case "parcheggio-su-disco": DefaultAllowDiskSpill = ParseBool(value, DefaultAllowDiskSpill); break;
case "cartella-parcheggio": SpillDirectory = value; break;
case "conferma-sovrascrittura": ConfirmOverwrite = ParseBool(value, ConfirmOverwrite); break;
case "anteprima-durante-esportazione":
LivePreviewDuringExport = ParseBool(value, LivePreviewDuringExport);
break;
case "anteprima-ogni": LivePreviewEvery = Math.Clamp(ParseInt(value, LivePreviewEvery), 1, 240); break;
case "apri-cartella-a-fine": RevealWhenFinished = ParseBool(value, RevealWhenFinished); break;
case "barra-sezioni-ridotta": RailCollapsed = ParseBool(value, RailCollapsed); break;
case "mostra-sponsor": ShowSponsors = ParseBool(value, ShowSponsors); break;
case "rotazione-sponsor-secondi":
SponsorRotationSeconds = Math.Clamp(ParseInt(value, SponsorRotationSeconds), 5, 600);
break;
case "cartella-sponsor": SponsorFolder = value; break;
}
}
/// <summary>Applica i valori predefiniti a un progetto appena creato.</summary>
public void ApplyTo(TitanoProject project)
{
project.ApplyQualityProfile(DefaultQuality);
project.General.DecodeParallelism = DefaultDecodeParallelism;
project.Cache.MemoryBudgetMiB = DefaultMemoryBudgetMiB;
project.Cache.AllowDiskSpill = DefaultAllowDiskSpill;
project.Cache.SpillDirectory = SpillDirectory;
}
// ------------------------------------------------------------------ formato
/// <summary>
/// Spezza le righe in coppie chiave/valore ignorando commenti e righe vuote. Il valore
/// conserva gli spazi interni: un percorso può contenerne, e troncarlo sarebbe un guasto
/// silenzioso difficile da capire.
/// </summary>
internal static IEnumerable<(string Key, string Value)> ReadPairs(IEnumerable<string> lines)
{
foreach (string raw in lines)
{
string line = raw.Trim();
if (line.Length == 0 || line[0] == '#') continue;
int separator = line.IndexOf('=');
if (separator <= 0) continue;
yield return (line[..separator].Trim().ToLowerInvariant(), line[(separator + 1)..].Trim());
}
}
private static string Format(bool value) => value ? "si" : "no";
internal static bool ParseBool(string value, bool fallback) => value.ToLowerInvariant() switch
{
"si" or "sì" or "vero" or "true" or "1" => true,
"no" or "falso" or "false" or "0" => false,
_ => fallback,
};
internal static int ParseInt(string value, int fallback)
=> int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) ? parsed : fallback;
internal static double ParseDouble(string value, double fallback)
=> double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)
? parsed
: fallback;
}
+249
View File
@@ -0,0 +1,249 @@
using Titano.Analysis;
using Titano.Core;
namespace Titano.Pipeline;
/// <summary>Un parametro che il programma sa dedurre da sé.</summary>
public enum AutoKey
{
AnalysisWidth,
DeflickerWindow,
TransitionFrames,
StabilizationWindow,
MemoryBudget,
RegionMode,
}
/// <summary>Valore scelto per un parametro, con il motivo per cui è stato scelto.</summary>
public sealed record AutoDecision(AutoKey Key, double Value, string Reason);
/// <summary>
/// Deduce i parametri che si possono ricavare dalle misure invece che chiedere.
///
/// Non è un pannello di scorciatoie: è lo strato che sta fra l'analisi e l'esecuzione e
/// scrive i valori che l'utente altrimenti dovrebbe indovinare. Ogni decisione porta con sé
/// la frase che la spiega, perché un pilota automatico che non dice cosa ha deciso è una
/// scatola nera — e una scatola nera in un programma di elaborazione si spegne al primo
/// risultato che non si capisce.
///
/// Vale una regola sopra tutte: dove il valore giusto non si può misurare, il direttore non
/// inventa. Restituisce meno decisioni e lascia il cursore dov'è.
/// </summary>
public static class AutoDirector
{
/// <summary>Finestre candidate per la lisciatura del percorso, dalla più corta.</summary>
private static readonly int[] StabilizationCandidates = [7, 15, 31, 61, 91, 121];
public static Dictionary<AutoKey, AutoDecision> Derive(TitanoProject project)
{
var decisions = new Dictionary<AutoKey, AutoDecision>();
DeriveAnalysisWidth(project, decisions);
DeriveMemoryBudget(decisions);
DeriveDeflickerWindow(project, decisions);
DeriveTransition(project, decisions);
DeriveStabilizationWindow(project, decisions);
return decisions;
}
/// <summary>Scrive nel progetto le decisioni che l'utente non ha preso a mano.</summary>
public static void Apply(TitanoProject project, IReadOnlyDictionary<AutoKey, AutoDecision> decisions,
IReadOnlySet<AutoKey> manual)
{
foreach (var (key, decision) in decisions)
{
if (manual.Contains(key)) continue;
switch (key)
{
case AutoKey.AnalysisWidth:
project.General.AnalysisWidth = (int)decision.Value;
break;
case AutoKey.DeflickerWindow:
project.Deflicker.WindowFrames = (int)decision.Value;
break;
case AutoKey.TransitionFrames:
project.HolyGrail.TransitionFrames = (int)decision.Value;
break;
case AutoKey.StabilizationWindow:
project.Stabilization.SmoothingFrames = (int)decision.Value;
break;
case AutoKey.MemoryBudget:
project.Cache.MemoryBudgetMiB = (int)decision.Value;
break;
}
}
}
// ------------------------------------------------------------------ singole deduzioni
private static void DeriveAnalysisWidth(TitanoProject project, Dictionary<AutoKey, AutoDecision> decisions)
{
var (width, _) = project.ResolveNativeSize();
if (width <= 0) return;
// Metà della risoluzione nativa, entro limiti sensati: la media logaritmica è
// invariante alla scala, quindi oltre un certo punto si paga tempo per nulla, ma
// scendere troppo toglie tessitura alla correlazione di fase.
int chosen = Math.Clamp((width / 2) & ~63, 512, 2048);
decisions[AutoKey.AnalysisWidth] = new AutoDecision(AutoKey.AnalysisWidth, chosen,
$"metà dei {width} px nativi, entro i limiti utili all'analisi");
}
private static void DeriveMemoryBudget(Dictionary<AutoKey, AutoDecision> decisions)
{
long available = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
if (available <= 0) return;
// Poco meno della metà di quanto il sistema dichiara: la finestra attiva, i buffer di
// lavoro e tutto il resto del sistema devono starci accanto.
int budget = (int)Math.Clamp(available / (1024L * 1024) * 45 / 100, 512, 32768);
decisions[AutoKey.MemoryBudget] = new AutoDecision(AutoKey.MemoryBudget, budget,
$"45% dei {available / (1024.0 * 1024 * 1024):0.0} GiB che il sistema dichiara disponibili");
}
/// <summary>
/// Finestra del deflicker dedotta dal periodo dello sfarfallio.
///
/// Lo sfarfallio di un intervallometro è quasi periodico: si stima il periodo dominante
/// cercando dove l'autocorrelazione delle differenze prime torna positiva, e si prende una
/// finestra che ne contenga alcuni. Più corta lascerebbe passare il disturbo, più lunga
/// comincerebbe a mangiare le variazioni di luce vere.
/// </summary>
private static void DeriveDeflickerWindow(TitanoProject project, Dictionary<AutoKey, AutoDecision> decisions)
{
if (project.Curve is not { Count: > 24 } curve) return;
var measured = curve.Measured;
int n = measured.Length;
var delta = new double[n - 1];
double mean = 0;
for (int i = 1; i < n; i++) { delta[i - 1] = measured[i] - measured[i - 1]; mean += delta[i - 1]; }
mean /= delta.Length;
double variance = 0;
for (int i = 0; i < delta.Length; i++)
{
delta[i] -= mean;
variance += delta[i] * delta[i];
}
if (variance < 1e-9) return;
int period = 0;
int maxLag = Math.Min(30, delta.Length / 4);
for (int lag = 2; lag <= maxLag; lag++)
{
double sum = 0;
for (int i = 0; i + lag < delta.Length; i++) sum += delta[i] * delta[i + lag];
if (sum / variance > 0.12) { period = lag; break; }
}
int window = period > 0
? Math.Clamp(period * 4 | 1, 7, 61)
: 15;
decisions[AutoKey.DeflickerWindow] = new AutoDecision(AutoKey.DeflickerWindow, window,
period > 0
? $"quattro periodi dello sfarfallio, che si ripete ogni {period} fotogrammi"
: "nessun periodo riconoscibile: resta la finestra prudente di quindici");
}
/// <summary>
/// Lunghezza della transizione dedotta dalla distanza fra i cambi di esposizione: spalmare
/// un gradino su cinquanta fotogrammi non ha senso se il successivo arriva al ventesimo,
/// perché le due transizioni si sovrapporrebbero e nessuna delle due sarebbe morbida.
/// </summary>
private static void DeriveTransition(TitanoProject project, Dictionary<AutoKey, AutoDecision> decisions)
{
if (project.Transitions is not { StepCount: > 0 } transitions) return;
var steps = transitions.StepFrames;
if (steps.Length < 2)
{
decisions[AutoKey.TransitionFrames] = new AutoDecision(AutoKey.TransitionFrames, 48,
"un solo cambio nella sequenza: la transizione può essere lunga");
return;
}
var gaps = new int[steps.Length - 1];
for (int i = 1; i < steps.Length; i++) gaps[i - 1] = steps[i] - steps[i - 1];
Array.Sort(gaps);
int median = gaps[gaps.Length / 2];
int frames = Math.Clamp(median / 2 * 2, 6, 160);
decisions[AutoKey.TransitionFrames] = new AutoDecision(AutoKey.TransitionFrames, frames,
$"metà della distanza tipica fra i {steps.Length} cambi, che è di {median} fotogrammi");
}
/// <summary>
/// Finestra della stabilizzazione scelta per misura, non per convenzione: si prova la più
/// corta e si sale finché il tremolio rimosso non smette di crescere in modo apprezzabile.
/// Una finestra più lunga del necessario comincia a togliere anche le panoramiche volute,
/// e si paga in ritaglio.
/// </summary>
private static void DeriveStabilizationWindow(TitanoProject project, Dictionary<AutoKey, AutoDecision> decisions)
{
if (project.MotionRelative is not { Length: > 24 } relative ||
project.MotionConfidence is not { } confidence) return;
int n = relative.Length;
var pathX = new double[n];
var pathY = new double[n];
var absolute = Motion.SimilarityTransform.Identity;
for (int i = 0; i < n; i++)
{
if (i > 0) absolute = Motion.SimilarityTransform.Compose(relative[i], absolute);
pathX[i] = absolute.Tx;
pathY[i] = absolute.Ty;
}
double baseline = Roughness(pathX) + Roughness(pathY);
if (baseline < 1e-9) return;
int chosen = StabilizationCandidates[^1];
double achieved = 0;
foreach (int window in StabilizationCandidates)
{
var smoothX = LocalRegression.Smooth(pathX, window, false);
var smoothY = LocalRegression.Smooth(pathY, window, false);
double residual = Roughness(Difference(pathX, smoothX)) + Roughness(Difference(pathY, smoothY));
double removed = 1 - (baseline - residual) / baseline;
// "removed" è la quota di irregolarità che resta nel percorso liscio: quando
// scende sotto un decimo, allungare ancora la finestra non serve più.
if (removed >= 0.10) continue;
chosen = window;
achieved = 1 - removed;
break;
}
decisions[AutoKey.StabilizationWindow] = new AutoDecision(AutoKey.StabilizationWindow, chosen,
achieved > 0
? $"la più corta che rende liscio il percorso ({achieved * 100:0}% del tremolio)"
: "il percorso resta irregolare anche con la finestra più lunga");
_ = confidence;
}
private static double[] Difference(double[] a, double[] b)
{
var result = new double[a.Length];
for (int i = 0; i < a.Length; i++) result[i] = a[i] - b[i];
return result;
}
/// <summary>Energia delle differenze seconde: zero su una rampa, alta su un percorso a scatti.</summary>
private static double Roughness(double[] series)
{
if (series.Length < 3) return 0;
double sum = 0;
for (int i = 1; i < series.Length - 1; i++)
{
double d = series[i + 1] - 2 * series[i] + series[i - 1];
sum += d * d;
}
return Math.Sqrt(sum / (series.Length - 2));
}
}
+314
View File
@@ -0,0 +1,314 @@
using Titano.Analysis;
using Titano.Core;
using Titano.Motion;
namespace Titano.Pipeline;
/// <summary>
/// Rilevamento delle impostazioni ottimali per una singola sezione.
///
/// Si distingue dal direttore, che lavora di continuo sui pochi parametri deducibili senza
/// ambiguità: questo si invoca a mano e prende decisioni più impegnative, comprese quelle di
/// accendere o spegnere interi moduli. Il motivo per cui va chiesto e non fatto da sé è che
/// cambia scelte che l'utente potrebbe aver preso apposta, e sostituirgliele senza dirlo
/// sarebbe peggio che lasciarle sbagliate.
///
/// Ogni decisione torna indietro come una frase: alla fine si legge cosa è cambiato e perché,
/// e se non convince si annulla a mano. Dove il dato manca — la sequenza non è ancora stata
/// analizzata — non si tira a indovinare: si dice che manca.
/// </summary>
public static class AutoOptimizer
{
public static List<string> Optimize(TitanoProject project, WorkspaceArea area)
{
var changes = new List<string>();
if (project.Sequence is not { Count: > 0 }) return ["Nessuna sequenza caricata."];
switch (area)
{
case WorkspaceArea.Sequence: OptimizeReading(project, changes); break;
case WorkspaceArea.Exposure: OptimizeExposure(project, changes); break;
case WorkspaceArea.Motion: OptimizeMotion(project, changes); break;
case WorkspaceArea.Timing: OptimizeTiming(project, changes); break;
default: OptimizeExport(project, changes); break;
}
if (changes.Count == 0) changes.Add("Nessuna modifica: le impostazioni erano già adeguate.");
return changes;
}
// ------------------------------------------------------------------ lettura
private static void OptimizeReading(TitanoProject project, List<string> changes)
{
var decisions = AutoDirector.Derive(project);
project.ManualParameters.Remove(AutoKey.AnalysisWidth);
project.ManualParameters.Remove(AutoKey.MemoryBudget);
AutoDirector.Apply(project, decisions, project.ManualParameters);
if (decisions.TryGetValue(AutoKey.AnalysisWidth, out var width))
changes.Add($"Passata di analisi a {width.Value:0} px: {width.Reason}.");
if (decisions.TryGetValue(AutoKey.MemoryBudget, out var memory))
changes.Add($"Tetto di memoria a {memory.Value:0} MiB: {memory.Reason}.");
// Le decodifiche simultanee non si spingono al numero di processori: la decodifica di
// un RAW è dominata dall'accesso al file, e oltre un certo punto i thread si
// contendono il disco invece di sommarsi.
int parallelism = Math.Clamp(Environment.ProcessorCount / 2, 2, 8);
if (project.General.DecodeParallelism != parallelism)
{
project.General.DecodeParallelism = parallelism;
changes.Add($"Decodifiche simultanee a {parallelism}: metà dei processori logici.");
}
// Una sequenza lunga guadagna da una lettura in anticipo profonda; una corta no,
// e occuperebbe memoria per fotogrammi che non fa in tempo a usare.
int prefetch = project.Sequence!.Count > 200 ? 12 : 6;
if (project.Cache.PrefetchDepth != prefetch)
{
project.Cache.PrefetchDepth = prefetch;
changes.Add($"Lettura in anticipo a {prefetch} fotogrammi.");
}
}
// ------------------------------------------------------------------ esposizione
private static void OptimizeExposure(TitanoProject project, List<string> changes)
{
if (project.Curve is not { Count: > 4 } curve || project.Stats is null)
{
changes.Add("Analizza la sequenza: senza le misure non c'è nulla su cui decidere.");
return;
}
double flicker = DeflickerCurve.FlickerIndex(curve.Measured);
double span = curve.Measured.Max() - curve.Measured.Min();
if (!project.Deflicker.Enabled && flicker > 0.02)
{
project.Deflicker.Enabled = true;
changes.Add($"Deflicker acceso: lo sfarfallio misurato è {flicker:0.000} EV.");
}
// La correzione massima deve coprire lo sfarfallio con margine, ma non tanto da poter
// inseguire un cambio di luce vero: tre volte lo scarto tipico è il compromesso che
// lascia passare le rampe e blocca gli sbandamenti.
double limit = Math.Clamp(Math.Round(flicker * 6, 1), 0.5, 2.5);
if (Math.Abs(project.Deflicker.MaxCorrectionStops - limit) > 0.05)
{
project.Deflicker.MaxCorrectionStops = limit;
changes.Add($"Correzione massima a {limit:0.0} EV, sei volte lo sfarfallio misurato.");
}
var decisions = AutoDirector.Derive(project);
project.ManualParameters.Remove(AutoKey.DeflickerWindow);
project.ManualParameters.Remove(AutoKey.TransitionFrames);
AutoDirector.Apply(project, decisions, project.ManualParameters);
if (decisions.TryGetValue(AutoKey.DeflickerWindow, out var window))
changes.Add($"Finestra del deflicker a {window.Value:0}: {window.Reason}.");
// Le transizioni servono solo se ci sono davvero dei gradini da ammorbidire.
bool hasSteps = project.Transitions is { StepCount: > 0 };
if (hasSteps != project.HolyGrail.Enabled)
{
project.HolyGrail.Enabled = hasSteps;
changes.Add(hasSteps
? $"Transizioni accese: {project.Transitions!.StepCount} cambi di impostazione rilevati."
: "Transizioni spente: nessun cambio di impostazione nella sequenza.");
}
// Una sequenza con poca escursione di luce non ha regioni che si comportino in modo
// diverso: la divisione aggiungerebbe complessità senza cambiare il risultato.
var suggested = span > 1.5 ? RegionMode.SkyGround : RegionMode.Off;
if (project.Regions.Mode != suggested)
{
project.Regions.Mode = suggested;
changes.Add(suggested == RegionMode.Off
? $"Divisione in regioni spenta: l'escursione di luce è di soli {span:0.0} EV."
: $"Divisione per orizzonte accesa: {span:0.0} EV di escursione la rendono utile.");
}
// Le alte luci vanno protette quando la correzione schiarisce e c'è già del saturo.
bool anyClipping = project.Stats.Any(s => s.ClippedFraction > 0.01);
if (anyClipping && !project.Deflicker.ProtectHighlights)
{
project.Deflicker.ProtectHighlights = true;
changes.Add("Protezione delle alte luci accesa: la sequenza contiene zone sature.");
}
}
// ------------------------------------------------------------------ movimento
private static void OptimizeMotion(TitanoProject project, List<string> changes)
{
var (sourceWidth, _) = project.ResolveSourceSize();
double toPixels = Math.Max(1, sourceWidth);
if (project.Motion is { } motion)
{
double shake = motion.MeanShake * toPixels;
// Sotto un terzo di pixel il tremolio non si vede nemmeno a schermo intero, e
// stabilizzare costerebbe il ritaglio senza restituire nulla.
bool worth = shake > 0.3;
if (worth != project.Stabilization.Enabled)
{
project.Stabilization.Enabled = worth;
changes.Add(worth
? $"Stabilizzazione accesa: il tremolio medio è di {shake:0.00} px."
: $"Stabilizzazione spenta: {shake:0.00} px di tremolio non si vedono.");
}
if (worth)
{
var decisions = AutoDirector.Derive(project);
project.ManualParameters.Remove(AutoKey.StabilizationWindow);
AutoDirector.Apply(project, decisions, project.ManualParameters);
if (decisions.TryGetValue(AutoKey.StabilizationWindow, out var window))
changes.Add($"Finestra del percorso a {window.Value:0}: {window.Reason}.");
// Il limite di correzione si allinea a quanto serve davvero, con un margine:
// più largo del necessario significa solo più ritaglio.
double needed = Math.Clamp(motion.MaxCorrection * 1.4, 0.01, 0.15);
if (Math.Abs(project.Stabilization.MaxCorrectionFraction - needed) > 0.004)
{
project.Stabilization.MaxCorrectionFraction = Math.Round(needed, 3);
changes.Add($"Correzione massima a {needed * 100:0.#}% della larghezza, " +
$"con margine su quanto misurato.");
}
}
if (motion.UnreliableFraction > 0.35 && project.Stabilization.Enabled)
{
changes.Add($"Attenzione: sul {motion.UnreliableFraction * 100:0}% delle coppie la " +
$"correlazione è debole, di solito per fotogrammi bruciati o senza tessitura.");
}
}
else
{
changes.Add("Attiva la stabilizzazione e analizza per misurare il tremolio.");
}
// La sfocatura sintetica ha senso solo se c'è movimento da sfocare.
if (project.Sequence is { Count: > 1 } sequence)
{
double motionMagnitude = sequence.Frames.Take(64).Select(f => f.MotionMagnitude).DefaultIfEmpty(0).Max();
if (motionMagnitude > 0)
{
bool worthBlur = motionMagnitude > 0.8;
if (worthBlur != project.MotionBlur.Enabled)
{
project.MotionBlur.Enabled = worthBlur;
changes.Add(worthBlur
? $"Sfocatura accesa: lo spostamento arriva a {motionMagnitude:0.0} px fra fotogrammi."
: "Sfocatura spenta: il movimento fra fotogrammi è troppo piccolo da sfocare.");
}
}
}
}
// ------------------------------------------------------------------ tempo
private static void OptimizeTiming(TitanoProject project, List<string> changes)
{
if (project.Curve is not { Count: > 24 } curve)
{
changes.Add("Analizza la sequenza: la curva di velocità si ricava dalla luce misurata.");
return;
}
// Rallentare dove la luce cambia più in fretta e correre dove non succede niente:
// è il tramonto che merita i fotogrammi, non l'ora di buio prima.
int count = curve.Count;
const int knots = 9;
var speeds = new double[knots];
for (int k = 0; k < knots; k++)
{
int centre = (int)Math.Round(k * (count - 1.0) / (knots - 1));
int from = Math.Max(1, centre - count / (2 * knots));
int to = Math.Min(count - 1, centre + count / (2 * knots));
double change = 0;
int samples = 0;
for (int i = from; i <= to; i++)
{
change += Math.Abs(curve.Target[i] - curve.Target[i - 1]);
samples++;
}
speeds[k] = samples > 0 ? change / samples : 0;
}
double busiest = speeds.Max();
if (busiest < 1e-4)
{
changes.Add("La luce non cambia abbastanza da giustificare una rimappatura.");
if (project.TimeRamp.Enabled)
{
project.TimeRamp.Enabled = false;
changes.Add("Curva di velocità spenta.");
}
return;
}
var curveKnots = new List<SplineKnot>(knots);
for (int k = 0; k < knots; k++)
{
// Dove la luce cambia di più la velocità scende verso 0,5; dove è ferma sale a 2,5.
double activity = speeds[k] / busiest;
double speed = 2.5 - 2.0 * activity;
curveKnots.Add(new SplineKnot(k / (knots - 1.0), Math.Round(Math.Clamp(speed, 0.4, 2.5), 2)));
}
project.TimeRamp.Speed = curveKnots;
project.TimeRamp.Enabled = true;
changes.Add($"Curva di velocità ricavata dalla luce: da {curveKnots.Min(k => k.Y):0.##}× " +
$"nei passaggi rapidi a {curveKnots.Max(k => k.Y):0.##}× dove la scena è ferma.");
// Con la rimappatura attiva i fotogrammi mancanti vanno sintetizzati, e per farlo
// serve il campo vettoriale: la modalità a durata costante lo renderebbe inutile.
if (project.Export.Timing != Video.FrameTimingMode.Constant)
{
project.Export.Timing = Video.FrameTimingMode.Constant;
changes.Add("Durata dei fotogrammi riportata a costante: la rimappatura governa già il ritmo.");
}
}
// ------------------------------------------------------------------ esportazione
private static void OptimizeExport(TitanoProject project, List<string> changes)
{
var (width, height) = project.ResolveWorkingSize();
if (width <= 0) return;
// Bitrate proporzionale ai pixel al secondo. Il coefficiente è quello che per H.264
// tiene insieme un cielo notturno senza banding evidente; HEVC ottiene lo stesso con
// circa il trenta per cento in meno.
double pixelsPerSecond = (double)width * height * project.Export.FrameRate;
double bits = pixelsPerSecond * (project.Export.Codec == Video.VideoCodec.H264 ? 0.085 : 0.060);
double suggested = Math.Clamp(Math.Round(bits / 1_000_000.0 / 5) * 5, 10, 250);
if (Math.Abs(project.Export.BitrateMbps - suggested) > 2)
{
project.Export.BitrateMbps = suggested;
changes.Add($"Bitrate a {suggested:0} Mb/s per {width}×{height} a {project.Export.FrameRate:0} fps.");
}
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
if (width != requestedWidth || height != requestedHeight)
{
changes.Add($"La risoluzione resta {width}×{height}: {requestedWidth}×{requestedHeight} " +
$"eccede il livello che i lettori supportano.");
}
int keyframe = project.Export.FrameRate >= 48 ? 2 : 3;
if (project.Export.KeyframeIntervalSeconds != keyframe)
{
project.Export.KeyframeIntervalSeconds = keyframe;
changes.Add($"Fotogrammi chiave ogni {keyframe} s.");
}
}
}
+26 -5
View File
@@ -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();
+497
View File
@@ -0,0 +1,497 @@
using System.Security.Cryptography;
using Titano.Imaging;
using Titano.Metadata;
namespace Titano.Pipeline;
/// <summary>Cosa fare del file durante l'importazione.</summary>
public enum ImportAction
{
/// <summary>Copia il file com'è: nessuna interpretazione, nessuna perdita.</summary>
Copy,
/// <summary>Converte in TIFF a 16 bit codificato in sRGB.</summary>
ConvertToTiff,
/// <summary>Converte in DNG lineare a 16 bit.</summary>
ConvertToDng,
/// <summary>Converte in JPEG.</summary>
ConvertToJpeg,
}
/// <summary>Come comportarsi quando la destinazione esiste già.</summary>
public enum ImportCollision
{
/// <summary>Salta il file: è quello che si vuole reimportando una scheda già scaricata.</summary>
Skip,
/// <summary>Aggiunge un suffisso numerico.</summary>
Rename,
/// <summary>Sovrascrive.</summary>
Overwrite,
}
/// <summary>Impostazioni dell'importazione da scheda o cartella.</summary>
public sealed class ImportSettings
{
public string SourcePath { get; set; } = string.Empty;
public bool SearchSubfolders { get; set; } = true;
public string DestinationRoot { get; set; } = string.Empty;
public string FolderTemplate { get; set; } = "{anno}/{data} {fotocamera}";
public string FileTemplate { get; set; } = "{data}_{ora}_{n:0000}";
public ImportAction Action { get; set; } = ImportAction.Copy;
public ImportCollision Collision { get; set; } = ImportCollision.Skip;
/// <summary>Conserva anche l'originale quando si converte.</summary>
public bool KeepOriginalWhenConverting { get; set; } = true;
/// <summary>Rilegge il file scritto e ne confronta l'impronta con la sorgente.</summary>
public bool VerifyCopy { get; set; } = true;
/// <summary>
/// Divide gli scatti in sessioni quando fra due c'è una pausa più lunga di così.
/// Una scheda contiene spesso più riprese, e finirebbero mescolate in una cartella sola.
/// </summary>
public bool GroupIntoSessions { get; set; } = true;
public double SessionGapMinutes { get; set; } = 45;
/// <summary>Numero minimo di scatti perché un gruppo valga come sessione a sé.</summary>
public int MinimumSessionFrames { get; set; } = 8;
public int JpegQuality { get; set; } = 92;
/// <summary>Larghezza massima dei file convertiti; 0 conserva quella originale.</summary>
public int ConvertedWidth { get; set; }
public ImportSettings Clone() => (ImportSettings)MemberwiseClone();
public RasterFormat Format => Action switch
{
ImportAction.ConvertToTiff => RasterFormat.Tiff16,
ImportAction.ConvertToDng => RasterFormat.LinearDng,
_ => RasterFormat.Jpeg,
};
}
/// <summary>Un supporto da cui si può importare.</summary>
public sealed record ImportVolume(string Path, string Label, long FreeBytes, long TotalBytes, bool Removable)
{
public string Description => Removable
? $"{Label} — supporto rimovibile, {TotalBytes / (1024.0 * 1024 * 1024):0.#} GB"
: $"{Label} — {TotalBytes / (1024.0 * 1024 * 1024):0.#} GB";
}
/// <summary>Un file candidato con i suoi metadati già letti.</summary>
public sealed record ImportCandidate(string SourcePath, FrameMetadata Metadata, long Size, int SessionIndex);
/// <summary>Dove finirà un singolo file, e perché.</summary>
public sealed record ImportStep(ImportCandidate Candidate, string DestinationPath, string? OriginalCopyPath, bool Skipped);
/// <summary>Il piano completo, prima di toccare il disco.</summary>
public sealed class ImportPlan
{
public required IReadOnlyList<ImportStep> Steps { get; init; }
public required int SessionCount { get; init; }
public required long TotalBytes { get; init; }
public int Pending => Steps.Count(s => !s.Skipped);
public int Skipped => Steps.Count(s => s.Skipped);
/// <summary>Cartelle che verranno create, in ordine.</summary>
public IEnumerable<string> Folders => Steps
.Where(s => !s.Skipped)
.Select(s => Path.GetDirectoryName(s.DestinationPath) ?? string.Empty)
.Where(f => f.Length > 0)
.Distinct()
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase);
}
/// <summary>Esito dell'importazione.</summary>
public sealed record ImportResult(int Imported, int Skipped, int Failed, long Bytes, TimeSpan Elapsed,
IReadOnlyList<string> Errors, string? FirstFolder);
/// <summary>
/// Importazione da scheda di memoria o cartella.
///
/// Fa tre cose che a mano si sbagliano sempre. Riconosce le sessioni: una scheda contiene di
/// norma più riprese, e scaricarle in una cartella sola significa doverle separare dopo, a
/// occhio, guardando gli orari. Costruisce i nomi da una regola invece che dall'ispirazione
/// del momento, così l'archivio resta ordinabile e confrontabile anche fra anni diversi. E
/// verifica ciò che ha scritto rileggendolo, perché una scheda che si scollega a metà copia
/// produce file di dimensione giusta e contenuto troncato, e ci si accorge del danno mesi
/// dopo, quando l'originale non c'è più.
/// </summary>
public static class MediaImporter
{
/// <summary>Nomi di cartella che indicano una scheda fotografica.</summary>
private static readonly string[] CameraFolders = ["DCIM", "PRIVATE", "MISC", "CANONMSC"];
/// <summary>Supporti disponibili, con quelli rimovibili in cima.</summary>
public static List<ImportVolume> Volumes()
{
var volumes = new List<ImportVolume>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
if (!drive.IsReady) continue;
if (drive.DriveType is not (DriveType.Removable or DriveType.Fixed)) continue;
bool removable = drive.DriveType == DriveType.Removable || LooksLikeCamera(drive.RootDirectory.FullName);
string label = string.IsNullOrWhiteSpace(drive.VolumeLabel)
? drive.Name
: $"{drive.Name.TrimEnd('\\')} {drive.VolumeLabel}";
volumes.Add(new ImportVolume(drive.RootDirectory.FullName, label,
drive.AvailableFreeSpace, drive.TotalSize, removable));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Un'unità che non risponde non deve impedire di elencare le altre.
}
}
return [.. volumes.OrderByDescending(v => v.Removable).ThenBy(v => v.Path, StringComparer.OrdinalIgnoreCase)];
}
/// <summary>Vero se la radice contiene una delle cartelle che le fotocamere creano.</summary>
public static bool LooksLikeCamera(string root)
{
try
{
return CameraFolders.Any(folder => Directory.Exists(Path.Combine(root, folder)));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false;
}
}
/// <summary>
/// Elenca i file importabili e ne legge i metadati, raggruppandoli in sessioni.
/// La lettura dei metadati serve subito: senza data di scatto non si possono né ordinare
/// né dare loro un nome, e leggerli due volte costerebbe il doppio su mille file.
/// </summary>
public static List<ImportCandidate> Scan(ImportSettings settings, IProgress<PipelineProgress>? progress,
CancellationToken cancellation)
{
if (!Directory.Exists(settings.SourcePath)) return [];
var option = settings.SearchSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
List<string> files;
try
{
files = [.. Directory.EnumerateFiles(settings.SourcePath, "*", new EnumerationOptions
{
RecurseSubdirectories = settings.SearchSubfolders,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.System,
}).Where(MetadataReader.IsSupported)];
_ = option;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return [];
}
var metadata = new FrameMetadata[files.Count];
var sizes = new long[files.Count];
int done = 0;
Parallel.For(0, files.Count, new ParallelOptions
{
CancellationToken = cancellation,
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount / 2, 1, 8),
}, i =>
{
metadata[i] = MetadataReader.Read(files[i]);
try { sizes[i] = new FileInfo(files[i]).Length; } catch (IOException) { sizes[i] = 0; }
int completed = Interlocked.Increment(ref done);
if (completed % 32 == 0 || completed == files.Count)
{
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, completed, files.Count,
"Lettura della scheda…"));
}
});
var ordered = Enumerable.Range(0, files.Count)
.Select(i => (Path: files[i], Metadata: metadata[i], Size: sizes[i]))
.OrderBy(f => f.Metadata.CaptureTime ?? DateTime.MaxValue)
.ThenBy(f => f.Metadata.FileName, Core.NaturalFileNameComparer.Instance)
.ToList();
return AssignSessions(ordered, settings);
}
/// <summary>
/// Riassegna le sessioni a un elenco di candidati già ordinato per istante di scatto.
/// Serve a ricalcolare i gruppi quando si sposta la soglia della pausa senza rileggere
/// la scheda, ed è anche il punto da cui la verifica automatica controlla la regola.
/// </summary>
public static List<ImportCandidate> Regroup(IReadOnlyList<ImportCandidate> candidates, ImportSettings settings)
{
var ordered = candidates
.OrderBy(c => c.Metadata.CaptureTime ?? DateTime.MaxValue)
.ThenBy(c => c.Metadata.FileName, Core.NaturalFileNameComparer.Instance)
.Select(c => (c.SourcePath, c.Metadata, c.Size))
.ToList();
return AssignSessions(ordered, settings);
}
/// <summary>
/// Divide gli scatti in sessioni sulle pause. Un gruppo troppo piccolo non diventa una
/// sessione a sé: verrebbe una cartella con tre file, quando quasi sempre si tratta degli
/// scatti di prova fatti prima di cominciare davvero.
/// </summary>
private static List<ImportCandidate> AssignSessions(
List<(string Path, FrameMetadata Metadata, long Size)> ordered, ImportSettings settings)
{
var candidates = new List<ImportCandidate>(ordered.Count);
if (ordered.Count == 0) return candidates;
if (!settings.GroupIntoSessions)
{
foreach (var file in ordered) candidates.Add(new ImportCandidate(file.Path, file.Metadata, file.Size, 0));
return candidates;
}
var gap = TimeSpan.FromMinutes(Math.Max(1, settings.SessionGapMinutes));
var groups = new List<List<(string Path, FrameMetadata Metadata, long Size)>>();
groups.Add([ordered[0]]);
for (int i = 1; i < ordered.Count; i++)
{
var previous = ordered[i - 1].Metadata.CaptureTime;
var current = ordered[i].Metadata.CaptureTime;
bool newSession = previous is { } a && current is { } b && b - a > gap;
if (newSession) groups.Add([]);
groups[^1].Add(ordered[i]);
}
// I gruppi minuscoli confluiscono nel precedente invece di generare una cartella propria.
var merged = new List<List<(string Path, FrameMetadata Metadata, long Size)>>();
foreach (var group in groups)
{
if (merged.Count > 0 && group.Count < Math.Max(1, settings.MinimumSessionFrames))
{
merged[^1].AddRange(group);
continue;
}
merged.Add(group);
}
for (int session = 0; session < merged.Count; session++)
{
foreach (var file in merged[session])
{
candidates.Add(new ImportCandidate(file.Path, file.Metadata, file.Size, session));
}
}
return candidates;
}
/// <summary>
/// Costruisce il piano senza scrivere nulla: percorsi di destinazione, collisioni,
/// cartelle da creare. Serve a poterlo mostrare prima di eseguirlo, perché
/// un'importazione sbagliata su mille file non si annulla.
/// </summary>
public static ImportPlan Plan(IReadOnlyList<ImportCandidate> candidates, ImportSettings settings)
{
var steps = new List<ImportStep>(candidates.Count);
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sessionStarts = new Dictionary<int, DateTime>();
var sessionCounters = new Dictionary<int, int>();
long bytes = 0;
foreach (var candidate in candidates)
{
if (!sessionStarts.ContainsKey(candidate.SessionIndex))
{
sessionStarts[candidate.SessionIndex] = candidate.Metadata.CaptureTime ?? DateTime.Now;
}
}
for (int i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
int withinSession = sessionCounters.GetValueOrDefault(candidate.SessionIndex);
sessionCounters[candidate.SessionIndex] = withinSession + 1;
var context = new NamingContext(candidate.Metadata, i, candidate.SessionIndex,
sessionStarts[candidate.SessionIndex], withinSession);
string folder = PathTemplate.Expand(settings.FolderTemplate, context, allowSeparators: true);
string name = PathTemplate.Expand(settings.FileTemplate, context, allowSeparators: false);
if (name.Length == 0) name = Path.GetFileNameWithoutExtension(candidate.Metadata.FileName);
string extension = settings.Action == ImportAction.Copy
? Path.GetExtension(candidate.SourcePath)
: RasterWriter.ExtensionFor(settings.Format);
string directory = Path.Combine(settings.DestinationRoot, folder);
string destination = Path.Combine(directory, name + extension);
bool skipped = false;
if (taken.Contains(destination) || File.Exists(destination))
{
switch (settings.Collision)
{
case ImportCollision.Skip:
skipped = true;
break;
case ImportCollision.Rename:
destination = Unique(destination, taken);
break;
}
}
string? original = null;
if (!skipped && settings.Action != ImportAction.Copy && settings.KeepOriginalWhenConverting)
{
original = Path.Combine(directory, "originali",
name + Path.GetExtension(candidate.SourcePath));
}
if (!skipped)
{
taken.Add(destination);
bytes += candidate.Size;
}
steps.Add(new ImportStep(candidate, destination, original, skipped));
}
return new ImportPlan
{
Steps = steps,
SessionCount = sessionStarts.Count,
TotalBytes = bytes,
};
}
private static string Unique(string path, HashSet<string> taken)
{
string directory = Path.GetDirectoryName(path) ?? string.Empty;
string name = Path.GetFileNameWithoutExtension(path);
string extension = Path.GetExtension(path);
for (int suffix = 2; suffix < 10000; suffix++)
{
string candidate = Path.Combine(directory, $"{name}-{suffix}{extension}");
if (!taken.Contains(candidate) && !File.Exists(candidate)) return candidate;
}
return path;
}
/// <summary>Esegue il piano.</summary>
public static ImportResult Execute(ImportPlan plan, ImportSettings settings,
IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var errors = new List<string>();
int imported = 0, failed = 0;
long bytes = 0;
string? firstFolder = null;
var pending = plan.Steps.Where(s => !s.Skipped).ToList();
var pool = new FrameBufferPool(4);
for (int i = 0; i < pending.Count; i++)
{
cancellation.ThrowIfCancellationRequested();
var step = pending[i];
try
{
string directory = Path.GetDirectoryName(step.DestinationPath)!;
Directory.CreateDirectory(directory);
firstFolder ??= directory;
if (settings.Action == ImportAction.Copy)
{
CopyVerified(step.Candidate.SourcePath, step.DestinationPath, settings.VerifyCopy);
}
else
{
Convert(step, settings, pool);
if (step.OriginalCopyPath is { } original)
{
Directory.CreateDirectory(Path.GetDirectoryName(original)!);
CopyVerified(step.Candidate.SourcePath, original, settings.VerifyCopy);
}
}
imported++;
bytes += step.Candidate.Size;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
failed++;
if (errors.Count < 20) errors.Add($"{step.Candidate.Metadata.FileName}: {ex.Message}");
}
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, i + 1, pending.Count,
"Importazione…"));
}
stopwatch.Stop();
return new ImportResult(imported, plan.Skipped, failed, bytes, stopwatch.Elapsed, errors, firstFolder);
}
/// <summary>
/// Copia e, se richiesto, rilegge entrambi i file confrontandone l'impronta.
///
/// Non è zelo: una scheda scollegata a metà scrittura, o un lettore che comincia a
/// sbagliare, producono file della dimensione giusta e del contenuto sbagliato. Senza
/// verifica il danno si scopre mesi dopo, quando l'originale è stato formattato.
/// </summary>
private static void CopyVerified(string source, string destination, bool verify)
{
File.Copy(source, destination, overwrite: true);
if (!verify) return;
if (!Hash(source).SequenceEqual(Hash(destination)))
{
try { File.Delete(destination); } catch (IOException) { }
throw new IOException("La verifica dopo la copia non coincide: file rimosso.");
}
}
private static byte[] Hash(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
1 << 20, FileOptions.SequentialScan);
return SHA256.HashData(stream);
}
private static void Convert(ImportStep step, ImportSettings settings, FrameBufferPool pool)
{
var metadata = step.Candidate.Metadata;
int orientation = metadata.Orientation is >= 1 and <= 8 ? metadata.Orientation : 1;
var (width, height) = ImageDecoder.ProbeDisplaySize(step.Candidate.SourcePath, orientation);
if (width <= 0 || height <= 0) throw new InvalidDataException("Dimensioni non leggibili.");
if (settings.ConvertedWidth > 0 && settings.ConvertedWidth < width)
{
height = Math.Max(2, (int)Math.Round(settings.ConvertedWidth * height / (double)width));
width = settings.ConvertedWidth;
}
using var buffer = ImageDecoder.Decode(step.Candidate.SourcePath, width, height, orientation, pool);
RasterWriter.Write(buffer, step.DestinationPath, metadata, settings.Format, settings.JpegQuality);
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Globalization;
using System.Text;
using Titano.Metadata;
namespace Titano.Pipeline;
/// <summary>Dati che un segnaposto può leggere: lo scatto e la sua posizione nell'importazione.</summary>
public readonly record struct NamingContext(
FrameMetadata Metadata,
int Index,
int SessionIndex,
DateTime SessionStart,
int SessionCount);
/// <summary>
/// Espansione dei segnaposto nei nomi di cartelle e file.
///
/// Il punto non è risparmiare battute: è che un archivio si consulta anni dopo, e il nome è
/// l'unica cosa che resta leggibile senza aprire nulla. Una regola scritta una volta produce
/// per sempre nomi coerenti, ordinabili e che dicono quando e con cosa è stato scattato —
/// dove invece la mano cambia idea a ogni cartella.
///
/// La sintassi è {segnaposto} oppure {segnaposto:formato}, con il formato passato così com'è
/// alla conversione numerica o di data. I caratteri che il file system non accetta vengono
/// sostituiti, mai lasciati passare: un segnaposto che restituisce un tempo di posa non deve
/// creare una sottocartella per via della barra.
/// </summary>
public static class PathTemplate
{
/// <summary>Segnaposto riconosciuti, con la descrizione mostrata nell'interfaccia.</summary>
public static readonly (string Token, string Description)[] Tokens =
[
("anno", "anno dello scatto, quattro cifre"),
("mese", "mese dello scatto, due cifre"),
("giorno", "giorno dello scatto, due cifre"),
("data", "data dello scatto come 2026-08-15"),
("ora", "ora dello scatto come 21-30-45"),
("oraminuti", "ora e minuti come 21-30"),
("sessione", "numero della sessione riconosciuta nella scheda"),
("iniziosessione", "istante del primo scatto della sessione"),
("fotocamera", "marca e modello dai metadati"),
("obiettivo", "obiettivo dai metadati"),
("iso", "sensibilità dello scatto"),
("posa", "tempo di posa, con la barra sostituita"),
("diaframma", "apertura come f2.8"),
("n", "numero progressivo nell'importazione"),
("nsessione", "numero progressivo dentro la sessione"),
("nome", "nome originale del file, senza estensione"),
("estensione", "estensione originale, senza il punto"),
];
/// <summary>Sostituisce i segnaposto e restituisce un frammento di percorso già ripulito.</summary>
public static string Expand(string template, in NamingContext context, bool allowSeparators)
{
if (string.IsNullOrWhiteSpace(template)) return string.Empty;
var result = new StringBuilder(template.Length + 32);
int i = 0;
while (i < template.Length)
{
char c = template[i];
if (c != '{') { result.Append(c); i++; continue; }
int close = template.IndexOf('}', i + 1);
if (close < 0) { result.Append(c); i++; continue; }
string body = template[(i + 1)..close];
int colon = body.IndexOf(':');
string token = (colon < 0 ? body : body[..colon]).Trim().ToLowerInvariant();
string format = colon < 0 ? string.Empty : body[(colon + 1)..].Trim();
result.Append(Sanitize(Resolve(token, format, context), allowSeparators));
i = close + 1;
}
return Tidy(result.ToString(), allowSeparators);
}
private static string Resolve(string token, string format, in NamingContext context)
{
var metadata = context.Metadata;
var capture = metadata.CaptureTime ?? DateTime.Now;
var invariant = CultureInfo.InvariantCulture;
return token switch
{
"anno" => capture.ToString("yyyy", invariant),
"mese" => capture.ToString("MM", invariant),
"giorno" => capture.ToString("dd", invariant),
"data" => capture.ToString(format.Length > 0 ? format : "yyyy-MM-dd", invariant),
"ora" => capture.ToString(format.Length > 0 ? format : "HH-mm-ss", invariant),
"oraminuti" => capture.ToString("HH-mm", invariant),
"sessione" => (context.SessionIndex + 1).ToString(format.Length > 0 ? format : "00", invariant),
"iniziosessione" => context.SessionStart.ToString(
format.Length > 0 ? format : "yyyy-MM-dd HH-mm", invariant),
"fotocamera" => metadata.Camera ?? "fotocamera",
"obiettivo" => metadata.Lens ?? "obiettivo",
"iso" => metadata.Iso?.ToString(invariant) ?? "iso",
"posa" => metadata.ExposureText,
"diaframma" => metadata.FNumber is { } aperture
? "f" + aperture.ToString("0.#", invariant)
: "f",
"n" => (context.Index + 1).ToString(format.Length > 0 ? format : "0000", invariant),
"nsessione" => (context.SessionCount + 1).ToString(format.Length > 0 ? format : "0000", invariant),
"nome" => Path.GetFileNameWithoutExtension(metadata.FileName),
"estensione" => Path.GetExtension(metadata.FileName).TrimStart('.'),
_ => string.Empty,
};
}
/// <summary>
/// Toglie i caratteri che il file system rifiuta. Il separatore sopravvive solo nei
/// modelli di cartella, dove dividere i livelli è il senso stesso del modello.
/// </summary>
private static string Sanitize(string value, bool allowSeparators)
{
if (value.Length == 0) return value;
var invalid = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(value.Length);
foreach (char c in value)
{
bool isSeparator = c == '/' || c == Path.DirectorySeparatorChar;
if (isSeparator)
{
builder.Append(allowSeparators ? Path.DirectorySeparatorChar : '-');
continue;
}
builder.Append(Array.IndexOf(invalid, c) >= 0 ? '-' : c);
}
return builder.ToString();
}
/// <summary>Compatta separatori ripetuti e spazi ai bordi di ogni livello.</summary>
private static string Tidy(string value, bool allowSeparators)
{
if (!allowSeparators) return value.Trim().Trim('.', ' ');
var parts = value.Split(['/', Path.DirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Trim().Trim('.', ' '))
.Where(part => part.Length > 0);
return string.Join(Path.DirectorySeparatorChar, parts);
}
/// <summary>
/// Verifica che un modello sia utilizzabile e spiega il primo problema trovato.
/// Restituisce null quando va bene.
/// </summary>
public static string? Validate(string template, bool isFolder)
{
if (string.IsNullOrWhiteSpace(template))
return isFolder ? null : "Il modello del nome file non può essere vuoto.";
int depth = 0;
foreach (char c in template)
{
if (c == '{') depth++;
else if (c == '}') depth--;
if (depth is < 0 or > 1) return "Le parentesi graffe non sono bilanciate.";
}
if (depth != 0) return "Manca una parentesi graffa di chiusura.";
var known = Tokens.Select(t => t.Token).ToHashSet();
int i = 0;
while (i < template.Length)
{
if (template[i] != '{') { i++; continue; }
int close = template.IndexOf('}', i + 1);
if (close < 0) break;
string body = template[(i + 1)..close];
int colon = body.IndexOf(':');
string token = (colon < 0 ? body : body[..colon]).Trim().ToLowerInvariant();
if (!known.Contains(token)) return "Segnaposto sconosciuto: " + token;
i = close + 1;
}
if (!isFolder && (template.Contains('/') || template.Contains(Path.DirectorySeparatorChar)))
return "Il nome del file non può contenere separatori di cartella.";
return null;
}
}
+17
View File
@@ -26,6 +26,16 @@ public sealed class RenderPipeline(TitanoProject project)
{
private readonly TitanoProject _project = project;
/// <summary>
/// Chiamato ogni tanto con il fotogramma appena consegnato all'encoder, per mostrarlo
/// mentre l'esportazione procede. Il buffer è valido solo per la durata della chiamata:
/// il ciclo lo riusa subito dopo, e trattenerlo significherebbe vederselo cambiare sotto.
/// </summary>
public Action<ImageBuffer, int, int>? FrameEncoded { get; set; }
/// <summary>Ogni quanti fotogrammi invocare <see cref="FrameEncoded"/>.</summary>
public int PreviewInterval { get; set; } = 8;
// ------------------------------------------------------------------ ingestion
/// <summary>Legge i metadati dei file indicati e costruisce la sequenza ordinata.</summary>
@@ -465,6 +475,13 @@ public sealed class RenderPipeline(TitanoProject project)
encoded++;
record.OutputDurationUnits = (int)planned.DurationUnits;
if (FrameEncoded is { } observer && encoded % Math.Max(1, PreviewInterval) == 1)
{
// Un'eccezione dell'anteprima non deve fermare un'esportazione di mezz'ora.
try { observer(composed, k, plan.Count); }
catch (Exception) { FrameEncoded = null; }
}
ReportRenderProgress(progress, k + 1, plan.Count, encoded, stopwatch);
}
}
+216
View File
@@ -0,0 +1,216 @@
using Titano.Analysis;
using Titano.Motion;
namespace Titano.Pipeline;
public enum WarningSeverity
{
/// <summary>Vale la pena saperlo, non c'è niente da correggere.</summary>
Note,
/// <summary>Il risultato ne risentirà, ma l'elaborazione va avanti.</summary>
Caution,
/// <summary>Va sistemato prima di esportare.</summary>
Problem,
}
/// <summary>Una cosa che il motore ha notato, con dove si corregge.</summary>
public sealed record SequenceWarning(
WarningSeverity Severity,
string Area,
string Title,
string Detail);
/// <summary>
/// Raccoglie in un posto solo quello che il motore ha già capito della sequenza.
///
/// Erano informazioni che esistevano tutte, sparse fra barra di stato, pannello delle
/// impostazioni, colonne della tabella e uscita a riga di comando — cioè, in pratica,
/// invisibili. Messe in fila e ordinate per gravità diventano una diagnosi: si legge in
/// dieci secondi se la sequenza è pronta per l'esportazione o se c'è qualcosa da guardare.
/// </summary>
public static class SequenceWarnings
{
public static List<SequenceWarning> Collect(TitanoProject project, AppSettings settings)
{
var warnings = new List<SequenceWarning>();
if (project.Sequence is not { Count: > 0 } sequence) return warnings;
// ---- lettura e cadenza
int unreadable = 0;
foreach (var frame in sequence.Frames)
{
if (project.IsAnalyzed && !frame.LuminanceAnalyzed) unreadable++;
}
if (unreadable > 0)
{
warnings.Add(new SequenceWarning(WarningSeverity.Problem, "Sequenza",
$"{unreadable} fotogrammi non sono stati letti",
"Vengono sostituiti dal vicino valido più prossimo: nel video si vede un fotogramma " +
"ripetuto invece di un salto, ma il contenuto di quegli scatti è perduto."));
}
if (sequence.CadenceAnomalies > sequence.Count / 10)
{
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Sequenza",
$"{sequence.CadenceAnomalies} intervalli fuori cadenza",
$"Più di un decimo della sequenza si discosta dai {sequence.NominalInterval:0.###} s nominali. " +
"Con la durata costante si vedranno accelerazioni; la durata adattiva o la cadenza " +
"uniformata le assorbono."));
}
if (!sequence.HasSubSecondPrecision && sequence.NominalInterval < 3)
{
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Sequenza",
"Nessun timestamp al sotto-secondo",
"Con intervalli brevi l'arrotondamento al secondo introduce un errore di cadenza " +
"che non viene dalla macchina ma dalla precisione con cui l'ha annotata."));
}
// ---- risoluzione
var (width, height) = project.ResolveWorkingSize();
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
if (width != requestedWidth || height != requestedHeight)
{
string codec = project.Export.Codec == Video.VideoCodec.H264 ? "H.264" : "HEVC";
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esportazione",
$"Risoluzione ridotta a {width}×{height}",
$"{requestedWidth}×{requestedHeight} eccede il livello {codec} che i lettori comuni " +
"supportano: il file uscirebbe, e non si aprirebbe."));
}
if (project.NeedsGeometry)
{
var (sourceWidth, _) = project.ResolveSourceSize();
if (sourceWidth > 0 && width > sourceWidth)
{
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esportazione",
"L'uscita è più grande della sorgente letta",
$"Si sta ingrandendo da {sourceWidth} px a {width} px. Con la camera virtuale " +
"attiva conviene lasciare la risoluzione di lavoro sulla nativa."));
}
}
// ---- esposizione
if (project.IsAnalyzed && project.Stats is { } stats)
{
int clipped = 0;
foreach (var frame in stats)
{
if (frame.ClippedFraction > 0.25) clipped++;
}
if (clipped > stats.Count / 20)
{
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esposizione",
$"{clipped} fotogrammi sono saturi per oltre un quarto",
"Dove il fotogramma è saturo la luminanza non risponde più all'esposizione: " +
"deflicker, segmentazione e riconoscimento dei cambi lavorano al buio."));
}
}
if (project.Regions.Mode != RegionMode.Off && project.IsAnalyzed && project.Mask is null)
{
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Esposizione",
"La divisione in regioni non ha trovato una separazione utile",
"La scena non si lascia dividere in due parti che si comportino in modo diverso. " +
"Resta attiva la curva unica, che per questa sequenza è la scelta giusta."));
}
else if (project.Mask is { } mask && mask.Log2High < mask.Log2Low && mask.Coverage < 0.35 &&
project.Regions.Mode == RegionMode.SkyGround)
{
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esposizione",
"Sopra la linea d'orizzonte forse non c'è il cielo",
"La regione superiore è insieme stretta e più scura dell'altra: è il segno di un " +
"primo piano che copre l'alto dell'inquadratura. Prova la divisione per luminanza."));
}
if (project.Transitions is { UnobservedSteps: > 0 } transitions)
{
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Esposizione",
$"{transitions.UnobservedSteps} cambi di impostazione non recepiti",
"La macchina li ha dichiarati ma la luminanza non si è mossa: fotogramma già saturo, " +
"oppure la scena ha compensato. Vengono lasciati stare, perché correggerli " +
"introdurrebbe il gradino invece di toglierlo."));
}
// ---- movimento
if (project.Motion is { } motion)
{
if (motion.UnreliableFraction > 0.25)
{
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Movimento",
$"Correlazione debole sul {motion.UnreliableFraction * 100:0}% delle coppie",
"Su quei fotogrammi lo spostamento non è misurabile — di solito perché sono " +
"bruciati o senza tessitura — e la stabilizzazione li lascia dove sono."));
}
double zoom = project.StabilizationZoom;
if (zoom > 1.06)
{
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Movimento",
$"La stabilizzazione consuma il {(zoom - 1) * 100:0.#}% dell'inquadratura",
"È il margine necessario a non mostrare i bordi scoperti dalla correzione. " +
"Una finestra del percorso più lunga o un'intensità minore lo riducono."));
}
}
if (project.Camera.Enabled && VirtualCamera.MaximumZoom(project.Camera) > 2.5)
{
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Movimento",
$"Ingrandimento virtuale fino a {VirtualCamera.MaximumZoom(project.Camera):0.0}×",
"Oltre un certo ritaglio si vede il rumore del sensore invece del dettaglio: " +
"è la risoluzione nativa a stabilire quanto si può stringere."));
}
// ---- tempo e memoria
if (project.Stacking.Mode == StackingMode.Median)
{
long perFrame = (long)width * height * 3 * sizeof(float);
long windowBytes = perFrame * (2L * project.Stacking.MedianRadius + 1);
if (windowBytes > 1024L * 1024 * 1024)
{
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Tempo",
$"La finestra della mediana occupa {windowBytes / (1024.0 * 1024 * 1024):0.0} GiB",
"Sono fotogrammi vivi tutti insieme, perché la mediana li vuole simultaneamente. " +
"Il tetto di memoria governa solo la lettura in anticipo, non la finestra."));
}
}
if (string.IsNullOrWhiteSpace(project.Export.OutputPath))
{
warnings.Add(new SequenceWarning(WarningSeverity.Problem, "Esportazione",
"Manca il file di destinazione",
"Scegli dove salvare il video prima di avviare l'esportazione."));
}
else if (settings.ConfirmOverwrite && File.Exists(project.Export.OutputPath))
{
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Esportazione",
"Il file di destinazione esiste già",
$"{Path.GetFileName(project.Export.OutputPath)} verrà sovrascritto."));
}
warnings.Sort((a, b) => b.Severity.CompareTo(a.Severity));
return warnings;
}
/// <summary>Sezione della barra a cui l'avviso appartiene, per il contatore.</summary>
public static WorkspaceArea AreaOf(SequenceWarning warning) => warning.Area switch
{
"Sequenza" => WorkspaceArea.Sequence,
"Esposizione" => WorkspaceArea.Exposure,
"Movimento" => WorkspaceArea.Motion,
"Tempo" => WorkspaceArea.Timing,
_ => WorkspaceArea.Export,
};
}
/// <summary>Sezioni note al modello, senza dipendere dai tipi dell'interfaccia.</summary>
public enum WorkspaceArea
{
Sequence,
Exposure,
Motion,
Timing,
Export,
}
+42 -5
View File
@@ -70,6 +70,16 @@ public sealed class TitanoProject
ApplyQualityProfile(General.Quality);
}
/// <summary>
/// Parametri di cui l'utente ha preso il controllo: il direttore non li tocca più.
/// Basta muovere un cursore per finirci dentro, e il pulsante «auto» per uscirne.
/// </summary>
public HashSet<AutoKey> ManualParameters { get; } = [];
/// <summary>Ultime decisioni automatiche, con il motivo di ciascuna.</summary>
public IReadOnlyDictionary<AutoKey, AutoDecision> AutoDecisions { get; set; }
= new Dictionary<AutoKey, AutoDecision>();
public TimelapseSequence? Sequence { get; set; }
/// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary>
@@ -113,7 +123,8 @@ public sealed class TitanoProject
public int EffectiveOrientation => General.OrientationOverride ?? Orientation.Orientation;
/// <summary>Vero se serve lo stadio geometrico finale, quindi un ricampionamento in più.</summary>
public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled;
public bool NeedsGeometry => Camera.Enabled || Stabilization.Enabled || Export.CropEnabled ||
Export.AspectRatio > 0.01;
/// <summary>
/// Esamina il primo fotogramma per stabilire come vanno raddrizzati i pixel. Va invocato
@@ -257,7 +268,10 @@ public sealed class TitanoProject
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
: sourceWidth;
double aspect = sourceHeight / (double)sourceWidth;
// Il rapporto scelto governa l'altezza; senza scelta si conserva quello della sorgente.
double aspect = Export.AspectRatio > 0.01
? 1.0 / Export.AspectRatio
: sourceHeight / (double)sourceWidth;
int targetHeight = Export.Height > 0 ? Export.Height : (int)Math.Round(targetWidth * aspect);
targetWidth = Math.Max(2, targetWidth & ~1);
@@ -304,10 +318,33 @@ public sealed class TitanoProject
}
}
/// <summary>Inquadratura effettiva a un dato punto della sequenza, vincoli compresi.</summary>
/// <summary>
/// Inquadratura effettiva a un dato punto della sequenza, vincoli compresi.
///
/// Il movimento virtuale, quando è attivo, ha la precedenza sul ritaglio fisso: sono due
/// modi di dire la stessa cosa, e averli entrambi in funzione significherebbe ritagliare
/// due volte. Il ritaglio fisso resta per chi vuole solo scegliere un'inquadratura.
/// </summary>
public CameraFraming FramingAt(double normalizedTime)
{
var framing = Camera.Enabled ? VirtualCamera.Resolve(Camera, normalizedTime) : CameraFraming.Full;
return VirtualCamera.Constrain(framing, Camera.KeepInsideFrame, StabilizationZoom);
var framing = Camera.Enabled
? VirtualCamera.Resolve(Camera, normalizedTime)
: Export.CropEnabled
? new CameraFraming(Export.CropCentreX, Export.CropCentreY, Export.CropZoom)
: CameraFraming.Full;
bool keepInside = Camera.Enabled ? Camera.KeepInsideFrame : true;
return VirtualCamera.Constrain(framing, keepInside, StabilizationZoom);
}
/// <summary>Rapporto larghezza/altezza dell'uscita: quello scelto, o quello della sorgente.</summary>
public double OutputAspect
{
get
{
if (Export.AspectRatio > 0.01) return Export.AspectRatio;
var (width, height) = ResolveNativeSize();
return width > 0 && height > 0 ? width / (double)height : 16.0 / 9.0;
}
}
}
+35 -7
View File
@@ -38,12 +38,39 @@ internal static class Program
{
EnsureConsole();
Console.WriteLine();
return SequenceDiagnostics.Run(args[1], Console.Out,
renderPath: args.Length > 2 ? args[2] : null,
renderFrames: args.Length > 3 && int.TryParse(args[3], out int n) ? n : 24,
renderWidth: args.Length > 4 && int.TryParse(args[4], out int w) ? w : 0,
renderCodec: args.Length > 5 && args[5].Equals("hevc", StringComparison.OrdinalIgnoreCase)
? Video.VideoCodec.Hevc : Video.VideoCodec.H264);
// Le parole chiave possono comparire in qualunque posizione: gli argomenti
// posizionali erano ormai troppi perché ricordarne l'ordine avesse senso.
var modules = SequenceDiagnostics.AdvancedModules.None;
int analysisFrames = 0;
var positional = new List<string>();
foreach (string argument in args[1..])
{
if (argument.Equals("avanzato", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.Analysis;
else if (argument.Equals("camera", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.Camera;
else if (argument.Equals("mediana", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.MedianStack;
else if (argument.Equals("scie", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.StarTrails;
else if (argument.Equals("luminanza", StringComparison.OrdinalIgnoreCase))
modules |= SequenceDiagnostics.AdvancedModules.RegionsByLuminance;
else if (argument.StartsWith("analisi=", StringComparison.OrdinalIgnoreCase))
int.TryParse(argument[8..], out analysisFrames);
else
positional.Add(argument);
}
return SequenceDiagnostics.Run(positional[0], Console.Out,
renderPath: positional.Count > 1 ? positional[1] : null,
renderFrames: positional.Count > 2 && int.TryParse(positional[2], out int n) ? n : 24,
renderWidth: positional.Count > 3 && int.TryParse(positional[3], out int w) ? w : 0,
renderCodec: positional.Count > 4 && positional[4].Equals("hevc", StringComparison.OrdinalIgnoreCase)
? Video.VideoCodec.Hevc : Video.VideoCodec.H264,
advanced: modules,
analysisFrames: analysisFrames);
}
if (args.Length > 1 && args[0] == "--capture")
@@ -56,6 +83,7 @@ internal static class Program
}
ApplicationConfiguration.Initialize();
CrashGuard.Install();
Application.Run(new UI.MainForm());
return 0;
}
@@ -100,7 +128,7 @@ internal static class Program
Pump(2500); // attesa del rendering asincrono dell'anteprima
}
if (settingsTab > 0)
if (settingsTab >= 0)
{
form.SelectSettingsTab(settingsTab);
Pump(200);
+301 -8
View File
@@ -45,15 +45,210 @@ dell'interfaccia riceve solo aggiornamenti di stato immutabili tramite `IProgres
```
Metadata/ parser binario TIFF/Exif, scanner XMP, riconoscimento contenitori
Core/ sequenza, cadenza, shutter angle, spline monotona e Bézier
Imaging/ buffer poolati, spazio colore lineare, decodifica WIC, stadio geometrico
Imaging/ buffer poolati, spazio colore lineare, decodifica WIC, geometria, scrittura TIFF/DNG
Analysis/ luminanza, deflicker, segmentazione in regioni, transizioni giorno-notte
Motion/ Fourier, correlazione di fase, stabilizzazione, optical flow, blur, stacking
Video/ conversione NV12, encoder Media Foundation, multiplexer MP4
Pipeline/ piano di rendering, finestra scorrevole, orchestrazione, impostazioni
UI/ tema scuro, grafico vettoriale, editor di curve e keyframe, pannelli
Pipeline/ piano di rendering, finestra scorrevole, direttore automatico, importazione
UI/ tema scuro, navigazione, strumenti di misura, editor di curve e keyframe
Diagnostics/ sequenze sintetiche, verifica end-to-end, ispettore MP4
```
## L'interfaccia
La navigazione è una barra verticale sul fianco sinistro, 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.
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
su i pixel si mostrano come sono, senza interpolazione che nasconda proprio ciò che si
vuole controllare;
- **confronto a tendina** fra originale e corretto, trascinabile, perché l'unico modo di
capire cosa una correzione stia facendo è vedere accanto ciò che c'era prima;
- **campo vettoriale sovrapposto**, che il motore calcola comunque, e che spiega in un colpo
d'occhio perché la sfocatura viene come viene — soprattutto quando è sbagliata;
- **confine delle regioni**, campionato attraverso la stessa mappatura del ritaglio, così la
linea resta al suo posto anche quando l'anteprima mostra una panoramica virtuale;
- **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
della stabilizzazione sul movimento, il piano temporale sul tempo. Tutte grandezze che il
motore già calcolava e che finivano in due numeri in fondo a una riga di stato.
## Archivio e importazione
La scheda Archivio scarica una scheda di memoria e la mette a posto. Riconosce i supporti
collegati — quelli rimovibili e quelli che contengono una cartella DCIM — cerca nelle
sottocartelle, e prima di toccare il disco mostra il piano: quali cartelle verranno create e
che nome avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e
vedere prima costa un istante.
Fa tre cose che a mano si sbagliano sempre.
**Riconosce le sessioni.** Una scheda contiene di norma più riprese; separarle sulla pausa fra
due scatti evita di doverle distinguere dopo guardando gli orari. I gruppi troppo piccoli
confluiscono nel precedente invece di generare una cartella propria: quasi sempre sono gli
scatti di prova fatti prima di cominciare.
**Costruisce i nomi da una regola.** Cartelle e file nascono da modelli con segnaposto —
`{anno}/{data} {fotocamera}` e `{data}_{ora}_{n:0000}` — perché un archivio si consulta anni
dopo e il nome è l'unica cosa leggibile senza aprire nulla. I caratteri che il file system
rifiuta vengono sostituiti, mai lasciati passare.
**Verifica quello che ha scritto.** Dopo la copia rilegge entrambi i file e ne confronta
l'impronta. Non è zelo: una scheda che si scollega a metà copia produce file della dimensione
giusta e del contenuto troncato, e il danno si scopre mesi dopo, quando l'originale non c'è
più.
### Sulla conversione in DNG
La copia resta l'impostazione predefinita, ed è quasi sempre la scelta giusta: conserva il
grezzo, quindi la libertà di rifare lo sviluppo.
Va detto con precisione cosa il programma può e non può fare. Un DNG *vero* contiene i valori
del sensore prima dell'interpolazione cromatica, ed è quello che permette di rifare da capo
bilanciamento del bianco e demosaicizzazione. Ottenerlo da un formato proprietario richiede di
interpretare quel sensore modello per modello, cioè la libreria del produttore — che il
vincolo sulle dipendenze esclude.
Quello che si può fare in-house è un **DNG lineare**: la specifica lo prevede
(PhotometricInterpretation 34892), contiene pixel già interpolati a 16 bit in luce lineare, ed
è un file valido e apribile ovunque. Non restituisce però la libertà del grezzo. Insieme a
TIFF 16 bit e JPEG serve a consegnare qualcosa a un altro programma, non ad archiviare.
Il contenitore è scritto a mano, come il multiplexer MP4. La verifica automatica lo rilegge
con il parser TIFF di questo stesso programma: sono due implementazioni indipendenti dello
stesso formato, e alla prima esecuzione il confronto ha trovato un errore di dodici byte per
voce nel calcolo degli scostamenti.
## Il pilota automatico
I parametri che si possono dedurre dalle misure non si chiedono. Un cursore con l'indicatore
**auto** mostra il valore che il programma ha scelto e il motivo per cui l'ha scelto;
toccarlo passa il comando all'utente, e l'indicatore lo restituisce. È lo stesso modello che
il menu dell'orientamento usava da solo, esteso a tutti i parametri deducibili.
| Parametro | Da cosa viene dedotto |
|---|---|
| Larghezza della passata di analisi | metà della risoluzione nativa, entro i limiti utili |
| Finestra del deflicker | quattro periodi dello sfarfallio, misurati sull'autocorrelazione |
| Lunghezza della transizione | metà della distanza tipica fra i cambi di esposizione |
| Finestra della stabilizzazione | la più corta che rende liscio il percorso |
| Tetto di memoria | 45% di quanto il sistema dichiara disponibile |
Vale una regola sopra tutte: dove il valore giusto non si può misurare, il direttore non
inventa — restituisce meno decisioni e lascia il cursore dov'è.
Ogni sezione ha inoltre un comando **Ottimizza** che rileva le impostazioni migliori per
quella sola parte e dice cosa ha cambiato e perché. Si distingue dal pilota automatico, che
lavora di continuo sui parametri deducibili senza ambiguità: l'ottimizzazione si chiede a mano
perché prende decisioni più impegnative, comprese quelle di accendere o spegnere interi
moduli, e sostituirle senza dirlo sarebbe peggio che lasciarle sbagliate. Dove il dato manca —
la sequenza non è ancora stata analizzata — non tira a indovinare: lo dice.
Le spiegazioni non stanno sotto ai controlli ma nei suggerimenti che compaiono passandoci
sopra. Una nota stampata occupa spazio a chi la conosce già, e per questo deve restare corta;
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
fare. È l'unico momento in cui uno spazio pubblicitario non toglie niente a nessuno, ed è per
questo che compare **soltanto nella scheda Esportazione**: accanto a un cursore che si sta
regolando sarebbe un ostacolo, accanto a una barra di avanzamento è qualcosa da guardare.
Gli annunci si leggono da una cartella locale, con un elenco chiamato `campagne.txt` nello
stesso formato delle preferenze. **Il programma non contatta alcun servizio, non invia
identificativi e non registra i clic.** È una scelta, non una mancanza: un circuito
pubblicitario vero richiederebbe di integrarne l'SDK, che il vincolo sulle dipendenze esclude,
e comunque significherebbe far uscire dati dalla macchina di chi sta soltanto montando un
time-lapse. Le campagne si aggiornano copiando file in una cartella. Quando il listino è
vuoto compaiono note interne sul funzionamento del programma, e l'intero riquadro si spegne
dalle preferenze.
## 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
@@ -114,9 +309,18 @@ sua inversa è la correzione. Una panoramica voluta sopravvive perché è già l
del fotogramma; il deflicker legge un calo di luce e schiarisce tutto, paesaggio compreso, che
invece non era cambiato. Titano divide il fotogramma in due regioni e dà a ciascuna la propria
curva. La maschera nasce una volta sola dalla mediana temporale di un campione di fotogrammi
— la mediana toglie di mezzo proprio ciò che passa e non resta — e la linea d'orizzonte viene
poi agganciata al massimo del gradiente verticale, che cade sul bordo vero e non dove capita
la soglia. Sulla scena di prova il terreno passa da 0,062 a 0,026 stop di oscillazione.
— la mediana toglie di mezzo proprio ciò che passa e non resta.
La linea d'orizzonte è il gradino più marcato del profilo di luminanza per riga, e viene poi
agganciata colonna per colonna al massimo del gradiente verticale. Il verso del gradino non
conta: cercare invece "la prima riga sotto soglia scendendo dall'alto" presuppone un cielo
chiaro e sgombro sopra la testa, e su una ripresa notturna fatta da sotto un pergolato quella
regola aggancia il bordo del tetto e chiama cielo le travi. Quando sopra la linea finisce
comunque un primo piano, la divisione lo dichiara e indica quella per luminanza, che non fa
ipotesi sulla forma.
Sulla sequenza reale della cometa il paesaggio passa da 0,156 a 0,020 stop di oscillazione:
l'87% dello sfarfallio che la curva globale gli avrebbe trasmesso non lo raggiunge.
**Transizioni giorno-notte.** Attraversando il tramonto la macchina deve cambiare tempo, ISO o
diaframma, e ogni cambio è quantizzato: nel filmato si vede uno scalino netto. Il deflicker
@@ -128,6 +332,13 @@ già priva di gradini. Il bilanciamento del bianco riceve un trattamento paralle
rapporto fra i canali invece della loro luminanza, così sparisce il tremolio del bilanciamento
automatico e resta il viaggio verso il caldo del tramonto.
La temperatura di colore si ricava con l'approssimazione di McCamy, ma solo dove ha senso:
fuori dall'intorno del luogo di Planck il polinomio diverge, e su un cielo notturno — dove il
colore medio non somiglia a nessun corpo nero — restituiva decine di migliaia di kelvin.
Adesso in quel caso si dichiara inapplicabile invece di produrre un numero dall'aria
plausibile. Quello che resta sempre definito, e che il motore corregge davvero, è la deviazione
verde-magenta in stop.
**Movimento di macchina virtuale.** Un sensore da dodici megapixel contiene un 4K con
abbondante margine di ritaglio: da lì si ricava una panoramica che in ripresa avrebbe richiesto
una slitta motorizzata, decisa dopo, guardando il materiale. Fra un nodo e il successivo il
@@ -193,6 +404,63 @@ video uscirebbe, peserebbe, e non si riprodurrebbe. Titano riduce quindi il foto
massimo riproducibile conservando le proporzioni, e lo dichiara in interfaccia. Le sorgenti
16:9 fino al 4K UHD non vengono toccate.
## Cosa ha insegnato il materiale vero
I moduli avanzati sono stati provati su quattro sequenze reali — da 554 a 1005 scatti in DNG,
riprese notturne di comete e aurore con una GoPro HERO8 — dopo essere già passati da
cinquantaquattro controlli su scene sintetiche. Le scene sintetiche sono costruite perché la
risposta sia nota, e per questo non possono sorprendere: tutto ciò che segue è emerso solo dai
file veri, e ha portato a correzioni nel motore.
**La ricerca dell'orizzonte agganciava la tettoia.** Una delle riprese è fatta da sotto un
pergolato: travi scure in cima, cielo nel mezzo, alberi e case in fondo. Sul tratto notturno di
quella sequenza la vecchia regola — "la prima riga sotto soglia scendendo dall'alto" — trovava
il bordo del tetto e chiamava cielo le travi, il 7% dell'inquadratura e per giunta più scuro
del resto. Sulla ripresa di sedici ore l'errore si vedeva sull'intera sequenza: cielo al 29% e
separazione nulla fra le due regioni, cioè una divisione che non portava alcuna informazione.
Sostituita con il gradino più marcato del profilo di luminanza per riga, che non presuppone un
cielo chiaro in cima e funziona anche quando il cielo è il più scuro dei due: 91%
dell'inquadratura e 1,9 EV di separazione, con lo sfarfallio trasmesso al paesaggio ridotto del
79% invece che del 57%.
**La temperatura di colore inventava numeri.** Su un cielo stellato il colore medio non
somiglia a nessun corpo nero, l'approssimazione di McCamy diverge e usciva un intervallo da
1000 a 40000 K, troncato agli estremi. Ora fuori dall'intorno del luogo di Planck la misura si
dichiara inapplicabile. Il controllo che verifica la nuova regola ha subito trovato un buco nel
primo tentativo di filtro: un riquadro sulle coordinate cromatiche lascia passare un rosso
saturo, perché sa dire in quale zona si è ma non quanto si è vicini a una curva.
**La correlazione di fase inseguiva spostamenti inventati.** Una ripresa attraversa sedici ore
con pose da trenta secondi, quindi di giorno esce completamente bruciata. Su un riquadro
uniforme la normalizzazione al modulo unitario amplifica il solo rumore numerico e
l'antitrasformata dà un picco qualunque: la stabilizzazione misurava 9,8 px di tremolio medio,
181 px di punta, e chiedeva di ritagliare il 15% dell'inquadratura. Con un controllo di
tessitura sul riquadro il tremolio scende a 0,07 px e il ritaglio a 0,1%.
**I gradini dichiarati non sempre si vedono.** Se il fotogramma è già saturo, dimezzare la
sensibilità non lo scurisce: sopra il bianco non c'è niente da togliere. Sottrarre comunque il
gradino letto nei metadati introduceva nel segnale un salto che nell'immagine non esisteva. Ora
ogni gradino viene ridotto alla quota che la luminanza ha davvero recepito; su quella sequenza
7 cambi su 17 risultano non recepiti e vengono lasciati stare.
**La curva obiettivo poteva essere più a scatti del segnale che lisciava.** È il difetto più
serio, e stava nel nucleo del deflicker, non nei moduli nuovi. La robustezza di Tukey
presuppone anomalie sparse; dove invece un tratto contiguo si discosta — il crollo di luce del
crepuscolo — azzera l'intera finestra, e la stima commutava fra "uso i soli sopravvissuti" e
"uso tutto" aprendo uno scalino di 2,3 stop proprio dove serviva la massima continuità. Adesso
le due stime si mescolano con continuità secondo quanta finestra è sopravvissuta. Sulla ripresa
di sedici ore la riduzione dello sfarfallio passa dal 17% al 67%, e il salto massimo della
curva obiettivo da 1,73 a 0,21 stop.
Le scie stellari sono state verificate sull'uscita vera con la proprietà che le definisce: la
luminanza non può calare, perché ogni pixel trattiene il valore più alto incontrato. Su 150
fotogrammi cresce di 1,6 volte ed è non decrescente sul 99,3% dei passi — il resto è il rumore
della compressione.
Sul costo: con stabilizzazione, mediana, movimento virtuale e rimappatura tutti attivi insieme,
al profilo Massima e a 1920×1440, il rendering va a 0,7 fotogrammi al secondo. È il baratto che
il profilo dichiara, e si sceglie dal pannello Generale.
## Compilazione ed esecuzione
```
@@ -209,7 +477,7 @@ Titano.exe --selftest [cartella]
```
Genera sequenze sintetiche dalle proprietà note e le fa attraversare l'intera pipeline,
confrontando 51 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.
@@ -221,12 +489,24 @@ della maschera delle regioni; riconoscimento e ridistribuzione dei cambi di espo
lisciatura cromatica con la deriva del tramonto conservata; monotonia e simmetria delle curve
di accelerazione; trasparenza e ritaglio dello stadio geometrico; monotonia della rimappatura
temporale; rimozione dell'intruso da parte della mediana e conservazione dei passaggi da parte
del massimo; allineamento delle NAL dentro ogni campione del contenitore; e — prova conclusiva
del massimo; la garanzia che la curva lisciata non sia mai più a scatti del segnale che liscia,
nemmeno quando un tratto contiguo di fotogrammi si discosta; il rifiuto di dichiarare una
temperatura di colore dove non ne esiste una; allineamento delle NAL dentro ogni campione del
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]
```
Riporta cosa il programma riesce davvero a leggere da una cartella reale: metadati file per
@@ -235,6 +515,19 @@ risoluzioni diverse, cadenza sull'intera sequenza. Indicando un file di uscita e
render di prova e ne riverifica il contenitore. È lo strumento con cui si distingue un difetto
del motore da un formato che il sistema non sa aprire.
Le parole chiave finali, in qualunque ordine, accendono i moduli avanzati e aggiungono una
sezione che riporta cosa ciascuno ha trovato: entità del tremolio e ritaglio necessario, forma
della maschera e quanto la curva di regione stabilizza il paesaggio rispetto a quella globale,
numero e ampiezza dei cambi di impostazione, escursione della tinta. `luminanza` divide per
luminanza invece che per linea d'orizzonte, `camera` aggiunge movimento virtuale e rimappatura
del tempo al render di prova, `mediana` e `scie` scelgono l'accumulo temporale, `analisi=N`
limita l'analisi ai primi N fotogrammi — contigui, perché la stabilizzazione confronta ogni
fotogramma con il precedente.
È lo strumento che ha fatto emergere i due difetti visibili solo su materiale vero: la ricerca
dell'orizzonte che agganciava il bordo di una tettoia, e la temperatura di colore che su un
cielo notturno restituiva decine di migliaia di kelvin invece di dichiararsi inapplicabile.
```
Titano.exe --capture <file.png> [cartella-sequenza] [scheda] [avanzato]
```
+140
View File
@@ -0,0 +1,140 @@
using System.Reflection;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Pagina della sezione Impostazioni: mentre la colonna di destra contiene le preferenze,
/// qui sta ciò che serve sapere sul programma e sullo stato in cui si trova — versione,
/// dove tiene i propri file, cosa il sistema mette a disposizione, quali moduli sono accesi.
///
/// È anche il posto dove il vincolo che definisce il progetto viene detto per esteso, perché
/// è la cosa che spiega tutte le altre scelte.
/// </summary>
internal sealed class AboutPanel : Panel
{
private readonly List<(string Label, string Value)> _rows = [];
private readonly List<string> _modules = [];
public AboutPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Padding = new Padding(16);
AutoScroll = true;
}
public void Update(TitanoProject project, AppSettings app)
{
_rows.Clear();
_modules.Clear();
var version = Assembly.GetExecutingAssembly().GetName().Version;
var memory = GC.GetGCMemoryInfo();
_rows.Add(("Versione", version?.ToString(3) ?? "1.0.0"));
_rows.Add(("Runtime", $".NET {Environment.Version}"));
_rows.Add(("Sistema", $"{Environment.OSVersion.VersionString} ({(Environment.Is64BitProcess ? "x64" : "x86")})"));
_rows.Add(("Processori", $"{Environment.ProcessorCount} logici"));
_rows.Add(("Vettori SIMD", $"{System.Numerics.Vector<float>.Count} float per registro"));
_rows.Add(("Memoria disponibile", $"{memory.TotalAvailableMemoryBytes / (1024.0 * 1024 * 1024):0.0} GiB"));
_rows.Add(("Memoria in uso", $"{GC.GetTotalMemory(false) / (1024.0 * 1024):0} MiB"));
_rows.Add(("Impostazioni", AppSettings.SettingsPath));
_rows.Add(("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()
: app.SpillDirectory));
if (project.Deflicker.Enabled) _modules.Add("Deflicker");
if (project.Regions.Mode != Analysis.RegionMode.Off) _modules.Add("Deflicker per regioni");
if (project.HolyGrail.Enabled) _modules.Add("Transizioni giorno-notte");
if (project.Stabilization.Enabled) _modules.Add("Stabilizzazione sub-pixel");
if (project.Camera.Enabled) _modules.Add("Camera virtuale");
if (project.TimeRamp.Enabled) _modules.Add("Rimappatura del tempo");
if (project.Stacking.Mode != Motion.StackingMode.Off) _modules.Add("Accumulo temporale");
if (project.MotionBlur.Enabled) _modules.Add("Motion blur sintetico");
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
int width = Math.Min(760, Math.Max(320, Width - 32));
int y = 16;
TextRenderer.DrawText(g, "Titano", Theme.Title, new Rectangle(16, y, width, 34), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
y += 36;
TextRenderer.DrawText(g,
"Elaborazione di time-lapse di livello professionale, sviluppata interamente in-house.",
Theme.Body, new Rectangle(16, y, width, 22), Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
y += 34;
y = DrawCard(g, 16, y, width, "IL VINCOLO CHE DEFINISCE IL PROGETTO",
"Nessuna libreria di terze parti. Il file di progetto non contiene un solo pacchetto: " +
"parser dei metadati, analisi fotometrica, campo vettoriale, trasformata di Fourier, " +
"multiplexer MP4 e ogni controllo di questa finestra sono scritti qui dentro, oppure " +
"ottenuti tramite chiamate dirette a componenti del sistema operativo. Non viene avviato " +
"nessun processo esterno.");
y += 12;
y = DrawRows(g, 16, y, width, "STATO", _rows);
y += 12;
DrawCard(g, 16, y, width, "MODULI ATTIVI SU QUESTA SEQUENZA",
_modules.Count > 0
? string.Join(" · ", _modules)
: "Nessun modulo avanzato attivo: la sequenza verrà elaborata con il solo percorso di base.");
}
private static int DrawCard(Graphics g, int x, int y, int width, string title, string body)
{
int textHeight = TextRenderer.MeasureText(body, Theme.Small, new Size(width - 32, 0),
TextFormatFlags.WordBreak).Height;
int height = 34 + textHeight + 14;
Theme.FillAndStroke(g, new RectangleF(x + 0.5f, y + 0.5f, width - 1, height - 1), 6f,
Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(x + 16, y + 10, width - 32, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, body, Theme.Small, new Rectangle(x + 16, y + 30, width - 32, textHeight + 4),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
return y + height;
}
private static int DrawRows(Graphics g, int x, int y, int width, string title,
List<(string Label, string Value)> rows)
{
int height = 34 + rows.Count * 20 + 10;
Theme.FillAndStroke(g, new RectangleF(x + 0.5f, y + 0.5f, width - 1, height - 1), 6f,
Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(x + 16, y + 10, width - 32, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
int row = y + 32;
foreach (var (label, value) in rows)
{
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 16, row, 180, 18), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, value, Theme.Small, new Rectangle(x + 200, row, width - 216, 18),
Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.PathEllipsis);
row += 20;
}
return y + height;
}
}
+87
View File
@@ -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);
}
}
+319 -13
View File
@@ -145,6 +145,9 @@ internal sealed class ParameterSlider : Control
private double _value;
private bool _dragging;
private bool _hover;
private bool _hoverAuto;
private bool _isAuto;
private TextBox? _entry;
public string Caption { get; set; } = string.Empty;
public string Unit { get; set; } = string.Empty;
@@ -153,8 +156,33 @@ internal sealed class ParameterSlider : Control
public double Maximum { get; set; } = 1;
public double Step { get; set; }
/// <summary>Il valore di questo parametro il programma sa dedurlo: compare l'indicatore automatico.</summary>
public bool AutoSupported { get; set; }
/// <summary>Motivo della scelta automatica, mostrato come suggerimento.</summary>
public string AutoReason { get; set; } = string.Empty;
public event EventHandler? ValueChanged;
/// <summary>L'utente ha preso o restituito il controllo di questo parametro.</summary>
public event EventHandler? AutoChanged;
/// <summary>
/// Vero quando il valore lo sceglie il programma. Toccare il cursore lo disattiva: chi
/// mette le mani su un parametro se lo prende, senza dover prima dichiarare l'intenzione.
/// </summary>
public bool IsAuto
{
get => _isAuto;
set
{
if (_isAuto == value) return;
_isAuto = value;
Invalidate();
AutoChanged?.Invoke(this, EventArgs.Empty);
}
}
public double Value
{
get => _value;
@@ -185,13 +213,32 @@ internal sealed class ParameterSlider : Control
}
private Rectangle TrackBounds => new(2, Height - 20, Width - 4, 12);
private Rectangle ValueBounds => new(Width - 92, 2, 92, 16);
private Rectangle AutoBounds => new(Width - 92 - 46, 1, 42, 17);
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseLeave(EventArgs e)
{
_hover = false;
_hoverAuto = false;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left || !Enabled) return;
if (AutoSupported && AutoBounds.Contains(e.Location))
{
IsAuto = !IsAuto;
return;
}
// Toccare il cursore significa prenderne il controllo.
if (IsAuto) IsAuto = false;
_dragging = true;
UpdateFromMouse(e.X);
base.OnMouseDown(e);
@@ -199,7 +246,12 @@ internal sealed class ParameterSlider : Control
protected override void OnMouseMove(MouseEventArgs e)
{
if (_dragging) UpdateFromMouse(e.X);
if (_dragging) { UpdateFromMouse(e.X); return; }
bool overAuto = AutoSupported && AutoBounds.Contains(e.Location);
if (overAuto != _hoverAuto) { _hoverAuto = overAuto; Invalidate(); }
Cursor = overAuto || ValueBounds.Contains(e.Location) ? Cursors.Hand : Cursors.Default;
base.OnMouseMove(e);
}
@@ -209,9 +261,16 @@ internal sealed class ParameterSlider : Control
base.OnMouseUp(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
if (Enabled && ValueBounds.Contains(e.Location)) BeginEdit();
base.OnMouseDoubleClick(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (!Enabled) return;
if (IsAuto) IsAuto = false;
double increment = Step > 0 ? Step : (Maximum - Minimum) / 50.0;
Value += Math.Sign(e.Delta) * increment;
}
@@ -223,6 +282,59 @@ internal sealed class ParameterSlider : Control
Value = Minimum + fraction * (Maximum - Minimum);
}
/// <summary>
/// Immissione diretta del valore. Per una tolleranza di cadenza o un angolo di otturatore
/// si vuole scrivere il numero: trascinare fino a 0,35 è un esercizio di mira, non una
/// regolazione.
/// </summary>
private void BeginEdit()
{
if (_entry is not null) return;
_entry = new TextBox
{
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.Body,
TextAlign = HorizontalAlignment.Right,
Bounds = ValueBounds,
Text = _value.ToString(ValueFormat, CultureInfo.CurrentCulture),
};
_entry.KeyDown += (_, args) =>
{
if (args.KeyCode == Keys.Enter) { CommitEdit(true); args.Handled = args.SuppressKeyPress = true; }
else if (args.KeyCode == Keys.Escape) { CommitEdit(false); args.Handled = args.SuppressKeyPress = true; }
};
_entry.LostFocus += (_, _) => CommitEdit(true);
Controls.Add(_entry);
_entry.Focus();
_entry.SelectAll();
}
private void CommitEdit(bool accept)
{
var entry = _entry;
if (entry is null) return;
_entry = null;
if (accept)
{
string text = entry.Text.Trim().Replace(',', '.');
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed))
{
if (IsAuto) IsAuto = false;
Value = parsed;
}
}
Controls.Remove(entry);
entry.Dispose();
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
@@ -232,13 +344,21 @@ internal sealed class ParameterSlider : Control
Color captionColor = Enabled ? Theme.TextMuted : Theme.TextFaint;
Color valueColor = Enabled ? Theme.Text : Theme.TextFaint;
TextRenderer.DrawText(g, Caption, Theme.Small, new Rectangle(0, 2, Width - 90, 16),
captionColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
int captionWidth = Width - 92 - (AutoSupported ? 50 : 4);
TextRenderer.DrawText(g, Caption, Theme.Small, new Rectangle(0, 2, Math.Max(40, captionWidth), 16),
captionColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis);
string display = _value.ToString(ValueFormat, CultureInfo.CurrentCulture) +
(string.IsNullOrEmpty(Unit) ? string.Empty : " " + Unit);
TextRenderer.DrawText(g, display, Theme.SmallBold, new Rectangle(Width - 92, 2, 92, 16),
valueColor, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
if (AutoSupported) DrawAutoPill(g);
if (_entry is null)
{
string display = _value.ToString(ValueFormat, CultureInfo.CurrentCulture) +
(string.IsNullOrEmpty(Unit) ? string.Empty : " " + Unit);
TextRenderer.DrawText(g, display, Theme.SmallBold, ValueBounds,
_isAuto ? Theme.Accent : valueColor,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
var track = TrackBounds;
float centerY = track.Top + track.Height / 2f;
@@ -246,19 +366,39 @@ internal sealed class ParameterSlider : Control
Theme.FillRounded(g, groove, 2f, Enabled ? Theme.SurfaceAlt : Theme.Surface);
double span = Maximum - Minimum;
float fraction = span <= 0 ? 0 : (float)((_value - Minimum) / span);
float fraction = span <= 0 ? 0 : (float)Math.Clamp((_value - Minimum) / span, 0, 1);
// In automatico il cursore resta leggibile ma smorzato: dice qual è il valore senza
// sembrare qualcosa che è stato impostato a mano.
Color fill = !Enabled ? Theme.Border
: _isAuto ? Theme.AccentDim
: Theme.Accent;
var filled = new RectangleF(track.Left, centerY - 2f, track.Width * fraction, 4f);
if (filled.Width > 0.5f)
Theme.FillRounded(g, filled, 2f, Enabled ? Theme.Accent : Theme.Border);
if (filled.Width > 0.5f) Theme.FillRounded(g, filled, 2f, fill);
float knobX = track.Left + track.Width * fraction;
float radius = _dragging ? 7.5f : _hover ? 7f : 6f;
var knob = new RectangleF(knobX - radius, centerY - radius, radius * 2, radius * 2);
using (var brush = new SolidBrush(Enabled ? Theme.Text : Theme.TextFaint)) g.FillEllipse(brush, knob);
using (var pen = new Pen(Enabled ? Theme.Accent : Theme.Border, 2f))
using (var brush = new SolidBrush(Enabled ? (_isAuto ? Theme.TextMuted : Theme.Text) : Theme.TextFaint))
g.FillEllipse(brush, knob);
using (var pen = new Pen(Enabled ? fill : Theme.Border, 2f))
g.DrawEllipse(pen, RectangleF.Inflate(knob, -1f, -1f));
}
private void DrawAutoPill(Graphics g)
{
var pill = AutoBounds;
Color border = _isAuto ? Theme.Accent : _hoverAuto ? Theme.BorderStrong : Theme.Border;
Color background = _isAuto ? Theme.AccentDim : _hoverAuto ? Theme.SurfaceHover : Theme.SurfaceAlt;
Color text = _isAuto ? Color.White : Theme.TextFaint;
Theme.FillAndStroke(g, new RectangleF(pill.X + 0.5f, pill.Y + 0.5f, pill.Width - 1, pill.Height - 1),
8f, background, border);
TextRenderer.DrawText(g, "AUTO", Theme.SmallBold, pill, text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
/// <summary>Etichetta e menu a discesa affiancati, con lo stile del tema.</summary>
@@ -309,6 +449,172 @@ internal sealed class LabeledCombo : Panel
}
}
/// <summary>Simboli degli interruttori dell'anteprima.</summary>
internal enum ToggleGlyph
{
Compare,
Zoom,
Motion,
Region,
Crop,
FullScreen,
}
/// <summary>
/// Interruttore compatto a simbolo, per i comandi che vivono nell'intestazione di un riquadro
/// e devono occupare poco. Il simbolo è disegnato con primitive e resta nitido a qualunque
/// risoluzione; lo stato acceso si legge dal fondo, non da una spia accanto.
/// </summary>
internal sealed class DarkToggle : Control
{
private bool _checked;
private bool _hover;
public event EventHandler? CheckedChanged;
public ToggleGlyph Glyph { get; set; }
/// <summary>Testo del suggerimento; compare come descrizione al passaggio del puntatore.</summary>
public string Hint
{
get => _hint;
set { _hint = value; Tips.Set(this, value); }
}
private string _hint = string.Empty;
/// <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
{
get => _checked;
set
{
if (_checked == value) return;
_checked = value;
Invalidate();
CheckedChanged?.Invoke(this, EventArgs.Empty);
}
}
public DarkToggle()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Size = new Size(26, 24);
Cursor = Cursors.Hand;
}
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnClick(EventArgs e)
{
if (Momentary) Pressed?.Invoke(this, EventArgs.Empty);
else Checked = !Checked;
base.OnClick(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Background);
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
Color fill = _checked ? Theme.AccentDim : _hover ? Theme.SurfaceAlt : Theme.Surface;
Color stroke = _checked ? Theme.Accent : _hover ? Theme.BorderStrong : Theme.Border;
Theme.FillAndStroke(g, bounds, 4f, fill, stroke);
Color tint = _checked ? Color.White : _hover ? Theme.Text : Theme.TextMuted;
DrawGlyph(g, new RectangleF(6, 5, Width - 12, Height - 10), tint);
}
private void DrawGlyph(Graphics g, RectangleF box, Color tint)
{
using var pen = new Pen(tint, 1.4f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
};
using var brush = new SolidBrush(tint);
float x = box.X, y = box.Y, w = box.Width, h = box.Height;
switch (Glyph)
{
case ToggleGlyph.Compare:
// Riquadro diviso a metà, con una metà piena: prima e dopo.
g.DrawRectangle(pen, x, y, w, h);
using (var half = new SolidBrush(Color.FromArgb(150, tint)))
g.FillRectangle(half, x + w / 2, y, w / 2, h);
break;
case ToggleGlyph.Zoom:
g.DrawEllipse(pen, x, y, w * 0.72f, h * 0.72f);
g.DrawLine(pen, x + w * 0.62f, y + h * 0.62f, x + w, y + h);
break;
case ToggleGlyph.Motion:
g.DrawLine(pen, x, y + h, x + w * 0.75f, y + h * 0.25f);
g.DrawLines(pen,
[
new PointF(x + w * 0.42f, y + h * 0.18f),
new PointF(x + w * 0.80f, y + h * 0.14f),
new PointF(x + w * 0.76f, y + h * 0.52f),
]);
break;
case ToggleGlyph.Region:
// Riquadro tagliato da una linea d'orizzonte.
g.DrawRectangle(pen, x, y, w, h);
g.DrawCurve(pen,
[
new PointF(x, y + h * 0.62f),
new PointF(x + w * 0.45f, y + h * 0.44f),
new PointF(x + w, y + h * 0.58f),
]);
break;
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;
}
}
/// <summary>Intestazione di sezione con filetto di separazione.</summary>
internal sealed class SectionHeader : Control
{
+317
View File
@@ -0,0 +1,317 @@
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Pagina dell'esportazione: cosa verrà prodotto, a che punto è, e — mentre si aspetta —
/// il riquadro degli sponsor.
///
/// La codifica di una sequenza lunga sono minuti o decine di minuti in cui non c'è niente da
/// fare. È l'unico momento del programma in cui uno spazio pubblicitario non toglie niente a
/// nessuno, ed è per questo che sta soltanto qui: accanto a un cursore che si sta regolando
/// sarebbe un ostacolo, accanto a una barra di avanzamento è qualcosa da guardare.
/// </summary>
internal sealed class ExportPanel : Panel
{
private readonly TitanoProject _project;
private readonly SponsorPanel _sponsors = new() { Dock = DockStyle.Bottom };
private readonly SummaryCard _summary = new() { Dock = DockStyle.Top, Height = 168 };
private readonly ProgressCard _progress = new() { Dock = DockStyle.Top, Height = 112 };
private readonly ResultCard _result = new() { Dock = DockStyle.Fill };
public ExportPanel(TitanoProject project)
{
_project = project;
BackColor = Theme.Background;
Padding = new Padding(14, 12, 14, 12);
// Ordine di ancoraggio: il riepilogo in cima, l'avanzamento sotto, l'esito riempie,
// gli sponsor restano ancorati in basso e non si muovono mai.
Controls.Add(_result);
Controls.Add(_progress);
Controls.Add(_summary);
Controls.Add(_sponsors);
}
public void Configure(AppSettings settings) => _sponsors.Configure(settings);
public void SetActive(bool active) => _sponsors.SetActive(active);
public void Refresh(TitanoProject project)
{
_summary.Update(project);
Invalidate(true);
}
public void ReportProgress(PipelineProgress progress)
{
_progress.Report(progress);
}
public void BeginExport()
{
_progress.Begin();
_result.Clear();
}
public void ShowResult(RenderResult result)
{
_progress.Finish();
_result.Show(result);
}
public void ShowFailure(string message)
{
_progress.Finish();
_result.ShowFailure(message);
}
// ================================================================== riepilogo
private sealed class SummaryCard : Control
{
private readonly List<(string Label, string Value)> _rows = [];
private string _title = "Nessuna sequenza caricata";
public SummaryCard()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Update(TitanoProject project)
{
_rows.Clear();
if (project.Sequence is not { Count: > 0 } sequence)
{
_title = "Nessuna sequenza caricata";
Invalidate();
return;
}
var (width, height) = project.ResolveWorkingSize();
uint baseUnits = (uint)Math.Max(1, Math.Round(project.Export.Timescale /
Math.Max(1.0, project.Export.FrameRate)));
var plan = RenderPlanner.Build(sequence, project.Export, project.TimeRamp, baseUnits);
double seconds = plan.Count / Math.Max(1.0, project.Export.FrameRate);
double megabytes = project.Export.BitrateMbps * seconds / 8.0;
_title = Path.GetFileName(project.Export.OutputPath) is { Length: > 0 } name
? name
: "destinazione non impostata";
_rows.Add(("Fotogrammi in uscita", $"{plan.Count} da {sequence.Count} scatti"));
_rows.Add(("Piano temporale", plan.Description));
_rows.Add(("Risoluzione", $"{width}×{height}"));
_rows.Add(("Durata", $"{TimeSpan.FromSeconds(seconds):mm\\:ss} a {project.Export.FrameRate:0} fps"));
_rows.Add(("Formato", $"{(project.Export.Codec == Video.VideoCodec.H264 ? "H.264" : "HEVC")} " +
$"a {project.Export.BitrateMbps:0} Mb/s · circa {megabytes:0} MiB"));
var active = new List<string>();
if (project.Deflicker.Enabled) active.Add("deflicker");
if (project.Regions.Mode != Analysis.RegionMode.Off) active.Add("regioni");
if (project.HolyGrail.Enabled) active.Add("transizioni");
if (project.Stabilization.Enabled) active.Add("stabilizzazione");
if (project.Camera.Enabled) active.Add("camera virtuale");
if (project.TimeRamp.Enabled) active.Add("rimappatura");
if (project.Stacking.Mode != Motion.StackingMode.Off) active.Add("accumulo");
if (project.MotionBlur.Enabled) active.Add("sfocatura");
_rows.Add(("Moduli attivi", active.Count > 0 ? string.Join(" · ", active) : "nessuno"));
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 0.5f, Width - 1, Height - 7);
Theme.FillAndStroke(g, card, 6f, Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, "VERRÀ PRODOTTO", Theme.SmallBold, new Rectangle(16, 10, 240, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, _title, Theme.BodyBold, new Rectangle(16, 28, Width - 32, 20),
Theme.Text, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
int y = 54;
foreach (var (label, value) in _rows)
{
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(16, y, 160, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, value, Theme.Small, new Rectangle(180, y, Width - 196, 16),
Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
y += 18;
}
}
}
// ================================================================== avanzamento
private sealed class ProgressCard : Control
{
private PipelineProgress _progress;
private bool _running;
private DateTime _started = DateTime.Now;
public ProgressCard()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Begin()
{
_running = true;
_started = DateTime.Now;
Invalidate();
}
public void Finish()
{
_running = false;
Invalidate();
}
public void Report(PipelineProgress progress)
{
_progress = progress;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 0.5f, Width - 1, Height - 7);
Theme.FillAndStroke(g, card, 6f, Theme.Surface, Theme.Border);
TextRenderer.DrawText(g, "AVANZAMENTO", Theme.SmallBold, new Rectangle(16, 10, 240, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (!_running && _progress.Total == 0)
{
TextRenderer.DrawText(g, "In attesa di un'esportazione", Theme.Body,
new Rectangle(16, 34, Width - 32, 40), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
TextRenderer.DrawText(g, _progress.Message, Theme.Body, new Rectangle(16, 30, Width - 32, 20),
Theme.Text, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
// 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} 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;
string right = _progress.FramesPerSecond > 0.01
? $"{_progress.FramesPerSecond:0.0} fps · {_progress.Remaining:hh\\:mm\\:ss} rimanenti" +
$" · {elapsed:hh\\:mm\\:ss} trascorsi"
: $"{elapsed:hh\\:mm\\:ss} trascorsi";
TextRenderer.DrawText(g, right, Theme.Small, new Rectangle(Width - 400, 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, 78, Width - 32, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
}
}
}
// ================================================================== esito
private sealed class ResultCard : Control
{
private readonly List<(string Label, string Value)> _rows = [];
private string _title = string.Empty;
private bool _failed;
public ResultCard()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Clear()
{
_rows.Clear();
_title = string.Empty;
_failed = false;
Invalidate();
}
public void Show(RenderResult result)
{
_failed = false;
_title = "Esportazione completata";
_rows.Clear();
_rows.Add(("File", result.OutputPath));
_rows.Add(("Fotogrammi", $"{result.EncodedFrames}"));
_rows.Add(("Dimensione", $"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB"));
_rows.Add(("Tempo", $"{result.Elapsed.TotalSeconds:0.0} s " +
$"({result.EncodedFrames / Math.Max(0.001, result.Elapsed.TotalSeconds):0.00} fps)"));
_rows.Add(("Encoder", result.EncoderName + (result.HardwareAccelerated ? " (hardware)" : " (software)")));
_rows.Add(("Memoria", $"{result.PeakPixelMemoryBytes / (1024.0 * 1024.0):0} MiB di picco"));
if (result.UsedDisk)
{
_rows.Add(("Parcheggio su disco", $"{result.SpilledFrames} fotogrammi, " +
$"{result.SpillBytes / (1024.0 * 1024.0):0} MiB"));
}
Invalidate();
}
public void ShowFailure(string message)
{
_failed = true;
_title = "Esportazione non riuscita";
_rows.Clear();
_rows.Add(("Motivo", message));
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
if (_title.Length == 0) return;
var card = new RectangleF(0.5f, 0.5f, Width - 1, Math.Min(Height - 8, 40 + _rows.Count * 20));
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _failed ? Theme.Danger : Theme.Border);
TextRenderer.DrawText(g, _title, Theme.BodyBold, new Rectangle(16, 12, Width - 32, 20),
_failed ? Theme.Danger : Theme.Success,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
int y = 38;
foreach (var (label, value) in _rows)
{
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(16, y, 160, 18), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, value, Theme.Small, new Rectangle(180, y, Width - 196, 18),
Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
y += 20;
}
}
}
}
+302
View File
@@ -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);
}
}
+216
View File
@@ -0,0 +1,216 @@
using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Scelta dell'area di uscita dentro il fotogramma sorgente: si trascina il rettangolo per
/// spostarlo e la maniglia d'angolo per stringerlo.
///
/// Il rettangolo ha sempre il rapporto scelto per l'uscita, non quello della sorgente. È la
/// differenza fra tagliare e deformare: passando da 4:3 a 16:9 quello che si perde sono due
/// fasce, e deciderle guardando l'immagine è l'unico modo sensato di farlo.
/// </summary>
internal sealed class FramingEditor : Control
{
private const float HandleSize = 10f;
private readonly ExportSettings _export;
private bool _dragging;
private bool _draggingZoom;
private PointF _grabOffset;
public event EventHandler? Changed;
/// <summary>Rapporto larghezza/altezza del fotogramma sorgente.</summary>
public double SourceAspect { get; set; } = 4.0 / 3.0;
/// <summary>Rapporto richiesto per l'uscita.</summary>
public double OutputAspect { get; set; } = 4.0 / 3.0;
public FramingEditor(ExportSettings export)
{
_export = export;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 172;
}
/// <summary>Riquadro che rappresenta il fotogramma sorgente, con le sue proporzioni.</summary>
private RectangleF Stage
{
get
{
var available = new RectangleF(10, 22, Math.Max(20, Width - 20), Math.Max(20, Height - 44));
float aspect = (float)Math.Max(0.1, SourceAspect);
float width = available.Width;
float height = width / aspect;
if (height > available.Height)
{
height = available.Height;
width = height * aspect;
}
return new RectangleF(available.Left + (available.Width - width) / 2,
available.Top + (available.Height - height) / 2, width, height);
}
}
/// <summary>Rettangolo di ritaglio, con il rapporto dell'uscita e vincolato dentro la sorgente.</summary>
private RectangleF CropRect
{
get
{
var stage = Stage;
double zoom = Math.Max(1.0, _export.CropZoom);
// Il più grande rettangolo con il rapporto dell'uscita che sta nella sorgente,
// poi ristretto dallo zoom: la stessa regola che applica lo stadio geometrico.
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
double baseHeight = baseWidth / Math.Max(1e-6, OutputAspect);
float width = (float)(baseWidth / zoom);
float height = (float)(baseHeight / zoom);
float halfX = width / 2f / stage.Width;
float halfY = height / 2f / stage.Height;
float centreX = (float)Math.Clamp(_export.CropCentreX, halfX, 1 - halfX);
float centreY = (float)Math.Clamp(_export.CropCentreY, halfY, 1 - halfY);
return new RectangleF(stage.Left + centreX * stage.Width - width / 2,
stage.Top + centreY * stage.Height - height / 2, width, height);
}
}
/// <summary>Maniglia d'angolo con cui si stringe il ritaglio.</summary>
private RectangleF Grip
{
get
{
var crop = CropRect;
return new RectangleF(crop.Right - HandleSize, crop.Bottom - HandleSize,
HandleSize * 2, HandleSize * 2);
}
}
// ------------------------------------------------------------------ interazione
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
Focus();
if (Grip.Contains(e.Location)) { _draggingZoom = true; return; }
var crop = CropRect;
if (!crop.Contains(e.Location)) return;
_dragging = true;
_grabOffset = new PointF(e.X - (crop.Left + crop.Width / 2), e.Y - (crop.Top + crop.Height / 2));
}
protected override void OnMouseMove(MouseEventArgs e)
{
var stage = Stage;
if (_draggingZoom)
{
float halfWidth = Math.Max(6f, e.X - (stage.Left + (float)_export.CropCentreX * stage.Width));
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
_export.CropZoom = Math.Clamp(baseWidth / (2.0 * halfWidth), 1.0, 6.0);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
if (_dragging)
{
_export.CropCentreX = Math.Clamp((e.X - _grabOffset.X - stage.Left) / stage.Width, 0, 1);
_export.CropCentreY = Math.Clamp((e.Y - _grabOffset.Y - stage.Top) / stage.Height, 0, 1);
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
return;
}
Cursor = Grip.Contains(e.Location) ? Cursors.SizeNWSE
: CropRect.Contains(e.Location) ? Cursors.SizeAll
: Cursors.Default;
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_dragging = false;
_draggingZoom = false;
base.OnMouseUp(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
// Doppio clic: si torna all'inquadratura piena, che è la richiesta più frequente
// dopo aver provato un ritaglio e non esserne convinti.
_export.CropCentreX = 0.5;
_export.CropCentreY = 0.5;
_export.CropZoom = 1.0;
Changed?.Invoke(this, EventArgs.Empty);
Invalidate();
base.OnMouseDoubleClick(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var stage = Stage;
Theme.FillRounded(g, stage, 3f, Theme.SurfaceAlt);
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(stage));
var crop = CropRect;
// Le fasce escluse si scuriscono: si vede subito quanto si sta buttando via.
using (var shade = new SolidBrush(Color.FromArgb(150, Theme.Background)))
{
g.FillRectangle(shade, stage.Left, stage.Top, stage.Width, crop.Top - stage.Top);
g.FillRectangle(shade, stage.Left, crop.Bottom, stage.Width, stage.Bottom - crop.Bottom);
g.FillRectangle(shade, stage.Left, crop.Top, crop.Left - stage.Left, crop.Height);
g.FillRectangle(shade, crop.Right, crop.Top, stage.Right - crop.Right, crop.Height);
}
using (var pen = new Pen(Theme.Accent, 2f)) g.DrawRectangle(pen, crop.Left, crop.Top, crop.Width, crop.Height);
using (var handle = new SolidBrush(Theme.Accent))
{
g.FillRectangle(handle, crop.Right - HandleSize / 2, crop.Bottom - HandleSize / 2, HandleSize, HandleSize);
}
// Terzi dentro il ritaglio: servono a comporre, ed è per comporre che si sta qui.
using (var thirds = new Pen(Color.FromArgb(70, Color.White)))
{
for (int i = 1; i < 3; i++)
{
float x = crop.Left + crop.Width * i / 3f;
float y = crop.Top + crop.Height * i / 3f;
g.DrawLine(thirds, x, crop.Top, x, crop.Bottom);
g.DrawLine(thirds, crop.Left, y, crop.Right, y);
}
}
TextRenderer.DrawText(g, "AREA DI USCITA", Theme.SmallBold, new Rectangle(2, 2, 200, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
double coverage = stage.Width * stage.Height > 0
? crop.Width * crop.Height / (stage.Width * stage.Height)
: 1;
TextRenderer.DrawText(g, $"{_export.CropZoom:0.00}× · {coverage * 100:0}% dell'area sorgente",
Theme.Small, new Rectangle(Width - 260, 2, 252, 16), Theme.TextMuted,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, "trascina per spostare · angolo per stringere · doppio clic azzera",
Theme.Small, new Rectangle(10, Height - 18, Width - 20, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
+348
View File
@@ -0,0 +1,348 @@
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Pagina dell'archivio: da dove si importa, cosa verrà scritto e dove.
///
/// L'anteprima del piano non è un vezzo. Un'importazione sbagliata su mille file non si
/// annulla: o si è scritto nel posto giusto con il nome giusto, o si passa la serata a
/// rimettere in ordine. Vedere prima le cartelle che verranno create e i primi nomi che ne
/// escono costa un istante e toglie l'unico rischio serio dell'operazione.
/// </summary>
internal sealed class ImportPanel : Panel
{
private readonly ImportSettings _settings;
private readonly VolumeList _volumes = new() { Dock = DockStyle.Top, Height = 148 };
private readonly PlanView _plan = new() { Dock = DockStyle.Fill };
private readonly ProgressStrip _progress = new() { Dock = DockStyle.Bottom, Height = 78 };
private List<ImportCandidate> _candidates = [];
private ImportPlan? _current;
/// <summary>L'utente ha scelto un supporto: il chiamante aggiorna la sorgente e rilegge.</summary>
public event EventHandler<string>? VolumeChosen;
public ImportPanel(ImportSettings settings)
{
_settings = settings;
BackColor = Theme.Background;
Padding = new Padding(14, 12, 14, 12);
_volumes.VolumeChosen += (_, path) => VolumeChosen?.Invoke(this, path);
Controls.Add(_plan);
Controls.Add(_progress);
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 new void Refresh()
{
_volumes.SetVolumes(MediaImporter.Volumes(), _settings.SourcePath);
_plan.Update(_settings, _candidates, _current);
}
public void SetScan(List<ImportCandidate> candidates, ImportPlan? plan)
{
_candidates = candidates;
_current = plan;
_plan.Update(_settings, candidates, plan);
}
public void RebuildPlan()
{
_current = _candidates.Count > 0 ? MediaImporter.Plan(_candidates, _settings) : null;
_plan.Update(_settings, _candidates, _current);
}
public void ReportProgress(PipelineProgress progress) => _progress.Report(progress);
public void ShowResult(ImportResult result) => _progress.Show(result);
public void ShowMessage(string message) => _progress.ShowMessage(message);
// ================================================================== supporti
private sealed class VolumeList : Control
{
private List<ImportVolume> _items = [];
private string _selected = string.Empty;
private int _hovered = -1;
public event EventHandler<string>? VolumeChosen;
public VolumeList()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Cursor = Cursors.Hand;
}
public void SetVolumes(List<ImportVolume> volumes, string selected)
{
_items = volumes;
_selected = selected;
Invalidate();
}
private const int RowHeight = 30;
private Rectangle Body => new(0, 26, Width, Math.Max(20, Height - 32));
private int IndexAt(int y)
{
int index = (y - Body.Top) / RowHeight;
return index >= 0 && index < _items.Count ? index : -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.Y);
if (index != _hovered) { _hovered = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
int index = IndexAt(e.Y);
if (index < 0) return;
_selected = _items[index].Path;
Invalidate();
VolumeChosen?.Invoke(this, _items[index].Path);
base.OnMouseDown(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
TextRenderer.DrawText(g, "SUPPORTI COLLEGATI", Theme.SmallBold, new Rectangle(2, 4, 300, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (_items.Count == 0)
{
TextRenderer.DrawText(g, "Nessun supporto rilevato. Indica una cartella dalle impostazioni.",
Theme.Small, Body, Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
var body = Body;
for (int i = 0; i < _items.Count; i++)
{
var volume = _items[i];
var row = new Rectangle(0, body.Top + i * RowHeight, Width, RowHeight - 2);
if (row.Bottom > body.Bottom) break;
bool chosen = string.Equals(volume.Path, _selected, StringComparison.OrdinalIgnoreCase);
if (chosen) Theme.FillRounded(g, row, 4f, Theme.SurfaceAlt);
else if (i == _hovered) Theme.FillRounded(g, row, 4f, Theme.Surface);
// Il pallino distingue a colpo d'occhio una scheda da un disco interno: sono
// due cose diverse e si sbaglia facilmente a scaricare dalla seconda.
using (var dot = new SolidBrush(volume.Removable ? Theme.Accent : Theme.TextFaint))
g.FillEllipse(dot, 8, row.Top + RowHeight / 2f - 4, 7, 7);
TextRenderer.DrawText(g, volume.Description, chosen ? Theme.BodyBold : Theme.Body,
new Rectangle(24, row.Top, Width - 200, row.Height),
chosen ? Theme.Text : Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
string free = $"{volume.FreeBytes / (1024.0 * 1024 * 1024):0.#} GB liberi";
TextRenderer.DrawText(g, free, Theme.Small, new Rectangle(Width - 190, row.Top, 182, row.Height),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
}
}
// ================================================================== piano
private sealed class PlanView : Control
{
private readonly List<string> _lines = [];
private string _headline = "Nessuna scansione eseguita";
private string _detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
private bool _warning;
public PlanView()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Update(ImportSettings settings, List<ImportCandidate> candidates, ImportPlan? plan)
{
_lines.Clear();
_warning = false;
if (candidates.Count == 0)
{
_headline = "Nessuna scansione eseguita";
_detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
Invalidate();
return;
}
if (plan is null)
{
_headline = $"{candidates.Count} file trovati";
_detail = "Imposta la destinazione per costruire il piano.";
Invalidate();
return;
}
string size = plan.TotalBytes >= 1L << 30
? $"{plan.TotalBytes / (1024.0 * 1024 * 1024):0.0} GB"
: $"{plan.TotalBytes / (1024.0 * 1024):0} MB";
_headline = $"{plan.Pending} da importare · {plan.Skipped} già presenti · {size}";
_detail = plan.SessionCount > 1
? $"{plan.SessionCount} sessioni riconosciute con pause oltre {settings.SessionGapMinutes:0} minuti"
: "Una sola sessione riconosciuta";
if (string.IsNullOrWhiteSpace(settings.DestinationRoot))
{
_warning = true;
_detail = "Manca la cartella di destinazione.";
}
foreach (string folder in plan.Folders.Take(6))
{
_lines.Add("cartella " + folder);
}
foreach (var step in plan.Steps.Where(s => !s.Skipped).Take(6))
{
_lines.Add("file " + Path.GetFileName(step.DestinationPath));
}
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 4.5f, Width - 1, Math.Max(40, Height - 12));
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _warning ? Theme.Warning : Theme.Border);
TextRenderer.DrawText(g, "COSA VERRÀ IMPORTATO", Theme.SmallBold, new Rectangle(16, 14, 300, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, _headline, Theme.BodyBold, new Rectangle(16, 34, Width - 32, 22),
_warning ? Theme.Warning : Theme.Text,
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, _detail, Theme.Small, new Rectangle(16, 58, Width - 32, 18),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
int y = 86;
foreach (string line in _lines)
{
if (y > Height - 30) break;
TextRenderer.DrawText(g, line, Theme.Small, new Rectangle(16, y, Width - 32, 18),
Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.PathEllipsis);
y += 18;
}
}
}
// ================================================================== avanzamento
private sealed class ProgressStrip : Control
{
private PipelineProgress _progress;
private string _message = string.Empty;
private bool _failed;
public ProgressStrip()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Report(PipelineProgress progress)
{
_progress = progress;
_message = string.Empty;
Invalidate();
}
public void ShowMessage(string message)
{
_message = message;
_failed = false;
Invalidate();
}
public void Show(ImportResult result)
{
_failed = result.Failed > 0;
_message = $"Importati {result.Imported} file in {result.Elapsed.TotalSeconds:0.0} s" +
(result.Skipped > 0 ? $", {result.Skipped} già presenti" : string.Empty) +
(result.Failed > 0 ? $", {result.Failed} non riusciti" : string.Empty) +
(result.Errors.Count > 0 ? " — " + result.Errors[0] : string.Empty);
_progress = default;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var card = new RectangleF(0.5f, 6.5f, Width - 1, Height - 10);
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _failed ? Theme.Danger : Theme.Border);
if (_message.Length > 0)
{
TextRenderer.DrawText(g, _message, Theme.Body, new Rectangle(16, 6, Width - 32, Height - 12),
_failed ? Theme.Danger : Theme.Success,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
return;
}
if (_progress.Total <= 0)
{
TextRenderer.DrawText(g, "In attesa", Theme.Small, new Rectangle(16, 6, Width - 32, Height - 12),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
return;
}
TextRenderer.DrawText(g, $"{_progress.Message} {_progress.Completed}/{_progress.Total}",
Theme.Body, new Rectangle(16, 14, Width - 32, 20), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
var bar = new RectangleF(16, 46, 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);
}
}
}
}
+803 -202
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
using System.Drawing.Drawing2D;
using Titano.Motion;
namespace Titano.UI;
/// <summary>
/// Percorso della camera ricostruito dalla stabilizzazione: dove il fotogramma si trovava
/// davvero e dove la lisciatura ha deciso che dovesse stare.
///
/// I due vettori per fotogramma erano già calcolati e conservati nel progetto, e finivano in
/// due numeri nel riepilogo. Disegnati, dicono la cosa che i numeri non dicono: quanta parte
/// del movimento la stabilizzazione ha giudicato voluta — e lì le due curve coincidono — e
/// quanta ha giudicato tremolio, che è la distanza fra loro.
/// </summary>
internal sealed class MotionPathChart : Control
{
private StabilizationPath? _path;
private double _pixelsPerUnit = 1;
private int _selectedIndex = -1;
private int _hoverIndex = -1;
private const int GutterLeft = 46;
private const int GutterTop = 22;
private const int GutterBottom = 18;
public event EventHandler? SelectionChanged;
public MotionPathChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public int SelectedIndex
{
get => _selectedIndex;
set { _selectedIndex = value; Invalidate(); }
}
/// <summary>Il percorso e la larghezza sorgente, che converte le unità normalizzate in pixel.</summary>
public void SetPath(StabilizationPath? path, int sourceWidth)
{
_path = path;
_pixelsPerUnit = Math.Max(1, sourceWidth);
Invalidate();
}
private Rectangle Plot => new(GutterLeft, GutterTop,
Math.Max(20, Width - GutterLeft - 12),
Math.Max(20, Height - GutterTop - GutterBottom));
private int IndexAt(int x)
{
if (_path is not { Count: > 1 }) return -1;
var plot = Plot;
double fraction = Math.Clamp((x - plot.Left) / (double)plot.Width, 0, 1);
return (int)Math.Round(fraction * (_path.Count - 1));
}
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.X);
if (index != _hoverIndex) { _hoverIndex = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hoverIndex = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
int index = IndexAt(e.X);
if (index >= 0)
{
_selectedIndex = index;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
base.OnMouseDown(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = Plot;
if (_path is not { Count: > 2 })
{
TextRenderer.DrawText(g,
_path is null
? "Attiva la stabilizzazione e analizza la sequenza per vedere il percorso"
: "Sequenza troppo corta per ricostruire un percorso",
Theme.Body, new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
int count = _path.Count;
var measuredX = new double[count];
var measuredY = new double[count];
var targetX = new double[count];
var targetY = new double[count];
for (int i = 0; i < count; i++)
{
var measured = _path.Measured[i];
// La posizione corretta è quella misurata portata dov'è finita dopo la correzione.
var corrected = SimilarityTransform.Compose(_path.Correction[i], measured);
measuredX[i] = measured.Tx * _pixelsPerUnit;
measuredY[i] = measured.Ty * _pixelsPerUnit;
targetX[i] = corrected.Tx * _pixelsPerUnit;
targetY[i] = corrected.Ty * _pixelsPerUnit;
}
double lo = double.MaxValue, hi = double.MinValue;
foreach (var series in (double[][])[measuredX, measuredY, targetX, targetY])
{
foreach (double value in series)
{
lo = Math.Min(lo, value);
hi = Math.Max(hi, value);
}
}
if (hi - lo < 2) { double mid = (hi + lo) / 2; lo = mid - 1; hi = mid + 1; }
double padding = (hi - lo) * 0.12;
lo -= padding;
hi += padding;
DrawGrid(g, plot, lo, hi);
// Prima il misurato, sottile, poi il liscio sopra: la distanza fra i due è la
// grandezza che interessa, e va letta come uno scarto dal secondo.
DrawSeries(g, plot, measuredX, lo, hi, Color.FromArgb(110, Theme.Measured), 1.1f);
DrawSeries(g, plot, measuredY, lo, hi, Color.FromArgb(110, Theme.RegionLow), 1.1f);
DrawSeries(g, plot, targetX, lo, hi, Theme.Measured, 1.9f);
DrawSeries(g, plot, targetY, lo, hi, Theme.RegionLow, 1.9f);
DrawLegend(g);
DrawMarker(g, plot, _selectedIndex, count, Color.FromArgb(150, Theme.Text), DashStyle.Dash);
DrawMarker(g, plot, _hoverIndex, count, Color.FromArgb(90, Theme.Text), DashStyle.Solid);
DrawReadout(g, plot, measuredX, measuredY, targetX, targetY);
}
private void DrawGrid(Graphics g, Rectangle plot, double lo, double hi)
{
using var grid = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
using var axis = new Pen(Theme.BorderStrong);
double range = hi - lo;
double step = range > 200 ? 50 : range > 80 ? 20 : range > 30 ? 10 : range > 12 ? 5 : range > 4 ? 2 : 1;
for (double value = Math.Ceiling(lo / step) * step; value <= hi; value += step)
{
float y = YFor(value, plot, lo, hi);
g.DrawLine(grid, plot.Left, y, plot.Right, y);
TextRenderer.DrawText(g, value.ToString("0"), Theme.Small,
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
g.DrawLine(axis, plot.Left, plot.Top, plot.Left, plot.Bottom);
TextRenderer.DrawText(g, "px", Theme.Small, new Rectangle(0, plot.Top - 18, GutterLeft - 6, 16),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
private static float YFor(double value, Rectangle plot, double lo, double hi)
=> plot.Bottom - (float)((value - lo) / Math.Max(1e-9, hi - lo) * plot.Height);
private static void DrawSeries(Graphics g, Rectangle plot, double[] values, double lo, double hi,
Color color, float width)
{
int count = values.Length;
// Con più campioni che pixel si disegna un punto per colonna: tracciare centomila
// segmenti dentro mille pixel costa e non aggiunge un'informazione leggibile.
int stride = Math.Max(1, count / Math.Max(1, plot.Width));
var points = new List<PointF>(count / stride + 2);
for (int i = 0; i < count; i += stride)
{
float x = plot.Left + plot.Width * i / (float)(count - 1);
points.Add(new PointF(x, YFor(values[i], plot, lo, hi)));
}
if (points.Count < 2) return;
using var pen = new Pen(color, width) { LineJoin = LineJoin.Round };
var clip = g.Clip;
g.SetClip(Rectangle.Inflate(plot, 1, 1));
g.DrawLines(pen, [.. points]);
g.Clip = clip;
}
private void DrawLegend(Graphics g)
{
var entries = new (Color Color, string Label)[]
{
(Theme.Measured, "orizzontale"),
(Theme.RegionLow, "verticale"),
};
int x = GutterLeft;
foreach (var (color, label) in entries)
{
using (var faint = new SolidBrush(Color.FromArgb(110, color))) g.FillRectangle(faint, x, 8, 10, 2);
using (var solid = new SolidBrush(color)) g.FillRectangle(solid, x + 12, 8, 10, 2);
var size = TextRenderer.MeasureText(g, label, Theme.Small);
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 27, 1, size.Width + 4, 16),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
x += 27 + size.Width + 18;
}
TextRenderer.DrawText(g, "tenue: misurato · pieno: dopo la stabilizzazione", Theme.Small,
new Rectangle(x, 1, Math.Max(10, Width - x - 10), 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
private static void DrawMarker(Graphics g, Rectangle plot, int index, int count, Color color, DashStyle dash)
{
if (index < 0 || index >= count) return;
float x = plot.Left + plot.Width * index / (float)(count - 1);
using var pen = new Pen(color) { DashStyle = dash };
g.DrawLine(pen, x, plot.Top, x, plot.Bottom);
}
private void DrawReadout(Graphics g, Rectangle plot, double[] mx, double[] my, double[] tx, double[] ty)
{
int index = _hoverIndex >= 0 ? _hoverIndex : _selectedIndex;
if (index < 0 || index >= mx.Length) return;
double shiftX = tx[index] - mx[index];
double shiftY = ty[index] - my[index];
string text = $"#{index + 1} correzione {Math.Sqrt(shiftX * shiftX + shiftY * shiftY):0.00} px";
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
var box = new RectangleF(plot.Right - size.Width - 16, plot.Top + 6, size.Width + 12, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(232, Theme.Background), Theme.BorderStrong);
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
+382
View File
@@ -0,0 +1,382 @@
using System.Drawing.Drawing2D;
namespace Titano.UI;
/// <summary>Le sezioni in cui è divisa la finestra, nell'ordine in cui compaiono nella barra.</summary>
internal enum WorkspaceSection
{
Archive,
Sequence,
Exposure,
Motion,
Timing,
Export,
Preferences,
}
/// <summary>
/// Barra di navigazione verticale sul fianco sinistro: una voce per sezione, ciascuna con il
/// proprio simbolo disegnato a mano e la propria etichetta.
///
/// In verticale ci sta il nome per esteso, che in una barra orizzontale avrebbe dovuto essere
/// abbreviato o troncato; e la voce selezionata resta leggibile mentre si lavora, cosa che in
/// una fila di schede in cima si perde appena l'occhio scende sul contenuto. Il segno di
/// selezione è una barretta sul bordo interno, dove l'occhio la ritrova senza cercarla.
/// </summary>
internal sealed class NavigationRail : Control
{
private sealed record Item(WorkspaceSection Section, string Label, string Hint);
private const int ItemHeight = 52;
private const int GlyphSize = 20;
private const int 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 =
[
new(WorkspaceSection.Archive, "Archivio", "Importazione da schede e fotocamere, nomi e cartelle"),
new(WorkspaceSection.Sequence, "Sequenza", "Fotogrammi, cadenza e lettura dei file"),
new(WorkspaceSection.Exposure, "Esposizione", "Deflicker, regioni e transizioni giorno-notte"),
new(WorkspaceSection.Motion, "Movimento", "Stabilizzazione, camera virtuale e sfocatura"),
new(WorkspaceSection.Timing, "Tempo", "Andamento, rimappatura e accumulo temporale"),
new(WorkspaceSection.Export, "Esportazione", "Codifica, destinazione e avanzamento"),
new(WorkspaceSection.Preferences, "Impostazioni", "Preferenze dell'applicazione"),
];
private int _selected;
private int _hovered = -1;
private 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 = 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;
set
{
int index = Array.FindIndex(_items, i => i.Section == value);
if (index < 0 || index == _selected) return;
_selected = index;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// Numero da mostrare accanto a una sezione: gli avvisi che la riguardano. Zero lo toglie.
/// Serve a far sapere che c'è qualcosa da guardare senza obbligare a passare di lì.
/// </summary>
public void SetBadge(WorkspaceSection section, int count)
{
int previous = _badges.TryGetValue(section, out int existing) ? existing : 0;
if (previous == count) return;
if (count > 0) _badges[section] = count;
else _badges.Remove(section);
Invalidate();
}
// ------------------------------------------------------------------ interazione
private int IndexAt(int y)
{
int index = (y - TopPadding) / ItemHeight;
return index >= 0 && index < _items.Length ? index : -1;
}
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);
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);
}
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);
}
protected override bool IsInputKey(Keys keyData) => keyData is Keys.Up or Keys.Down || base.IsInputKey(keyData);
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode is Keys.Up or Keys.Down)
{
int step = e.KeyCode == Keys.Down ? 1 : -1;
Selected = _items[Math.Clamp(_selected + step, 0, _items.Length - 1)].Section;
e.Handled = true;
}
base.OnKeyDown(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
DrawHeader(g);
int glyphLeft = _collapsed ? (Width - GlyphSize) / 2 : 20;
for (int i = 0; i < _items.Length; i++)
{
var item = _items[i];
var bounds = new Rectangle(0, TopPadding + i * ItemHeight, Width, ItemHeight);
bool active = i == _selected;
bool hover = i == _hovered;
if (active)
{
using var fill = new SolidBrush(Theme.SurfaceAlt);
g.FillRectangle(fill, 6, bounds.Y + 3, Width - 6, ItemHeight - 6);
using var mark = new SolidBrush(Theme.Accent);
g.FillRectangle(mark, 0, bounds.Y + 11, 3, ItemHeight - 22);
}
else if (hover)
{
using var fill = new SolidBrush(Theme.Surface);
g.FillRectangle(fill, 6, bounds.Y + 3, Width - 6, ItemHeight - 6);
}
Color tint = active ? Theme.Accent : hover ? Theme.Text : Theme.TextMuted;
DrawGlyph(g, item.Section,
new Rectangle(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, _collapsed);
}
using var border = new Pen(Theme.Border);
g.DrawLine(border, Width - 1, 0, Width - 1, Height);
}
/// <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);
// 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),
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
/// <summary>
/// Simboli disegnati con primitive, non caratteri di un font di icone: restano nitidi a
/// qualunque risoluzione dello schermo e non dipendono da nulla di installato.
/// </summary>
private static void DrawGlyph(Graphics g, WorkspaceSection section, Rectangle box, Color tint)
{
using var pen = new Pen(tint, 1.6f) { LineJoin = LineJoin.Round, StartCap = LineCap.Round, EndCap = LineCap.Round };
using var brush = new SolidBrush(tint);
float x = box.X, y = box.Y, w = box.Width, h = box.Height;
switch (section)
{
case WorkspaceSection.Archive:
// Cartella con la linguetta: l'archivio, non un supporto specifico.
g.DrawLines(pen,
[
new PointF(x + 1, y + h - 1.5f),
new PointF(x + 1, y + 3.5f),
new PointF(x + w * 0.42f, y + 3.5f),
new PointF(x + w * 0.55f, y + 6.5f),
new PointF(x + w - 1, y + 6.5f),
new PointF(x + w - 1, y + h - 1.5f),
]);
g.DrawLine(pen, x + 1, y + h - 1.5f, x + w - 1, y + h - 1.5f);
break;
case WorkspaceSection.Sequence:
// Tre fotogrammi impilati e sfalsati.
g.DrawRectangle(pen, x + 0.5f, y + 5.5f, w - 6, h - 8);
g.DrawLine(pen, x + 3.5f, y + 3.5f, x + w - 2.5f, y + 3.5f);
g.DrawLine(pen, x + 6.5f, y + 0.5f, x + w - 0.5f, y + 0.5f);
break;
case WorkspaceSection.Exposure:
// La curva di luminanza che sale, con il punto misurato.
g.DrawCurve(pen,
[
new PointF(x, y + h - 2),
new PointF(x + w * 0.35f, y + h * 0.62f),
new PointF(x + w * 0.62f, y + h * 0.30f),
new PointF(x + w, y + 2),
]);
g.FillEllipse(brush, x + w * 0.62f - 2.4f, y + h * 0.30f - 2.4f, 4.8f, 4.8f);
break;
case WorkspaceSection.Motion:
// Vettore di spostamento con la sua origine.
g.DrawEllipse(pen, x + 1, y + h - 7, 6, 6);
g.DrawLine(pen, x + 6.5f, y + h - 5.5f, x + w - 3, y + 3);
g.DrawLines(pen,
[
new PointF(x + w - 7.5f, y + 3.5f),
new PointF(x + w - 2.5f, y + 2.5f),
new PointF(x + w - 3.5f, y + 7.5f),
]);
break;
case WorkspaceSection.Timing:
// Quadrante con le lancette.
g.DrawEllipse(pen, x + 1.5f, y + 1.5f, w - 3, h - 3);
g.DrawLine(pen, x + w / 2, y + h / 2, x + w / 2, y + 4.5f);
g.DrawLine(pen, x + w / 2, y + h / 2, x + w - 5.5f, y + h / 2);
break;
case WorkspaceSection.Export:
// Contenitore aperto con la freccia che ne esce.
g.DrawLines(pen,
[
new PointF(x + 1.5f, y + h * 0.55f),
new PointF(x + 1.5f, y + h - 1.5f),
new PointF(x + w - 1.5f, y + h - 1.5f),
new PointF(x + w - 1.5f, y + h * 0.55f),
]);
g.DrawLine(pen, x + w / 2, y + h - 6, x + w / 2, y + 1.5f);
g.DrawLines(pen,
[
new PointF(x + w / 2 - 3.5f, y + 5),
new PointF(x + w / 2, y + 1.5f),
new PointF(x + w / 2 + 3.5f, y + 5),
]);
break;
case WorkspaceSection.Preferences:
// Tre cursori: dice "regolazioni" molto meglio di un ingranaggio a questa scala.
for (int row = 0; row < 3; row++)
{
float ly = y + 3.5f + row * ((h - 7) / 2f);
g.DrawLine(pen, x + 1, ly, x + w - 1, ly);
float knob = x + 3 + (row == 1 ? w - 10 : row * (w - 10) / 2f + 3);
g.FillEllipse(brush, knob - 2.6f, ly - 2.6f, 5.2f, 5.2f);
}
break;
}
}
}
+186
View File
@@ -0,0 +1,186 @@
using System.Drawing.Drawing2D;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Il piano temporale disegnato: a ogni fotogramma del video finale corrisponde una posizione
/// nella sequenza sorgente, e la curva che le lega è tutto ciò che c'è da sapere sul ritmo.
///
/// Una pendenza di uno significa un fotogramma per scatto. Più ripida, la sequenza corre e
/// salta scatti; più dolce, rallenta e i fotogrammi mancanti vengono sintetizzati dal campo
/// vettoriale. Sono le stesse informazioni della curva di velocità, ma viste dall'altro capo:
/// lì si regola quanto andare veloce, qui si vede dove si è arrivati.
/// </summary>
internal sealed class PlanChart : Control
{
private const int GutterLeft = 56;
private const int GutterBottom = 26;
private const int GutterTop = 26;
private RenderPlan? _plan;
private int _sourceCount;
private double _frameRate = 30;
private int _hover = -1;
public PlanChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public void SetPlan(RenderPlan? plan, int sourceCount, double frameRate)
{
_plan = plan;
_sourceCount = Math.Max(1, sourceCount);
_frameRate = Math.Max(1, frameRate);
Invalidate();
}
private Rectangle Plot => new(GutterLeft, GutterTop,
Math.Max(20, Width - GutterLeft - 16),
Math.Max(20, Height - GutterTop - GutterBottom));
protected override void OnMouseMove(MouseEventArgs e)
{
if (_plan is not { Count: > 1 }) return;
var plot = Plot;
double fraction = Math.Clamp((e.X - plot.Left) / (double)plot.Width, 0, 1);
int index = (int)Math.Round(fraction * (_plan.Count - 1));
if (index != _hover) { _hover = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hover = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = Plot;
if (_plan is not { Count: > 1 } plan)
{
TextRenderer.DrawText(g, "Carica una sequenza per vedere il piano temporale", Theme.Body,
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
DrawGrid(g, plot, plan);
// Riferimento: la diagonale è il rapporto uno a uno fra scatti e fotogrammi d'uscita.
// Serve a leggere a colpo d'occhio dove il piano accelera e dove rallenta.
using (var reference = new Pen(Color.FromArgb(90, Theme.Text)) { DashStyle = DashStyle.Dash })
{
double slope = Math.Min(1.0, (_sourceCount - 1.0) / Math.Max(1, plan.Count - 1));
float endX = plot.Left + (float)(plot.Width * Math.Min(1.0, (plan.Count - 1.0) * slope / Math.Max(1, _sourceCount - 1)));
g.DrawLine(reference, plot.Left, plot.Bottom, endX, plot.Top);
}
int stride = Math.Max(1, plan.Count / Math.Max(1, plot.Width));
var points = new List<PointF>(plan.Count / stride + 2);
for (int i = 0; i < plan.Count; i += stride)
{
points.Add(new PointF(
plot.Left + plot.Width * i / (float)(plan.Count - 1),
plot.Bottom - (float)(plan.Frames[i].SourcePosition / Math.Max(1, _sourceCount - 1) * plot.Height)));
}
if (points.Count >= 2)
{
using var area = new GraphicsPath();
var polygon = new List<PointF>(points) { new(points[^1].X, plot.Bottom), new(points[0].X, plot.Bottom) };
area.AddPolygon([.. polygon]);
using (var fill = new SolidBrush(Color.FromArgb(30, Theme.Accent))) g.FillPath(fill, area);
using var pen = new Pen(Theme.Accent, 2f) { LineJoin = LineJoin.Round };
g.DrawLines(pen, [.. points]);
}
DrawSpeedLane(g, plot, plan, stride);
DrawReadout(g, plot, plan);
}
private void DrawGrid(Graphics g, Rectangle plot, RenderPlan plan)
{
using var grid = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
using var axis = new Pen(Theme.BorderStrong);
for (int i = 0; i <= 4; i++)
{
float y = plot.Bottom - plot.Height * i / 4f;
g.DrawLine(grid, plot.Left, y, plot.Right, y);
TextRenderer.DrawText(g, (_sourceCount * i / 4).ToString(), Theme.Small,
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
g.DrawLine(axis, plot.Left, plot.Top, plot.Left, plot.Bottom);
g.DrawLine(axis, plot.Left, plot.Bottom, plot.Right, plot.Bottom);
TextRenderer.DrawText(g, "scatto", Theme.Small, new Rectangle(0, plot.Top - 18, GutterLeft - 6, 16),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
double seconds = plan.Count / _frameRate;
TextRenderer.DrawText(g,
$"{plan.Count} fotogrammi d'uscita · {TimeSpan.FromSeconds(seconds):mm\\:ss} · {plan.Description}",
Theme.Small, new Rectangle(GutterLeft, 4, Math.Max(20, Width - GutterLeft - 12), 16),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, "fotogramma del video", Theme.Small,
new Rectangle(GutterLeft, Height - GutterBottom + 4, 200, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
/// <summary>Fascia in basso: la velocità istantanea, che è la pendenza resa esplicita.</summary>
private void DrawSpeedLane(Graphics g, Rectangle plot, RenderPlan plan, int stride)
{
double fastest = 0.1;
foreach (var frame in plan.Frames) fastest = Math.Max(fastest, frame.Speed);
int laneHeight = Math.Min(28, plot.Height / 5);
var lane = new Rectangle(plot.Left, plot.Bottom - laneHeight, plot.Width, laneHeight);
using var brush = new SolidBrush(Color.FromArgb(120, Theme.Warning));
for (int i = 0; i < plan.Count; i += stride)
{
float x = lane.Left + lane.Width * i / (float)(plan.Count - 1);
float height = (float)(plan.Frames[i].Speed / fastest * lane.Height);
g.FillRectangle(brush, x, lane.Bottom - height, Math.Max(1f, lane.Width / (float)(plan.Count / stride + 1)), height);
}
TextRenderer.DrawText(g, $"velocità · fondoscala {fastest:0.##}× ", Theme.Small,
new Rectangle(lane.Right - 200, lane.Top - 15, 200, 14), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
private void DrawReadout(Graphics g, Rectangle plot, RenderPlan plan)
{
if (_hover < 0 || _hover >= plan.Count) return;
var frame = plan.Frames[_hover];
float x = plot.Left + plot.Width * _hover / (float)(plan.Count - 1);
float y = plot.Bottom - (float)(frame.SourcePosition / Math.Max(1, _sourceCount - 1) * plot.Height);
using (var pen = new Pen(Color.FromArgb(90, Theme.Text))) g.DrawLine(pen, x, plot.Top, x, plot.Bottom);
using (var dot = new SolidBrush(Theme.Accent)) g.FillEllipse(dot, x - 3.5f, y - 3.5f, 7, 7);
string text = $"uscita #{_hover + 1} scatto {frame.SourcePosition:0.00} {frame.Speed:0.00}×";
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
int left = (int)Math.Min(x + 12, plot.Right - size.Width - 14);
var box = new RectangleF(left, plot.Top + 6, size.Width + 12, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(232, Theme.Background), Theme.BorderStrong);
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
+655 -76
View File
@@ -1,3 +1,4 @@
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using Titano.Core;
using Titano.Imaging;
@@ -6,23 +7,46 @@ using Titano.Pipeline;
namespace Titano.UI;
/// <summary>Frecce del campo vettoriale, in coordinate normalizzate sull'immagine mostrata.</summary>
internal sealed record MotionArrows(PointF[] Origins, PointF[] Vectors, double MedianMagnitude);
/// <summary>
/// Anteprima del fotogramma selezionato, resa dallo stesso motore usato in esportazione:
/// decodifica, correzione di esposizione e — se attivo — motion blur sintetico calcolato
/// sul campo vettoriale verso il fotogramma successivo.
/// Anteprima del fotogramma selezionato, resa dallo stesso motore usato in esportazione.
///
/// Tre cose la rendono utilizzabile per giudicare, e non solo per guardare. Lo zoom, perché
/// una stabilizzazione sotto il pixel o il bordo di una scia non si vedono su un'immagine
/// rimpicciolita per stare in un riquadro. Il confronto a tendina, perché l'unico modo di
/// capire cosa una correzione stia facendo è vedere accanto ciò che c'era prima. E la
/// sovrapposizione del campo vettoriale, che il motore calcola comunque e che spiega in un
/// colpo d'occhio perché la sfocatura viene come viene — soprattutto quando è sbagliata.
/// </summary>
internal sealed class PreviewPanel : Control
{
private readonly TitanoProject _project;
private Bitmap? _bitmap;
private Bitmap? _after;
private Bitmap? _before;
private PointF[]? _regionContour;
private MotionArrows? _arrows;
private RectangleF? _cropRect;
private string _caption = string.Empty;
private string _status = "Nessun fotogramma selezionato";
private CancellationTokenSource? _pending;
private int _requestId;
private bool _busy;
private bool _live;
/// <summary>Contorno delle regioni in coordinate normalizzate dell'anteprima.</summary>
private PointF[]? _regionContour;
// ---- stato della vista
private float _zoom; // 0 = adatta al riquadro
private PointF _centre = new(0.5f, 0.5f); // punto dell'immagine al centro della vista
private float _wipe = 1f; // 1 = tutto "dopo", 0 = tutto "prima"
private bool _panning;
private bool _draggingWipe;
private Point _dragOrigin;
private PointF _centreOrigin;
public event EventHandler<FrameScopes?>? ScopesChanged;
public PreviewPanel(TitanoProject project)
{
@@ -32,16 +56,126 @@ internal sealed class PreviewPanel : Control
BackColor = Theme.Background;
}
/// <summary>Mostra la tendina di confronto fra fotogramma corretto e originale.</summary>
public bool CompareMode { get; private set; }
/// <summary>Disegna il campo vettoriale sopra il fotogramma.</summary>
public bool ShowMotionField { get; private set; }
/// <summary>Disegna il confine fra le regioni del deflicker.</summary>
public bool ShowRegions { get; private set; } = true;
/// <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}%";
// ------------------------------------------------------------------ comandi della vista
public void SetCompareMode(bool value)
{
if (CompareMode == value) return;
CompareMode = value;
_wipe = value ? 0.5f : 1f;
Invalidate();
RequestRefresh();
}
public void SetMotionField(bool value)
{
if (ShowMotionField == value) return;
ShowMotionField = value;
RequestRefresh();
}
public void SetRegionOverlay(bool value)
{
ShowRegions = value;
Invalidate();
}
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()
{
_zoom = _zoom > 0 ? 0 : 1f;
_centre = new PointF(0.5f, 0.5f);
Invalidate();
}
public void ResetView()
{
_zoom = 0;
_centre = new PointF(0.5f, 0.5f);
Invalidate();
}
private event EventHandler? RefreshRequested;
/// <summary>Rende disponibile al chiamante la richiesta di rigenerare il fotogramma.</summary>
public void OnRefreshNeeded(EventHandler handler) => RefreshRequested += handler;
private void RequestRefresh() => RefreshRequested?.Invoke(this, EventArgs.Empty);
// ------------------------------------------------------------------ contenuto
public void Clear()
{
Interlocked.Increment(ref _requestId);
_pending?.Cancel();
SwapBitmap(null);
SwapBitmaps(null, null);
_regionContour = null;
_arrows = null;
_cropRect = null;
_live = false;
_caption = string.Empty;
_status = "Nessun fotogramma selezionato";
ScopesChanged?.Invoke(this, null);
Invalidate();
}
/// <summary>
/// Mostra un fotogramma appena codificato durante l'esportazione. Non passa dalla
/// pipeline dell'anteprima: è già il risultato, arrivato dall'encoder.
/// </summary>
public void ShowLiveFrame(Bitmap frame, string caption, string status)
{
Interlocked.Increment(ref _requestId);
_pending?.Cancel();
SwapBitmaps(frame, null);
_regionContour = null;
_arrows = null;
_cropRect = null;
_live = true;
_busy = false;
_caption = caption;
_status = status;
Invalidate();
}
public void EndLiveFrames() => _live = false;
/// <summary>Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.</summary>
public void Show(TimelapseSequence sequence, int index)
{
@@ -54,30 +188,44 @@ internal sealed class PreviewPanel : Control
var record = sequence.Frames[index];
_caption = $"#{index + 1} {record.FileName}";
_live = false;
_busy = true;
Invalidate();
var project = _project;
var token = source.Token;
bool compare = CompareMode;
bool motion = ShowMotionField;
bool cropOverlay = ShowCrop;
_ = Task.Run(() =>
{
try
{
var (bitmap, status, contour) = Render(project, sequence, index, PreviewSize(), token);
var output = Render(project, sequence, index, PreviewSize(), compare, motion, cropOverlay, token);
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
{
bitmap?.Dispose();
output.After?.Dispose();
output.Before?.Dispose();
return;
}
BeginInvoke(() =>
{
if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; }
SwapBitmap(bitmap);
_regionContour = contour;
_status = status;
if (requestId != Volatile.Read(ref _requestId))
{
output.After?.Dispose();
output.Before?.Dispose();
return;
}
SwapBitmaps(output.After, output.Before);
_regionContour = output.Contour;
_arrows = output.Arrows;
_cropRect = output.Crop;
_status = output.Status;
_busy = false;
ScopesChanged?.Invoke(this, output.Scopes);
Invalidate();
});
}
@@ -88,9 +236,10 @@ internal sealed class PreviewPanel : Control
{
BeginInvoke(() =>
{
SwapBitmap(null);
SwapBitmaps(null, null);
_status = "Anteprima non disponibile: " + ex.Message;
_busy = false;
ScopesChanged?.Invoke(this, null);
Invalidate();
});
}
@@ -99,47 +248,70 @@ 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()
{
int width = Math.Clamp(Width - 24, 160, 1600);
int height = Math.Clamp(Height - 46, 120, 1200);
// Con lo zoom attivo si decodifica più grande del riquadro: guardare un pixel
// richiede che quel pixel esista, non che venga interpolato dall'anteprima.
int factor = _zoom > 0 ? 2 : 1;
int width = Math.Clamp((Width - 24) * factor, 160, 2600);
int height = Math.Clamp((Height - 46) * factor, 120, 2000);
return new Size(width, height);
}
private void SwapBitmap(Bitmap? bitmap)
private void SwapBitmaps(Bitmap? after, Bitmap? before)
{
var previous = _bitmap;
_bitmap = bitmap;
previous?.Dispose();
var previousAfter = _after;
var previousBefore = _before;
_after = after;
_before = before;
previousAfter?.Dispose();
previousBefore?.Dispose();
}
// ------------------------------------------------------------------ rendering
private static (Bitmap? Bitmap, string Status, PointF[]? Contour) Render(
TitanoProject project, TimelapseSequence sequence, int index, Size available, CancellationToken token)
private sealed record RenderOutput(Bitmap? After, Bitmap? Before, PointF[]? Contour,
MotionArrows? Arrows, FrameScopes? Scopes, string Status,
RectangleF? Crop = null);
private static RenderOutput Render(TitanoProject project, TimelapseSequence sequence, int index,
Size available, bool compare, bool motionField, bool cropOverlay,
CancellationToken token)
{
var record = sequence.Frames[index];
var metadata = record.Metadata;
int orientation = project.EffectiveOrientation;
int sourceWidth = metadata.PixelWidth;
int sourceHeight = metadata.PixelHeight;
var (sourceWidth, sourceHeight) = project.ResolveNativeSize();
if (sourceWidth <= 0 || sourceHeight <= 0)
(sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, orientation);
else if (ImageDecoder.SwapsAxes(orientation))
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile", null);
return new RenderOutput(null, null, null, null, null, "Immagine non leggibile");
double scale = Math.Min(available.Width / (double)sourceWidth, available.Height / (double)sourceHeight);
scale = Math.Min(scale, 1.0);
int width = Math.Max(16, (int)(sourceWidth * scale) & ~1);
int height = Math.Max(16, (int)(sourceHeight * scale) & ~1);
var pool = new FrameBufferPool(4);
var pool = new FrameBufferPool(6);
using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, orientation, pool);
token.ThrowIfCancellationRequested();
Bitmap? before = compare ? ToBitmap(frame) : null;
var status = new System.Text.StringBuilder();
status.Append($"{sourceWidth}×{sourceHeight}");
@@ -152,8 +324,10 @@ internal sealed class PreviewPanel : Control
ImageBuffer result = frame;
ImageBuffer? blurred = null;
MotionArrows? arrows = null;
if (project.MotionBlur.Enabled && index + 1 < sequence.Count)
bool wantsFlow = motionField || project.MotionBlur.Enabled;
if (wantsFlow && index + 1 < sequence.Count)
{
var nextMetadata = sequence.Frames[index + 1].Metadata;
using var next = ImageDecoder.Decode(nextMetadata.FilePath, width, height, orientation, pool);
@@ -167,19 +341,24 @@ internal sealed class PreviewPanel : Control
var flow = new OpticalFlowEngine(project.Flow).Compute(frame, next);
token.ThrowIfCancellationRequested();
double missing = MotionBlurRenderer.MissingBlurFactor(record.ShutterAngle,
project.MotionBlur.TargetShutterAngle,
project.MotionBlur.Strength);
// La scia è proporzionale alla risoluzione: in anteprima va riscalata.
var scaledSettings = project.MotionBlur.Clone();
scaledSettings.MaxBlurPixels = project.MotionBlur.MaxBlurPixels * scale;
if (motionField) arrows = SampleArrows(flow, width, height);
blurred = pool.Rent(width, height);
double length = MotionBlurRenderer.Render(frame, blurred, flow, missing, scaledSettings);
result = blurred;
if (project.MotionBlur.Enabled)
{
double missing = MotionBlurRenderer.MissingBlurFactor(record.ShutterAngle,
project.MotionBlur.TargetShutterAngle,
project.MotionBlur.Strength);
// La scia è proporzionale alla risoluzione: in anteprima va riscalata.
var scaledSettings = project.MotionBlur.Clone();
scaledSettings.MaxBlurPixels = project.MotionBlur.MaxBlurPixels * scale;
status.Append($" otturatore {record.ShutterAngle:0.#}° → {project.MotionBlur.TargetShutterAngle:0}°");
status.Append($" scia {length:0.0} px");
blurred = pool.Rent(width, height);
double length = MotionBlurRenderer.Render(frame, blurred, flow, missing, scaledSettings);
result = blurred;
status.Append($" otturatore {record.ShutterAngle:0.#}° → {project.MotionBlur.TargetShutterAngle:0}°");
status.Append($" scia {length:0.0} px");
}
}
// Inquadratura virtuale e stabilizzazione: l'anteprima deve mostrare il fotogramma
@@ -189,24 +368,50 @@ internal sealed class PreviewPanel : Control
var mapping = SourceMapping.Identity;
int framedWidth = width, framedHeight = height;
RectangleF? cropRect = null;
if (project.NeedsGeometry)
{
var (outputWidth, outputHeight) = project.ResolveWorkingSize();
if (outputWidth > 0 && outputHeight > 0)
{
framedWidth = width;
framedHeight = Math.Max(2, (int)Math.Round(width * outputHeight / (double)outputWidth) & ~1);
double normalized = sequence.Count > 1 ? index / (double)(sequence.Count - 1) : 0;
var framing = project.FramingAt(normalized);
var stabilization = project.Motion?.At(index) ?? Motion.SimilarityTransform.Identity;
var stabilization = project.Motion?.At(index) ?? SimilarityTransform.Identity;
mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization);
framed = pool.Rent(framedWidth, framedHeight);
GeometryStage.Resample(result, framed, mapping);
result = framed;
var (cropLeft, cropTop, cropWidth, cropHeight) =
GeometryStage.CropRect(width, height, outputWidth, outputHeight, framing);
bool cuts = cropLeft > 0.5 || cropTop > 0.5 ||
cropLeft + cropWidth < width - 0.5 || cropTop + cropHeight < height - 0.5;
status.Append($" inquadratura {framing.Zoom:0.00}×");
if (cropOverlay && cuts)
{
// Il fotogramma resta intero: si applica la sola stabilizzazione, così il
// rettangolo disegnato sopra cade dove cadrà davvero, e ciò che verrà
// tagliato si vede invece di essere già sparito.
if (!stabilization.IsIdentity)
{
mapping = GeometryStage.Build(width, height, width, height,
CameraFraming.Full, stabilization);
framed = pool.Rent(width, height);
GeometryStage.Resample(result, framed, mapping);
result = framed;
}
cropRect = new RectangleF((float)(cropLeft / width), (float)(cropTop / height),
(float)(cropWidth / width), (float)(cropHeight / height));
status.Append($" ritaglio {framing.Zoom:0.00}× su {outputWidth}×{outputHeight}");
}
else
{
framedHeight = Math.Max(2, (int)Math.Round(width * outputHeight / (double)outputWidth) & ~1);
mapping = GeometryStage.Build(width, height, framedWidth, framedHeight, framing, stabilization);
framed = pool.Rent(framedWidth, framedHeight);
GeometryStage.Resample(result, framed, mapping);
result = framed;
status.Append($" inquadratura {framing.Zoom:0.00}×");
}
}
}
@@ -215,10 +420,44 @@ internal sealed class PreviewPanel : Control
contour = TraceRegionBoundary(mask, mapping, framedWidth, framedHeight, width, height);
}
var scopes = FrameScopes.Compute(result);
var bitmap = ToBitmap(result);
blurred?.Dispose();
framed?.Dispose();
return (bitmap, status.ToString(), contour);
return new RenderOutput(bitmap, before, contour, arrows, scopes, status.ToString(), cropRect);
}
/// <summary>
/// Estrae dal campo una griglia rada di frecce. Disegnarle tutte sarebbe illeggibile:
/// se ne prende una ogni tot pixel dell'immagine mostrata, che è la densità a cui la
/// direzione del movimento si legge senza che le frecce si accavallino.
/// </summary>
private static MotionArrows SampleArrows(MotionField field, int width, int height)
{
const int spacing = 46;
int columns = Math.Max(2, width / spacing);
int rows = Math.Max(2, height / spacing);
var origins = new PointF[columns * rows];
var vectors = new PointF[columns * rows];
int n = 0;
for (int row = 0; row < rows; row++)
{
float y = (row + 0.5f) * height / rows;
for (int column = 0; column < columns; column++)
{
float x = (column + 0.5f) * width / columns;
field.Sample(x, y, out float vx, out float vy);
origins[n] = new PointF(x / width, y / height);
vectors[n] = new PointF(vx / width, vy / width); // stessa scala sui due assi
n++;
}
}
return new MotionArrows(origins[..n], vectors[..n], field.MedianMagnitude());
}
private static void ApplyExposure(TitanoProject project, Analysis.DeflickerCurve curve,
@@ -270,6 +509,48 @@ internal sealed class PreviewPanel : Control
return points.Count >= 2 ? [.. points] : null;
}
/// <summary>
/// Riduzione veloce a bitmap per l'anteprima dal vivo durante l'esportazione: campiona a
/// passo costante invece di mediare. Converte un fotogramma da dodici megapixel in pochi
/// millisecondi, e siccome gira sul thread che sta codificando, quei millisecondi sono
/// tolti al lavoro vero.
/// </summary>
internal static unsafe Bitmap ToThumbnail(ImageBuffer buffer, int maxWidth)
{
int step = Math.Max(1, (int)Math.Ceiling(buffer.Width / (double)Math.Max(16, maxWidth)));
int width = Math.Max(1, buffer.Width / step);
int height = Math.Max(1, buffer.Height / step);
var bitmap = new Bitmap(width, height, PixelFormat.Format32bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, width, height),
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
try
{
var data = buffer.Data;
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
int sourceRow = y * step * buffer.Width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int i = sourceRow + x * step * ImageBuffer.Channels;
byte* pixel = row + x * 4;
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]);
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]);
pixel[2] = ColorSpace.ToSrgbByte(data[i]);
pixel[3] = 255;
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
return bitmap;
}
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
private static unsafe Bitmap ToBitmap(ImageBuffer buffer)
{
@@ -303,6 +584,132 @@ internal sealed class PreviewPanel : Control
return bitmap;
}
// ------------------------------------------------------------------ vista: zoom e trascinamento
private Rectangle Viewport => new(0, 0, Width, Math.Max(20, Height - 22));
private float ScaleFor(Bitmap bitmap)
{
if (_zoom > 0) return _zoom;
var view = Viewport;
return (float)Math.Min(view.Width / (double)bitmap.Width, view.Height / (double)bitmap.Height);
}
private RectangleF TargetFor(Bitmap bitmap)
{
var view = Viewport;
float scale = ScaleFor(bitmap);
float width = bitmap.Width * scale;
float height = bitmap.Height * scale;
if (_zoom <= 0)
{
return new RectangleF(view.Left + (view.Width - width) / 2f,
view.Top + (view.Height - height) / 2f, width, height);
}
return new RectangleF(view.Left + view.Width / 2f - _centre.X * width,
view.Top + view.Height / 2f - _centre.Y * height, width, height);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (_after is null) return;
var target = TargetFor(_after);
// Punto dell'immagine sotto il puntatore, che deve restare fermo mentre si ingrandisce.
float anchorX = target.Width > 0 ? (e.X - target.Left) / target.Width : 0.5f;
float anchorY = target.Height > 0 ? (e.Y - target.Top) / target.Height : 0.5f;
float current = ScaleFor(_after);
float next = Math.Clamp(current * (e.Delta > 0 ? 1.25f : 0.8f), 0.05f, 8f);
var view = Viewport;
float fit = (float)Math.Min(view.Width / (double)_after.Width, view.Height / (double)_after.Height);
if (next <= fit * 1.02f)
{
ResetView();
return;
}
_zoom = next;
float width = _after.Width * next;
float height = _after.Height * next;
_centre = new PointF(
Math.Clamp(anchorX + (view.Width / 2f - e.X) / Math.Max(1f, width), 0f, 1f),
Math.Clamp(anchorY + (view.Height / 2f - e.Y) / Math.Max(1f, height), 0f, 1f));
Invalidate();
}
private bool OverWipeHandle(Point location)
{
if (!CompareMode || _before is null || _after is null) return false;
var target = TargetFor(_after);
float x = target.Left + target.Width * _wipe;
return Math.Abs(location.X - x) <= 7 && location.Y >= target.Top && location.Y <= target.Bottom;
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.Button != MouseButtons.Left) { base.OnMouseDown(e); return; }
if (OverWipeHandle(e.Location))
{
_draggingWipe = true;
return;
}
if (_zoom > 0)
{
_panning = true;
_dragOrigin = e.Location;
_centreOrigin = _centre;
}
base.OnMouseDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_draggingWipe && _after is not null)
{
var target = TargetFor(_after);
_wipe = target.Width > 0 ? Math.Clamp((e.X - target.Left) / target.Width, 0f, 1f) : 0.5f;
Invalidate();
return;
}
if (_panning && _after is not null)
{
var target = TargetFor(_after);
_centre = new PointF(
Math.Clamp(_centreOrigin.X - (e.X - _dragOrigin.X) / Math.Max(1f, target.Width), 0f, 1f),
Math.Clamp(_centreOrigin.Y - (e.Y - _dragOrigin.Y) / Math.Max(1f, target.Height), 0f, 1f));
Invalidate();
return;
}
Cursor = OverWipeHandle(e.Location) ? Cursors.SizeWE
: _zoom > 0 ? Cursors.SizeAll
: Cursors.Default;
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_panning = false;
_draggingWipe = false;
base.OnMouseUp(e);
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
if (!OverWipeHandle(e.Location)) ToggleZoom();
base.OnMouseDoubleClick(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
@@ -311,48 +718,219 @@ internal sealed class PreviewPanel : Control
Theme.HighQuality(g);
g.Clear(Theme.Background);
var frame = new Rectangle(0, 0, Width, Height - 22);
var view = Viewport;
if (_bitmap is null)
if (_after is null)
{
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, frame,
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, view,
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
DrawFooter(g);
return;
}
double scale = Math.Min(frame.Width / (double)_bitmap.Width, frame.Height / (double)_bitmap.Height);
int width = Math.Max(1, (int)(_bitmap.Width * scale));
int height = Math.Max(1, (int)(_bitmap.Height * scale));
var target = new Rectangle(frame.Left + (frame.Width - width) / 2,
frame.Top + (frame.Height - height) / 2, width, height);
var target = TargetFor(_after);
g.DrawImage(_bitmap, target);
using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, target);
// A scala uno a uno l'interpolazione morbida nasconderebbe proprio ciò che si vuole
// guardare: da lì in su si mostrano i pixel come sono.
g.InterpolationMode = ScaleFor(_after) >= 1f ? InterpolationMode.NearestNeighbor
: InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.Half;
if (_regionContour is { Length: >= 2 } contour)
var clip = g.Clip;
g.SetClip(view);
if (CompareMode && _before is not null)
{
var line = new PointF[contour.Length];
for (int i = 0; i < contour.Length; i++)
{
line[i] = new PointF(target.Left + contour[i].X * target.Width,
target.Top + contour[i].Y * target.Height);
}
g.DrawImage(_before, target);
using var shadow = new Pen(Color.FromArgb(140, Color.Black), 3f);
using var boundary = new Pen(Color.FromArgb(210, Theme.Success), 1.6f);
g.DrawLines(shadow, line);
g.DrawLines(boundary, line);
float split = target.Left + target.Width * _wipe;
g.SetClip(new RectangleF(split, target.Top, Math.Max(0, target.Right - split), target.Height),
CombineMode.Intersect);
g.DrawImage(_after, target);
g.SetClip(view, CombineMode.Replace);
DrawWipeHandle(g, target, split);
}
else
{
g.DrawImage(_after, target);
}
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
if (_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);
g.Clip = clip;
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(target));
if (_busy)
{
using var overlay = new SolidBrush(Color.FromArgb(120, Theme.Background));
g.FillRectangle(overlay, target);
TextRenderer.DrawText(g, "Aggiornamento…", Theme.Small, target, Theme.Text,
g.FillRectangle(overlay, view);
TextRenderer.DrawText(g, "Aggiornamento…", Theme.Small, view, Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
if (_zoom > 0) DrawZoomBadge(g, view);
if (_live) DrawLiveBadge(g, view);
DrawFooter(g);
}
private static void DrawWipeHandle(Graphics g, RectangleF target, float x)
{
using (var line = new Pen(Color.FromArgb(220, Color.White), 1.6f))
g.DrawLine(line, x, target.Top, x, target.Bottom);
float cy = target.Top + target.Height / 2f;
var grip = new RectangleF(x - 11, cy - 15, 22, 30);
Theme.FillAndStroke(g, grip, 5f, Color.FromArgb(225, Theme.Background), Color.FromArgb(220, Color.White));
using var arrows = new Pen(Color.White, 1.5f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
};
g.DrawLines(arrows, [new PointF(x - 3.5f, cy - 4), new PointF(x - 6.5f, cy), new PointF(x - 3.5f, cy + 4)]);
g.DrawLines(arrows, [new PointF(x + 3.5f, cy - 4), new PointF(x + 6.5f, cy), new PointF(x + 3.5f, cy + 4)]);
TextRenderer.DrawText(g, "originale", Theme.SmallBold,
new Rectangle((int)target.Left + 8, (int)target.Top + 6, 90, 16),
Color.FromArgb(210, Color.White), TextFormatFlags.Left);
TextRenderer.DrawText(g, "corretto", Theme.SmallBold,
new Rectangle((int)target.Right - 98, (int)target.Top + 6, 90, 16),
Color.FromArgb(210, Color.White), TextFormatFlags.Right);
}
/// <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!;
var line = new PointF[contour.Length];
for (int i = 0; i < contour.Length; i++)
{
line[i] = new PointF(target.Left + contour[i].X * target.Width,
target.Top + contour[i].Y * target.Height);
}
using var shadow = new Pen(Color.FromArgb(140, Color.Black), 3f);
using var boundary = new Pen(Color.FromArgb(210, Theme.RegionHigh), 1.6f);
g.DrawLines(shadow, line);
g.DrawLines(boundary, line);
}
private void DrawMotionField(Graphics g, RectangleF target)
{
var arrows = _arrows!;
if (arrows.Origins.Length == 0) return;
// La lunghezza si normalizza sul vettore mediano: su una ripresa notturna gli
// spostamenti sono di pochi pixel e a scala reale le frecce sarebbero invisibili.
// Il limite inferiore però conta: su una scena ferma il mediano tende a zero, e senza
// di esso il rumore del campo verrebbe amplificato fino a sembrare movimento vero.
// Sotto un terzo di pixel non c'è movimento da mostrare, solo il rumore del campo:
// si smette di normalizzare e si usa una scala fissa modesta, così le frecce restano
// visibili senza raccontare uno spostamento che non c'è.
float gain = arrows.MedianMagnitude < 0.35
? 8f
: (float)Math.Min(30.0, 26.0 / arrows.MedianMagnitude);
using var pen = new Pen(Color.FromArgb(200, Theme.Accent), 1.4f)
{
EndCap = LineCap.ArrowAnchor,
StartCap = LineCap.Round,
};
using var dot = new SolidBrush(Color.FromArgb(150, Theme.Accent));
for (int i = 0; i < arrows.Origins.Length; i++)
{
float ox = target.Left + arrows.Origins[i].X * target.Width;
float oy = target.Top + arrows.Origins[i].Y * target.Height;
float dx = arrows.Vectors[i].X * target.Width * gain;
float dy = arrows.Vectors[i].Y * target.Width * gain;
float length = MathF.Sqrt(dx * dx + dy * dy);
if (length < 1.5f)
{
g.FillEllipse(dot, ox - 1.4f, oy - 1.4f, 2.8f, 2.8f);
continue;
}
// Un limite alla lunghezza disegnata: un vettore sbagliato non deve attraversare
// tutta l'immagine e coprire quelli giusti.
if (length > 44f) { dx *= 44f / length; dy *= 44f / length; }
g.DrawLine(pen, ox, oy, ox + dx, oy + dy);
}
string caption = arrows.MedianMagnitude < 0.35
? $"campo vettoriale · mediana {arrows.MedianMagnitude:0.00} px, movimento trascurabile"
: $"campo vettoriale · mediana {arrows.MedianMagnitude:0.0} px";
TextRenderer.DrawText(g, caption, Theme.Small,
new Rectangle((int)target.Left + 8, (int)target.Bottom - 20,
Math.Max(120, (int)target.Width - 16), 16),
Color.FromArgb(200, Theme.Accent), TextFormatFlags.Left);
}
private void DrawZoomBadge(Graphics g, Rectangle view)
{
string text = $"{ZoomText} · doppio clic per adattare";
var size = TextRenderer.MeasureText(g, text, Theme.Small);
var box = new RectangleF(view.Right - size.Width - 24, view.Top + 8, size.Width + 14, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(220, Theme.Background), Theme.Border);
TextRenderer.DrawText(g, text, Theme.Small, Rectangle.Round(box), Theme.TextMuted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
private static void DrawLiveBadge(Graphics g, Rectangle view)
{
const string text = "IN CODIFICA";
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
var box = new RectangleF(view.Left + 10, view.Top + 8, size.Width + 16, 20);
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(230, Theme.AccentDim), Theme.Accent);
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Color.White,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
private void DrawFooter(Graphics g)
{
var footer = new Rectangle(8, Height - 20, Width - 16, 18);
TextRenderer.DrawText(g, _caption, Theme.SmallBold, footer, Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
@@ -366,7 +944,8 @@ internal sealed class PreviewPanel : Control
{
_pending?.Cancel();
_pending?.Dispose();
_bitmap?.Dispose();
_after?.Dispose();
_before?.Dispose();
}
base.Dispose(disposing);
}
+253
View File
@@ -0,0 +1,253 @@
using Titano.Imaging;
namespace Titano.UI;
/// <summary>
/// Misure di distribuzione di un fotogramma, calcolate una volta sola dal buffer
/// dell'anteprima e riusate dai due strumenti che le mostrano.
/// </summary>
internal sealed class FrameScopes
{
public const int Levels = 256;
public const int WaveformColumns = 320;
public const int WaveformRows = 128;
public required int[] Red { get; init; }
public required int[] Green { get; init; }
public required int[] Blue { get; init; }
public required int Peak { get; init; }
/// <summary>Densità della forma d'onda: colonne dell'immagine per livelli di luminanza.</summary>
public required byte[] Waveform { get; init; }
public required double ClippedFraction { get; init; }
public required double BlackFraction { get; init; }
/// <summary>
/// Calcola istogramma e forma d'onda dal buffer in luce lineare, convertendo in sRGB:
/// è lo spazio in cui il fotogramma verrà mostrato e codificato, e quindi l'unico in cui
/// "saturo" e "nero" vogliono dire quello che l'occhio vede.
/// </summary>
public static FrameScopes Compute(ImageBuffer frame)
{
var red = new int[Levels];
var green = new int[Levels];
var blue = new int[Levels];
var waveform = new byte[WaveformColumns * WaveformRows];
var data = frame.Data;
int width = frame.Width;
int height = frame.Height;
// Un campionamento a griglia tiene il costo costante anche su un fotogramma da 12 MP:
// per una distribuzione bastano poche centinaia di migliaia di campioni.
int step = Math.Max(1, (int)Math.Sqrt(frame.PixelCount / 240_000.0));
long clipped = 0, black = 0, counted = 0;
var columnCounts = new int[WaveformColumns * WaveformRows];
for (int y = 0; y < height; y += step)
{
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x += step)
{
int i = rowBase + x * ImageBuffer.Channels;
byte r = ColorSpace.ToSrgbByte(data[i]);
byte g = ColorSpace.ToSrgbByte(data[i + 1]);
byte b = ColorSpace.ToSrgbByte(data[i + 2]);
red[r]++;
green[g]++;
blue[b]++;
counted++;
int luma = (r * 54 + g * 183 + b * 19) >> 8;
if (luma >= 253) clipped++;
else if (luma <= 2) black++;
int column = x * WaveformColumns / width;
int row = (Levels - 1 - luma) * WaveformRows / Levels;
columnCounts[row * WaveformColumns + Math.Min(column, WaveformColumns - 1)]++;
}
}
// Normalizzazione logaritmica: senza, la forma d'onda di un cielo notturno sarebbe
// una riga sola in basso e tutto il resto invisibile.
int densest = 1;
foreach (int value in columnCounts) densest = Math.Max(densest, value);
double scale = 255.0 / Math.Log(1 + densest);
for (int i = 0; i < columnCounts.Length; i++)
{
waveform[i] = columnCounts[i] == 0 ? (byte)0 : (byte)Math.Clamp(Math.Log(1 + columnCounts[i]) * scale, 0, 255);
}
int peak = 1;
for (int i = 1; i < Levels - 1; i++)
{
peak = Math.Max(peak, Math.Max(red[i], Math.Max(green[i], blue[i])));
}
return new FrameScopes
{
Red = red,
Green = green,
Blue = blue,
Peak = peak,
Waveform = waveform,
ClippedFraction = counted > 0 ? clipped / (double)counted : 0,
BlackFraction = counted > 0 ? black / (double)counted : 0,
};
}
}
/// <summary>
/// Istogramma e forma d'onda del fotogramma in anteprima.
///
/// Servono a una cosa sola ma importante: rendere ovvio il clipping. È la condizione che
/// rovina più decisioni a valle — sui file reali ha guastato insieme stabilizzazione,
/// segmentazione e riconoscimento dei cambi di esposizione — e finora si leggeva soltanto
/// come una percentuale in una colonna della tabella, dove nessuno la guarda.
/// </summary>
internal sealed class ScopesPanel : Control
{
private FrameScopes? _scopes;
private bool _waveform;
public ScopesPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 132;
Cursor = Cursors.Hand;
}
/// <summary>Alterna istogramma e forma d'onda: dicono la stessa cosa in due modi diversi.</summary>
public bool ShowWaveform
{
get => _waveform;
set { _waveform = value; Invalidate(); }
}
public void SetScopes(FrameScopes? scopes)
{
_scopes = scopes;
Invalidate();
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button == MouseButtons.Left) ShowWaveform = !ShowWaveform;
base.OnMouseDown(e);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = new Rectangle(8, 20, Math.Max(20, Width - 16), Math.Max(20, Height - 28));
TextRenderer.DrawText(g, _waveform ? "FORMA D'ONDA" : "ISTOGRAMMA", Theme.SmallBold,
new Rectangle(10, 2, 200, 16), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (_scopes is null)
{
TextRenderer.DrawText(g, "nessun fotogramma", Theme.Small, plot, Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
TextRenderer.DrawText(g,
$"saturi {_scopes.ClippedFraction * 100:0.00}% neri {_scopes.BlackFraction * 100:0.00}% " +
$"(clic per cambiare)",
Theme.Small, new Rectangle(Width - 320, 2, 312, 16),
_scopes.ClippedFraction > 0.02 ? Theme.Warning : Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
using (var frame = new Pen(Theme.Border)) g.DrawRectangle(frame, plot);
if (_waveform) DrawWaveform(g, plot, _scopes);
else DrawHistogram(g, plot, _scopes);
}
private static void DrawHistogram(Graphics g, Rectangle plot, FrameScopes scopes)
{
var clip = g.Clip;
g.SetClip(plot);
// I tre canali sovrapposti in additiva: dove coincidono si legge il grigio, dove
// divergono si vede subito quale canale sta andando a fondo scala.
DrawChannel(g, plot, scopes.Red, scopes.Peak, Color.FromArgb(150, 0xF0, 0x5D, 0x5D));
DrawChannel(g, plot, scopes.Green, scopes.Peak, Color.FromArgb(150, 0x5D, 0xE0, 0x8A));
DrawChannel(g, plot, scopes.Blue, scopes.Peak, Color.FromArgb(150, 0x5D, 0x9D, 0xF0));
g.Clip = clip;
using var guide = new Pen(Color.FromArgb(70, Theme.Text)) { DashStyle = System.Drawing.Drawing2D.DashStyle.Dot };
for (int i = 1; i < 4; i++)
{
float x = plot.Left + plot.Width * i / 4f;
g.DrawLine(guide, x, plot.Top, x, plot.Bottom);
}
}
private static void DrawChannel(Graphics g, Rectangle plot, int[] values, int peak, Color color)
{
var points = new PointF[FrameScopes.Levels + 2];
points[0] = new PointF(plot.Left, plot.Bottom);
for (int i = 0; i < FrameScopes.Levels; i++)
{
float x = plot.Left + plot.Width * i / (float)(FrameScopes.Levels - 1);
float height = Math.Min(1f, values[i] / (float)peak) * (plot.Height - 3);
points[i + 1] = new PointF(x, plot.Bottom - height);
}
points[^1] = new PointF(plot.Right, plot.Bottom);
using var brush = new SolidBrush(color);
g.FillPolygon(brush, points);
}
private static unsafe void DrawWaveform(Graphics g, Rectangle plot, FrameScopes scopes)
{
using var bitmap = new Bitmap(FrameScopes.WaveformColumns, FrameScopes.WaveformRows,
System.Drawing.Imaging.PixelFormat.Format32bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height),
System.Drawing.Imaging.ImageLockMode.WriteOnly,
System.Drawing.Imaging.PixelFormat.Format32bppRgb);
try
{
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < bitmap.Height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
for (int x = 0; x < bitmap.Width; x++)
{
byte density = scopes.Waveform[y * FrameScopes.WaveformColumns + x];
byte* pixel = row + x * 4;
pixel[0] = (byte)(density * 0.55f + 0x14); // B
pixel[1] = (byte)(density * 0.90f + 0x16); // G
pixel[2] = (byte)(density * 0.70f + 0x14); // R
pixel[3] = 255;
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
var previous = g.InterpolationMode;
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBilinear;
g.DrawImage(bitmap, Rectangle.Inflate(plot, -1, -1));
g.InterpolationMode = previous;
// Le due soglie che contano: il bianco e il nero. Una forma d'onda che le tocca
// dice che l'informazione oltre quel punto non c'è più.
using var limit = new Pen(Color.FromArgb(120, Theme.Danger));
g.DrawLine(limit, plot.Left, plot.Top + 1, plot.Right, plot.Top + 1);
g.DrawLine(limit, plot.Left, plot.Bottom - 1, plot.Right, plot.Bottom - 1);
}
}
+870 -214
View File
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
using System.Diagnostics;
using System.Text;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>Un annuncio del listino locale.</summary>
internal sealed record Sponsor(string Title, string Body, string? ImagePath, string? Link, int Weight)
{
public bool HasLink => !string.IsNullOrWhiteSpace(Link);
}
/// <summary>
/// Listino degli sponsor, letto da una cartella locale.
///
/// Nessuna rete: il programma non contatta alcun servizio, non invia identificativi e non
/// registra clic. È una scelta, non una mancanza — un circuito pubblicitario vero
/// richiederebbe di integrarne l'SDK, che il vincolo sulle dipendenze esclude, e comunque
/// significherebbe far uscire dati dalla macchina di chi sta soltanto montando un
/// time-lapse. Le campagne si aggiornano copiando file in una cartella.
///
/// Il formato è lo stesso delle impostazioni: blocchi introdotti da [campagna], una riga per
/// campo. Le immagini stanno accanto al listino.
/// </summary>
internal static class SponsorCatalogue
{
public const string ManifestName = "campagne.txt";
public static List<Sponsor> Load(string folder)
{
var sponsors = new List<Sponsor>();
try
{
string manifest = Path.Combine(folder, ManifestName);
if (!File.Exists(manifest)) return sponsors;
string? title = null, body = null, image = null, link = null;
int weight = 1;
void Flush()
{
if (string.IsNullOrWhiteSpace(title)) return;
string? resolved = string.IsNullOrWhiteSpace(image) ? null : Path.Combine(folder, image);
if (resolved is not null && !File.Exists(resolved)) resolved = null;
sponsors.Add(new Sponsor(title!, body ?? string.Empty, resolved, Sanitize(link), Math.Clamp(weight, 1, 10)));
}
foreach (string raw in File.ReadAllLines(manifest, Encoding.UTF8))
{
string line = raw.Trim();
if (line.Length == 0 || line[0] == '#') continue;
if (line.StartsWith('[') && line.EndsWith(']'))
{
Flush();
title = body = image = link = null;
weight = 1;
continue;
}
int separator = line.IndexOf('=');
if (separator <= 0) continue;
string key = line[..separator].Trim().ToLowerInvariant();
string value = line[(separator + 1)..].Trim();
switch (key)
{
case "titolo": title = value; break;
case "testo": body = value; break;
case "immagine": image = value; break;
case "collegamento": link = value; break;
case "peso": weight = AppSettings.ParseInt(value, 1); break;
}
}
Flush();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
// Listino illeggibile: restano gli annunci interni, che non mancano mai.
}
return sponsors;
}
/// <summary>
/// Crea la cartella e, se è vuota, vi scrive un listino di esempio commentato. Il formato
/// si impara guardando un file che c'è già, non cercando dove sia documentato.
/// </summary>
public static void EnsureExample(string folder)
{
try
{
Directory.CreateDirectory(folder);
string manifest = Path.Combine(folder, ManifestName);
if (File.Exists(manifest)) return;
File.WriteAllText(manifest, """
# Listino delle campagne mostrate durante l'esportazione.
#
# Un blocco per annuncio, introdotto da [campagna]. Le immagini stanno in
# questa stessa cartella; sono facoltative, e vengono ridimensionate
# conservando le proporzioni. Il collegamento deve essere http o https:
# qualunque altra cosa viene ignorata.
#
# Il peso governa la frequenza: peso 3 compare tre volte più spesso di peso 1.
#
# Titano non contatta nessun servizio e non registra i clic: questo file è
# l'unica sorgente degli annunci.
[campagna]
titolo = Titolo dell'annuncio
testo = Una o due righe di testo. Chi legge sta aspettando la fine di una codifica, quindi può leggere una frase intera.
immagine = esempio.jpg
collegamento = https://esempio.it
peso = 1
""", Encoding.UTF8);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Cartella non scrivibile: restano gli annunci interni.
}
}
/// <summary>
/// Accetta solo indirizzi web. Il listino è un file locale, ma aprire alla cieca ciò che
/// vi si trova scritto significherebbe eseguire quello che qualcuno vi ha messo dentro.
/// </summary>
private static string? Sanitize(string? link)
{
if (string.IsNullOrWhiteSpace(link)) return null;
return Uri.TryCreate(link, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
? uri.AbsoluteUri
: null;
}
/// <summary>
/// Contenuti interni, mostrati quando il listino è vuoto. Non sono un riempitivo: sono
/// le cose che un'attesa di mezz'ora è il momento giusto per leggere.
/// </summary>
public static List<Sponsor> House() =>
[
new("Il profilo di qualità governa il tempo",
"Massima infittisce il campo vettoriale e i campioni della scia. Su una sequenza senza " +
"movimento, Standard dà lo stesso risultato in un terzo del tempo.", null, null, 1),
new("La finestra della mediana vive in memoria",
"I fotogrammi della finestra sono vivi tutti insieme, perché la mediana li vuole " +
"simultaneamente. Il tetto di memoria governa la lettura in anticipo, non la finestra.", null, null, 1),
new("Un cielo più scuro del terreno è normale",
"Di notte, con un primo piano illuminato, la regione superiore è la più scura. " +
"La divisione per orizzonte se ne accorge e non la scambia per un errore.", null, null, 1),
new("I cambi di esposizione invisibili restano tali",
"Se il fotogramma è già saturo, cambiare sensibilità non lo scurisce. Titano lo verifica " +
"prima di correggere: togliere un gradino che non c'è significa introdurlo.", null, null, 1),
];
}
/// <summary>
/// Riquadro degli sponsor, mostrato durante l'attesa dell'esportazione.
///
/// Sta soltanto qui, nella scheda dove per forza di cose si aspetta, e non compare mai
/// altrove: un annuncio accanto a un cursore che si sta regolando sarebbe un ostacolo, uno
/// accanto a una barra di avanzamento è qualcosa da guardare mentre passa il tempo. È
/// dichiarato come tale, non si muove da solo se il sistema chiede di ridurre le animazioni,
/// e si spegne dalle impostazioni.
/// </summary>
internal sealed class SponsorPanel : Control
{
private readonly System.Windows.Forms.Timer _rotation = new();
private List<Sponsor> _sponsors = [];
private readonly List<int> _order = [];
private int _position;
private Image? _image;
private string? _imagePath;
private bool _hoverLink;
private bool _hoverNext;
public SponsorPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
Height = 132;
_rotation.Tick += (_, _) => Advance();
}
public Sponsor? Current => _order.Count > 0 && _position < _order.Count
? _sponsors[_order[_position]]
: null;
/// <summary>Carica il listino e avvia la rotazione secondo le preferenze.</summary>
public void Configure(AppSettings settings)
{
Visible = settings.ShowSponsors;
if (!settings.ShowSponsors)
{
_rotation.Stop();
return;
}
var loaded = SponsorCatalogue.Load(settings.ResolvedSponsorFolder);
_sponsors = loaded.Count > 0 ? loaded : SponsorCatalogue.House();
// L'ordine tiene conto del peso ripetendo le voci: una campagna di peso tre compare
// tre volte nel giro, senza bisogno di estrazioni casuali che potrebbero saltarla.
_order.Clear();
for (int i = 0; i < _sponsors.Count; i++)
{
for (int repeat = 0; repeat < _sponsors[i].Weight; repeat++) _order.Add(i);
}
Shuffle(_order);
_position = 0;
LoadImage();
_rotation.Interval = Math.Clamp(settings.SponsorRotationSeconds, 5, 600) * 1000;
_rotation.Start();
Invalidate();
}
/// <summary>Ferma la rotazione quando la scheda non è in vista: non serve a nessuno girare a vuoto.</summary>
public void SetActive(bool active)
{
if (!Visible) return;
if (active) _rotation.Start(); else _rotation.Stop();
}
private static void Shuffle(List<int> values)
{
for (int i = values.Count - 1; i > 0; i--)
{
int j = Random.Shared.Next(i + 1);
(values[i], values[j]) = (values[j], values[i]);
}
}
private void Advance()
{
if (_order.Count == 0) return;
_position = (_position + 1) % _order.Count;
LoadImage();
Invalidate();
}
private void LoadImage()
{
string? path = Current?.ImagePath;
if (path == _imagePath) return;
_image?.Dispose();
_image = null;
_imagePath = path;
if (path is null) return;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
_image = Image.FromStream(stream);
}
catch (Exception ex) when (ex is IOException or ArgumentException or OutOfMemoryException)
{
_image = null;
}
}
// ------------------------------------------------------------------ interazione
private Rectangle NextButton => new(Width - 26, 6, 18, 18);
private Rectangle TextArea
{
get
{
int left = _image is null ? 14 : 14 + ThumbWidth + 14;
return new Rectangle(left, 26, Math.Max(40, Width - left - 16), Math.Max(20, Height - 36));
}
}
private int ThumbWidth => Math.Min(168, Math.Max(80, (Height - 28) * 16 / 9));
protected override void OnMouseMove(MouseEventArgs e)
{
bool overNext = NextButton.Contains(e.Location);
bool overLink = Current is { HasLink: true } && TextArea.Contains(e.Location);
if (overNext != _hoverNext || overLink != _hoverLink)
{
_hoverNext = overNext;
_hoverLink = overLink;
Cursor = overNext || overLink ? Cursors.Hand : Cursors.Default;
Invalidate();
}
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hoverNext = _hoverLink = false;
Cursor = Cursors.Default;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
if (NextButton.Contains(e.Location)) { Advance(); return; }
if (Current is { HasLink: true, Link: { } link } && TextArea.Contains(e.Location))
{
try
{
Process.Start(new ProcessStartInfo(link) { UseShellExecute = true });
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException)
{
// Nessun browser predefinito: non c'è nulla di sensato da fare.
}
}
base.OnMouseDown(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Background);
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
Theme.FillAndStroke(g, bounds, 6f, Theme.Surface, Theme.Border);
if (Current is not { } sponsor)
{
TextRenderer.DrawText(g, "Nessuna campagna nel listino", Theme.Small,
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
// L'etichetta è la prima cosa che si legge: chi guarda deve sapere subito che cos'è.
TextRenderer.DrawText(g, "SPONSOR", Theme.SmallBold, new Rectangle(14, 5, 120, 16),
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
if (_order.Count > 1) DrawNextButton(g);
if (_image is not null)
{
var box = new Rectangle(14, 26, ThumbWidth, Height - 40);
DrawImageFitted(g, _image, box);
}
var text = TextArea;
TextRenderer.DrawText(g, sponsor.Title, Theme.BodyBold,
new Rectangle(text.Left, text.Top, text.Width, 20),
_hoverLink ? Theme.Accent : Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, sponsor.Body, Theme.Small,
new Rectangle(text.Left, text.Top + 22, text.Width, text.Height - 40),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
if (sponsor.HasLink)
{
TextRenderer.DrawText(g, _hoverLink ? "Apri nel browser →" : "Clic per saperne di più",
Theme.Small, new Rectangle(text.Left, Height - 20, text.Width, 16),
_hoverLink ? Theme.Accent : Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
private void DrawNextButton(Graphics g)
{
var box = NextButton;
if (_hoverNext) Theme.FillRounded(g, box, 4f, Theme.SurfaceAlt);
using var pen = new Pen(_hoverNext ? Theme.Text : Theme.TextFaint, 1.5f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
};
float cx = box.Left + box.Width / 2f;
float cy = box.Top + box.Height / 2f;
g.DrawLines(pen,
[
new PointF(cx - 2.5f, cy - 4f),
new PointF(cx + 2.5f, cy),
new PointF(cx - 2.5f, cy + 4f),
]);
}
/// <summary>Disegna l'immagine dentro il riquadro conservandone le proporzioni.</summary>
private static void DrawImageFitted(Graphics g, Image image, Rectangle box)
{
double scale = Math.Min(box.Width / (double)image.Width, box.Height / (double)image.Height);
int width = Math.Max(1, (int)(image.Width * scale));
int height = Math.Max(1, (int)(image.Height * scale));
var target = new Rectangle(box.Left + (box.Width - width) / 2,
box.Top + (box.Height - height) / 2, width, height);
g.DrawImage(image, target);
using var border = new Pen(Theme.Border);
g.DrawRectangle(border, target);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_rotation.Stop();
_rotation.Dispose();
_image?.Dispose();
}
base.Dispose(disposing);
}
}
+137
View File
@@ -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 };
}
+327
View File
@@ -0,0 +1,327 @@
using System.Collections.Concurrent;
using System.Drawing.Imaging;
using Titano.Core;
using Titano.Imaging;
namespace Titano.UI;
/// <summary>
/// Striscia di provini che copre l'intera sequenza: ogni riquadro è il fotogramma che sta in
/// quella posizione, non il fotogramma successivo. Scorrere mille scatti in una tabella dice
/// quando sono stati fatti; questa dice come cambiano, che è la domanda vera.
///
/// I provini si decodificano su un thread di servizio e si accumulano in una cache: la
/// striscia si riempie mentre si guarda, senza mai bloccare l'interfaccia, e i riquadri non
/// ancora pronti restano segnati come tali invece di apparire vuoti.
/// </summary>
internal sealed class TimelineStrip : Control
{
private const int ThumbHeight = 54;
private const int Inset = 5;
private readonly ConcurrentDictionary<int, Bitmap> _cache = [];
private readonly object _queueGate = new();
private readonly Queue<int> _pending = new();
private TimelapseSequence? _sequence;
private int _orientation = 1;
private int _thumbWidth = 72;
private int _selectedIndex = -1;
private int _hovered = -1;
private CancellationTokenSource? _loader;
private int[] _slots = [];
public event EventHandler? SelectionChanged;
public TimelineStrip()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
Height = ThumbHeight + 2 * Inset + 14;
Cursor = Cursors.Hand;
}
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectedIndex == value) return;
_selectedIndex = value;
Invalidate();
}
}
public void SetSequence(TimelapseSequence? sequence, int orientation)
{
StopLoading();
foreach (var bitmap in _cache.Values) bitmap.Dispose();
_cache.Clear();
_sequence = sequence;
_orientation = orientation;
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
if (sequence is { Count: > 0 })
{
var first = sequence.Frames[0].Metadata;
double aspect = first.PixelWidth > 0 && first.PixelHeight > 0
? first.PixelWidth / (double)first.PixelHeight
: 4.0 / 3.0;
if (ImageDecoder.SwapsAxes(orientation)) aspect = 1 / aspect;
_thumbWidth = Math.Clamp((int)Math.Round(ThumbHeight * aspect), 32, 160);
}
RebuildSlots();
Invalidate();
}
protected override void OnResize(EventArgs e)
{
RebuildSlots();
base.OnResize(e);
}
/// <summary>Sceglie quali fotogrammi mostrare: tanti quanti ne stanno, distribuiti sull'intera sequenza.</summary>
private void RebuildSlots()
{
if (_sequence is not { Count: > 0 } sequence) { _slots = []; return; }
int usable = Math.Max(_thumbWidth, Width - 2 * Inset);
int count = Math.Clamp(usable / (_thumbWidth + 3), 1, sequence.Count);
var slots = new int[count];
for (int i = 0; i < count; i++)
{
slots[i] = count == 1 ? 0 : (int)Math.Round(i * (sequence.Count - 1.0) / (count - 1));
}
_slots = slots;
RequestMissing();
}
// ------------------------------------------------------------------ caricamento
private void RequestMissing()
{
if (_sequence is null) return;
lock (_queueGate)
{
_pending.Clear();
foreach (int index in _slots)
{
if (!_cache.ContainsKey(index)) _pending.Enqueue(index);
}
if (_pending.Count == 0) return;
}
if (_loader is not null) return;
var source = new CancellationTokenSource();
_loader = source;
var token = source.Token;
var sequence = _sequence;
int orientation = _orientation;
int width = _thumbWidth;
_ = Task.Run(() =>
{
var pool = new FrameBufferPool(4);
try
{
while (!token.IsCancellationRequested)
{
int index;
lock (_queueGate)
{
if (_pending.Count == 0) break;
index = _pending.Dequeue();
}
if (index < 0 || index >= sequence.Count) continue;
Bitmap? thumb = null;
try
{
using var buffer = ImageDecoder.Decode(sequence.Frames[index].FilePath,
width, ThumbHeight, orientation, pool);
thumb = ToBitmap(buffer);
}
catch (Exception)
{
// Un provino che non si legge resta assente: il riquadro lo dichiara.
}
if (thumb is null) continue;
if (token.IsCancellationRequested || !_cache.TryAdd(index, thumb)) { thumb.Dispose(); continue; }
// Un solo ridisegno ogni pochi provini: ridipingere a ogni arrivo su una
// sequenza lunga costerebbe più della decodifica stessa.
if (_cache.Count % 4 == 0) RequestRedraw();
}
}
finally
{
RequestRedraw();
_loader = null;
}
}, token);
}
private void RequestRedraw()
{
try
{
if (IsHandleCreated && !IsDisposed) BeginInvoke(Invalidate);
}
catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
{
// Finestra già chiusa: non c'è più niente da ridisegnare.
}
}
private void StopLoading()
{
_loader?.Cancel();
_loader?.Dispose();
_loader = null;
lock (_queueGate) _pending.Clear();
}
private static unsafe Bitmap ToBitmap(ImageBuffer buffer)
{
var bitmap = new Bitmap(buffer.Width, buffer.Height, PixelFormat.Format32bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, buffer.Width, buffer.Height),
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
try
{
var data = buffer.Data;
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < buffer.Height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
int sourceIndex = y * buffer.Width * ImageBuffer.Channels;
for (int x = 0; x < buffer.Width; x++)
{
int i = sourceIndex + x * ImageBuffer.Channels;
byte* pixel = row + x * 4;
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]);
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]);
pixel[2] = ColorSpace.ToSrgbByte(data[i]);
pixel[3] = 255;
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
return bitmap;
}
// ------------------------------------------------------------------ interazione
private int SlotAt(int x)
{
if (_slots.Length == 0) return -1;
int relative = x - Inset;
int slot = relative / (_thumbWidth + 3);
return slot >= 0 && slot < _slots.Length ? slot : -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
int slot = SlotAt(e.X);
if (slot != _hovered) { _hovered = slot; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
int slot = SlotAt(e.X);
if (slot < 0) return;
_selectedIndex = _slots[slot];
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
base.OnMouseDown(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
if (_sequence is not { Count: > 0 } sequence || _slots.Length == 0)
{
TextRenderer.DrawText(g, "La striscia dei provini compare quando una sequenza è caricata",
Theme.Small, new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
for (int slot = 0; slot < _slots.Length; slot++)
{
int index = _slots[slot];
var box = new Rectangle(Inset + slot * (_thumbWidth + 3), Inset, _thumbWidth, ThumbHeight);
if (_cache.TryGetValue(index, out var thumb))
{
g.DrawImage(thumb, box);
}
else
{
using var placeholder = new SolidBrush(Theme.SurfaceAlt);
g.FillRectangle(placeholder, box);
TextRenderer.DrawText(g, "…", Theme.Small, box, Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
bool selected = index == _selectedIndex;
bool hovered = slot == _hovered;
using var border = new Pen(selected ? Theme.Accent : hovered ? Theme.BorderStrong : Theme.Border,
selected ? 2f : 1f);
g.DrawRectangle(border, selected ? Rectangle.Inflate(box, -1, -1) : box);
// Il numero solo dove c'è spazio: su una striscia fitta diventerebbe rumore.
if (_thumbWidth >= 56)
{
var label = new Rectangle(box.Left, box.Bottom + 1, box.Width, 12);
TextRenderer.DrawText(g, (index + 1).ToString(), Theme.Small, label,
selected ? Theme.Text : Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
int loaded = _cache.Count;
if (loaded < _slots.Length)
{
TextRenderer.DrawText(g, $"{loaded}/{_slots.Length}", Theme.Small,
new Rectangle(Width - 60, Height - 14, 52, 12), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
StopLoading();
foreach (var bitmap in _cache.Values) bitmap.Dispose();
_cache.Clear();
}
base.Dispose(disposing);
}
}
+83
View File
@@ -0,0 +1,83 @@
using System.Drawing.Drawing2D;
namespace Titano.UI;
/// <summary>
/// Suggerimenti a comparsa, disegnati a mano nel tema scuro.
///
/// Le spiegazioni stanno qui invece che sotto ai controlli per un motivo pratico: una nota
/// stampata sotto un cursore deve essere corta, perché occupa spazio a chi la conosce già.
/// Un suggerimento che compare solo quando serve non ha quel vincolo, e può permettersi di
/// dire l'unica cosa che conta davvero — perché quel parametro esiste, e cosa succede se lo
/// si sposta nella direzione sbagliata.
///
/// Il suggerimento di sistema è disegnato dal tema di Windows, che qui sarebbe chiaro su
/// scuro: viene quindi disegnato per intero, con la stessa tavolozza del resto.
/// </summary>
internal static class Tips
{
private const int MaximumWidth = 380;
private static readonly ToolTip Instance = Create();
private static ToolTip Create()
{
var tip = new ToolTip
{
OwnerDraw = true,
InitialDelay = 380,
ReshowDelay = 120,
AutoPopDelay = 32000, // le spiegazioni lunghe devono restare leggibili
ShowAlways = true,
UseAnimation = false,
UseFading = false,
};
tip.Popup += (_, e) =>
{
var size = TextRenderer.MeasureText(tip.GetToolTip(e.AssociatedControl) ?? string.Empty,
Theme.Small, new Size(MaximumWidth, 0),
TextFormatFlags.WordBreak);
e.ToolTipSize = new Size(Math.Min(MaximumWidth, size.Width) + 22, size.Height + 18);
};
tip.Draw += (_, e) =>
{
var g = e.Graphics;
Theme.HighQuality(g);
g.SmoothingMode = SmoothingMode.AntiAlias;
var bounds = new RectangleF(0.5f, 0.5f, e.Bounds.Width - 1, e.Bounds.Height - 1);
Theme.FillAndStroke(g, bounds, 5f, Theme.SurfaceAlt, Theme.BorderStrong);
TextRenderer.DrawText(g, e.ToolTipText, Theme.Small,
Rectangle.Inflate(e.Bounds, -11, -9), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
};
return tip;
}
/// <summary>Associa una spiegazione a un controllo e a tutti i suoi figli.</summary>
public static void Set(Control control, string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
Instance.SetToolTip(control, text);
// Un LabeledCombo è un pannello con dentro etichetta e menu: senza propagare, il
// 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);
}
}
+153
View File
@@ -0,0 +1,153 @@
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Elenco degli avvisi raccolti dal motore, ordinati per gravità. Ogni voce dice cosa è
/// stato notato e cosa comporta; nessuna chiede di essere risolta prima di andare avanti,
/// perché quasi sempre la risposta giusta è "va bene così, volevo saperlo".
/// </summary>
internal sealed class WarningsPanel : Control
{
private IReadOnlyList<SequenceWarning> _warnings = [];
private int _scroll;
private int _hovered = -1;
public event EventHandler<string>? AreaActivated;
public WarningsPanel()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public int Count => _warnings.Count;
public void SetWarnings(IReadOnlyList<SequenceWarning> warnings)
{
_warnings = warnings;
_scroll = 0;
Invalidate();
}
// ------------------------------------------------------------------ misura e interazione
/// <summary>
/// Altezza di una voce: dipende dal testo, che va misurato sulla larghezza reale della
/// colonna. A larghezza fissa le spiegazioni lunghe finirebbero tagliate a metà frase.
/// </summary>
private int HeightOf(SequenceWarning warning, Graphics g)
{
int textWidth = Math.Max(80, Width - 66);
int detail = TextRenderer.MeasureText(g, warning.Detail, Theme.Small,
new Size(textWidth, 0), TextFormatFlags.WordBreak).Height;
return 26 + detail + 14;
}
private int IndexAt(int y, Graphics g)
{
int cursor = 8 - _scroll;
for (int i = 0; i < _warnings.Count; i++)
{
int height = HeightOf(_warnings[i], g);
if (y >= cursor && y < cursor + height) return i;
cursor += height;
}
return -1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
using var g = CreateGraphics();
int index = IndexAt(e.Y, g);
if (index != _hovered) { _hovered = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hovered = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
using var g = CreateGraphics();
int index = IndexAt(e.Y, g);
if (index >= 0) AreaActivated?.Invoke(this, _warnings[index].Area);
base.OnMouseDown(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
using var g = CreateGraphics();
int total = 16;
foreach (var warning in _warnings) total += HeightOf(warning, g);
int maximum = Math.Max(0, total - Height);
_scroll = Math.Clamp(_scroll - Math.Sign(e.Delta) * 48, 0, maximum);
Invalidate();
base.OnMouseWheel(e);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
if (_warnings.Count == 0)
{
TextRenderer.DrawText(g, "Nessun avviso: la sequenza non presenta anomalie note.",
Theme.Body, new Rectangle(12, 0, Width - 24, Height), Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.WordBreak);
return;
}
int y = 8 - _scroll;
for (int i = 0; i < _warnings.Count; i++)
{
var warning = _warnings[i];
int height = HeightOf(warning, g);
if (y + height > 0 && y < Height) DrawEntry(g, warning, y, height, i == _hovered);
y += height;
}
}
private void DrawEntry(Graphics g, SequenceWarning warning, int top, int height, bool hover)
{
var bounds = new Rectangle(8, top, Math.Max(20, Width - 16), height - 6);
if (hover) Theme.FillRounded(g, bounds, 5f, Theme.SurfaceAlt);
Color accent = warning.Severity switch
{
WarningSeverity.Problem => Theme.Danger,
WarningSeverity.Caution => Theme.Warning,
_ => Theme.TextFaint,
};
// Filetto verticale colorato: la gravità si legge prima del testo.
using (var stripe = new SolidBrush(accent))
g.FillRectangle(stripe, bounds.Left + 2, bounds.Top + 3, 3, bounds.Height - 6);
var titleBounds = new Rectangle(bounds.Left + 14, bounds.Top + 2, bounds.Width - 22, 18);
TextRenderer.DrawText(g, warning.Title, Theme.BodyBold, titleBounds, Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
var areaSize = TextRenderer.MeasureText(g, warning.Area.ToUpperInvariant(), Theme.Small);
TextRenderer.DrawText(g, warning.Area.ToUpperInvariant(), Theme.SmallBold,
new Rectangle(bounds.Right - areaSize.Width - 10, bounds.Top + 3, areaSize.Width + 6, 16),
accent, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
var detailBounds = new Rectangle(bounds.Left + 14, bounds.Top + 22, bounds.Width - 26, bounds.Height - 26);
TextRenderer.DrawText(g, warning.Detail, Theme.Small, detailBounds, Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
}
}
+111
View File
@@ -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;
}
}
+13
View File
@@ -32,6 +32,19 @@ public sealed class ExportSettings
public int Width { get; set; }
public int Height { get; set; }
/// <summary>
/// Rapporto larghezza/altezza dell'uscita; 0 conserva quello della sorgente. Cambiarlo
/// non deforma l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il
/// fotogramma, che è la sola cosa sensata da fare quando si passa da 4:3 a 16:9.
/// </summary>
public double AspectRatio { get; set; }
/// <summary>Ritaglio fisso dell'inquadratura, indipendente dal movimento virtuale.</summary>
public bool CropEnabled { get; set; }
public double CropCentreX { get; set; } = 0.5;
public double CropCentreY { get; set; } = 0.5;
public double CropZoom { get; set; } = 1.0;
public double FrameRate { get; set; } = 30.0;
/// <summary>Bitrate medio in megabit al secondo.</summary>