Compare commits
4
Commits
3d51e98ca3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15735a7b11 | ||
|
|
db21a31e56 | ||
|
|
2e5a441938 | ||
|
|
790637bc0d |
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,16 @@ public sealed class RenderPipeline(TitanoProject project)
|
||||
{
|
||||
private readonly TitanoProject _project = project;
|
||||
|
||||
/// <summary>
|
||||
/// Chiamato ogni tanto con il fotogramma appena consegnato all'encoder, per mostrarlo
|
||||
/// mentre l'esportazione procede. Il buffer è valido solo per la durata della chiamata:
|
||||
/// il ciclo lo riusa subito dopo, e trattenerlo significherebbe vederselo cambiare sotto.
|
||||
/// </summary>
|
||||
public Action<ImageBuffer, int, int>? FrameEncoded { get; set; }
|
||||
|
||||
/// <summary>Ogni quanti fotogrammi invocare <see cref="FrameEncoded"/>.</summary>
|
||||
public int PreviewInterval { get; set; } = 8;
|
||||
|
||||
// ------------------------------------------------------------------ ingestion
|
||||
|
||||
/// <summary>Legge i metadati dei file indicati e costruisce la sequenza ordinata.</summary>
|
||||
@@ -465,6 +475,13 @@ public sealed class RenderPipeline(TitanoProject project)
|
||||
encoded++;
|
||||
record.OutputDurationUnits = (int)planned.DurationUnits;
|
||||
|
||||
if (FrameEncoded is { } observer && encoded % Math.Max(1, PreviewInterval) == 1)
|
||||
{
|
||||
// Un'eccezione dell'anteprima non deve fermare un'esportazione di mezz'ora.
|
||||
try { observer(composed, k, plan.Count); }
|
||||
catch (Exception) { FrameEncoded = null; }
|
||||
}
|
||||
|
||||
ReportRenderProgress(progress, k + 1, plan.Count, encoded, stopwatch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using Titano.Analysis;
|
||||
using Titano.Motion;
|
||||
|
||||
namespace Titano.Pipeline;
|
||||
|
||||
public enum WarningSeverity
|
||||
{
|
||||
/// <summary>Vale la pena saperlo, non c'è niente da correggere.</summary>
|
||||
Note,
|
||||
|
||||
/// <summary>Il risultato ne risentirà, ma l'elaborazione va avanti.</summary>
|
||||
Caution,
|
||||
|
||||
/// <summary>Va sistemato prima di esportare.</summary>
|
||||
Problem,
|
||||
}
|
||||
|
||||
/// <summary>Una cosa che il motore ha notato, con dove si corregge.</summary>
|
||||
public sealed record SequenceWarning(
|
||||
WarningSeverity Severity,
|
||||
string Area,
|
||||
string Title,
|
||||
string Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Raccoglie in un posto solo quello che il motore ha già capito della sequenza.
|
||||
///
|
||||
/// Erano informazioni che esistevano tutte, sparse fra barra di stato, pannello delle
|
||||
/// impostazioni, colonne della tabella e uscita a riga di comando — cioè, in pratica,
|
||||
/// invisibili. Messe in fila e ordinate per gravità diventano una diagnosi: si legge in
|
||||
/// dieci secondi se la sequenza è pronta per l'esportazione o se c'è qualcosa da guardare.
|
||||
/// </summary>
|
||||
public static class SequenceWarnings
|
||||
{
|
||||
public static List<SequenceWarning> Collect(TitanoProject project, AppSettings settings)
|
||||
{
|
||||
var warnings = new List<SequenceWarning>();
|
||||
if (project.Sequence is not { Count: > 0 } sequence) return warnings;
|
||||
|
||||
// ---- lettura e cadenza
|
||||
int unreadable = 0;
|
||||
foreach (var frame in sequence.Frames)
|
||||
{
|
||||
if (project.IsAnalyzed && !frame.LuminanceAnalyzed) unreadable++;
|
||||
}
|
||||
if (unreadable > 0)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Problem, "Sequenza",
|
||||
$"{unreadable} fotogrammi non sono stati letti",
|
||||
"Vengono sostituiti dal vicino valido più prossimo: nel video si vede un fotogramma " +
|
||||
"ripetuto invece di un salto, ma il contenuto di quegli scatti è perduto."));
|
||||
}
|
||||
|
||||
if (sequence.CadenceAnomalies > sequence.Count / 10)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Sequenza",
|
||||
$"{sequence.CadenceAnomalies} intervalli fuori cadenza",
|
||||
$"Più di un decimo della sequenza si discosta dai {sequence.NominalInterval:0.###} s nominali. " +
|
||||
"Con la durata costante si vedranno accelerazioni; la durata adattiva o la cadenza " +
|
||||
"uniformata le assorbono."));
|
||||
}
|
||||
|
||||
if (!sequence.HasSubSecondPrecision && sequence.NominalInterval < 3)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Sequenza",
|
||||
"Nessun timestamp al sotto-secondo",
|
||||
"Con intervalli brevi l'arrotondamento al secondo introduce un errore di cadenza " +
|
||||
"che non viene dalla macchina ma dalla precisione con cui l'ha annotata."));
|
||||
}
|
||||
|
||||
// ---- risoluzione
|
||||
var (width, height) = project.ResolveWorkingSize();
|
||||
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
|
||||
if (width != requestedWidth || height != requestedHeight)
|
||||
{
|
||||
string codec = project.Export.Codec == Video.VideoCodec.H264 ? "H.264" : "HEVC";
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esportazione",
|
||||
$"Risoluzione ridotta a {width}×{height}",
|
||||
$"{requestedWidth}×{requestedHeight} eccede il livello {codec} che i lettori comuni " +
|
||||
"supportano: il file uscirebbe, e non si aprirebbe."));
|
||||
}
|
||||
|
||||
if (project.NeedsGeometry)
|
||||
{
|
||||
var (sourceWidth, _) = project.ResolveSourceSize();
|
||||
if (sourceWidth > 0 && width > sourceWidth)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esportazione",
|
||||
"L'uscita è più grande della sorgente letta",
|
||||
$"Si sta ingrandendo da {sourceWidth} px a {width} px. Con la camera virtuale " +
|
||||
"attiva conviene lasciare la risoluzione di lavoro sulla nativa."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- esposizione
|
||||
if (project.IsAnalyzed && project.Stats is { } stats)
|
||||
{
|
||||
int clipped = 0;
|
||||
foreach (var frame in stats)
|
||||
{
|
||||
if (frame.ClippedFraction > 0.25) clipped++;
|
||||
}
|
||||
if (clipped > stats.Count / 20)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esposizione",
|
||||
$"{clipped} fotogrammi sono saturi per oltre un quarto",
|
||||
"Dove il fotogramma è saturo la luminanza non risponde più all'esposizione: " +
|
||||
"deflicker, segmentazione e riconoscimento dei cambi lavorano al buio."));
|
||||
}
|
||||
}
|
||||
|
||||
if (project.Regions.Mode != RegionMode.Off && project.IsAnalyzed && project.Mask is null)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Esposizione",
|
||||
"La divisione in regioni non ha trovato una separazione utile",
|
||||
"La scena non si lascia dividere in due parti che si comportino in modo diverso. " +
|
||||
"Resta attiva la curva unica, che per questa sequenza è la scelta giusta."));
|
||||
}
|
||||
else if (project.Mask is { } mask && mask.Log2High < mask.Log2Low && mask.Coverage < 0.35 &&
|
||||
project.Regions.Mode == RegionMode.SkyGround)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Esposizione",
|
||||
"Sopra la linea d'orizzonte forse non c'è il cielo",
|
||||
"La regione superiore è insieme stretta e più scura dell'altra: è il segno di un " +
|
||||
"primo piano che copre l'alto dell'inquadratura. Prova la divisione per luminanza."));
|
||||
}
|
||||
|
||||
if (project.Transitions is { UnobservedSteps: > 0 } transitions)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Esposizione",
|
||||
$"{transitions.UnobservedSteps} cambi di impostazione non recepiti",
|
||||
"La macchina li ha dichiarati ma la luminanza non si è mossa: fotogramma già saturo, " +
|
||||
"oppure la scena ha compensato. Vengono lasciati stare, perché correggerli " +
|
||||
"introdurrebbe il gradino invece di toglierlo."));
|
||||
}
|
||||
|
||||
// ---- movimento
|
||||
if (project.Motion is { } motion)
|
||||
{
|
||||
if (motion.UnreliableFraction > 0.25)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Movimento",
|
||||
$"Correlazione debole sul {motion.UnreliableFraction * 100:0}% delle coppie",
|
||||
"Su quei fotogrammi lo spostamento non è misurabile — di solito perché sono " +
|
||||
"bruciati o senza tessitura — e la stabilizzazione li lascia dove sono."));
|
||||
}
|
||||
|
||||
double zoom = project.StabilizationZoom;
|
||||
if (zoom > 1.06)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Caution, "Movimento",
|
||||
$"La stabilizzazione consuma il {(zoom - 1) * 100:0.#}% dell'inquadratura",
|
||||
"È il margine necessario a non mostrare i bordi scoperti dalla correzione. " +
|
||||
"Una finestra del percorso più lunga o un'intensità minore lo riducono."));
|
||||
}
|
||||
}
|
||||
|
||||
if (project.Camera.Enabled && VirtualCamera.MaximumZoom(project.Camera) > 2.5)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Movimento",
|
||||
$"Ingrandimento virtuale fino a {VirtualCamera.MaximumZoom(project.Camera):0.0}×",
|
||||
"Oltre un certo ritaglio si vede il rumore del sensore invece del dettaglio: " +
|
||||
"è la risoluzione nativa a stabilire quanto si può stringere."));
|
||||
}
|
||||
|
||||
// ---- tempo e memoria
|
||||
if (project.Stacking.Mode == StackingMode.Median)
|
||||
{
|
||||
long perFrame = (long)width * height * 3 * sizeof(float);
|
||||
long windowBytes = perFrame * (2L * project.Stacking.MedianRadius + 1);
|
||||
if (windowBytes > 1024L * 1024 * 1024)
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Tempo",
|
||||
$"La finestra della mediana occupa {windowBytes / (1024.0 * 1024 * 1024):0.0} GiB",
|
||||
"Sono fotogrammi vivi tutti insieme, perché la mediana li vuole simultaneamente. " +
|
||||
"Il tetto di memoria governa solo la lettura in anticipo, non la finestra."));
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(project.Export.OutputPath))
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Problem, "Esportazione",
|
||||
"Manca il file di destinazione",
|
||||
"Scegli dove salvare il video prima di avviare l'esportazione."));
|
||||
}
|
||||
else if (settings.ConfirmOverwrite && File.Exists(project.Export.OutputPath))
|
||||
{
|
||||
warnings.Add(new SequenceWarning(WarningSeverity.Note, "Esportazione",
|
||||
"Il file di destinazione esiste già",
|
||||
$"{Path.GetFileName(project.Export.OutputPath)} verrà sovrascritto."));
|
||||
}
|
||||
|
||||
warnings.Sort((a, b) => b.Severity.CompareTo(a.Severity));
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>Sezione della barra a cui l'avviso appartiene, per il contatore.</summary>
|
||||
public static WorkspaceArea AreaOf(SequenceWarning warning) => warning.Area switch
|
||||
{
|
||||
"Sequenza" => WorkspaceArea.Sequence,
|
||||
"Esposizione" => WorkspaceArea.Exposure,
|
||||
"Movimento" => WorkspaceArea.Motion,
|
||||
"Tempo" => WorkspaceArea.Timing,
|
||||
_ => WorkspaceArea.Export,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Sezioni note al modello, senza dipendere dai tipi dell'interfaccia.</summary>
|
||||
public enum WorkspaceArea
|
||||
{
|
||||
Sequence,
|
||||
Exposure,
|
||||
Motion,
|
||||
Timing,
|
||||
Export,
|
||||
}
|
||||
@@ -70,6 +70,16 @@ public sealed class TitanoProject
|
||||
ApplyQualityProfile(General.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parametri di cui l'utente ha preso il controllo: il direttore non li tocca più.
|
||||
/// Basta muovere un cursore per finirci dentro, e il pulsante «auto» per uscirne.
|
||||
/// </summary>
|
||||
public HashSet<AutoKey> ManualParameters { get; } = [];
|
||||
|
||||
/// <summary>Ultime decisioni automatiche, con il motivo di ciascuna.</summary>
|
||||
public IReadOnlyDictionary<AutoKey, AutoDecision> AutoDecisions { get; set; }
|
||||
= new Dictionary<AutoKey, AutoDecision>();
|
||||
|
||||
public TimelapseSequence? Sequence { get; set; }
|
||||
|
||||
/// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary>
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using Titano.Motion;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Percorso della camera ricostruito dalla stabilizzazione: dove il fotogramma si trovava
|
||||
/// davvero e dove la lisciatura ha deciso che dovesse stare.
|
||||
///
|
||||
/// I due vettori per fotogramma erano già calcolati e conservati nel progetto, e finivano in
|
||||
/// due numeri nel riepilogo. Disegnati, dicono la cosa che i numeri non dicono: quanta parte
|
||||
/// del movimento la stabilizzazione ha giudicato voluta — e lì le due curve coincidono — e
|
||||
/// quanta ha giudicato tremolio, che è la distanza fra loro.
|
||||
/// </summary>
|
||||
internal sealed class MotionPathChart : Control
|
||||
{
|
||||
private StabilizationPath? _path;
|
||||
private double _pixelsPerUnit = 1;
|
||||
private int _selectedIndex = -1;
|
||||
private int _hoverIndex = -1;
|
||||
|
||||
private const int GutterLeft = 46;
|
||||
private const int GutterTop = 22;
|
||||
private const int GutterBottom = 18;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
|
||||
public MotionPathChart()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
}
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set { _selectedIndex = value; Invalidate(); }
|
||||
}
|
||||
|
||||
/// <summary>Il percorso e la larghezza sorgente, che converte le unità normalizzate in pixel.</summary>
|
||||
public void SetPath(StabilizationPath? path, int sourceWidth)
|
||||
{
|
||||
_path = path;
|
||||
_pixelsPerUnit = Math.Max(1, sourceWidth);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private Rectangle Plot => new(GutterLeft, GutterTop,
|
||||
Math.Max(20, Width - GutterLeft - 12),
|
||||
Math.Max(20, Height - GutterTop - GutterBottom));
|
||||
|
||||
private int IndexAt(int x)
|
||||
{
|
||||
if (_path is not { Count: > 1 }) return -1;
|
||||
var plot = Plot;
|
||||
double fraction = Math.Clamp((x - plot.Left) / (double)plot.Width, 0, 1);
|
||||
return (int)Math.Round(fraction * (_path.Count - 1));
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
int index = IndexAt(e.X);
|
||||
if (index != _hoverIndex) { _hoverIndex = index; Invalidate(); }
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hoverIndex = -1;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
int index = IndexAt(e.X);
|
||||
if (index >= 0)
|
||||
{
|
||||
_selectedIndex = index;
|
||||
Invalidate();
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Surface);
|
||||
|
||||
var plot = Plot;
|
||||
|
||||
if (_path is not { Count: > 2 })
|
||||
{
|
||||
TextRenderer.DrawText(g,
|
||||
_path is null
|
||||
? "Attiva la stabilizzazione e analizza la sequenza per vedere il percorso"
|
||||
: "Sequenza troppo corta per ricostruire un percorso",
|
||||
Theme.Body, new Rectangle(0, 0, Width, Height), Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
int count = _path.Count;
|
||||
var measuredX = new double[count];
|
||||
var measuredY = new double[count];
|
||||
var targetX = new double[count];
|
||||
var targetY = new double[count];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var measured = _path.Measured[i];
|
||||
// La posizione corretta è quella misurata portata dov'è finita dopo la correzione.
|
||||
var corrected = SimilarityTransform.Compose(_path.Correction[i], measured);
|
||||
|
||||
measuredX[i] = measured.Tx * _pixelsPerUnit;
|
||||
measuredY[i] = measured.Ty * _pixelsPerUnit;
|
||||
targetX[i] = corrected.Tx * _pixelsPerUnit;
|
||||
targetY[i] = corrected.Ty * _pixelsPerUnit;
|
||||
}
|
||||
|
||||
double lo = double.MaxValue, hi = double.MinValue;
|
||||
foreach (var series in (double[][])[measuredX, measuredY, targetX, targetY])
|
||||
{
|
||||
foreach (double value in series)
|
||||
{
|
||||
lo = Math.Min(lo, value);
|
||||
hi = Math.Max(hi, value);
|
||||
}
|
||||
}
|
||||
if (hi - lo < 2) { double mid = (hi + lo) / 2; lo = mid - 1; hi = mid + 1; }
|
||||
double padding = (hi - lo) * 0.12;
|
||||
lo -= padding;
|
||||
hi += padding;
|
||||
|
||||
DrawGrid(g, plot, lo, hi);
|
||||
|
||||
// Prima il misurato, sottile, poi il liscio sopra: la distanza fra i due è la
|
||||
// grandezza che interessa, e va letta come uno scarto dal secondo.
|
||||
DrawSeries(g, plot, measuredX, lo, hi, Color.FromArgb(110, Theme.Measured), 1.1f);
|
||||
DrawSeries(g, plot, measuredY, lo, hi, Color.FromArgb(110, Theme.RegionLow), 1.1f);
|
||||
DrawSeries(g, plot, targetX, lo, hi, Theme.Measured, 1.9f);
|
||||
DrawSeries(g, plot, targetY, lo, hi, Theme.RegionLow, 1.9f);
|
||||
|
||||
DrawLegend(g);
|
||||
DrawMarker(g, plot, _selectedIndex, count, Color.FromArgb(150, Theme.Text), DashStyle.Dash);
|
||||
DrawMarker(g, plot, _hoverIndex, count, Color.FromArgb(90, Theme.Text), DashStyle.Solid);
|
||||
DrawReadout(g, plot, measuredX, measuredY, targetX, targetY);
|
||||
}
|
||||
|
||||
private void DrawGrid(Graphics g, Rectangle plot, double lo, double hi)
|
||||
{
|
||||
using var grid = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
|
||||
using var axis = new Pen(Theme.BorderStrong);
|
||||
|
||||
double range = hi - lo;
|
||||
double step = range > 200 ? 50 : range > 80 ? 20 : range > 30 ? 10 : range > 12 ? 5 : range > 4 ? 2 : 1;
|
||||
|
||||
for (double value = Math.Ceiling(lo / step) * step; value <= hi; value += step)
|
||||
{
|
||||
float y = YFor(value, plot, lo, hi);
|
||||
g.DrawLine(grid, plot.Left, y, plot.Right, y);
|
||||
TextRenderer.DrawText(g, value.ToString("0"), Theme.Small,
|
||||
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
g.DrawLine(axis, plot.Left, plot.Top, plot.Left, plot.Bottom);
|
||||
TextRenderer.DrawText(g, "px", Theme.Small, new Rectangle(0, plot.Top - 18, GutterLeft - 6, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
private static float YFor(double value, Rectangle plot, double lo, double hi)
|
||||
=> plot.Bottom - (float)((value - lo) / Math.Max(1e-9, hi - lo) * plot.Height);
|
||||
|
||||
private static void DrawSeries(Graphics g, Rectangle plot, double[] values, double lo, double hi,
|
||||
Color color, float width)
|
||||
{
|
||||
int count = values.Length;
|
||||
// Con più campioni che pixel si disegna un punto per colonna: tracciare centomila
|
||||
// segmenti dentro mille pixel costa e non aggiunge un'informazione leggibile.
|
||||
int stride = Math.Max(1, count / Math.Max(1, plot.Width));
|
||||
var points = new List<PointF>(count / stride + 2);
|
||||
|
||||
for (int i = 0; i < count; i += stride)
|
||||
{
|
||||
float x = plot.Left + plot.Width * i / (float)(count - 1);
|
||||
points.Add(new PointF(x, YFor(values[i], plot, lo, hi)));
|
||||
}
|
||||
if (points.Count < 2) return;
|
||||
|
||||
using var pen = new Pen(color, width) { LineJoin = LineJoin.Round };
|
||||
var clip = g.Clip;
|
||||
g.SetClip(Rectangle.Inflate(plot, 1, 1));
|
||||
g.DrawLines(pen, [.. points]);
|
||||
g.Clip = clip;
|
||||
}
|
||||
|
||||
private void DrawLegend(Graphics g)
|
||||
{
|
||||
var entries = new (Color Color, string Label)[]
|
||||
{
|
||||
(Theme.Measured, "orizzontale"),
|
||||
(Theme.RegionLow, "verticale"),
|
||||
};
|
||||
|
||||
int x = GutterLeft;
|
||||
foreach (var (color, label) in entries)
|
||||
{
|
||||
using (var faint = new SolidBrush(Color.FromArgb(110, color))) g.FillRectangle(faint, x, 8, 10, 2);
|
||||
using (var solid = new SolidBrush(color)) g.FillRectangle(solid, x + 12, 8, 10, 2);
|
||||
|
||||
var size = TextRenderer.MeasureText(g, label, Theme.Small);
|
||||
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 27, 1, size.Width + 4, 16),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
x += 27 + size.Width + 18;
|
||||
}
|
||||
|
||||
TextRenderer.DrawText(g, "tenue: misurato · pieno: dopo la stabilizzazione", Theme.Small,
|
||||
new Rectangle(x, 1, Math.Max(10, Width - x - 10), 16), Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
private static void DrawMarker(Graphics g, Rectangle plot, int index, int count, Color color, DashStyle dash)
|
||||
{
|
||||
if (index < 0 || index >= count) return;
|
||||
float x = plot.Left + plot.Width * index / (float)(count - 1);
|
||||
using var pen = new Pen(color) { DashStyle = dash };
|
||||
g.DrawLine(pen, x, plot.Top, x, plot.Bottom);
|
||||
}
|
||||
|
||||
private void DrawReadout(Graphics g, Rectangle plot, double[] mx, double[] my, double[] tx, double[] ty)
|
||||
{
|
||||
int index = _hoverIndex >= 0 ? _hoverIndex : _selectedIndex;
|
||||
if (index < 0 || index >= mx.Length) return;
|
||||
|
||||
double shiftX = tx[index] - mx[index];
|
||||
double shiftY = ty[index] - my[index];
|
||||
string text = $"#{index + 1} correzione {Math.Sqrt(shiftX * shiftX + shiftY * shiftY):0.00} px";
|
||||
|
||||
var size = TextRenderer.MeasureText(g, text, Theme.SmallBold);
|
||||
var box = new RectangleF(plot.Right - size.Width - 16, plot.Top + 6, size.Width + 12, 20);
|
||||
Theme.FillAndStroke(g, box, 4f, Color.FromArgb(232, Theme.Background), Theme.BorderStrong);
|
||||
TextRenderer.DrawText(g, text, Theme.SmallBold, Rectangle.Round(box), Theme.Text,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Un annuncio del listino locale.</summary>
|
||||
internal sealed record Sponsor(string Title, string Body, string? ImagePath, string? Link, int Weight)
|
||||
{
|
||||
public bool HasLink => !string.IsNullOrWhiteSpace(Link);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Listino degli sponsor, letto da una cartella locale.
|
||||
///
|
||||
/// Nessuna rete: il programma non contatta alcun servizio, non invia identificativi e non
|
||||
/// registra clic. È una scelta, non una mancanza — un circuito pubblicitario vero
|
||||
/// richiederebbe di integrarne l'SDK, che il vincolo sulle dipendenze esclude, e comunque
|
||||
/// significherebbe far uscire dati dalla macchina di chi sta soltanto montando un
|
||||
/// time-lapse. Le campagne si aggiornano copiando file in una cartella.
|
||||
///
|
||||
/// Il formato è lo stesso delle impostazioni: blocchi introdotti da [campagna], una riga per
|
||||
/// campo. Le immagini stanno accanto al listino.
|
||||
/// </summary>
|
||||
internal static class SponsorCatalogue
|
||||
{
|
||||
public const string ManifestName = "campagne.txt";
|
||||
|
||||
public static List<Sponsor> Load(string folder)
|
||||
{
|
||||
var sponsors = new List<Sponsor>();
|
||||
try
|
||||
{
|
||||
string manifest = Path.Combine(folder, ManifestName);
|
||||
if (!File.Exists(manifest)) return sponsors;
|
||||
|
||||
string? title = null, body = null, image = null, link = null;
|
||||
int weight = 1;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return;
|
||||
string? resolved = string.IsNullOrWhiteSpace(image) ? null : Path.Combine(folder, image);
|
||||
if (resolved is not null && !File.Exists(resolved)) resolved = null;
|
||||
sponsors.Add(new Sponsor(title!, body ?? string.Empty, resolved, Sanitize(link), Math.Clamp(weight, 1, 10)));
|
||||
}
|
||||
|
||||
foreach (string raw in File.ReadAllLines(manifest, Encoding.UTF8))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
|
||||
if (line.StartsWith('[') && line.EndsWith(']'))
|
||||
{
|
||||
Flush();
|
||||
title = body = image = link = null;
|
||||
weight = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
int separator = line.IndexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
string key = line[..separator].Trim().ToLowerInvariant();
|
||||
string value = line[(separator + 1)..].Trim();
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "titolo": title = value; break;
|
||||
case "testo": body = value; break;
|
||||
case "immagine": image = value; break;
|
||||
case "collegamento": link = value; break;
|
||||
case "peso": weight = AppSettings.ParseInt(value, 1); break;
|
||||
}
|
||||
}
|
||||
Flush();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
// Listino illeggibile: restano gli annunci interni, che non mancano mai.
|
||||
}
|
||||
|
||||
return sponsors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea la cartella e, se è vuota, vi scrive un listino di esempio commentato. Il formato
|
||||
/// si impara guardando un file che c'è già, non cercando dove sia documentato.
|
||||
/// </summary>
|
||||
public static void EnsureExample(string folder)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
string manifest = Path.Combine(folder, ManifestName);
|
||||
if (File.Exists(manifest)) return;
|
||||
|
||||
File.WriteAllText(manifest, """
|
||||
# Listino delle campagne mostrate durante l'esportazione.
|
||||
#
|
||||
# Un blocco per annuncio, introdotto da [campagna]. Le immagini stanno in
|
||||
# questa stessa cartella; sono facoltative, e vengono ridimensionate
|
||||
# conservando le proporzioni. Il collegamento deve essere http o https:
|
||||
# qualunque altra cosa viene ignorata.
|
||||
#
|
||||
# Il peso governa la frequenza: peso 3 compare tre volte più spesso di peso 1.
|
||||
#
|
||||
# Titano non contatta nessun servizio e non registra i clic: questo file è
|
||||
# l'unica sorgente degli annunci.
|
||||
|
||||
[campagna]
|
||||
titolo = Titolo dell'annuncio
|
||||
testo = Una o due righe di testo. Chi legge sta aspettando la fine di una codifica, quindi può leggere una frase intera.
|
||||
immagine = esempio.jpg
|
||||
collegamento = https://esempio.it
|
||||
peso = 1
|
||||
""", Encoding.UTF8);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Cartella non scrivibile: restano gli annunci interni.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accetta solo indirizzi web. Il listino è un file locale, ma aprire alla cieca ciò che
|
||||
/// vi si trova scritto significherebbe eseguire quello che qualcuno vi ha messo dentro.
|
||||
/// </summary>
|
||||
private static string? Sanitize(string? link)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(link)) return null;
|
||||
return Uri.TryCreate(link, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
|
||||
? uri.AbsoluteUri
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contenuti interni, mostrati quando il listino è vuoto. Non sono un riempitivo: sono
|
||||
/// le cose che un'attesa di mezz'ora è il momento giusto per leggere.
|
||||
/// </summary>
|
||||
public static List<Sponsor> House() =>
|
||||
[
|
||||
new("Il profilo di qualità governa il tempo",
|
||||
"Massima infittisce il campo vettoriale e i campioni della scia. Su una sequenza senza " +
|
||||
"movimento, Standard dà lo stesso risultato in un terzo del tempo.", null, null, 1),
|
||||
new("La finestra della mediana vive in memoria",
|
||||
"I fotogrammi della finestra sono vivi tutti insieme, perché la mediana li vuole " +
|
||||
"simultaneamente. Il tetto di memoria governa la lettura in anticipo, non la finestra.", null, null, 1),
|
||||
new("Un cielo più scuro del terreno è normale",
|
||||
"Di notte, con un primo piano illuminato, la regione superiore è la più scura. " +
|
||||
"La divisione per orizzonte se ne accorge e non la scambia per un errore.", null, null, 1),
|
||||
new("I cambi di esposizione invisibili restano tali",
|
||||
"Se il fotogramma è già saturo, cambiare sensibilità non lo scurisce. Titano lo verifica " +
|
||||
"prima di correggere: togliere un gradino che non c'è significa introdurlo.", null, null, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Riquadro degli sponsor, mostrato durante l'attesa dell'esportazione.
|
||||
///
|
||||
/// Sta soltanto qui, nella scheda dove per forza di cose si aspetta, e non compare mai
|
||||
/// altrove: un annuncio accanto a un cursore che si sta regolando sarebbe un ostacolo, uno
|
||||
/// accanto a una barra di avanzamento è qualcosa da guardare mentre passa il tempo. È
|
||||
/// dichiarato come tale, non si muove da solo se il sistema chiede di ridurre le animazioni,
|
||||
/// e si spegne dalle impostazioni.
|
||||
/// </summary>
|
||||
internal sealed class SponsorPanel : Control
|
||||
{
|
||||
private readonly System.Windows.Forms.Timer _rotation = new();
|
||||
private List<Sponsor> _sponsors = [];
|
||||
private readonly List<int> _order = [];
|
||||
private int _position;
|
||||
private Image? _image;
|
||||
private string? _imagePath;
|
||||
private bool _hoverLink;
|
||||
private bool _hoverNext;
|
||||
|
||||
public SponsorPanel()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
Height = 132;
|
||||
|
||||
_rotation.Tick += (_, _) => Advance();
|
||||
}
|
||||
|
||||
public Sponsor? Current => _order.Count > 0 && _position < _order.Count
|
||||
? _sponsors[_order[_position]]
|
||||
: null;
|
||||
|
||||
/// <summary>Carica il listino e avvia la rotazione secondo le preferenze.</summary>
|
||||
public void Configure(AppSettings settings)
|
||||
{
|
||||
Visible = settings.ShowSponsors;
|
||||
if (!settings.ShowSponsors)
|
||||
{
|
||||
_rotation.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
var loaded = SponsorCatalogue.Load(settings.ResolvedSponsorFolder);
|
||||
_sponsors = loaded.Count > 0 ? loaded : SponsorCatalogue.House();
|
||||
|
||||
// L'ordine tiene conto del peso ripetendo le voci: una campagna di peso tre compare
|
||||
// tre volte nel giro, senza bisogno di estrazioni casuali che potrebbero saltarla.
|
||||
_order.Clear();
|
||||
for (int i = 0; i < _sponsors.Count; i++)
|
||||
{
|
||||
for (int repeat = 0; repeat < _sponsors[i].Weight; repeat++) _order.Add(i);
|
||||
}
|
||||
Shuffle(_order);
|
||||
|
||||
_position = 0;
|
||||
LoadImage();
|
||||
|
||||
_rotation.Interval = Math.Clamp(settings.SponsorRotationSeconds, 5, 600) * 1000;
|
||||
_rotation.Start();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Ferma la rotazione quando la scheda non è in vista: non serve a nessuno girare a vuoto.</summary>
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (!Visible) return;
|
||||
if (active) _rotation.Start(); else _rotation.Stop();
|
||||
}
|
||||
|
||||
private static void Shuffle(List<int> values)
|
||||
{
|
||||
for (int i = values.Count - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Shared.Next(i + 1);
|
||||
(values[i], values[j]) = (values[j], values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private void Advance()
|
||||
{
|
||||
if (_order.Count == 0) return;
|
||||
_position = (_position + 1) % _order.Count;
|
||||
LoadImage();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void LoadImage()
|
||||
{
|
||||
string? path = Current?.ImagePath;
|
||||
if (path == _imagePath) return;
|
||||
|
||||
_image?.Dispose();
|
||||
_image = null;
|
||||
_imagePath = path;
|
||||
|
||||
if (path is null) return;
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||
_image = Image.FromStream(stream);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ArgumentException or OutOfMemoryException)
|
||||
{
|
||||
_image = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
private Rectangle NextButton => new(Width - 26, 6, 18, 18);
|
||||
|
||||
private Rectangle TextArea
|
||||
{
|
||||
get
|
||||
{
|
||||
int left = _image is null ? 14 : 14 + ThumbWidth + 14;
|
||||
return new Rectangle(left, 26, Math.Max(40, Width - left - 16), Math.Max(20, Height - 36));
|
||||
}
|
||||
}
|
||||
|
||||
private int ThumbWidth => Math.Min(168, Math.Max(80, (Height - 28) * 16 / 9));
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
bool overNext = NextButton.Contains(e.Location);
|
||||
bool overLink = Current is { HasLink: true } && TextArea.Contains(e.Location);
|
||||
|
||||
if (overNext != _hoverNext || overLink != _hoverLink)
|
||||
{
|
||||
_hoverNext = overNext;
|
||||
_hoverLink = overLink;
|
||||
Cursor = overNext || overLink ? Cursors.Hand : Cursors.Default;
|
||||
Invalidate();
|
||||
}
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hoverNext = _hoverLink = false;
|
||||
Cursor = Cursors.Default;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left) return;
|
||||
|
||||
if (NextButton.Contains(e.Location)) { Advance(); return; }
|
||||
|
||||
if (Current is { HasLink: true, Link: { } link } && TextArea.Contains(e.Location))
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(link) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException)
|
||||
{
|
||||
// Nessun browser predefinito: non c'è nulla di sensato da fare.
|
||||
}
|
||||
}
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Background);
|
||||
|
||||
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
|
||||
Theme.FillAndStroke(g, bounds, 6f, Theme.Surface, Theme.Border);
|
||||
|
||||
if (Current is not { } sponsor)
|
||||
{
|
||||
TextRenderer.DrawText(g, "Nessuna campagna nel listino", Theme.Small,
|
||||
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
// L'etichetta è la prima cosa che si legge: chi guarda deve sapere subito che cos'è.
|
||||
TextRenderer.DrawText(g, "SPONSOR", Theme.SmallBold, new Rectangle(14, 5, 120, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
|
||||
if (_order.Count > 1) DrawNextButton(g);
|
||||
|
||||
if (_image is not null)
|
||||
{
|
||||
var box = new Rectangle(14, 26, ThumbWidth, Height - 40);
|
||||
DrawImageFitted(g, _image, box);
|
||||
}
|
||||
|
||||
var text = TextArea;
|
||||
TextRenderer.DrawText(g, sponsor.Title, Theme.BodyBold,
|
||||
new Rectangle(text.Left, text.Top, text.Width, 20),
|
||||
_hoverLink ? Theme.Accent : Theme.Text,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
|
||||
TextRenderer.DrawText(g, sponsor.Body, Theme.Small,
|
||||
new Rectangle(text.Left, text.Top + 22, text.Width, text.Height - 40),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
|
||||
|
||||
if (sponsor.HasLink)
|
||||
{
|
||||
TextRenderer.DrawText(g, _hoverLink ? "Apri nel browser →" : "Clic per saperne di più",
|
||||
Theme.Small, new Rectangle(text.Left, Height - 20, text.Width, 16),
|
||||
_hoverLink ? Theme.Accent : Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawNextButton(Graphics g)
|
||||
{
|
||||
var box = NextButton;
|
||||
if (_hoverNext) Theme.FillRounded(g, box, 4f, Theme.SurfaceAlt);
|
||||
|
||||
using var pen = new Pen(_hoverNext ? Theme.Text : Theme.TextFaint, 1.5f)
|
||||
{
|
||||
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
};
|
||||
float cx = box.Left + box.Width / 2f;
|
||||
float cy = box.Top + box.Height / 2f;
|
||||
g.DrawLines(pen,
|
||||
[
|
||||
new PointF(cx - 2.5f, cy - 4f),
|
||||
new PointF(cx + 2.5f, cy),
|
||||
new PointF(cx - 2.5f, cy + 4f),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>Disegna l'immagine dentro il riquadro conservandone le proporzioni.</summary>
|
||||
private static void DrawImageFitted(Graphics g, Image image, Rectangle box)
|
||||
{
|
||||
double scale = Math.Min(box.Width / (double)image.Width, box.Height / (double)image.Height);
|
||||
int width = Math.Max(1, (int)(image.Width * scale));
|
||||
int height = Math.Max(1, (int)(image.Height * scale));
|
||||
var target = new Rectangle(box.Left + (box.Width - width) / 2,
|
||||
box.Top + (box.Height - height) / 2, width, height);
|
||||
|
||||
g.DrawImage(image, target);
|
||||
using var border = new Pen(Theme.Border);
|
||||
g.DrawRectangle(border, target);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_rotation.Stop();
|
||||
_rotation.Dispose();
|
||||
_image?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Drawing.Imaging;
|
||||
using Titano.Core;
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Striscia di provini che copre l'intera sequenza: ogni riquadro è il fotogramma che sta in
|
||||
/// quella posizione, non il fotogramma successivo. Scorrere mille scatti in una tabella dice
|
||||
/// quando sono stati fatti; questa dice come cambiano, che è la domanda vera.
|
||||
///
|
||||
/// I provini si decodificano su un thread di servizio e si accumulano in una cache: la
|
||||
/// striscia si riempie mentre si guarda, senza mai bloccare l'interfaccia, e i riquadri non
|
||||
/// ancora pronti restano segnati come tali invece di apparire vuoti.
|
||||
/// </summary>
|
||||
internal sealed class TimelineStrip : Control
|
||||
{
|
||||
private const int ThumbHeight = 54;
|
||||
private const int Inset = 5;
|
||||
|
||||
private readonly ConcurrentDictionary<int, Bitmap> _cache = [];
|
||||
private readonly object _queueGate = new();
|
||||
private readonly Queue<int> _pending = new();
|
||||
|
||||
private TimelapseSequence? _sequence;
|
||||
private int _orientation = 1;
|
||||
private int _thumbWidth = 72;
|
||||
private int _selectedIndex = -1;
|
||||
private int _hovered = -1;
|
||||
private CancellationTokenSource? _loader;
|
||||
private int[] _slots = [];
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
|
||||
public TimelineStrip()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Background;
|
||||
Height = ThumbHeight + 2 * Inset + 14;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (_selectedIndex == value) return;
|
||||
_selectedIndex = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSequence(TimelapseSequence? sequence, int orientation)
|
||||
{
|
||||
StopLoading();
|
||||
foreach (var bitmap in _cache.Values) bitmap.Dispose();
|
||||
_cache.Clear();
|
||||
|
||||
_sequence = sequence;
|
||||
_orientation = orientation;
|
||||
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
|
||||
|
||||
if (sequence is { Count: > 0 })
|
||||
{
|
||||
var first = sequence.Frames[0].Metadata;
|
||||
double aspect = first.PixelWidth > 0 && first.PixelHeight > 0
|
||||
? first.PixelWidth / (double)first.PixelHeight
|
||||
: 4.0 / 3.0;
|
||||
if (ImageDecoder.SwapsAxes(orientation)) aspect = 1 / aspect;
|
||||
_thumbWidth = Math.Clamp((int)Math.Round(ThumbHeight * aspect), 32, 160);
|
||||
}
|
||||
|
||||
RebuildSlots();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs e)
|
||||
{
|
||||
RebuildSlots();
|
||||
base.OnResize(e);
|
||||
}
|
||||
|
||||
/// <summary>Sceglie quali fotogrammi mostrare: tanti quanti ne stanno, distribuiti sull'intera sequenza.</summary>
|
||||
private void RebuildSlots()
|
||||
{
|
||||
if (_sequence is not { Count: > 0 } sequence) { _slots = []; return; }
|
||||
|
||||
int usable = Math.Max(_thumbWidth, Width - 2 * Inset);
|
||||
int count = Math.Clamp(usable / (_thumbWidth + 3), 1, sequence.Count);
|
||||
|
||||
var slots = new int[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
slots[i] = count == 1 ? 0 : (int)Math.Round(i * (sequence.Count - 1.0) / (count - 1));
|
||||
}
|
||||
|
||||
_slots = slots;
|
||||
RequestMissing();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ caricamento
|
||||
|
||||
private void RequestMissing()
|
||||
{
|
||||
if (_sequence is null) return;
|
||||
|
||||
lock (_queueGate)
|
||||
{
|
||||
_pending.Clear();
|
||||
foreach (int index in _slots)
|
||||
{
|
||||
if (!_cache.ContainsKey(index)) _pending.Enqueue(index);
|
||||
}
|
||||
if (_pending.Count == 0) return;
|
||||
}
|
||||
|
||||
if (_loader is not null) return;
|
||||
|
||||
var source = new CancellationTokenSource();
|
||||
_loader = source;
|
||||
var token = source.Token;
|
||||
var sequence = _sequence;
|
||||
int orientation = _orientation;
|
||||
int width = _thumbWidth;
|
||||
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
var pool = new FrameBufferPool(4);
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
int index;
|
||||
lock (_queueGate)
|
||||
{
|
||||
if (_pending.Count == 0) break;
|
||||
index = _pending.Dequeue();
|
||||
}
|
||||
if (index < 0 || index >= sequence.Count) continue;
|
||||
|
||||
Bitmap? thumb = null;
|
||||
try
|
||||
{
|
||||
using var buffer = ImageDecoder.Decode(sequence.Frames[index].FilePath,
|
||||
width, ThumbHeight, orientation, pool);
|
||||
thumb = ToBitmap(buffer);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Un provino che non si legge resta assente: il riquadro lo dichiara.
|
||||
}
|
||||
|
||||
if (thumb is null) continue;
|
||||
if (token.IsCancellationRequested || !_cache.TryAdd(index, thumb)) { thumb.Dispose(); continue; }
|
||||
|
||||
// Un solo ridisegno ogni pochi provini: ridipingere a ogni arrivo su una
|
||||
// sequenza lunga costerebbe più della decodifica stessa.
|
||||
if (_cache.Count % 4 == 0) RequestRedraw();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
RequestRedraw();
|
||||
_loader = null;
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
private void RequestRedraw()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsHandleCreated && !IsDisposed) BeginInvoke(Invalidate);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
|
||||
{
|
||||
// Finestra già chiusa: non c'è più niente da ridisegnare.
|
||||
}
|
||||
}
|
||||
|
||||
private void StopLoading()
|
||||
{
|
||||
_loader?.Cancel();
|
||||
_loader?.Dispose();
|
||||
_loader = null;
|
||||
lock (_queueGate) _pending.Clear();
|
||||
}
|
||||
|
||||
private static unsafe Bitmap ToBitmap(ImageBuffer buffer)
|
||||
{
|
||||
var bitmap = new Bitmap(buffer.Width, buffer.Height, PixelFormat.Format32bppRgb);
|
||||
var locked = bitmap.LockBits(new Rectangle(0, 0, buffer.Width, buffer.Height),
|
||||
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
|
||||
try
|
||||
{
|
||||
var data = buffer.Data;
|
||||
byte* basePtr = (byte*)locked.Scan0;
|
||||
for (int y = 0; y < buffer.Height; y++)
|
||||
{
|
||||
byte* row = basePtr + (long)y * locked.Stride;
|
||||
int sourceIndex = y * buffer.Width * ImageBuffer.Channels;
|
||||
for (int x = 0; x < buffer.Width; x++)
|
||||
{
|
||||
int i = sourceIndex + x * ImageBuffer.Channels;
|
||||
byte* pixel = row + x * 4;
|
||||
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]);
|
||||
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]);
|
||||
pixel[2] = ColorSpace.ToSrgbByte(data[i]);
|
||||
pixel[3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(locked);
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
private int SlotAt(int x)
|
||||
{
|
||||
if (_slots.Length == 0) return -1;
|
||||
int relative = x - Inset;
|
||||
int slot = relative / (_thumbWidth + 3);
|
||||
return slot >= 0 && slot < _slots.Length ? slot : -1;
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
int slot = SlotAt(e.X);
|
||||
if (slot != _hovered) { _hovered = slot; Invalidate(); }
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = -1;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
int slot = SlotAt(e.X);
|
||||
if (slot < 0) return;
|
||||
|
||||
_selectedIndex = _slots[slot];
|
||||
Invalidate();
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
|
||||
if (_sequence is not { Count: > 0 } sequence || _slots.Length == 0)
|
||||
{
|
||||
TextRenderer.DrawText(g, "La striscia dei provini compare quando una sequenza è caricata",
|
||||
Theme.Small, new Rectangle(0, 0, Width, Height), Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int slot = 0; slot < _slots.Length; slot++)
|
||||
{
|
||||
int index = _slots[slot];
|
||||
var box = new Rectangle(Inset + slot * (_thumbWidth + 3), Inset, _thumbWidth, ThumbHeight);
|
||||
|
||||
if (_cache.TryGetValue(index, out var thumb))
|
||||
{
|
||||
g.DrawImage(thumb, box);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var placeholder = new SolidBrush(Theme.SurfaceAlt);
|
||||
g.FillRectangle(placeholder, box);
|
||||
TextRenderer.DrawText(g, "…", Theme.Small, box, Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
bool selected = index == _selectedIndex;
|
||||
bool hovered = slot == _hovered;
|
||||
|
||||
using var border = new Pen(selected ? Theme.Accent : hovered ? Theme.BorderStrong : Theme.Border,
|
||||
selected ? 2f : 1f);
|
||||
g.DrawRectangle(border, selected ? Rectangle.Inflate(box, -1, -1) : box);
|
||||
|
||||
// Il numero solo dove c'è spazio: su una striscia fitta diventerebbe rumore.
|
||||
if (_thumbWidth >= 56)
|
||||
{
|
||||
var label = new Rectangle(box.Left, box.Bottom + 1, box.Width, 12);
|
||||
TextRenderer.DrawText(g, (index + 1).ToString(), Theme.Small, label,
|
||||
selected ? Theme.Text : Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
|
||||
int loaded = _cache.Count;
|
||||
if (loaded < _slots.Length)
|
||||
{
|
||||
TextRenderer.DrawText(g, $"{loaded}/{_slots.Length}", Theme.Small,
|
||||
new Rectangle(Width - 60, Height - 14, 52, 12), Theme.TextFaint,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
StopLoading();
|
||||
foreach (var bitmap in _cache.Values) bitmap.Dispose();
|
||||
_cache.Clear();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user