Titano: motore e interfaccia per time-lapse, senza dipendenze di terze parti
Applicazione desktop completa per creazione e ottimizzazione di time-lapse. Il vincolo che ne definisce l'architettura è l'assenza totale di componenti di terze parti: il progetto non ha alcun PackageReference e non invoca processi esterni. Oltre alla libreria standard di .NET si usano solo API native di Windows (WIC, Media Foundation, GDI+, DWM) richiamate via P/Invoke scritto a mano. Sono in-house tutte le parti che di norma si delegherebbero a una libreria: il parser binario EXIF/XMP, la misura di luminanza e la curva di deflicker, il calcolo del campo vettoriale di movimento con il motion blur sintetico, il multiplexer MP4 e ogni controllo dell'interfaccia. Scelte algoritmiche che meritano una nota: - il deflicker usa una regressione lineare locale pesata con seconda passata robusta, così le rampe reali di luce (alba, tramonto) sopravvivono mentre lo sfarfallio del diaframma viene rimosso; una media mobile semplice le appiattirebbe entrambe; - la sfocatura mancante si compone in quadratura con quella già incisa nello scatto, perché sommarla linearmente renderebbe l'immagine troppo morbida; - la luminanza si misura come media logaritmica troncata, invariante alla scala e insensibile a cieli bruciati e ombre chiuse. L'elaborazione non produce file temporanei e mantiene un'occupazione di memoria stazionaria: buffer poolati e canale a capacità limitata rendono i fotogrammi vivi indipendenti dalla lunghezza della sequenza. Verificato con "Titano.exe --selftest": 23 controlli su una sequenza sintetica dalle proprietà note, incluse la struttura del contenitore prodotto e la sua ri-decodifica con il lettore di sistema. Tutti superati. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
namespace Titano.Analysis;
|
||||
|
||||
/// <summary>Parametri del motore di deflicker, esposti nel pannello "Elaborazione immagini".</summary>
|
||||
public sealed class DeflickerSettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Ampiezza della finestra mobile in fotogrammi (viene resa dispari internamente).</summary>
|
||||
public int WindowFrames { get; set; } = 15;
|
||||
|
||||
/// <summary>Quota della correzione applicata: 1 = curva target piena, 0 = nessuna correzione.</summary>
|
||||
public double Strength { get; set; } = 1.0;
|
||||
|
||||
/// <summary>Limite di sicurezza della correzione, in stop.</summary>
|
||||
public double MaxCorrectionStops { get; set; } = 1.5;
|
||||
|
||||
/// <summary>Scarta i fotogrammi anomali (lampi, passaggi di persone) dal calcolo della curva.</summary>
|
||||
public bool RejectOutliers { get; set; } = true;
|
||||
|
||||
/// <summary>Applica lo stesso smoothing ai singoli canali per stabilizzare il bilanciamento colore.</summary>
|
||||
public bool StabilizeColor { get; set; }
|
||||
|
||||
/// <summary>Comprime dolcemente le alte luci quando il guadagno è maggiore di 1.</summary>
|
||||
public bool ProtectHighlights { get; set; } = true;
|
||||
|
||||
/// <summary>Punto d'innesco della compressione, in luce lineare.</summary>
|
||||
public double HighlightKnee { get; set; } = 0.75;
|
||||
|
||||
public DeflickerSettings Clone() => (DeflickerSettings)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>Risultato del calcolo della curva: valori per fotogramma, in log2.</summary>
|
||||
public sealed class DeflickerCurve
|
||||
{
|
||||
public required double[] Measured { get; init; }
|
||||
public required double[] Target { get; init; }
|
||||
public required double[] GainStops { get; init; }
|
||||
|
||||
/// <summary>Guadagni per canale (R,G,B); pari a quello di luminanza se la stabilizzazione colore è spenta.</summary>
|
||||
public required double[][] ChannelGain { get; init; }
|
||||
|
||||
public int Count => Measured.Length;
|
||||
|
||||
/// <summary>Deviazione standard delle differenze fra fotogrammi adiacenti, in stop: misura lo sfarfallio.</summary>
|
||||
public static double FlickerIndex(IReadOnlyList<double> log2Series)
|
||||
{
|
||||
if (log2Series.Count < 3) return 0;
|
||||
double mean = 0;
|
||||
int n = log2Series.Count - 1;
|
||||
var deltas = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
deltas[i] = log2Series[i + 1] - log2Series[i];
|
||||
mean += deltas[i];
|
||||
}
|
||||
mean /= n;
|
||||
|
||||
double variance = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double d = deltas[i] - mean;
|
||||
variance += d * d;
|
||||
}
|
||||
return Math.Sqrt(variance / n);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Algoritmo proprietario di smoothing temporale dell'esposizione.
|
||||
///
|
||||
/// Per ogni fotogramma si esegue una regressione lineare locale pesata sulla finestra mobile:
|
||||
/// i pesi combinano una gaussiana sulla distanza temporale e, in seconda passata, un peso di
|
||||
/// robustezza di Tukey che neutralizza i fotogrammi anomali. Rispetto a una media mobile
|
||||
/// semplice, la componente lineare segue senza ritardo le rampe reali di luce (alba, tramonto)
|
||||
/// e rimuove solo la componente ad alta frequenza generata dalle micro-variazioni di diaframma.
|
||||
/// </summary>
|
||||
public static class DeflickerEngine
|
||||
{
|
||||
private const double TukeyConstant = 4.685;
|
||||
|
||||
public static DeflickerCurve Compute(IReadOnlyList<LuminanceStats> stats, DeflickerSettings settings)
|
||||
{
|
||||
int n = stats.Count;
|
||||
var measured = new double[n];
|
||||
for (int i = 0; i < n; i++) measured[i] = stats[i].Log2Average;
|
||||
|
||||
var target = Smooth(measured, settings);
|
||||
var gainStops = new double[n];
|
||||
var channelGain = new double[n][];
|
||||
|
||||
double limit = Math.Max(0, settings.MaxCorrectionStops);
|
||||
double strength = Math.Clamp(settings.Strength, 0, 1);
|
||||
|
||||
double[]? targetR = null, targetG = null, targetB = null;
|
||||
if (settings.StabilizeColor && n > 0)
|
||||
{
|
||||
var r = new double[n];
|
||||
var g = new double[n];
|
||||
var b = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
r[i] = stats[i].Log2AverageR;
|
||||
g[i] = stats[i].Log2AverageG;
|
||||
b[i] = stats[i].Log2AverageB;
|
||||
}
|
||||
targetR = Smooth(r, settings);
|
||||
targetG = Smooth(g, settings);
|
||||
targetB = Smooth(b, settings);
|
||||
}
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double delta = settings.Enabled ? Math.Clamp((target[i] - measured[i]) * strength, -limit, limit) : 0;
|
||||
gainStops[i] = delta;
|
||||
|
||||
if (settings.Enabled && targetR is not null && targetG is not null && targetB is not null)
|
||||
{
|
||||
// Il canale verde definisce il livello, gli altri due lo inseguono: si corregge
|
||||
// la deriva cromatica senza spostare la luminanza complessiva.
|
||||
double dr = Math.Clamp((targetR[i] - stats[i].Log2AverageR) * strength, -limit, limit);
|
||||
double dg = Math.Clamp((targetG[i] - stats[i].Log2AverageG) * strength, -limit, limit);
|
||||
double db = Math.Clamp((targetB[i] - stats[i].Log2AverageB) * strength, -limit, limit);
|
||||
channelGain[i] =
|
||||
[
|
||||
Math.Pow(2, delta + (dr - dg)),
|
||||
Math.Pow(2, delta),
|
||||
Math.Pow(2, delta + (db - dg)),
|
||||
];
|
||||
}
|
||||
else
|
||||
{
|
||||
double gain = Math.Pow(2, delta);
|
||||
channelGain[i] = [gain, gain, gain];
|
||||
}
|
||||
}
|
||||
|
||||
return new DeflickerCurve
|
||||
{
|
||||
Measured = measured,
|
||||
Target = target,
|
||||
GainStops = gainStops,
|
||||
ChannelGain = channelGain,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Regressione lineare locale pesata, con seconda passata robusta agli outlier.</summary>
|
||||
public static double[] Smooth(double[] series, DeflickerSettings settings)
|
||||
{
|
||||
int n = series.Length;
|
||||
var result = new double[n];
|
||||
if (n == 0) return result;
|
||||
if (n <= 2)
|
||||
{
|
||||
Array.Copy(series, result, n);
|
||||
return result;
|
||||
}
|
||||
|
||||
int radius = Math.Clamp((Math.Max(3, settings.WindowFrames) - 1) / 2, 1, Math.Max(1, n - 1));
|
||||
double sigma = Math.Max(radius / 2.0, 0.5);
|
||||
|
||||
var robust = new double[n];
|
||||
Array.Fill(robust, 1.0);
|
||||
|
||||
FitAll(series, result, radius, sigma, robust);
|
||||
|
||||
if (settings.RejectOutliers)
|
||||
{
|
||||
UpdateRobustWeights(series, result, robust);
|
||||
FitAll(series, result, radius, sigma, robust);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void FitAll(double[] y, double[] output, int radius, double sigma, double[] robust)
|
||||
{
|
||||
int n = y.Length;
|
||||
double twoSigmaSq = 2.0 * sigma * sigma;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int from = Math.Max(0, i - radius);
|
||||
int to = Math.Min(n - 1, i + radius);
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
if (sw <= 1e-9)
|
||||
{
|
||||
output[i] = y[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
double det = sw * swt2 - swt * swt;
|
||||
if (Math.Abs(det) < 1e-12)
|
||||
{
|
||||
output[i] = swy / sw; // finestra degenere: media pesata
|
||||
continue;
|
||||
}
|
||||
|
||||
double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0
|
||||
output[i] = a;
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust)
|
||||
{
|
||||
int n = y.Length;
|
||||
var residuals = new double[n];
|
||||
for (int i = 0; i < n; i++) residuals[i] = Math.Abs(y[i] - fit[i]);
|
||||
|
||||
var sorted = (double[])residuals.Clone();
|
||||
Array.Sort(sorted);
|
||||
double mad = sorted[n / 2];
|
||||
double scale = 1.4826 * mad;
|
||||
|
||||
// Se la sequenza è già pulita non si scarta nulla: evita di amplificare il rumore numerico.
|
||||
if (scale < 1e-4)
|
||||
{
|
||||
Array.Fill(robust, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double u = residuals[i] / (TukeyConstant * scale);
|
||||
robust[i] = u >= 1.0 ? 0.0 : Math.Pow(1.0 - u * u, 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Numerics;
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// Applicazione dei guadagni di esposizione sui canali colore.
|
||||
///
|
||||
/// L'operazione avviene in luce lineare (moltiplicazione pura, fisicamente corretta) e,
|
||||
/// se richiesto, con una compressione dolce delle alte luci: la parte di segnale che
|
||||
/// supererebbe il bianco viene ripiegata con una tangente iperbolica invece di essere
|
||||
/// troncata, evitando le classiche macchie piatte e le derive di tinta sui bordi bruciati.
|
||||
/// </summary>
|
||||
public static class ExposureProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Moltiplica il buffer per i tre guadagni indicati, in-place.
|
||||
/// Restituisce la frazione stimata di campioni saturati dopo la correzione.
|
||||
/// </summary>
|
||||
public static double Apply(ImageBuffer frame, ReadOnlySpan<double> channelGain,
|
||||
bool protectHighlights, double knee)
|
||||
{
|
||||
float gr = (float)channelGain[0];
|
||||
float gg = (float)channelGain[1];
|
||||
float gb = (float)channelGain[2];
|
||||
|
||||
bool identity = Math.Abs(gr - 1f) < 1e-5f && Math.Abs(gg - 1f) < 1e-5f && Math.Abs(gb - 1f) < 1e-5f;
|
||||
bool rolloff = protectHighlights && Math.Max(gr, Math.Max(gg, gb)) > 1.0001f;
|
||||
|
||||
var data = frame.Data;
|
||||
int count = frame.SampleCount;
|
||||
|
||||
if (!identity)
|
||||
{
|
||||
if (rolloff) ApplyWithRolloff(data, count, gr, gg, gb, (float)Math.Clamp(knee, 0.05, 0.98));
|
||||
else ApplyLinear(data, count, gr, gg, gb);
|
||||
}
|
||||
|
||||
return MeasureClipping(data, count);
|
||||
}
|
||||
|
||||
private static void ApplyLinear(float[] data, int count, float gr, float gg, float gb)
|
||||
{
|
||||
int width = Vector<float>.Count;
|
||||
|
||||
if (width >= 4 && count >= width * 3)
|
||||
{
|
||||
// I guadagni si ripetono ogni 3 campioni: con vettori di larghezza non multipla di 3
|
||||
// il pattern si richiude su 3 vettori consecutivi (lcm(3, width) / width == 3).
|
||||
var phases = BuildGainPhases(gr, gg, gb, width, out int phaseCount);
|
||||
int blocks = count / width;
|
||||
int i = 0;
|
||||
|
||||
for (int block = 0; block < blocks; block++, i += width)
|
||||
{
|
||||
var v = new Vector<float>(data, i);
|
||||
(v * phases[block % phaseCount]).CopyTo(data, i);
|
||||
}
|
||||
|
||||
for (; i < count; i++) data[i] *= GainFor(i, gr, gg, gb);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; i++) data[i] *= GainFor(i, gr, gg, gb);
|
||||
}
|
||||
|
||||
private static void ApplyWithRolloff(float[] data, int count, float gr, float gg, float gb, float knee)
|
||||
{
|
||||
float span = 1f - knee;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
float v = data[i] * GainFor(i, gr, gg, gb);
|
||||
data[i] = v <= knee ? v : knee + span * MathF.Tanh((v - knee) / span);
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector<float>[] BuildGainPhases(float gr, float gg, float gb, int width, out int phaseCount)
|
||||
{
|
||||
phaseCount = width % 3 == 0 ? 1 : 3;
|
||||
var phases = new Vector<float>[phaseCount];
|
||||
var scratch = new float[width];
|
||||
|
||||
for (int phase = 0; phase < phaseCount; phase++)
|
||||
{
|
||||
for (int k = 0; k < width; k++)
|
||||
{
|
||||
scratch[k] = GainFor(phase * width + k, gr, gg, gb);
|
||||
}
|
||||
phases[phase] = new Vector<float>(scratch);
|
||||
}
|
||||
return phases;
|
||||
}
|
||||
|
||||
private static float GainFor(int sampleIndex, float gr, float gg, float gb)
|
||||
=> (sampleIndex % 3) switch { 0 => gr, 1 => gg, _ => gb };
|
||||
|
||||
/// <summary>Stima del clipping su campionamento regolare: costo indipendente dalla risoluzione.</summary>
|
||||
private static double MeasureClipping(float[] data, int count)
|
||||
{
|
||||
if (count == 0) return 0;
|
||||
int step = Math.Max(3, (count / 3 / 200_000) * 3);
|
||||
int clipped = 0, samples = 0;
|
||||
|
||||
for (int i = 0; i + 2 < count; i += step)
|
||||
{
|
||||
float luma = ColorSpace.Luminance(data[i], data[i + 1], data[i + 2]);
|
||||
if (luma >= 0.995f) clipped++;
|
||||
samples++;
|
||||
}
|
||||
return samples == 0 ? 0 : (double)clipped / samples;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Analysis;
|
||||
|
||||
/// <summary>Statistiche fotometriche di un fotogramma, calcolate in luce lineare.</summary>
|
||||
public readonly struct LuminanceStats
|
||||
{
|
||||
/// <summary>Media logaritmica (base 2) della luminanza sui pixel non estremi.</summary>
|
||||
public double Log2Average { get; init; }
|
||||
|
||||
/// <summary>Media geometrica in scala lineare: 2^<see cref="Log2Average"/>.</summary>
|
||||
public double Linear => Math.Pow(2.0, Log2Average);
|
||||
|
||||
public double Log2AverageR { get; init; }
|
||||
public double Log2AverageG { get; init; }
|
||||
public double Log2AverageB { get; init; }
|
||||
|
||||
/// <summary>Percentili della distribuzione di luminanza, in scala lineare.</summary>
|
||||
public double Percentile01 { get; init; }
|
||||
public double Percentile50 { get; init; }
|
||||
public double Percentile99 { get; init; }
|
||||
|
||||
/// <summary>Frazione di pixel già saturati nel fotogramma sorgente.</summary>
|
||||
public double ClippedFraction { get; init; }
|
||||
|
||||
public double BlackFraction { get; init; }
|
||||
|
||||
public static LuminanceStats Empty => new() { Log2Average = -8 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Misura la luminanza dei buffer in-house, senza dipendenze esterne.
|
||||
///
|
||||
/// La stima usa la media logaritmica (media geometrica) su un campionamento a griglia fissa,
|
||||
/// con esclusione delle code della distribuzione: è la metrica che segue la variazione di
|
||||
/// esposizione reale senza farsi trascinare da cieli bruciati o ombre chiuse.
|
||||
/// </summary>
|
||||
public static class LuminanceAnalyzer
|
||||
{
|
||||
private const int Bins = 1024;
|
||||
private const double LogMin = -16.0; // 2^-16 ≈ nero assoluto
|
||||
private const double LogMax = 2.0; // headroom sopra il bianco
|
||||
private const double Epsilon = 1.0 / 65536.0;
|
||||
|
||||
/// <summary>Numero massimo di pixel campionati per fotogramma: fissa il costo dell'analisi.</summary>
|
||||
private const int TargetSamples = 262_144;
|
||||
|
||||
public static LuminanceStats Analyze(ImageBuffer frame, double trimLow = 0.02, double trimHigh = 0.02)
|
||||
{
|
||||
int step = ComputeStep(frame.PixelCount);
|
||||
Span<int> histogram = stackalloc int[Bins];
|
||||
histogram.Clear();
|
||||
|
||||
double sumR = 0, sumG = 0, sumB = 0;
|
||||
int samples = 0, clipped = 0, black = 0;
|
||||
var data = frame.Data;
|
||||
int totalPixels = frame.PixelCount;
|
||||
|
||||
for (int p = 0; p < totalPixels; p += step)
|
||||
{
|
||||
int i = p * ImageBuffer.Channels;
|
||||
float r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
|
||||
double luma = ColorSpace.Luminance(r, g, b);
|
||||
if (double.IsNaN(luma)) continue;
|
||||
|
||||
if (luma >= 0.995) clipped++;
|
||||
if (luma <= 0.0008) black++;
|
||||
|
||||
sumR += Log2Safe(r + Epsilon);
|
||||
sumG += Log2Safe(g + Epsilon);
|
||||
sumB += Log2Safe(b + Epsilon);
|
||||
|
||||
double l = Log2Safe(luma + Epsilon);
|
||||
int bin = (int)((l - LogMin) / (LogMax - LogMin) * (Bins - 1) + 0.5);
|
||||
histogram[Math.Clamp(bin, 0, Bins - 1)]++;
|
||||
samples++;
|
||||
}
|
||||
|
||||
if (samples == 0) return LuminanceStats.Empty;
|
||||
|
||||
// Media troncata calcolata direttamente sull'istogramma: le code (cielo bruciato,
|
||||
// ombre chiuse) non influenzano la stima dell'esposizione media.
|
||||
int lowCut = (int)(samples * trimLow);
|
||||
int highCut = (int)(samples * (1.0 - trimHigh));
|
||||
|
||||
double weighted = 0;
|
||||
long counted = 0;
|
||||
int running = 0;
|
||||
double p01 = LogMin, p50 = LogMin, p99 = LogMin;
|
||||
int q01 = (int)(samples * 0.01), q50 = samples / 2, q99 = (int)(samples * 0.99);
|
||||
bool got01 = false, got50 = false, got99 = false;
|
||||
|
||||
for (int bin = 0; bin < Bins; bin++)
|
||||
{
|
||||
int count = histogram[bin];
|
||||
if (count == 0) continue;
|
||||
double value = LogMin + bin * (LogMax - LogMin) / (Bins - 1);
|
||||
|
||||
int before = running;
|
||||
running += count;
|
||||
|
||||
if (!got01 && running >= q01) { p01 = value; got01 = true; }
|
||||
if (!got50 && running >= q50) { p50 = value; got50 = true; }
|
||||
if (!got99 && running >= q99) { p99 = value; got99 = true; }
|
||||
|
||||
int from = Math.Max(before, lowCut);
|
||||
int to = Math.Min(running, highCut);
|
||||
if (to > from)
|
||||
{
|
||||
weighted += value * (to - from);
|
||||
counted += to - from;
|
||||
}
|
||||
}
|
||||
|
||||
if (counted == 0)
|
||||
{
|
||||
// Distribuzione degenere (immagine uniforme): ricadiamo sulla mediana.
|
||||
weighted = p50;
|
||||
counted = 1;
|
||||
}
|
||||
|
||||
return new LuminanceStats
|
||||
{
|
||||
Log2Average = weighted / counted,
|
||||
Log2AverageR = sumR / samples,
|
||||
Log2AverageG = sumG / samples,
|
||||
Log2AverageB = sumB / samples,
|
||||
Percentile01 = Math.Pow(2.0, p01),
|
||||
Percentile50 = Math.Pow(2.0, p50),
|
||||
Percentile99 = Math.Pow(2.0, p99),
|
||||
ClippedFraction = (double)clipped / samples,
|
||||
BlackFraction = (double)black / samples,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Passo di campionamento a griglia: deterministico, quindi identico per ogni fotogramma.</summary>
|
||||
private static int ComputeStep(int pixelCount)
|
||||
=> Math.Max(1, pixelCount / TargetSamples);
|
||||
|
||||
/// <summary>Logaritmo base 2 con saturazione al nero: evita -infinito sui pixel spenti.</summary>
|
||||
private static double Log2Safe(double value)
|
||||
=> value <= 0 ? LogMin : Math.Max(LogMin, Math.Log2(value));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Titano.Metadata;
|
||||
|
||||
namespace Titano.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Stato mutabile di un fotogramma lungo tutta la pipeline. Contiene solo scalari:
|
||||
/// i pixel non vivono mai qui, restano nei buffer poolati (impronta di memoria costante).
|
||||
/// </summary>
|
||||
public sealed class FrameRecord(int index, FrameMetadata metadata)
|
||||
{
|
||||
public int Index { get; } = index;
|
||||
public FrameMetadata Metadata { get; } = metadata;
|
||||
|
||||
public string FileName => Metadata.FileName;
|
||||
public string FilePath => Metadata.FilePath;
|
||||
|
||||
/// <summary>Secondi trascorsi dal primo scatto della sequenza.</summary>
|
||||
public double ElapsedSeconds { get; internal set; }
|
||||
|
||||
/// <summary>Intervallo verso lo scatto successivo, in secondi (l'ultimo eredita il precedente).</summary>
|
||||
public double IntervalSeconds { get; internal set; }
|
||||
|
||||
/// <summary>True se l'intervallo devia in modo significativo dalla cadenza nominale.</summary>
|
||||
public bool IsCadenceAnomaly { get; internal set; }
|
||||
|
||||
/// <summary>Shutter angle reale: 360 * tempo di posa / intervallo.</summary>
|
||||
public double ShutterAngle { get; internal set; }
|
||||
|
||||
// ---- analisi luminanza -------------------------------------------------
|
||||
/// <summary>Luminanza media geometrica misurata (log-average), in scala lineare 0..1.</summary>
|
||||
public double MeasuredLuminance { get; internal set; }
|
||||
|
||||
/// <summary>Luminanza obiettivo dopo lo smoothing temporale.</summary>
|
||||
public double TargetLuminance { get; internal set; }
|
||||
|
||||
/// <summary>Guadagno lineare applicato ai canali colore.</summary>
|
||||
public double Gain { get; internal set; } = 1.0;
|
||||
|
||||
/// <summary>Correzione espressa in stop (log2 del guadagno), per la lettura in UI.</summary>
|
||||
public double GainStops => Gain > 0 ? Math.Log2(Gain) : 0;
|
||||
|
||||
/// <summary>Percentuale di pixel oltre la soglia di clipping dopo la correzione.</summary>
|
||||
public double ClippedFraction { get; internal set; }
|
||||
|
||||
public bool LuminanceAnalyzed { get; internal set; }
|
||||
|
||||
// ---- motion ------------------------------------------------------------
|
||||
/// <summary>Modulo mediano del vettore di movimento verso il fotogramma successivo, in pixel.</summary>
|
||||
public double MotionMagnitude { get; internal set; }
|
||||
|
||||
/// <summary>Direzione dominante del movimento, in gradi.</summary>
|
||||
public double MotionDirection { get; internal set; }
|
||||
|
||||
/// <summary>Lunghezza della scia di motion blur sintetico effettivamente applicata, in pixel.</summary>
|
||||
public double BlurLength { get; internal set; }
|
||||
|
||||
/// <summary>Durata del fotogramma nel video finale, in unità di timescale (playback adattivo).</summary>
|
||||
public int OutputDurationUnits { get; internal set; }
|
||||
|
||||
public string CadenceText => IntervalSeconds <= 0
|
||||
? "—"
|
||||
: IntervalSeconds < 1
|
||||
? $"{IntervalSeconds * 1000:0} ms"
|
||||
: $"{IntervalSeconds:0.###} s";
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Titano.Metadata;
|
||||
|
||||
namespace Titano.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Sequenza ordinata di fotogrammi con la relativa analisi temporale: intervalli reali,
|
||||
/// cadenza nominale dell'intervallometro, pause e shutter angle per singolo scatto.
|
||||
/// </summary>
|
||||
public sealed class TimelapseSequence
|
||||
{
|
||||
private readonly List<FrameRecord> _frames = [];
|
||||
|
||||
public IReadOnlyList<FrameRecord> Frames => _frames;
|
||||
public int Count => _frames.Count;
|
||||
|
||||
/// <summary>Cadenza nominale (mediana degli intervalli), in secondi.</summary>
|
||||
public double NominalInterval { get; private set; }
|
||||
|
||||
/// <summary>Numero di intervalli che deviano oltre la tolleranza dalla cadenza nominale.</summary>
|
||||
public int CadenceAnomalies { get; private set; }
|
||||
|
||||
public TimeSpan TotalDuration { get; private set; }
|
||||
|
||||
/// <summary>True se almeno un fotogramma ha un timestamp con precisione al sotto-secondo.</summary>
|
||||
public bool HasSubSecondPrecision { get; private set; }
|
||||
|
||||
/// <summary>Costruisce la sequenza ordinando per timestamp e, a parità, per nome file naturale.</summary>
|
||||
public static TimelapseSequence Build(IEnumerable<FrameMetadata> metadata)
|
||||
{
|
||||
var ordered = metadata
|
||||
.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
|
||||
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
|
||||
.ToList();
|
||||
|
||||
var sequence = new TimelapseSequence();
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
sequence._frames.Add(new FrameRecord(i, ordered[i]));
|
||||
}
|
||||
sequence.RecomputeTiming();
|
||||
return sequence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ricalcola intervalli reali, cadenza nominale e shutter angle.
|
||||
/// Gli intervalli assenti (timestamp mancanti) ereditano la cadenza nominale.
|
||||
/// </summary>
|
||||
public void RecomputeTiming(double cadenceTolerance = 0.35)
|
||||
{
|
||||
if (_frames.Count == 0)
|
||||
{
|
||||
NominalInterval = 0;
|
||||
TotalDuration = TimeSpan.Zero;
|
||||
CadenceAnomalies = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
HasSubSecondPrecision = _frames.Any(f => f.Metadata.CaptureSource == TimestampSource.ExifSubSecond);
|
||||
|
||||
DateTime? origin = _frames[0].Metadata.CaptureTime;
|
||||
var raw = new double[_frames.Count];
|
||||
|
||||
for (int i = 0; i < _frames.Count; i++)
|
||||
{
|
||||
var current = _frames[i].Metadata.CaptureTime;
|
||||
var next = i + 1 < _frames.Count ? _frames[i + 1].Metadata.CaptureTime : null;
|
||||
|
||||
_frames[i].ElapsedSeconds = origin is { } o && current is { } c ? (c - o).TotalSeconds : i;
|
||||
raw[i] = current is { } a && next is { } b ? (b - a).TotalSeconds : double.NaN;
|
||||
}
|
||||
|
||||
// Cadenza nominale = mediana degli intervalli validi: robusta a pause e scatti doppi.
|
||||
var valid = raw.Where(v => !double.IsNaN(v) && v > 0).ToArray();
|
||||
if (valid.Length > 0)
|
||||
{
|
||||
Array.Sort(valid);
|
||||
NominalInterval = valid[valid.Length / 2];
|
||||
}
|
||||
else
|
||||
{
|
||||
NominalInterval = 1.0;
|
||||
}
|
||||
if (NominalInterval <= 0 || double.IsNaN(NominalInterval)) NominalInterval = 1.0;
|
||||
|
||||
CadenceAnomalies = 0;
|
||||
for (int i = 0; i < _frames.Count; i++)
|
||||
{
|
||||
double interval = raw[i];
|
||||
if (double.IsNaN(interval) || interval <= 0)
|
||||
{
|
||||
interval = i > 0 ? _frames[i - 1].IntervalSeconds : NominalInterval;
|
||||
}
|
||||
_frames[i].IntervalSeconds = interval;
|
||||
|
||||
bool anomaly = Math.Abs(interval - NominalInterval) > NominalInterval * cadenceTolerance;
|
||||
_frames[i].IsCadenceAnomaly = anomaly && i < _frames.Count - 1;
|
||||
if (_frames[i].IsCadenceAnomaly) CadenceAnomalies++;
|
||||
|
||||
_frames[i].ShutterAngle = ComputeShutterAngle(_frames[i].Metadata.ExposureSeconds, interval);
|
||||
}
|
||||
|
||||
TotalDuration = _frames.Count > 1
|
||||
? TimeSpan.FromSeconds(Math.Max(0, _frames[^1].ElapsedSeconds))
|
||||
: TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shutter angle cinematografico: la frazione dell'intervallo effettivamente esposta,
|
||||
/// espressa in gradi su un giro completo dell'otturatore rotante.
|
||||
/// </summary>
|
||||
public static double ComputeShutterAngle(double? exposureSeconds, double intervalSeconds)
|
||||
{
|
||||
if (exposureSeconds is not { } e || e <= 0 || intervalSeconds <= 0) return 0;
|
||||
return Math.Min(360.0, 360.0 * e / intervalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Sotto-insieme contiguo di indici, usato dalle anteprime e dai render parziali.</summary>
|
||||
public IEnumerable<FrameRecord> Range(int start, int count)
|
||||
{
|
||||
int from = Math.Clamp(start, 0, Math.Max(0, _frames.Count - 1));
|
||||
int to = Math.Clamp(from + count, from, _frames.Count);
|
||||
for (int i = from; i < to; i++) yield return _frames[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordinamento "naturale" dei nomi file: IMG_2.jpg precede IMG_10.jpg.
|
||||
/// Necessario perché molte sequenze condividono lo stesso timestamp al secondo.
|
||||
/// </summary>
|
||||
public sealed class NaturalFileNameComparer : IComparer<string>
|
||||
{
|
||||
public static readonly NaturalFileNameComparer Instance = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
|
||||
int i = 0, j = 0;
|
||||
while (i < x.Length && j < y.Length)
|
||||
{
|
||||
char cx = x[i], cy = y[j];
|
||||
bool dx = cx is >= '0' and <= '9';
|
||||
bool dy = cy is >= '0' and <= '9';
|
||||
|
||||
if (dx && dy)
|
||||
{
|
||||
int si = i, sj = j;
|
||||
while (i < x.Length && x[i] is >= '0' and <= '9') i++;
|
||||
while (j < y.Length && y[j] is >= '0' and <= '9') j++;
|
||||
|
||||
var nx = x.AsSpan(si, i - si).TrimStart('0');
|
||||
var ny = y.AsSpan(sj, j - sj).TrimStart('0');
|
||||
if (nx.Length != ny.Length) return nx.Length - ny.Length;
|
||||
int cmp = nx.SequenceCompareTo(ny);
|
||||
if (cmp != 0) return cmp;
|
||||
}
|
||||
else
|
||||
{
|
||||
int cmp = char.ToUpperInvariant(cx).CompareTo(char.ToUpperInvariant(cy));
|
||||
if (cmp != 0) return cmp;
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
return (x.Length - i) - (y.Length - j);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Titano.Metadata;
|
||||
|
||||
namespace Titano.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Generatore di blocchi Exif usato dalla diagnostica interna: costruisce un APP1 completo
|
||||
/// (header TIFF, IFD0, Exif IFD, area dati) e lo inserisce in un JPEG esistente.
|
||||
/// Serve a verificare il parser binario su dati di forma nota.
|
||||
/// </summary>
|
||||
internal static class ExifWriter
|
||||
{
|
||||
private sealed record Entry(ushort Tag, TiffType Type, uint Count, byte[] Data);
|
||||
|
||||
public static byte[] BuildExifBlock(DateTime capture, int subSecond, double exposureSeconds,
|
||||
double fNumber, int iso, int width, int height,
|
||||
string make, string model)
|
||||
{
|
||||
var ifd0 = new List<Entry>
|
||||
{
|
||||
Ascii(TiffTags.Make, make),
|
||||
Ascii(TiffTags.Model, model),
|
||||
Short(TiffTags.Orientation, 1),
|
||||
};
|
||||
|
||||
var exif = new List<Entry>
|
||||
{
|
||||
Rational(TiffTags.ExposureTime, exposureSeconds),
|
||||
Rational(TiffTags.FNumber, fNumber),
|
||||
Short(TiffTags.IsoSpeedRatings, (ushort)Math.Clamp(iso, 0, ushort.MaxValue)),
|
||||
Ascii(TiffTags.DateTimeOriginal, capture.ToString("yyyy:MM:dd HH:mm:ss")),
|
||||
Ascii(TiffTags.SubSecTimeOriginal, subSecond.ToString("D2")),
|
||||
Ascii(TiffTags.OffsetTimeOriginal, "+02:00"),
|
||||
Long(TiffTags.PixelXDimension, (uint)width),
|
||||
Long(TiffTags.PixelYDimension, (uint)height),
|
||||
};
|
||||
|
||||
// Le voci di ogni IFD devono essere ordinate per tag crescente.
|
||||
ifd0.Sort((a, b) => a.Tag.CompareTo(b.Tag));
|
||||
exif.Sort((a, b) => a.Tag.CompareTo(b.Tag));
|
||||
|
||||
const int headerSize = 8;
|
||||
int ifd0Size = 2 + 12 * (ifd0.Count + 1) + 4; // + puntatore all'Exif IFD
|
||||
int exifSize = 2 + 12 * exif.Count + 4;
|
||||
int ifd0Offset = headerSize;
|
||||
int exifOffset = ifd0Offset + ifd0Size;
|
||||
int dataOffset = exifOffset + exifSize;
|
||||
|
||||
var data = new MemoryStream();
|
||||
var body = new MemoryStream();
|
||||
|
||||
// Header TIFF little-endian.
|
||||
WriteUInt16(body, 0x4949);
|
||||
WriteUInt16(body, 42);
|
||||
WriteUInt32(body, (uint)ifd0Offset);
|
||||
|
||||
var pointerEntry = new Entry(TiffTags.ExifIfdPointer, TiffType.Long, 1, BitConverter.GetBytes((uint)exifOffset));
|
||||
var ifd0WithPointer = new List<Entry>(ifd0) { pointerEntry };
|
||||
ifd0WithPointer.Sort((a, b) => a.Tag.CompareTo(b.Tag));
|
||||
|
||||
WriteDirectory(body, ifd0WithPointer, data, dataOffset);
|
||||
WriteUInt32(body, 0); // nessuna IFD successiva
|
||||
WriteDirectory(body, exif, data, dataOffset);
|
||||
WriteUInt32(body, 0);
|
||||
|
||||
body.Write(data.GetBuffer(), 0, (int)data.Length);
|
||||
return body.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Inserisce il segmento APP1 in un JPEG, subito dopo l'eventuale APP0/JFIF.</summary>
|
||||
public static byte[] InsertIntoJpeg(byte[] jpeg, byte[] exifBlock)
|
||||
{
|
||||
int insertAt = 2;
|
||||
if (jpeg.Length > 4 && jpeg[2] == 0xFF && jpeg[3] == 0xE0)
|
||||
{
|
||||
int app0Length = (jpeg[4] << 8) | jpeg[5];
|
||||
insertAt = 4 + app0Length;
|
||||
}
|
||||
|
||||
byte[] signature = "Exif\0\0"u8.ToArray();
|
||||
int payload = signature.Length + exifBlock.Length;
|
||||
int segmentLength = payload + 2;
|
||||
|
||||
var result = new byte[jpeg.Length + 4 + payload];
|
||||
int position = 0;
|
||||
|
||||
Array.Copy(jpeg, 0, result, position, insertAt);
|
||||
position += insertAt;
|
||||
|
||||
result[position++] = 0xFF;
|
||||
result[position++] = 0xE1;
|
||||
result[position++] = (byte)(segmentLength >> 8);
|
||||
result[position++] = (byte)(segmentLength & 0xFF);
|
||||
Array.Copy(signature, 0, result, position, signature.Length);
|
||||
position += signature.Length;
|
||||
Array.Copy(exifBlock, 0, result, position, exifBlock.Length);
|
||||
position += exifBlock.Length;
|
||||
|
||||
Array.Copy(jpeg, insertAt, result, position, jpeg.Length - insertAt);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ scrittura IFD
|
||||
|
||||
private static void WriteDirectory(Stream body, List<Entry> entries, MemoryStream data, int dataOffset)
|
||||
{
|
||||
WriteUInt16(body, (ushort)entries.Count);
|
||||
Span<byte> inline = stackalloc byte[4];
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
WriteUInt16(body, entry.Tag);
|
||||
WriteUInt16(body, (ushort)entry.Type);
|
||||
WriteUInt32(body, entry.Count);
|
||||
|
||||
if (entry.Data.Length <= 4)
|
||||
{
|
||||
inline.Clear();
|
||||
entry.Data.CopyTo(inline);
|
||||
body.Write(inline);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteUInt32(body, (uint)(dataOffset + data.Length));
|
||||
data.Write(entry.Data);
|
||||
if ((data.Length & 1) != 0) data.WriteByte(0); // le aree dati restano allineate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Entry Ascii(ushort tag, string value)
|
||||
{
|
||||
byte[] bytes = Encoding.ASCII.GetBytes(value + "\0");
|
||||
return new Entry(tag, TiffType.Ascii, (uint)bytes.Length, bytes);
|
||||
}
|
||||
|
||||
private static Entry Short(ushort tag, ushort value)
|
||||
=> new(tag, TiffType.Short, 1, BitConverter.GetBytes(value));
|
||||
|
||||
private static Entry Long(ushort tag, uint value)
|
||||
=> new(tag, TiffType.Long, 1, BitConverter.GetBytes(value));
|
||||
|
||||
private static Entry Rational(ushort tag, double value)
|
||||
{
|
||||
// Approssimazione con denominatore fisso: sufficiente e priva di ambiguità.
|
||||
uint denominator = 1_000_000;
|
||||
uint numerator = (uint)Math.Round(value * denominator);
|
||||
var bytes = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, numerator);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), denominator);
|
||||
return new Entry(tag, TiffType.Rational, 1, bytes);
|
||||
}
|
||||
|
||||
private static void WriteUInt16(Stream stream, ushort value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[2];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
|
||||
stream.Write(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(Stream stream, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
stream.Write(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Titano.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Verificatore della struttura ISO-BMFF prodotta dal multiplexer: percorre l'albero dei box
|
||||
/// e ne estrae i valori che devono risultare coerenti (numero di campioni, dimensioni,
|
||||
/// presenza della configurazione del codec).
|
||||
/// </summary>
|
||||
internal static class Mp4Inspector
|
||||
{
|
||||
public sealed record Report(
|
||||
bool Valid,
|
||||
string Summary,
|
||||
int SampleCount,
|
||||
int Width,
|
||||
int Height,
|
||||
uint Timescale,
|
||||
long MediaDuration,
|
||||
int CodecConfigBytes,
|
||||
List<string> TopLevelBoxes);
|
||||
|
||||
private static readonly string[] Containers =
|
||||
["moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "avc1", "hvc1"];
|
||||
|
||||
public static Report Inspect(string path)
|
||||
{
|
||||
var boxes = new List<string>();
|
||||
int sampleCount = 0, width = 0, height = 0, configBytes = 0;
|
||||
uint timescale = 0;
|
||||
long mediaDuration = 0;
|
||||
var problems = new List<string>();
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||
long length = stream.Length;
|
||||
|
||||
Walk(stream, 0, length, 0);
|
||||
|
||||
bool hasFtyp = boxes.Contains("ftyp");
|
||||
bool hasMdat = boxes.Contains("mdat");
|
||||
bool hasMoov = boxes.Contains("moov");
|
||||
|
||||
if (!hasFtyp) problems.Add("box ftyp assente");
|
||||
if (!hasMdat) problems.Add("box mdat assente");
|
||||
if (!hasMoov) problems.Add("box moov assente");
|
||||
if (sampleCount == 0) problems.Add("tabella stsz vuota");
|
||||
if (configBytes == 0) problems.Add("configurazione del codec assente");
|
||||
|
||||
string summary = problems.Count == 0
|
||||
? "struttura conforme"
|
||||
: string.Join(", ", problems);
|
||||
|
||||
return new Report(problems.Count == 0, summary, sampleCount, width, height,
|
||||
timescale, mediaDuration, configBytes, boxes);
|
||||
|
||||
void Walk(FileStream file, long start, long end, int depth)
|
||||
{
|
||||
Span<byte> header = stackalloc byte[8];
|
||||
long position = start;
|
||||
|
||||
while (position + 8 <= end && depth < 8)
|
||||
{
|
||||
file.Position = position;
|
||||
if (file.Read(header) != 8) return;
|
||||
|
||||
long size = BinaryPrimitives.ReadUInt32BigEndian(header);
|
||||
string type = Encoding.ASCII.GetString(header[4..]);
|
||||
long payload = position + 8;
|
||||
|
||||
if (size == 1)
|
||||
{
|
||||
if (file.Read(header) != 8) return;
|
||||
size = BinaryPrimitives.ReadInt64BigEndian(header);
|
||||
payload += 8;
|
||||
}
|
||||
else if (size == 0)
|
||||
{
|
||||
size = end - position;
|
||||
}
|
||||
|
||||
if (size < 8 || position + size > end) return;
|
||||
if (depth == 0) boxes.Add(type);
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "stsz":
|
||||
file.Position = payload + 8; // versione/flag + sample_size
|
||||
sampleCount = (int)ReadUInt32(file);
|
||||
break;
|
||||
case "mdhd":
|
||||
file.Position = payload + 12; // versione/flag + due timestamp
|
||||
timescale = ReadUInt32(file);
|
||||
mediaDuration = ReadUInt32(file);
|
||||
break;
|
||||
case "avcC" or "hvcC":
|
||||
configBytes = (int)(size - (payload - position));
|
||||
break;
|
||||
case "avc1" or "hvc1":
|
||||
file.Position = payload + 24;
|
||||
width = ReadUInt16(file);
|
||||
height = ReadUInt16(file);
|
||||
Walk(file, payload + 78, position + size, depth + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (Containers.Contains(type) && type is not ("avc1" or "hvc1"))
|
||||
{
|
||||
long childStart = type == "stsd" ? payload + 8 : payload;
|
||||
Walk(file, childStart, position + size, depth + 1);
|
||||
}
|
||||
else if (type == "stsd")
|
||||
{
|
||||
Walk(file, payload + 8, position + size, depth + 1);
|
||||
}
|
||||
|
||||
position += size;
|
||||
}
|
||||
}
|
||||
|
||||
static uint ReadUInt32(FileStream file)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
return file.Read(buffer) == 4 ? BinaryPrimitives.ReadUInt32BigEndian(buffer) : 0;
|
||||
}
|
||||
|
||||
static ushort ReadUInt16(FileStream file)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[2];
|
||||
return file.Read(buffer) == 2 ? BinaryPrimitives.ReadUInt16BigEndian(buffer) : (ushort)0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Titano.Video;
|
||||
|
||||
namespace Titano.Diagnostics;
|
||||
|
||||
[ComImport, Guid("70ae66f2-c809-4e4f-8915-bdcb406b7993"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFSourceReader
|
||||
{
|
||||
[PreserveSig] int GetStreamSelection(uint streamIndex, [MarshalAs(UnmanagedType.Bool)] out bool selected);
|
||||
[PreserveSig] int SetStreamSelection(uint streamIndex, [MarshalAs(UnmanagedType.Bool)] bool selected);
|
||||
[PreserveSig] int GetNativeMediaType(uint streamIndex, uint typeIndex, out IMFMediaType type);
|
||||
[PreserveSig] int GetCurrentMediaType(uint streamIndex, out IMFMediaType type);
|
||||
[PreserveSig] int SetCurrentMediaType(uint streamIndex, IntPtr reserved, IMFMediaType type);
|
||||
[PreserveSig] int SetCurrentPosition(ref Guid timeFormat, ref PropVariant position);
|
||||
[PreserveSig] int ReadSample(uint streamIndex, uint controlFlags, out uint actualStreamIndex,
|
||||
out uint streamFlags, out long timestamp, out IMFSample? sample);
|
||||
[PreserveSig] int Flush(uint streamIndex);
|
||||
[PreserveSig] int GetServiceForStream(uint streamIndex, ref Guid service, ref Guid riid, out IntPtr instance);
|
||||
[PreserveSig] int GetPresentationAttribute(uint streamIndex, ref Guid attribute, IntPtr value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rilegge un MP4 prodotto dal multiplexer usando il lettore di sistema: è la prova che il
|
||||
/// contenitore non è soltanto ben formato sulla carta, ma effettivamente riproducibile.
|
||||
/// </summary>
|
||||
internal static class Mp4Playback
|
||||
{
|
||||
private const uint FirstVideoStream = 0xFFFFFFFC;
|
||||
private const uint AllStreams = 0xFFFFFFFE;
|
||||
private const uint EndOfStream = 0x00000002;
|
||||
|
||||
private static Guid VideoFormatRgb32 = new("00000016-0000-0010-8000-00aa00389b71");
|
||||
private static Guid EnableVideoProcessing = new("fb394f3d-ccf1-42ee-bbb3-f9b845d5681d");
|
||||
|
||||
[DllImport("mfreadwrite.dll", ExactSpelling = true)]
|
||||
private static extern int MFCreateSourceReaderFromURL([MarshalAs(UnmanagedType.LPWStr)] string url,
|
||||
IMFAttributes? attributes, out IMFSourceReader reader);
|
||||
|
||||
[DllImport("mfplat.dll", ExactSpelling = true)]
|
||||
private static extern int MFCreateAttributes(out IMFAttributes attributes, uint initialSize);
|
||||
|
||||
public sealed record Playback(int FrameCount, int Width, int Height, List<double> MeanLuma, string? Error);
|
||||
|
||||
public static Playback Read(string path, int maxFrames = 4096)
|
||||
{
|
||||
MediaFoundationRuntime.Startup();
|
||||
|
||||
IMFAttributes? attributes = null;
|
||||
IMFSourceReader? reader = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (MFCreateAttributes(out attributes, 1) >= 0)
|
||||
{
|
||||
var key = EnableVideoProcessing;
|
||||
attributes.SetUINT32(ref key, 1);
|
||||
}
|
||||
|
||||
int hr = MFCreateSourceReaderFromURL(path, attributes, out reader);
|
||||
if (hr < 0) return new Playback(0, 0, 0, [], $"apertura non riuscita (HRESULT 0x{hr:X8})");
|
||||
|
||||
reader.SetStreamSelection(AllStreams, false);
|
||||
reader.SetStreamSelection(FirstVideoStream, true);
|
||||
|
||||
int width = 0, height = 0;
|
||||
if (reader.GetNativeMediaType(FirstVideoStream, 0, out IMFMediaType native) >= 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sizeKey = MediaFoundation.MtFrameSize;
|
||||
if (native.GetUINT64(ref sizeKey, out ulong packed) >= 0)
|
||||
{
|
||||
width = (int)(packed >> 32);
|
||||
height = (int)(packed & 0xFFFFFFFF);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(native);
|
||||
}
|
||||
}
|
||||
|
||||
// Si richiede RGB a 8 bit: il lettore inserisce da sé il convertitore di formato.
|
||||
if (MediaFoundation.MFCreateMediaType(out IMFMediaType target) >= 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var majorKey = MediaFoundation.MtMajorType;
|
||||
var majorValue = MediaFoundation.MajorTypeVideo;
|
||||
target.SetGUID(ref majorKey, ref majorValue);
|
||||
var subKey = MediaFoundation.MtSubtype;
|
||||
target.SetGUID(ref subKey, ref VideoFormatRgb32);
|
||||
reader.SetCurrentMediaType(FirstVideoStream, IntPtr.Zero, target);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(target);
|
||||
}
|
||||
}
|
||||
|
||||
var luma = new List<double>();
|
||||
int frames = 0;
|
||||
|
||||
while (frames < maxFrames)
|
||||
{
|
||||
hr = reader.ReadSample(FirstVideoStream, 0, out _, out uint flags, out _, out IMFSample? sample);
|
||||
if (hr < 0) return new Playback(frames, width, height, luma, $"lettura interrotta (HRESULT 0x{hr:X8})");
|
||||
|
||||
if (sample is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
luma.Add(MeasureMeanLuma(sample, width, height));
|
||||
frames++;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(sample);
|
||||
}
|
||||
}
|
||||
|
||||
if ((flags & EndOfStream) != 0) break;
|
||||
}
|
||||
|
||||
return new Playback(frames, width, height, luma, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new Playback(0, 0, 0, [], ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (reader is not null) Marshal.ReleaseComObject(reader);
|
||||
if (attributes is not null) Marshal.ReleaseComObject(attributes);
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe double MeasureMeanLuma(IMFSample sample, int width, int height)
|
||||
{
|
||||
sample.ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
|
||||
try
|
||||
{
|
||||
buffer.Lock(out IntPtr pointer, out _, out uint length);
|
||||
try
|
||||
{
|
||||
if (length == 0 || width <= 0 || height <= 0) return 0;
|
||||
|
||||
int stride = (int)(length / Math.Max(1, height));
|
||||
if (stride < width * 4) return 0;
|
||||
|
||||
byte* data = (byte*)pointer;
|
||||
double sum = 0;
|
||||
int count = 0;
|
||||
|
||||
for (int y = 0; y < height; y += 4)
|
||||
{
|
||||
byte* row = data + (long)y * stride;
|
||||
for (int x = 0; x < width; x += 4)
|
||||
{
|
||||
byte* pixel = row + x * 4; // BGRX
|
||||
sum += 0.2126 * pixel[2] + 0.7152 * pixel[1] + 0.0722 * pixel[0];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count == 0 ? 0 : sum / count / 255.0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
buffer.Unlock();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Titano.Analysis;
|
||||
using Titano.Imaging;
|
||||
using Titano.Metadata;
|
||||
using Titano.Motion;
|
||||
using Titano.Pipeline;
|
||||
using Titano.Video;
|
||||
|
||||
namespace Titano.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Verifica end-to-end del motore su una sequenza sintetica dalle proprietà note:
|
||||
/// parser Exif, analisi di cadenza, deflicker, optical flow, motion blur, encoder e
|
||||
/// multiplexer vengono esercitati nell'ordine in cui lavorano in produzione.
|
||||
/// </summary>
|
||||
public static class SelfTest
|
||||
{
|
||||
public static int Run(string workingDirectory, TextWriter output)
|
||||
{
|
||||
var checks = new List<(string Name, bool Passed, string Detail)>();
|
||||
var definition = new SyntheticSequence.Definition();
|
||||
|
||||
output.WriteLine("Titano — verifica del motore");
|
||||
output.WriteLine(new string('-', 74));
|
||||
|
||||
string sequenceDirectory = Path.Combine(workingDirectory, "sequenza");
|
||||
output.WriteLine($"Generazione di {definition.FrameCount} fotogrammi sintetici in {sequenceDirectory}");
|
||||
var paths = SyntheticSequence.Write(sequenceDirectory, definition);
|
||||
|
||||
// ---------------------------------------------------------------- 1. metadati
|
||||
var metadata = paths.Select(MetadataReader.Read).ToList();
|
||||
|
||||
int withSubSecond = metadata.Count(m => m.CaptureSource == TimestampSource.ExifSubSecond);
|
||||
Add(checks, "Parser Exif — timestamp con frazione di secondo",
|
||||
withSubSecond == metadata.Count, $"{withSubSecond}/{metadata.Count}");
|
||||
|
||||
bool exposureOk = metadata.All(m => m.ExposureSeconds is { } e && Math.Abs(e - definition.ExposureSeconds) < 1e-6);
|
||||
Add(checks, "Parser Exif — tempo di posa", exposureOk,
|
||||
Format(metadata[0].ExposureSeconds) + " s");
|
||||
|
||||
bool apertureOk = metadata.All(m => m.FNumber is { } f && Math.Abs(f - 8.0) < 1e-6);
|
||||
bool isoOk = metadata.All(m => m.Iso == 200);
|
||||
Add(checks, "Parser Exif — apertura e ISO", apertureOk && isoOk,
|
||||
$"f/{Format(metadata[0].FNumber)}, ISO {metadata[0].Iso}");
|
||||
|
||||
bool offsetOk = metadata.All(m => m.UtcOffset == TimeSpan.FromHours(2));
|
||||
Add(checks, "Parser Exif — fuso orario", offsetOk, metadata[0].UtcOffset?.ToString() ?? "assente");
|
||||
|
||||
bool sizeOk = metadata.All(m => m.PixelWidth == definition.Width && m.PixelHeight == definition.Height);
|
||||
Add(checks, "Parser Exif — dimensioni dichiarate", sizeOk,
|
||||
$"{metadata[0].PixelWidth}×{metadata[0].PixelHeight}");
|
||||
|
||||
// ---------------------------------------------------------------- 2. cadenza
|
||||
var project = new TitanoProject();
|
||||
project.Sequence = Core.TimelapseSequence.Build(metadata);
|
||||
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
|
||||
|
||||
double expectedInterval = definition.IntervalSeconds + 0.37;
|
||||
bool cadenceOk = Math.Abs(project.Sequence.NominalInterval - expectedInterval) < 0.05;
|
||||
Add(checks, "Cadenza nominale rilevata", cadenceOk,
|
||||
$"{project.Sequence.NominalInterval:0.###} s (attesa {expectedInterval:0.###} s)");
|
||||
|
||||
bool pauseOk = project.Sequence.CadenceAnomalies >= definition.PauseFrames;
|
||||
Add(checks, "Pause dell'intervallometro individuate", pauseOk,
|
||||
$"{project.Sequence.CadenceAnomalies} intervalli anomali");
|
||||
|
||||
double expectedAngle = 360.0 * definition.ExposureSeconds / expectedInterval;
|
||||
double measuredAngle = project.Sequence.Frames[0].ShutterAngle;
|
||||
bool angleOk = Math.Abs(measuredAngle - expectedAngle) < 0.2;
|
||||
Add(checks, "Shutter angle reale", angleOk,
|
||||
$"{measuredAngle:0.00}° (atteso {expectedAngle:0.00}°)");
|
||||
|
||||
// ---------------------------------------------------------------- 3. deflicker
|
||||
project.Export.OutputPath = Path.Combine(workingDirectory, "titano-selftest.mp4");
|
||||
project.Export.FrameRate = 24;
|
||||
project.Export.BitrateMbps = 20;
|
||||
project.General.DecodeParallelism = 4;
|
||||
|
||||
var pipeline = new RenderPipeline(project);
|
||||
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var curve = project.Curve!;
|
||||
double flickerBefore = 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 flickerAfter = DeflickerCurve.FlickerIndex(corrected);
|
||||
|
||||
Add(checks, "Sfarfallio misurato prima della correzione", flickerBefore > 0.04,
|
||||
$"{flickerBefore:0.0000} stop RMS");
|
||||
Add(checks, "Sfarfallio residuo dopo la correzione", flickerAfter < flickerBefore * 0.35,
|
||||
$"{flickerAfter:0.0000} stop RMS ({100 * (1 - flickerAfter / flickerBefore):0.#}% di riduzione)");
|
||||
|
||||
// La rampa di luce deve sopravvivere: è un cambio reale, non sfarfallio.
|
||||
double rampBefore = curve.Measured[^1] - curve.Measured[0];
|
||||
double rampAfter = corrected[^1] - corrected[0];
|
||||
bool rampOk = Math.Abs(rampAfter - rampBefore) < 0.25 && Math.Abs(rampAfter) > 0.4;
|
||||
Add(checks, "Rampa di luce preservata", rampOk,
|
||||
$"{rampBefore:0.00} stop → {rampAfter:0.00} stop");
|
||||
|
||||
// ---------------------------------------------------------------- 4. optical flow
|
||||
var pool = new FrameBufferPool(6);
|
||||
using var frameA = ImageDecoder.Decode(paths[10], definition.Width, definition.Height, 1, pool);
|
||||
using var frameB = ImageDecoder.Decode(paths[11], definition.Width, definition.Height, 1, pool);
|
||||
|
||||
var flowEngine = new OpticalFlowEngine(project.Flow);
|
||||
var field = flowEngine.Compute(frameA, frameB);
|
||||
|
||||
double expectedMagnitude = Math.Sqrt(definition.ShiftX * definition.ShiftX +
|
||||
definition.ShiftY * definition.ShiftY);
|
||||
double measuredMagnitude = field.MedianMagnitude();
|
||||
bool flowOk = Math.Abs(measuredMagnitude - expectedMagnitude) < expectedMagnitude * 0.25;
|
||||
Add(checks, "Campo vettoriale — modulo del movimento", flowOk,
|
||||
$"{measuredMagnitude:0.00} px (atteso {expectedMagnitude:0.00} px)");
|
||||
|
||||
// Il generatore avanza le coordinate di campionamento della tessitura, quindi il
|
||||
// contenuto visibile scorre nel verso opposto: il flusso atteso è l'opposto dello shift.
|
||||
double expectedDirection = Math.Atan2(-definition.ShiftY, -definition.ShiftX) * 180 / Math.PI;
|
||||
double measuredDirection = field.DominantDirection();
|
||||
double directionError = Math.Abs(NormalizeAngle(measuredDirection - expectedDirection));
|
||||
Add(checks, "Campo vettoriale — direzione dominante", directionError < 12,
|
||||
$"{measuredDirection:0.0}° (atteso {expectedDirection:0.0}°)");
|
||||
|
||||
// ---------------------------------------------------------------- 5. motion blur
|
||||
double missing = MotionBlurRenderer.MissingBlurFactor(measuredAngle, 180.0, 1.0);
|
||||
using var blurred = pool.Rent(definition.Width, definition.Height);
|
||||
double blurLength = MotionBlurRenderer.Render(frameA, blurred, field, missing, project.MotionBlur);
|
||||
|
||||
bool blurOk = blurLength > 1.5 && blurLength < 8.0;
|
||||
Add(checks, "Motion blur sintetico — lunghezza della scia", blurOk,
|
||||
$"{blurLength:0.00} px con fattore mancante {missing:0.000}");
|
||||
|
||||
double detailBefore = MeasureDetail(frameA);
|
||||
double detailAfter = MeasureDetail(blurred);
|
||||
Add(checks, "Motion blur sintetico — dettaglio ridotto lungo il moto",
|
||||
detailAfter < detailBefore * 0.85,
|
||||
$"{detailBefore:0.0000} → {detailAfter:0.0000} " +
|
||||
$"({100 * (1 - detailAfter / detailBefore):0.#}% di attenuazione)");
|
||||
|
||||
// ---------------------------------------------------------------- 6. encoder + muxer
|
||||
RenderResult? result = null;
|
||||
string encodeDetail;
|
||||
try
|
||||
{
|
||||
result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
|
||||
encodeDetail = $"{result.EncodedFrames} fotogrammi, {result.OutputBytes / 1024} KiB, " +
|
||||
$"{result.Elapsed.TotalSeconds:0.0} s, {result.EncoderName}" +
|
||||
(result.HardwareAccelerated ? " (hardware)" : " (software)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
encodeDetail = ex.Message;
|
||||
}
|
||||
|
||||
Add(checks, "Codifica video tramite encoder di sistema",
|
||||
result is { EncodedFrames: > 0 }, encodeDetail);
|
||||
|
||||
if (result is not null && File.Exists(result.OutputPath))
|
||||
{
|
||||
var report = Mp4Inspector.Inspect(result.OutputPath);
|
||||
Add(checks, "Contenitore MP4 — struttura dei box", report.Valid,
|
||||
$"{report.Summary}; box radice: {string.Join(", ", report.TopLevelBoxes)}");
|
||||
Add(checks, "Contenitore MP4 — numero di campioni",
|
||||
report.SampleCount == result.EncodedFrames,
|
||||
$"{report.SampleCount} campioni per {result.EncodedFrames} fotogrammi codificati");
|
||||
Add(checks, "Contenitore MP4 — risoluzione dichiarata",
|
||||
report.Width == definition.Width && report.Height == definition.Height,
|
||||
$"{report.Width}×{report.Height}");
|
||||
|
||||
double declaredSeconds = report.Timescale > 0 ? report.MediaDuration / (double)report.Timescale : 0;
|
||||
double expectedSeconds = result.EncodedFrames / project.Export.FrameRate;
|
||||
Add(checks, "Contenitore MP4 — durata dichiarata",
|
||||
Math.Abs(declaredSeconds - expectedSeconds) < 0.2,
|
||||
$"{declaredSeconds:0.00} s (attesa {expectedSeconds:0.00} s)");
|
||||
|
||||
// Riproduzione effettiva con il lettore di sistema: prova che il contenitore
|
||||
// scritto in-house è leggibile da un decoder indipendente.
|
||||
var playback = Mp4Playback.Read(result.OutputPath);
|
||||
Add(checks, "Riproduzione con il lettore di sistema",
|
||||
playback.Error is null && playback.FrameCount == result.EncodedFrames,
|
||||
playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati a {playback.Width}×{playback.Height}");
|
||||
|
||||
if (playback.MeanLuma.Count > 4)
|
||||
{
|
||||
// La luminanza riletta dal file deve essere già stabilizzata: il deflicker
|
||||
// sopravvive a codifica e contenitore.
|
||||
var log2 = playback.MeanLuma.Select(v => Math.Log2(Math.Max(v, 1e-4))).ToList();
|
||||
double residual = DeflickerCurve.FlickerIndex(log2);
|
||||
Add(checks, "Sfarfallio residuo nel video finale", residual < flickerBefore * 0.5,
|
||||
$"{residual:0.0000} stop RMS contro {flickerBefore:0.0000} in origine");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 7. impronta di memoria
|
||||
long expectedBytes = (long)definition.Width * definition.Height * 3 * sizeof(float);
|
||||
bool memoryOk = result is null || result.PeakPixelMemoryBytes < expectedBytes * 24;
|
||||
Add(checks, "Impronta di memoria del pool",
|
||||
memoryOk,
|
||||
result is null ? "non misurata" :
|
||||
$"{result.PeakPixelMemoryBytes / (1024 * 1024.0):0.0} MiB " +
|
||||
$"({result.PeakPixelMemoryBytes / (double)expectedBytes:0.0} fotogrammi)");
|
||||
|
||||
// ---------------------------------------------------------------- esito
|
||||
output.WriteLine();
|
||||
foreach (var (name, passed, detail) in checks)
|
||||
{
|
||||
output.WriteLine($" [{(passed ? "OK " : "FALLITO")}] {name}");
|
||||
output.WriteLine($" {detail}");
|
||||
}
|
||||
|
||||
int failed = checks.Count(c => !c.Passed);
|
||||
output.WriteLine();
|
||||
output.WriteLine(new string('-', 74));
|
||||
output.WriteLine(failed == 0
|
||||
? $"Tutte le {checks.Count} verifiche superate."
|
||||
: $"{failed} verifiche fallite su {checks.Count}.");
|
||||
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static void Add(List<(string, bool, string)> checks, string name, bool passed, string detail)
|
||||
=> checks.Add((name, passed, detail));
|
||||
|
||||
private static string Format(double? value)
|
||||
=> value?.ToString("0.######", CultureInfo.InvariantCulture) ?? "—";
|
||||
|
||||
private static double NormalizeAngle(double degrees)
|
||||
{
|
||||
while (degrees > 180) degrees -= 360;
|
||||
while (degrees < -180) degrees += 360;
|
||||
return degrees;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dettaglio fine orizzontale: differenza fra pixel adiacenti sul canale verde.
|
||||
/// Si usa la differenza a distanza 1 e non quella centrata, perché quest'ultima ha uno
|
||||
/// zero esatto alla frequenza di Nyquist, proprio dove la sfocatura agisce di più.
|
||||
/// </summary>
|
||||
private static double MeasureDetail(ImageBuffer frame)
|
||||
{
|
||||
double sum = 0;
|
||||
int count = 0;
|
||||
var data = frame.Data;
|
||||
|
||||
for (int y = 4; y < frame.Height - 4; y += 3)
|
||||
{
|
||||
int rowBase = y * frame.Width * ImageBuffer.Channels;
|
||||
for (int x = 4; x < frame.Width - 4; x += 3)
|
||||
{
|
||||
int i = rowBase + x * ImageBuffer.Channels;
|
||||
sum += Math.Abs(data[i + ImageBuffer.Channels + 1] - data[i + 1]);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count == 0 ? 0 : sum / count;
|
||||
}
|
||||
|
||||
/// <summary>Riepilogo testuale usato anche dal pannello diagnostica dell'interfaccia.</summary>
|
||||
public static string DescribeEnvironment()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"Sistema: {Environment.OSVersion.VersionString} ({(Environment.Is64BitProcess ? "x64" : "x86")})");
|
||||
builder.AppendLine($"Processori logici: {Environment.ProcessorCount}");
|
||||
builder.AppendLine($"Runtime: {Environment.Version}");
|
||||
builder.AppendLine($"Vettori SIMD: {System.Numerics.Vector<float>.Count} float per registro");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
|
||||
namespace Titano.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Generatore di sequenze sintetiche per la diagnostica: produce JPEG con Exif completo,
|
||||
/// una traslazione nota del contenuto (verifica dell'optical flow), uno sfarfallio noto
|
||||
/// sovrapposto a una rampa di luce (verifica del deflicker) e una pausa dell'intervallometro
|
||||
/// (verifica dell'analisi di cadenza).
|
||||
/// </summary>
|
||||
internal static class SyntheticSequence
|
||||
{
|
||||
public sealed record Definition(
|
||||
int FrameCount = 48,
|
||||
int Width = 640,
|
||||
int Height = 360,
|
||||
double IntervalSeconds = 2.0,
|
||||
double ExposureSeconds = 0.02,
|
||||
double FlickerStops = 0.12,
|
||||
double RampStops = -0.9,
|
||||
int PauseAtFrame = 20,
|
||||
int PauseFrames = 4,
|
||||
double PauseMultiplier = 3.0,
|
||||
int ShiftX = 6,
|
||||
int ShiftY = 2);
|
||||
|
||||
/// <summary>Scrive la sequenza nella cartella indicata e restituisce i percorsi generati.</summary>
|
||||
public static List<string> Write(string directory, Definition definition)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var paths = new List<string>(definition.FrameCount);
|
||||
|
||||
var origin = new DateTime(2026, 6, 1, 18, 30, 0, DateTimeKind.Unspecified);
|
||||
double elapsed = 0;
|
||||
|
||||
for (int i = 0; i < definition.FrameCount; i++)
|
||||
{
|
||||
double exposureScale = Math.Pow(2.0, Ramp(definition, i) + Flicker(definition, i));
|
||||
byte[] jpeg = RenderJpeg(definition, i, exposureScale);
|
||||
|
||||
var timestamp = origin.AddSeconds(elapsed);
|
||||
int subSecond = (int)Math.Round((elapsed - Math.Floor(elapsed)) * 100) % 100;
|
||||
|
||||
byte[] exif = ExifWriter.BuildExifBlock(timestamp, subSecond, definition.ExposureSeconds,
|
||||
8.0, 200, definition.Width, definition.Height,
|
||||
"TITANO", "TITANO Synthetic");
|
||||
|
||||
string path = Path.Combine(directory, $"TITANO_{i:D4}.jpg");
|
||||
File.WriteAllBytes(path, ExifWriter.InsertIntoJpeg(jpeg, exif));
|
||||
paths.Add(path);
|
||||
|
||||
bool inPause = i >= definition.PauseAtFrame && i < definition.PauseAtFrame + definition.PauseFrames;
|
||||
elapsed += definition.IntervalSeconds * (inPause ? definition.PauseMultiplier : 1.0) + 0.37;
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
public static double Ramp(Definition definition, int index)
|
||||
=> definition.RampStops * index / Math.Max(1, definition.FrameCount - 1);
|
||||
|
||||
/// <summary>Sfarfallio deterministico ma non periodico rispetto alla finestra di smoothing.</summary>
|
||||
public static double Flicker(Definition definition, int index)
|
||||
{
|
||||
int pattern = (index * 7 + (index * index) % 5) % 7;
|
||||
return definition.FlickerStops * ((pattern - 3) / 3.0);
|
||||
}
|
||||
|
||||
private static byte[] RenderJpeg(Definition definition, int index, double exposureScale)
|
||||
{
|
||||
int width = definition.Width;
|
||||
int height = definition.Height;
|
||||
double offsetX = index * definition.ShiftX;
|
||||
double offsetY = index * definition.ShiftY;
|
||||
|
||||
using var bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb);
|
||||
var rect = new Rectangle(0, 0, width, height);
|
||||
var locked = bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
|
||||
|
||||
try
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
byte* basePtr = (byte*)locked.Scan0;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
byte* row = basePtr + (long)y * locked.Stride;
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
double sx = x + offsetX;
|
||||
double sy = y + offsetY;
|
||||
|
||||
// Rumore di valore su tre ottave: tessitura ovunque e a banda limitata,
|
||||
// condizioni ideali per uno schema differenziale di optical flow.
|
||||
// L'ottava più fine serve a rendere misurabile l'effetto del motion blur.
|
||||
double texture = 0.50 * ValueNoise(sx / 24.0, sy / 24.0, 11)
|
||||
+ 0.30 * ValueNoise(sx / 7.0, sy / 7.0, 37)
|
||||
+ 0.20 * ValueNoise(sx / 3.0, sy / 3.0, 53);
|
||||
|
||||
double value = Math.Clamp((0.18 + 0.62 * texture) * exposureScale, 0.0, 1.0);
|
||||
byte gray = (byte)Math.Clamp((int)(Math.Pow(value, 1.0 / 2.2) * 255.0 + 0.5), 0, 255);
|
||||
|
||||
byte* pixel = row + x * 3;
|
||||
pixel[0] = (byte)(gray * 0.94); // B
|
||||
pixel[1] = gray; // G
|
||||
pixel[2] = (byte)Math.Min(255, gray * 1.04); // R
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(locked);
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
|
||||
using var parameters = new EncoderParameters(1);
|
||||
using var quality = new EncoderParameter(Encoder.Quality, 92L);
|
||||
parameters.Param[0] = quality;
|
||||
bitmap.Save(stream, codec, parameters);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Rumore di valore bilineare su reticolo intero, deterministico.</summary>
|
||||
private static double ValueNoise(double x, double y, int seed)
|
||||
{
|
||||
int x0 = (int)Math.Floor(x);
|
||||
int y0 = (int)Math.Floor(y);
|
||||
double fx = x - x0;
|
||||
double fy = y - y0;
|
||||
|
||||
// Interpolazione con curva di Hermite: derivata continua, niente discontinuità di gradiente.
|
||||
double sx = fx * fx * (3 - 2 * fx);
|
||||
double sy = fy * fy * (3 - 2 * fy);
|
||||
|
||||
double a = Hash(x0, y0, seed);
|
||||
double b = Hash(x0 + 1, y0, seed);
|
||||
double c = Hash(x0, y0 + 1, seed);
|
||||
double d = Hash(x0 + 1, y0 + 1, seed);
|
||||
|
||||
double top = a + (b - a) * sx;
|
||||
double bottom = c + (d - c) * sx;
|
||||
return top + (bottom - top) * sy;
|
||||
}
|
||||
|
||||
private static double Hash(int x, int y, int seed)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
uint h = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(seed * 83492791);
|
||||
h ^= h >> 13;
|
||||
h *= 0x85EBCA6B;
|
||||
h ^= h >> 16;
|
||||
return (h & 0xFFFFFF) / (double)0xFFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Titano.Imaging;
|
||||
|
||||
/// <summary>
|
||||
/// Conversioni colore implementate in-house: curva sRGB ↔ luce lineare e pesi di luminanza.
|
||||
/// Tutta l'elaborazione (deflicker, blur, interpolazione) avviene in luce lineare, l'unico
|
||||
/// spazio in cui somme e medie pesate corrispondono al comportamento fisico della luce.
|
||||
/// </summary>
|
||||
public static class ColorSpace
|
||||
{
|
||||
/// <summary>LUT sRGB→lineare a 8 bit.</summary>
|
||||
private static readonly float[] SrgbToLinear8 = BuildLut(256);
|
||||
|
||||
/// <summary>LUT sRGB→lineare a 16 bit, campionata a 4096 punti con interpolazione lineare.</summary>
|
||||
private static readonly float[] SrgbToLinear16 = BuildLut(4096);
|
||||
|
||||
/// <summary>LUT inversa lineare→sRGB a 12 bit, per la scrittura verso l'encoder.</summary>
|
||||
private const int InverseLutSize = 4096;
|
||||
private static readonly float[] LinearToSrgbLut = BuildInverseLut();
|
||||
|
||||
// Coefficienti di luminanza Rec.709 (spazio di lavoro sRGB/Rec.709 lineare).
|
||||
public const float LumaR = 0.2126f;
|
||||
public const float LumaG = 0.7152f;
|
||||
public const float LumaB = 0.0722f;
|
||||
|
||||
private static float[] BuildLut(int size)
|
||||
{
|
||||
var lut = new float[size];
|
||||
for (int i = 0; i < size; i++) lut[i] = SrgbToLinearExact(i / (float)(size - 1));
|
||||
return lut;
|
||||
}
|
||||
|
||||
private static float[] BuildInverseLut()
|
||||
{
|
||||
var lut = new float[InverseLutSize + 1];
|
||||
for (int i = 0; i <= InverseLutSize; i++) lut[i] = LinearToSrgbExact(i / (float)InverseLutSize);
|
||||
return lut;
|
||||
}
|
||||
|
||||
public static float SrgbToLinearExact(float v)
|
||||
{
|
||||
if (v <= 0f) return 0f;
|
||||
if (v >= 1f) return 1f;
|
||||
return v <= 0.04045f ? v / 12.92f : MathF.Pow((v + 0.055f) / 1.055f, 2.4f);
|
||||
}
|
||||
|
||||
public static float LinearToSrgbExact(float v)
|
||||
{
|
||||
if (v <= 0f) return 0f;
|
||||
if (v >= 1f) return 1f;
|
||||
return v <= 0.0031308f ? v * 12.92f : 1.055f * MathF.Pow(v, 1f / 2.4f) - 0.055f;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static float FromByte(byte v) => SrgbToLinear8[v];
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static float FromUInt16(ushort v)
|
||||
{
|
||||
// Interpolazione fra i due campioni adiacenti della LUT: errore < 1e-5.
|
||||
int scaled = v * (SrgbToLinear16.Length - 1);
|
||||
int index = scaled / 65535;
|
||||
int rem = scaled - index * 65535;
|
||||
if (index >= SrgbToLinear16.Length - 1) return SrgbToLinear16[^1];
|
||||
float a = SrgbToLinear16[index], b = SrgbToLinear16[index + 1];
|
||||
return a + (b - a) * (rem / 65535f);
|
||||
}
|
||||
|
||||
/// <summary>Lineare → sRGB con LUT interpolata; valori fuori range vengono saturati.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static float ToSrgb(float linear)
|
||||
{
|
||||
if (linear <= 0f) return 0f;
|
||||
if (linear >= 1f) return 1f;
|
||||
float pos = linear * InverseLutSize;
|
||||
int i = (int)pos;
|
||||
float t = pos - i;
|
||||
return LinearToSrgbLut[i] + (LinearToSrgbLut[i + 1] - LinearToSrgbLut[i]) * t;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static byte ToSrgbByte(float linear)
|
||||
=> (byte)Math.Clamp((int)(ToSrgb(linear) * 255f + 0.5f), 0, 255);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static float Luminance(float r, float g, float b) => LumaR * r + LumaG * g + LumaB * b;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Titano.Imaging;
|
||||
|
||||
/// <summary>
|
||||
/// Fotogramma in memoria: RGB impacchettato, float32, luce lineare (gamma rimossa).
|
||||
/// L'array proviene sempre da un <see cref="FrameBufferPool"/>: nessuna allocazione
|
||||
/// per fotogramma dopo il riscaldamento, quindi impronta di memoria stazionaria.
|
||||
/// </summary>
|
||||
public sealed class ImageBuffer : IDisposable
|
||||
{
|
||||
public const int Channels = 3;
|
||||
|
||||
public int Width { get; private set; }
|
||||
public int Height { get; private set; }
|
||||
public float[] Data { get; private set; }
|
||||
|
||||
internal FrameBufferPool? Owner;
|
||||
private int _disposed;
|
||||
|
||||
public int PixelCount => Width * Height;
|
||||
public int SampleCount => Width * Height * Channels;
|
||||
|
||||
internal ImageBuffer(int width, int height, float[] data, FrameBufferPool? owner)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
Data = data;
|
||||
Owner = owner;
|
||||
}
|
||||
|
||||
/// <summary>Indice del primo campione (canale R) del pixel indicato.</summary>
|
||||
public int Offset(int x, int y) => (y * Width + x) * Channels;
|
||||
|
||||
public void CopyFrom(ImageBuffer other)
|
||||
{
|
||||
if (other.Width != Width || other.Height != Height)
|
||||
throw new ArgumentException("Dimensioni non compatibili.", nameof(other));
|
||||
Array.Copy(other.Data, Data, SampleCount);
|
||||
}
|
||||
|
||||
public ImageBuffer CloneFromPool(FrameBufferPool pool)
|
||||
{
|
||||
var copy = pool.Rent(Width, Height);
|
||||
Array.Copy(Data, copy.Data, SampleCount);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>Restituisce il buffer al pool. Idempotente.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
var owner = Owner;
|
||||
Owner = null;
|
||||
owner?.Return(Data, Width, Height);
|
||||
Data = [];
|
||||
Width = Height = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pool di array float per fotogrammi di dimensione omogenea. Gli array sono trattenuti
|
||||
/// per lunghezza esatta e in numero limitato: l'occupazione totale è funzione della
|
||||
/// profondità della pipeline, non del numero di fotogrammi della sequenza.
|
||||
/// </summary>
|
||||
public sealed class FrameBufferPool(int maxRetained = 8)
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, ConcurrentBag<float[]>> _bins = new();
|
||||
private readonly int _maxRetained = Math.Max(2, maxRetained);
|
||||
private int _retained;
|
||||
private long _allocatedBytes;
|
||||
|
||||
/// <summary>Numero di array attualmente trattenuti dal pool.</summary>
|
||||
public int Retained => Volatile.Read(ref _retained);
|
||||
|
||||
/// <summary>
|
||||
/// Byte di memoria pixel effettivamente allocati dall'avvio. Poiché gli array vengono
|
||||
/// riutilizzati, questo valore si stabilizza dopo i primi fotogrammi e rappresenta
|
||||
/// l'impronta stazionaria della pipeline.
|
||||
/// </summary>
|
||||
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
|
||||
|
||||
public ImageBuffer Rent(int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) throw new ArgumentOutOfRangeException(nameof(width));
|
||||
int length = checked(width * height * ImageBuffer.Channels);
|
||||
|
||||
if (_bins.TryGetValue(length, out var bag) && bag.TryTake(out var array))
|
||||
{
|
||||
Interlocked.Decrement(ref _retained);
|
||||
return new ImageBuffer(width, height, array, this);
|
||||
}
|
||||
|
||||
var fresh = GC.AllocateUninitializedArray<float>(length);
|
||||
Interlocked.Add(ref _allocatedBytes, (long)length * sizeof(float));
|
||||
return new ImageBuffer(width, height, fresh, this);
|
||||
}
|
||||
|
||||
internal void Return(float[] array, int width, int height)
|
||||
{
|
||||
if (array.Length == 0) return;
|
||||
if (Volatile.Read(ref _retained) >= _maxRetained) return; // eccedenza lasciata al GC
|
||||
|
||||
var bag = _bins.GetOrAdd(array.Length, static _ => []);
|
||||
bag.Add(array);
|
||||
Interlocked.Increment(ref _retained);
|
||||
}
|
||||
|
||||
/// <summary>Svuota il pool: usato al cambio di risoluzione di lavoro.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
_bins.Clear();
|
||||
Interlocked.Exchange(ref _retained, 0);
|
||||
Interlocked.Exchange(ref _allocatedBytes, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Titano.Imaging;
|
||||
|
||||
/// <summary>
|
||||
/// Decodifica un file immagine direttamente in un <see cref="ImageBuffer"/> in luce lineare,
|
||||
/// scalandolo alla risoluzione di lavoro e applicando l'orientamento Exif.
|
||||
///
|
||||
/// La catena WIC è pull-based: il ridimensionamento avviene a bande mentre i pixel vengono
|
||||
/// prelevati, quindi il fotogramma a piena risoluzione non viene mai materializzato per intero
|
||||
/// e non tocca mai il disco.
|
||||
/// </summary>
|
||||
public static class ImageDecoder
|
||||
{
|
||||
/// <summary>Dimensioni dell'immagine così come verrà mostrata (orientamento già applicato).</summary>
|
||||
public static (int Width, int Height) ProbeDisplaySize(string path, int orientation)
|
||||
{
|
||||
IWICBitmapDecoder? decoder = null;
|
||||
IWICBitmapFrameDecode? frame = null;
|
||||
try
|
||||
{
|
||||
Wic.Factory.CreateDecoderFromFilename(path, IntPtr.Zero, Wic.GenericRead,
|
||||
Wic.MetadataCacheOnDemand, out decoder);
|
||||
decoder.GetFrame(0, out frame);
|
||||
frame.GetSize(out uint w, out uint h);
|
||||
return SwapsAxes(orientation) ? ((int)h, (int)w) : ((int)w, (int)h);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Wic.Release(frame);
|
||||
Wic.Release(decoder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodifica il file producendo un buffer di esattamente <paramref name="targetWidth"/> ×
|
||||
/// <paramref name="targetHeight"/> pixel (misure già nell'orientamento finale).
|
||||
/// </summary>
|
||||
public static ImageBuffer Decode(string path, int targetWidth, int targetHeight,
|
||||
int orientation, FrameBufferPool pool)
|
||||
{
|
||||
if (targetWidth <= 0 || targetHeight <= 0) throw new ArgumentOutOfRangeException(nameof(targetWidth));
|
||||
|
||||
bool swap = SwapsAxes(orientation);
|
||||
uint decodeWidth = (uint)(swap ? targetHeight : targetWidth);
|
||||
uint decodeHeight = (uint)(swap ? targetWidth : targetHeight);
|
||||
|
||||
IWICBitmapDecoder? decoder = null;
|
||||
IWICBitmapFrameDecode? frame = null;
|
||||
IWICFormatConverter? converter = null;
|
||||
IWICBitmapScaler? scaler = null;
|
||||
|
||||
try
|
||||
{
|
||||
Wic.Factory.CreateDecoderFromFilename(path, IntPtr.Zero, Wic.GenericRead,
|
||||
Wic.MetadataCacheOnDemand, out decoder);
|
||||
decoder.GetFrame(0, out frame);
|
||||
frame.GetSize(out uint sourceWidth, out uint sourceHeight);
|
||||
if (sourceWidth == 0 || sourceHeight == 0)
|
||||
throw new InvalidDataException("Il decoder di sistema ha riportato dimensioni nulle.");
|
||||
|
||||
bool wantDeep = PrefersHighBitDepth(path);
|
||||
Guid destinationFormat = wantDeep ? Wic.PixelFormat48bppRGB : Wic.PixelFormat32bppBGRA;
|
||||
|
||||
IWICBitmapSource source = CreateChain(frame, ref destinationFormat, decodeWidth, decodeHeight,
|
||||
sourceWidth, sourceHeight, ref converter, ref scaler);
|
||||
|
||||
int bytesPerPixel = destinationFormat == Wic.PixelFormat48bppRGB ? 6 : 4;
|
||||
int stride = checked((int)decodeWidth * bytesPerPixel);
|
||||
long total = (long)stride * decodeHeight;
|
||||
if (total > int.MaxValue) throw new InvalidDataException("Fotogramma troppo grande per il buffer di trasferimento.");
|
||||
|
||||
byte[] scratch = ArrayPool<byte>.Shared.Rent((int)total);
|
||||
try
|
||||
{
|
||||
var handle = GCHandle.Alloc(scratch, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
source.CopyPixels(IntPtr.Zero, (uint)stride, (uint)total, handle.AddrOfPinnedObject());
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
|
||||
var buffer = pool.Rent(targetWidth, targetHeight);
|
||||
try
|
||||
{
|
||||
if (bytesPerPixel == 6)
|
||||
Convert48(scratch, stride, (int)decodeWidth, (int)decodeHeight, buffer, orientation);
|
||||
else
|
||||
Convert32(scratch, stride, (int)decodeWidth, (int)decodeHeight, buffer, orientation);
|
||||
}
|
||||
catch
|
||||
{
|
||||
buffer.Dispose();
|
||||
throw;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(scratch);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Wic.Release(scaler);
|
||||
Wic.Release(converter);
|
||||
Wic.Release(frame);
|
||||
Wic.Release(decoder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Costruisce la catena WIC: conversione al formato di lavoro e, se necessario, scalatura.
|
||||
/// In caso di sorgenti che il codec non sa convertire a 16 bit si ripiega su 8 bit.
|
||||
/// </summary>
|
||||
private static IWICBitmapSource CreateChain(IWICBitmapFrameDecode frame, ref Guid destinationFormat,
|
||||
uint decodeWidth, uint decodeHeight,
|
||||
uint sourceWidth, uint sourceHeight,
|
||||
ref IWICFormatConverter? converter, ref IWICBitmapScaler? scaler)
|
||||
{
|
||||
IWICBitmapSource current = (IWICBitmapSource)frame;
|
||||
|
||||
try
|
||||
{
|
||||
Wic.Factory.CreateFormatConverter(out converter);
|
||||
converter.Initialize(current, ref destinationFormat, Wic.DitherTypeNone,
|
||||
IntPtr.Zero, 0.0, Wic.PaletteTypeCustom);
|
||||
}
|
||||
catch (COMException) when (destinationFormat == Wic.PixelFormat48bppRGB)
|
||||
{
|
||||
Wic.Release(converter);
|
||||
converter = null;
|
||||
destinationFormat = Wic.PixelFormat32bppBGRA;
|
||||
Wic.Factory.CreateFormatConverter(out converter);
|
||||
converter.Initialize(current, ref destinationFormat, Wic.DitherTypeNone,
|
||||
IntPtr.Zero, 0.0, Wic.PaletteTypeCustom);
|
||||
}
|
||||
|
||||
current = (IWICBitmapSource)converter!;
|
||||
|
||||
if (decodeWidth != sourceWidth || decodeHeight != sourceHeight)
|
||||
{
|
||||
Wic.Factory.CreateBitmapScaler(out scaler);
|
||||
scaler.Initialize(current, decodeWidth, decodeHeight, Wic.InterpolationFant);
|
||||
current = (IWICBitmapSource)scaler;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static bool PrefersHighBitDepth(string path)
|
||||
{
|
||||
string ext = Path.GetExtension(path);
|
||||
return !(ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".jpe", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".jfif", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static bool SwapsAxes(int orientation) => orientation is 5 or 6 or 7 or 8;
|
||||
|
||||
// ------------------------------------------------------------------ conversione + orientamento
|
||||
|
||||
private static unsafe void Convert32(byte[] scratch, int stride, int width, int height,
|
||||
ImageBuffer destination, int orientation)
|
||||
{
|
||||
fixed (byte* basePtr = scratch)
|
||||
fixed (float* destBase = destination.Data)
|
||||
{
|
||||
byte* src = basePtr;
|
||||
float* dst = destBase;
|
||||
int destWidth = destination.Width;
|
||||
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
byte* row = src + (long)y * stride;
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
byte* px = row + x * 4; // BGRA
|
||||
int index = MapIndex(x, y, width, height, destWidth, orientation);
|
||||
dst[index] = ColorSpace.FromByte(px[2]);
|
||||
dst[index + 1] = ColorSpace.FromByte(px[1]);
|
||||
dst[index + 2] = ColorSpace.FromByte(px[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe void Convert48(byte[] scratch, int stride, int width, int height,
|
||||
ImageBuffer destination, int orientation)
|
||||
{
|
||||
fixed (byte* basePtr = scratch)
|
||||
fixed (float* destBase = destination.Data)
|
||||
{
|
||||
float* dst = destBase;
|
||||
int destWidth = destination.Width;
|
||||
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
ushort* row = (ushort*)(basePtr + (long)y * stride);
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
ushort* px = row + x * 3; // RGB 16 bit
|
||||
int index = MapIndex(x, y, width, height, destWidth, orientation);
|
||||
dst[index] = ColorSpace.FromUInt16(px[0]);
|
||||
dst[index + 1] = ColorSpace.FromUInt16(px[1]);
|
||||
dst[index + 2] = ColorSpace.FromUInt16(px[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applica la trasformazione Exif (valori 1..8) restituendo l'offset di destinazione.</summary>
|
||||
private static int MapIndex(int x, int y, int width, int height, int destWidth, int orientation)
|
||||
{
|
||||
int dx, dy;
|
||||
switch (orientation)
|
||||
{
|
||||
case 2: dx = width - 1 - x; dy = y; break;
|
||||
case 3: dx = width - 1 - x; dy = height - 1 - y; break;
|
||||
case 4: dx = x; dy = height - 1 - y; break;
|
||||
case 5: dx = y; dy = x; break;
|
||||
case 6: dx = height - 1 - y; dy = x; break;
|
||||
case 7: dx = height - 1 - y; dy = width - 1 - x; break;
|
||||
case 8: dx = y; dy = width - 1 - x; break;
|
||||
default: dx = x; dy = y; break;
|
||||
}
|
||||
return (dy * destWidth + dx) * ImageBuffer.Channels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Titano.Imaging;
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Binding manuale verso Windows Imaging Component (windowscodecs.dll), componente di
|
||||
// sistema. Nessun wrapper di terze parti: le interfacce COM sono dichiarate qui con
|
||||
// l'ordine di vtable esatto e vengono usate solo le voci necessarie alla pipeline.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
[ComImport, Guid("00000120-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IWICBitmapSource
|
||||
{
|
||||
void GetSize(out uint width, out uint height);
|
||||
void GetPixelFormat(out Guid format);
|
||||
void GetResolution(out double dpiX, out double dpiY);
|
||||
void CopyPalette(IntPtr palette);
|
||||
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
|
||||
}
|
||||
|
||||
[ComImport, Guid("3B16811B-6A43-4ec9-A813-3D930C13B940"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IWICBitmapFrameDecode
|
||||
{
|
||||
// --- IWICBitmapSource
|
||||
void GetSize(out uint width, out uint height);
|
||||
void GetPixelFormat(out Guid format);
|
||||
void GetResolution(out double dpiX, out double dpiY);
|
||||
void CopyPalette(IntPtr palette);
|
||||
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
|
||||
// --- IWICBitmapFrameDecode
|
||||
void GetMetadataQueryReader(out IntPtr reader);
|
||||
void GetColorContexts(uint count, IntPtr contexts, out uint actual);
|
||||
void GetThumbnail(out IWICBitmapSource thumbnail);
|
||||
}
|
||||
|
||||
[ComImport, Guid("9EDDE9E7-8DEE-47ea-99DF-E6FAF2ED44BF"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IWICBitmapDecoder
|
||||
{
|
||||
void QueryCapability(IntPtr stream, out uint capability);
|
||||
void Initialize(IntPtr stream, int cacheOptions);
|
||||
void GetContainerFormat(out Guid format);
|
||||
void GetDecoderInfo(out IntPtr info);
|
||||
void CopyPalette(IntPtr palette);
|
||||
void GetMetadataQueryReader(out IntPtr reader);
|
||||
void GetPreview(out IWICBitmapSource preview);
|
||||
void GetColorContexts(uint count, IntPtr contexts, out uint actual);
|
||||
void GetThumbnail(out IWICBitmapSource thumbnail);
|
||||
void GetFrameCount(out uint count);
|
||||
void GetFrame(uint index, out IWICBitmapFrameDecode frame);
|
||||
}
|
||||
|
||||
[ComImport, Guid("00000301-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IWICFormatConverter
|
||||
{
|
||||
// --- IWICBitmapSource
|
||||
void GetSize(out uint width, out uint height);
|
||||
void GetPixelFormat(out Guid format);
|
||||
void GetResolution(out double dpiX, out double dpiY);
|
||||
void CopyPalette(IntPtr palette);
|
||||
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
|
||||
// --- IWICFormatConverter
|
||||
void Initialize(IWICBitmapSource source, ref Guid destinationFormat, int dither,
|
||||
IntPtr palette, double alphaThreshold, int paletteTranslate);
|
||||
void CanConvert(ref Guid source, ref Guid destination, [MarshalAs(UnmanagedType.Bool)] out bool canConvert);
|
||||
}
|
||||
|
||||
[ComImport, Guid("00000302-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IWICBitmapScaler
|
||||
{
|
||||
// --- IWICBitmapSource
|
||||
void GetSize(out uint width, out uint height);
|
||||
void GetPixelFormat(out Guid format);
|
||||
void GetResolution(out double dpiX, out double dpiY);
|
||||
void CopyPalette(IntPtr palette);
|
||||
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
|
||||
// --- IWICBitmapScaler
|
||||
void Initialize(IWICBitmapSource source, uint width, uint height, int interpolationMode);
|
||||
}
|
||||
|
||||
[ComImport, Guid("EC5EC8A9-C395-4314-9C77-54D7A935FF70"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IWICImagingFactory
|
||||
{
|
||||
void CreateDecoderFromFilename([MarshalAs(UnmanagedType.LPWStr)] string filename, IntPtr vendor,
|
||||
uint desiredAccess, int metadataOptions, out IWICBitmapDecoder decoder);
|
||||
void CreateDecoderFromStream(IntPtr stream, IntPtr vendor, int metadataOptions, out IntPtr decoder);
|
||||
void CreateDecoderFromFileHandle(UIntPtr file, IntPtr vendor, int metadataOptions, out IntPtr decoder);
|
||||
void CreateComponentInfo(ref Guid component, out IntPtr info);
|
||||
void CreateDecoder(ref Guid containerFormat, IntPtr vendor, out IntPtr decoder);
|
||||
void CreateEncoder(ref Guid containerFormat, IntPtr vendor, out IntPtr encoder);
|
||||
void CreatePalette(out IntPtr palette);
|
||||
void CreateFormatConverter(out IWICFormatConverter converter);
|
||||
void CreateBitmapScaler(out IWICBitmapScaler scaler);
|
||||
// Le voci successive della vtable non sono usate e volutamente non dichiarate.
|
||||
}
|
||||
|
||||
internal static class Wic
|
||||
{
|
||||
public static readonly Guid ClsidImagingFactory = new("cacaf262-9370-4615-a13b-9f5539da4c0a");
|
||||
public static readonly Guid IidImagingFactory = new("ec5ec8a9-c395-4314-9c77-54d7a935ff70");
|
||||
|
||||
public static readonly Guid PixelFormat32bppBGRA = new("6fddc324-4e03-4bfe-b185-3d77768dc90f");
|
||||
public static readonly Guid PixelFormat48bppRGB = new("6fddc324-4e03-4bfe-b185-3d77768dc915");
|
||||
|
||||
public const uint GenericRead = 0x80000000;
|
||||
public const int MetadataCacheOnDemand = 0;
|
||||
public const int DitherTypeNone = 0;
|
||||
public const int PaletteTypeCustom = 0;
|
||||
public const int InterpolationFant = 3;
|
||||
|
||||
private const uint ClsCtxInprocServer = 1;
|
||||
private const uint CoInitMultithreaded = 0;
|
||||
|
||||
[ThreadStatic] private static IWICImagingFactory? _threadFactory;
|
||||
[ThreadStatic] private static bool _comReady;
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoCreateInstance(ref Guid clsid, IntPtr outer, uint context, ref Guid iid, out IntPtr instance);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoInitializeEx(IntPtr reserved, uint flags);
|
||||
|
||||
/// <summary>
|
||||
/// Factory WIC per thread: gli oggetti COM restano confinati al thread che li crea,
|
||||
/// così i worker di decodifica lavorano davvero in parallelo senza marshalling.
|
||||
/// </summary>
|
||||
public static IWICImagingFactory Factory
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_threadFactory is not null) return _threadFactory;
|
||||
|
||||
if (!_comReady)
|
||||
{
|
||||
// S_OK, S_FALSE e RPC_E_CHANGED_MODE sono tutti esiti accettabili.
|
||||
CoInitializeEx(IntPtr.Zero, CoInitMultithreaded);
|
||||
_comReady = true;
|
||||
}
|
||||
|
||||
var clsid = ClsidImagingFactory;
|
||||
var iid = IidImagingFactory;
|
||||
int hr = CoCreateInstance(ref clsid, IntPtr.Zero, ClsCtxInprocServer, ref iid, out IntPtr raw);
|
||||
if (hr < 0 || raw == IntPtr.Zero)
|
||||
throw new InvalidOperationException($"Impossibile creare la factory WIC (HRESULT 0x{hr:X8}).");
|
||||
|
||||
try
|
||||
{
|
||||
_threadFactory = (IWICImagingFactory)Marshal.GetObjectForIUnknown(raw);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.Release(raw);
|
||||
}
|
||||
return _threadFactory!;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Release(object? comObject)
|
||||
{
|
||||
if (comObject is not null && Marshal.IsComObject(comObject))
|
||||
{
|
||||
try { Marshal.ReleaseComObject(comObject); } catch (ArgumentException) { /* già rilasciato */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Titano.Metadata;
|
||||
|
||||
/// <summary>Origine del timestamp di scatto, usata per segnalare in UI la qualità del dato.</summary>
|
||||
public enum TimestampSource
|
||||
{
|
||||
None = 0,
|
||||
Exif,
|
||||
ExifSubSecond,
|
||||
Xmp,
|
||||
FileSystem,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadati di un singolo fotogramma della sequenza, estratti dal parser binario in-house.
|
||||
/// Immutabile per costruzione: la pipeline la tratta come dato condivisibile fra thread.
|
||||
/// </summary>
|
||||
public sealed class FrameMetadata
|
||||
{
|
||||
public required string FilePath { get; init; }
|
||||
public required string FileName { get; init; }
|
||||
public long FileSize { get; init; }
|
||||
|
||||
/// <summary>Istante di scatto, comprensivo di frazione di secondo quando disponibile.</summary>
|
||||
public DateTime? CaptureTime { get; init; }
|
||||
public TimestampSource CaptureSource { get; init; }
|
||||
public TimeSpan? UtcOffset { get; init; }
|
||||
|
||||
public double? ExposureSeconds { get; init; }
|
||||
public double? FNumber { get; init; }
|
||||
public int? Iso { get; init; }
|
||||
public double? FocalLength { get; init; }
|
||||
public double? ExposureBias { get; init; }
|
||||
|
||||
public int PixelWidth { get; init; }
|
||||
public int PixelHeight { get; init; }
|
||||
public int Orientation { get; init; } = 1;
|
||||
|
||||
public string? Camera { get; init; }
|
||||
public string? Lens { get; init; }
|
||||
|
||||
/// <summary>Errori non fatali incontrati durante il parsing (file troncato, IFD corrotta...).</summary>
|
||||
public string? Warning { get; init; }
|
||||
|
||||
public string ExposureText => ExposureSeconds is not { } e
|
||||
? "—"
|
||||
: e >= 1
|
||||
? string.Format(CultureInfo.CurrentCulture, "{0:0.#}s", e)
|
||||
: string.Format(CultureInfo.CurrentCulture, "1/{0:0.#}", 1.0 / Math.Max(e, 1e-9));
|
||||
|
||||
public string ApertureText => FNumber is { } f ? "f/" + f.ToString("0.#", CultureInfo.CurrentCulture) : "—";
|
||||
public string IsoText => Iso is { } i ? i.ToString(CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
/// <summary>Costruisce l'istante completo a partire dai campi Exif grezzi.</summary>
|
||||
internal static DateTime? CombineDateTime(string? exifDateTime, string? subSec, out TimestampSource source)
|
||||
{
|
||||
source = TimestampSource.None;
|
||||
if (string.IsNullOrWhiteSpace(exifDateTime)) return null;
|
||||
|
||||
// Formato canonico Exif: "YYYY:MM:DD HH:MM:SS" (alcuni firmware usano '-' o '/').
|
||||
Span<int> parts = stackalloc int[6];
|
||||
int found = 0;
|
||||
int value = 0;
|
||||
bool inNumber = false;
|
||||
foreach (char c in exifDateTime)
|
||||
{
|
||||
if (c is >= '0' and <= '9')
|
||||
{
|
||||
value = value * 10 + (c - '0');
|
||||
inNumber = true;
|
||||
if (value > 999999) return null;
|
||||
}
|
||||
else if (inNumber)
|
||||
{
|
||||
if (found < 6) parts[found++] = value;
|
||||
value = 0;
|
||||
inNumber = false;
|
||||
if (found == 6) break;
|
||||
}
|
||||
}
|
||||
if (inNumber && found < 6) parts[found++] = value;
|
||||
if (found < 6) return null;
|
||||
|
||||
int year = parts[0], month = parts[1], day = parts[2], hour = parts[3], minute = parts[4], second = parts[5];
|
||||
if (year < 1900 || year > 3000 || month is < 1 or > 12 || day is < 1 or > 31) return null;
|
||||
if (hour > 23 || minute > 59 || second > 60) return null;
|
||||
if (second == 60) second = 59; // leap second difensivo
|
||||
if (day > DateTime.DaysInMonth(year, month)) return null;
|
||||
|
||||
var stamp = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
|
||||
source = TimestampSource.Exif;
|
||||
|
||||
// SubSecTime è una stringa di cifre decimali: "37" -> 0.37 s, "004" -> 0.004 s.
|
||||
if (!string.IsNullOrWhiteSpace(subSec))
|
||||
{
|
||||
double fraction = 0, scale = 0.1;
|
||||
int digits = 0;
|
||||
foreach (char c in subSec.Trim())
|
||||
{
|
||||
if (c is < '0' or > '9') break;
|
||||
fraction += (c - '0') * scale;
|
||||
scale *= 0.1;
|
||||
if (++digits >= 7) break;
|
||||
}
|
||||
if (digits > 0 && fraction > 0)
|
||||
{
|
||||
stamp = stamp.AddTicks((long)Math.Round(fraction * TimeSpan.TicksPerSecond));
|
||||
source = TimestampSource.ExifSubSecond;
|
||||
}
|
||||
else if (digits > 0)
|
||||
{
|
||||
source = TimestampSource.ExifSubSecond; // frazione presente ma nulla: precisione comunque nota
|
||||
}
|
||||
}
|
||||
|
||||
return stamp;
|
||||
}
|
||||
|
||||
/// <summary>Interpreta un offset fuso orario Exif nella forma "+02:00".</summary>
|
||||
internal static TimeSpan? ParseUtcOffset(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
text = text.Trim();
|
||||
if (text.Length < 3) return null;
|
||||
int sign = text[0] == '-' ? -1 : text[0] == '+' ? 1 : 0;
|
||||
if (sign == 0) return null;
|
||||
|
||||
int colon = text.IndexOf(':');
|
||||
string hh = colon > 0 ? text[1..colon] : text[1..Math.Min(3, text.Length)];
|
||||
string mm = colon > 0 && colon + 1 < text.Length ? text[(colon + 1)..] : "0";
|
||||
|
||||
if (!int.TryParse(hh, NumberStyles.Integer, CultureInfo.InvariantCulture, out int h)) return null;
|
||||
if (!int.TryParse(mm, NumberStyles.Integer, CultureInfo.InvariantCulture, out int m)) m = 0;
|
||||
if (h > 14 || m > 59) return null;
|
||||
return new TimeSpan(sign * h, sign * m, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Titano.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Ingestion: apre il file, riconosce il contenitore (JPEG, TIFF/RAW, PNG, HEIF/AVIF, WebP),
|
||||
/// individua i blocchi Exif e XMP e li decodifica con i parser binari in-house.
|
||||
/// Nessuna libreria esterna, nessun processo figlio.
|
||||
/// </summary>
|
||||
public static class MetadataReader
|
||||
{
|
||||
/// <summary>Quantità massima di header letta per i contenitori TIFF/RAW (le IFD stanno all'inizio).</summary>
|
||||
private const int TiffHeaderBudget = 4 * 1024 * 1024;
|
||||
|
||||
private static readonly byte[] ExifSignature = "Exif\0\0"u8.ToArray();
|
||||
private static readonly byte[] XmpSignature = "http://ns.adobe.com/xap/1.0/\0"u8.ToArray();
|
||||
|
||||
public static readonly string[] SupportedExtensions =
|
||||
[
|
||||
".jpg", ".jpeg", ".jpe", ".jfif",
|
||||
".tif", ".tiff",
|
||||
".png",
|
||||
".heic", ".heif", ".avif",
|
||||
".webp",
|
||||
".dng", ".cr2", ".cr3", ".nef", ".nrw", ".arw", ".srf", ".sr2",
|
||||
".orf", ".rw2", ".raf", ".pef", ".raw", ".3fr", ".iiq",
|
||||
];
|
||||
|
||||
public static bool IsSupported(string path)
|
||||
=> SupportedExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static FrameMetadata Read(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
string? warning = null;
|
||||
|
||||
DateTime? capture = null;
|
||||
var source = TimestampSource.None;
|
||||
TimeSpan? utcOffset = null;
|
||||
double? exposure = null, fnumber = null, focal = null, bias = null;
|
||||
int? iso = null;
|
||||
int width = 0, height = 0, orientation = 1;
|
||||
string? camera = null, lens = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
|
||||
64 * 1024, FileOptions.SequentialScan);
|
||||
|
||||
var container = DetectContainer(stream, out byte[] head);
|
||||
TiffBlock? tiff = null;
|
||||
string? xmpPacket = null;
|
||||
|
||||
switch (container)
|
||||
{
|
||||
case ContainerKind.Jpeg:
|
||||
ScanJpeg(stream, ref tiff, ref xmpPacket, ref width, ref height, ref warning);
|
||||
break;
|
||||
|
||||
case ContainerKind.Tiff:
|
||||
{
|
||||
int budget = (int)Math.Min(info.Length, TiffHeaderBudget);
|
||||
byte[] buffer = ReadAt(stream, 0, budget);
|
||||
tiff = TiffBlock.Parse(buffer, 0, buffer.Length);
|
||||
xmpPacket = FindXmpInBuffer(buffer);
|
||||
if (tiff is null) warning = "Header TIFF non interpretabile.";
|
||||
break;
|
||||
}
|
||||
|
||||
case ContainerKind.Png:
|
||||
ScanPng(stream, ref tiff, ref xmpPacket, ref width, ref height);
|
||||
break;
|
||||
|
||||
default:
|
||||
{
|
||||
// Contenitori ISO-BMFF (HEIC/AVIF), WebP e formati proprietari: individuiamo
|
||||
// il blocco Exif/XMP per scansione diretta dell'header.
|
||||
int budget = (int)Math.Min(info.Length, TiffHeaderBudget);
|
||||
byte[] buffer = ReadAt(stream, 0, budget);
|
||||
tiff = FindTiffInBuffer(buffer);
|
||||
xmpPacket = FindXmpInBuffer(buffer);
|
||||
if (tiff is null && xmpPacket is null)
|
||||
warning = "Nessun blocco Exif/XMP riconosciuto nell'header.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tiff is not null)
|
||||
{
|
||||
string? dt = tiff.GetString(TiffTags.DateTimeOriginal)
|
||||
?? tiff.GetString(TiffTags.DateTimeDigitized)
|
||||
?? tiff.GetString(TiffTags.DateTime);
|
||||
string? sub = tiff.GetString(TiffTags.SubSecTimeOriginal)
|
||||
?? tiff.GetString(TiffTags.SubSecTimeDigitized)
|
||||
?? tiff.GetString(TiffTags.SubSecTime);
|
||||
capture = FrameMetadata.CombineDateTime(dt, sub, out source);
|
||||
|
||||
utcOffset = FrameMetadata.ParseUtcOffset(
|
||||
tiff.GetString(TiffTags.OffsetTimeOriginal) ?? tiff.GetString(TiffTags.OffsetTime));
|
||||
|
||||
if (tiff.TryGetDouble(TiffTags.ExposureTime, out double e) && e > 0) exposure = e;
|
||||
else if (tiff.TryGetDouble(TiffTags.ShutterSpeedValue, out double apexTv))
|
||||
exposure = Math.Pow(2.0, -apexTv); // APEX: Tv = -log2(t)
|
||||
|
||||
if (tiff.TryGetDouble(TiffTags.FNumber, out double f) && f > 0) fnumber = f;
|
||||
else if (tiff.TryGetDouble(TiffTags.ApertureValue, out double apexAv))
|
||||
fnumber = Math.Pow(2.0, apexAv / 2.0); // APEX: Av = 2*log2(N)
|
||||
|
||||
if (tiff.TryGetFirstUInt32(out uint isoValue, TiffTags.IsoSpeedRatings,
|
||||
TiffTags.RecommendedExposureIndex, TiffTags.IsoSpeed))
|
||||
iso = (int)Math.Min(isoValue, int.MaxValue);
|
||||
|
||||
if (tiff.TryGetDouble(TiffTags.FocalLength, out double fl) && fl > 0) focal = fl;
|
||||
if (tiff.TryGetDouble(TiffTags.ExposureBiasValue, out double eb)) bias = eb;
|
||||
|
||||
if (tiff.TryGetUInt32(TiffTags.Orientation, out uint o) && o is >= 1 and <= 8) orientation = (int)o;
|
||||
|
||||
if (width == 0 && tiff.TryGetUInt32(TiffTags.PixelXDimension, out uint pw)) width = (int)pw;
|
||||
if (height == 0 && tiff.TryGetUInt32(TiffTags.PixelYDimension, out uint ph)) height = (int)ph;
|
||||
if (width == 0 && tiff.TryGetUInt32(TiffTags.ImageWidth, out uint iw)) width = (int)iw;
|
||||
if (height == 0 && tiff.TryGetUInt32(TiffTags.ImageLength, out uint ih)) height = (int)ih;
|
||||
|
||||
string? make = tiff.GetString(TiffTags.Make);
|
||||
string? model = tiff.GetString(TiffTags.Model);
|
||||
camera = ComposeCamera(make, model);
|
||||
lens = tiff.GetString(TiffTags.LensModel);
|
||||
|
||||
// Alcuni contenitori riportano l'XMP dentro il tag TIFF 0x02BC.
|
||||
if (xmpPacket is null && tiff.TryFind(TiffTags.XmpPacket, out var xmpEntry))
|
||||
{
|
||||
byte[] raw = tiff.View.ToArray(xmpEntry.ValuePosition, Math.Min(xmpEntry.ByteLength, 1 << 20));
|
||||
if (raw.Length > 0) xmpPacket = XmpScanner.ExtractPacket(Encoding.UTF8.GetString(raw));
|
||||
}
|
||||
}
|
||||
|
||||
// XMP come sorgente complementare: riempie solo i campi ancora ignoti.
|
||||
if (xmpPacket is not null)
|
||||
{
|
||||
var x = XmpScanner.Scan(xmpPacket);
|
||||
if (capture is null && x.CaptureTime is { } xc)
|
||||
{
|
||||
capture = xc;
|
||||
source = TimestampSource.Xmp;
|
||||
}
|
||||
utcOffset ??= x.UtcOffset;
|
||||
exposure ??= x.ExposureSeconds;
|
||||
fnumber ??= x.FNumber;
|
||||
iso ??= x.Iso;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
warning = "Lettura non riuscita: " + ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warning = "Metadati non interpretabili: " + ex.Message;
|
||||
}
|
||||
|
||||
if (capture is null)
|
||||
{
|
||||
// Ultima risorsa: la data di modifica del file, marcata come tale in UI.
|
||||
capture = info.Exists ? info.LastWriteTime : null;
|
||||
if (capture is not null) source = TimestampSource.FileSystem;
|
||||
}
|
||||
|
||||
return new FrameMetadata
|
||||
{
|
||||
FilePath = path,
|
||||
FileName = Path.GetFileName(path),
|
||||
FileSize = info.Exists ? info.Length : 0,
|
||||
CaptureTime = capture,
|
||||
CaptureSource = source,
|
||||
UtcOffset = utcOffset,
|
||||
ExposureSeconds = exposure,
|
||||
FNumber = fnumber,
|
||||
Iso = iso,
|
||||
FocalLength = focal,
|
||||
ExposureBias = bias,
|
||||
PixelWidth = width,
|
||||
PixelHeight = height,
|
||||
Orientation = orientation,
|
||||
Camera = camera,
|
||||
Lens = lens,
|
||||
Warning = warning,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ComposeCamera(string? make, string? model)
|
||||
{
|
||||
make = make?.Trim();
|
||||
model = model?.Trim();
|
||||
if (string.IsNullOrEmpty(model)) return string.IsNullOrEmpty(make) ? null : make;
|
||||
if (string.IsNullOrEmpty(make)) return model;
|
||||
// Molti modelli ripetono già il produttore ("NIKON D850" con make "NIKON CORPORATION").
|
||||
string firstWord = make.Split(' ')[0];
|
||||
return model.StartsWith(firstWord, StringComparison.OrdinalIgnoreCase) ? model : make + " " + model;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ contenitori
|
||||
|
||||
private enum ContainerKind { Unknown, Jpeg, Tiff, Png }
|
||||
|
||||
private static ContainerKind DetectContainer(FileStream stream, out byte[] head)
|
||||
{
|
||||
head = ReadAt(stream, 0, 16);
|
||||
if (head.Length >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF) return ContainerKind.Jpeg;
|
||||
if (head.Length >= 4 && ((head[0] == 0x49 && head[1] == 0x49) || (head[0] == 0x4D && head[1] == 0x4D)))
|
||||
return ContainerKind.Tiff;
|
||||
if (head.Length >= 8 && head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47)
|
||||
return ContainerKind.Png;
|
||||
return ContainerKind.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Percorre i marker JPEG senza decodificare l'immagine: legge solo gli header di segmento
|
||||
/// e i payload APP1 (Exif/XMP), estraendo le dimensioni dal segmento SOF.
|
||||
/// </summary>
|
||||
private static void ScanJpeg(FileStream stream, ref TiffBlock? tiff, ref string? xmp,
|
||||
ref int width, ref int height, ref string? warning)
|
||||
{
|
||||
stream.Position = 2;
|
||||
Span<byte> header = stackalloc byte[4];
|
||||
long length = stream.Length;
|
||||
int guard = 0;
|
||||
|
||||
while (stream.Position < length && guard++ < 4096)
|
||||
{
|
||||
int b = stream.ReadByte();
|
||||
if (b < 0) break;
|
||||
if (b != 0xFF) continue; // risincronizzazione sul prossimo marker
|
||||
|
||||
int marker;
|
||||
do { marker = stream.ReadByte(); } while (marker == 0xFF);
|
||||
if (marker < 0) break;
|
||||
|
||||
// Marker senza payload.
|
||||
if (marker is 0x01 or 0xD8 or 0xD9 || (marker >= 0xD0 && marker <= 0xD7)) continue;
|
||||
if (marker == 0xDA) break; // inizio dati compressi: oltre non serve
|
||||
|
||||
if (stream.Read(header[..2]) != 2) break;
|
||||
int segLength = (header[0] << 8) | header[1];
|
||||
if (segLength < 2) break;
|
||||
int payload = segLength - 2;
|
||||
long payloadStart = stream.Position;
|
||||
|
||||
if (marker == 0xE1 && payload > 6)
|
||||
{
|
||||
byte[] data = ReadExactly(stream, Math.Min(payload, 16 * 1024 * 1024));
|
||||
if (data.Length >= ExifSignature.Length && Matches(data, ExifSignature))
|
||||
{
|
||||
tiff ??= TiffBlock.Parse(data, ExifSignature.Length, data.Length - ExifSignature.Length);
|
||||
if (tiff is null) warning = "Blocco Exif presente ma non interpretabile.";
|
||||
}
|
||||
else if (data.Length >= XmpSignature.Length && Matches(data, XmpSignature))
|
||||
{
|
||||
xmp ??= XmpScanner.ExtractPacket(Encoding.UTF8.GetString(data, XmpSignature.Length,
|
||||
data.Length - XmpSignature.Length));
|
||||
}
|
||||
}
|
||||
else if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC)
|
||||
{
|
||||
// SOFn: precision(1) height(2) width(2)
|
||||
byte[] sof = ReadExactly(stream, Math.Min(payload, 9));
|
||||
if (sof.Length >= 5)
|
||||
{
|
||||
height = (sof[1] << 8) | sof[2];
|
||||
width = (sof[3] << 8) | sof[4];
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = payloadStart + payload;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanPng(FileStream stream, ref TiffBlock? tiff, ref string? xmp, ref int width, ref int height)
|
||||
{
|
||||
stream.Position = 8;
|
||||
Span<byte> header = stackalloc byte[8];
|
||||
int guard = 0;
|
||||
|
||||
while (stream.Position + 8 <= stream.Length && guard++ < 1024)
|
||||
{
|
||||
if (stream.Read(header) != 8) break;
|
||||
uint len = ((uint)header[0] << 24) | ((uint)header[1] << 16) | ((uint)header[2] << 8) | header[3];
|
||||
string type = Encoding.ASCII.GetString(header[4..].ToArray());
|
||||
long dataStart = stream.Position;
|
||||
if (len > 64 * 1024 * 1024) break;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "IHDR" when len >= 8:
|
||||
{
|
||||
byte[] ihdr = ReadExactly(stream, 8);
|
||||
if (ihdr.Length == 8)
|
||||
{
|
||||
width = (ihdr[0] << 24) | (ihdr[1] << 16) | (ihdr[2] << 8) | ihdr[3];
|
||||
height = (ihdr[4] << 24) | (ihdr[5] << 16) | (ihdr[6] << 8) | ihdr[7];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "eXIf":
|
||||
{
|
||||
byte[] data = ReadExactly(stream, (int)len);
|
||||
tiff ??= TiffBlock.Parse(data, 0, data.Length);
|
||||
break;
|
||||
}
|
||||
case "iTXt" or "tEXt" or "zTXt":
|
||||
{
|
||||
byte[] data = ReadExactly(stream, (int)Math.Min(len, 4 * 1024 * 1024));
|
||||
xmp ??= XmpScanner.ExtractPacket(Encoding.UTF8.GetString(data));
|
||||
break;
|
||||
}
|
||||
case "IDAT" or "IEND":
|
||||
return; // i metadati utili precedono i dati pixel
|
||||
}
|
||||
|
||||
stream.Position = dataStart + len + 4; // + CRC
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ fallback per scansione
|
||||
|
||||
private static TiffBlock? FindTiffInBuffer(byte[] buffer)
|
||||
{
|
||||
var view = new ByteView(buffer, 0, buffer.Length, false);
|
||||
int idx = view.IndexOf(ExifSignature);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var block = TiffBlock.Parse(buffer, idx + ExifSignature.Length, buffer.Length - idx - ExifSignature.Length);
|
||||
if (block is not null) return block;
|
||||
}
|
||||
// Alcuni contenitori scrivono l'header TIFF senza prefisso "Exif\0\0".
|
||||
foreach (var marker in (ReadOnlySpan<byte[]>)[[0x49, 0x49, 0x2A, 0x00], [0x4D, 0x4D, 0x00, 0x2A]])
|
||||
{
|
||||
int at = view.IndexOf(marker);
|
||||
if (at < 0) continue;
|
||||
var block = TiffBlock.Parse(buffer, at, buffer.Length - at);
|
||||
if (block is not null && (block.Exif.Count > 0 || block.Ifd0.Count > 3)) return block;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindXmpInBuffer(byte[] buffer)
|
||||
{
|
||||
var view = new ByteView(buffer, 0, buffer.Length, false);
|
||||
int idx = view.IndexOf("<x:xmpmeta"u8);
|
||||
if (idx < 0) idx = view.IndexOf("<?xpacket"u8);
|
||||
if (idx < 0) return null;
|
||||
int take = Math.Min(buffer.Length - idx, 512 * 1024);
|
||||
return XmpScanner.ExtractPacket(Encoding.UTF8.GetString(buffer, idx, take));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ utilità di I/O
|
||||
|
||||
private static bool Matches(byte[] data, byte[] signature)
|
||||
{
|
||||
if (data.Length < signature.Length) return false;
|
||||
for (int i = 0; i < signature.Length; i++)
|
||||
{
|
||||
if (data[i] != signature[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] ReadAt(FileStream stream, long offset, int count)
|
||||
{
|
||||
if (count <= 0) return [];
|
||||
stream.Position = offset;
|
||||
return ReadExactly(stream, count);
|
||||
}
|
||||
|
||||
private static byte[] ReadExactly(FileStream stream, int count)
|
||||
{
|
||||
if (count <= 0) return [];
|
||||
var buffer = new byte[count];
|
||||
int read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
int n = stream.Read(buffer, read, count - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
if (read == count) return buffer;
|
||||
Array.Resize(ref buffer, read);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
namespace Titano.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Parser in-house di un blocco TIFF (header + catena di IFD), condiviso da JPEG/APP1,
|
||||
/// file TIFF nativi, DNG e dai principali formati RAW derivati da TIFF (CR2, NEF, ARW, ORF...).
|
||||
/// </summary>
|
||||
internal sealed class TiffBlock
|
||||
{
|
||||
public ByteView View { get; }
|
||||
public Dictionary<ushort, TiffEntry> Ifd0 { get; } = [];
|
||||
public Dictionary<ushort, TiffEntry> Exif { get; } = [];
|
||||
public Dictionary<ushort, TiffEntry> Gps { get; } = [];
|
||||
public List<Dictionary<ushort, TiffEntry>> SubIfds { get; } = [];
|
||||
|
||||
private TiffBlock(ByteView view) => View = view;
|
||||
|
||||
/// <summary>Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory.</summary>
|
||||
public static TiffBlock? Parse(byte[] data, int origin, int length)
|
||||
{
|
||||
var probe = new ByteView(data, origin, length, false);
|
||||
if (!probe.TryGetUInt16(0, out ushort order)) return null;
|
||||
|
||||
bool bigEndian;
|
||||
if (order == 0x4949) bigEndian = false; // "II" - Intel
|
||||
else if (order == 0x4D4D) bigEndian = true; // "MM" - Motorola
|
||||
else return null;
|
||||
|
||||
var view = probe.WithEndianness(bigEndian);
|
||||
if (!view.TryGetUInt16(2, out ushort magic)) return null;
|
||||
// 42 = TIFF classico. 0x4F52/0x5352 compaiono in alcuni RAW Olympus, li accettiamo.
|
||||
if (magic != 42 && magic != 0x4F52 && magic != 0x5352) return null;
|
||||
if (!view.TryGetUInt32(4, out uint firstIfd)) return null;
|
||||
|
||||
var block = new TiffBlock(view);
|
||||
block.ReadChain(firstIfd);
|
||||
return block;
|
||||
}
|
||||
|
||||
private void ReadChain(uint offset)
|
||||
{
|
||||
var visited = new HashSet<uint>();
|
||||
int guard = 0;
|
||||
uint next = offset;
|
||||
|
||||
while (next != 0 && visited.Add(next) && guard++ < 32)
|
||||
{
|
||||
var dir = ReadDirectory(next, out uint following);
|
||||
if (dir is null) break;
|
||||
|
||||
if (Ifd0.Count == 0) MergeInto(Ifd0, dir);
|
||||
else SubIfds.Add(dir);
|
||||
|
||||
// Puntatori alle sub-directory standard.
|
||||
if (dir.TryGetValue(TiffTags.ExifIfdPointer, out var exifPtr) && TryReadPointer(exifPtr, out uint eo))
|
||||
{
|
||||
var d = ReadDirectory(eo, out _);
|
||||
if (d is not null) MergeInto(Exif, d);
|
||||
}
|
||||
if (dir.TryGetValue(TiffTags.GpsIfdPointer, out var gpsPtr) && TryReadPointer(gpsPtr, out uint go))
|
||||
{
|
||||
var d = ReadDirectory(go, out _);
|
||||
if (d is not null) MergeInto(Gps, d);
|
||||
}
|
||||
// SubIFDs (DNG / RAW): possono contenere le dimensioni dell'immagine full-res.
|
||||
if (dir.TryGetValue(TiffTags.SubIfds, out var subs))
|
||||
{
|
||||
int count = (int)Math.Min(subs.Count, 8);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!View.TryGetUInt32(subs.ValuePosition + i * 4, out uint so)) break;
|
||||
var d = ReadDirectory(so, out _);
|
||||
if (d is not null) SubIfds.Add(d);
|
||||
}
|
||||
}
|
||||
|
||||
next = following;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeInto(Dictionary<ushort, TiffEntry> target, Dictionary<ushort, TiffEntry> source)
|
||||
{
|
||||
foreach (var kv in source) target.TryAdd(kv.Key, kv.Value);
|
||||
}
|
||||
|
||||
private bool TryReadPointer(in TiffEntry entry, out uint offset)
|
||||
{
|
||||
offset = 0;
|
||||
if (entry.Count < 1) return false;
|
||||
return View.TryGetUInt32(entry.ValuePosition, out offset);
|
||||
}
|
||||
|
||||
private Dictionary<ushort, TiffEntry>? ReadDirectory(uint offset, out uint nextIfd)
|
||||
{
|
||||
nextIfd = 0;
|
||||
if (!View.TryGetUInt16(offset, out ushort count) || count == 0 || count > 4096) return null;
|
||||
|
||||
var dir = new Dictionary<ushort, TiffEntry>(count);
|
||||
long p = offset + 2L;
|
||||
|
||||
for (int i = 0; i < count; i++, p += 12)
|
||||
{
|
||||
if (!View.TryGetUInt16(p, out ushort tag)) break;
|
||||
if (!View.TryGetUInt16(p + 2, out ushort rawType)) break;
|
||||
if (!View.TryGetUInt32(p + 4, out uint n)) break;
|
||||
|
||||
var type = (TiffType)rawType;
|
||||
int unit = TiffEntry.SizeOf(type);
|
||||
if (unit == 0) continue; // tipo sconosciuto: voce ignorata
|
||||
if (n > int.MaxValue / Math.Max(unit, 1)) continue;
|
||||
|
||||
long bytes = (long)unit * n;
|
||||
long valuePos;
|
||||
if (bytes <= 4)
|
||||
{
|
||||
valuePos = p + 8; // valore inline nei 4 byte della voce
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!View.TryGetUInt32(p + 8, out uint far)) continue;
|
||||
valuePos = far;
|
||||
}
|
||||
if (!View.InRange(valuePos, bytes)) continue;
|
||||
|
||||
dir[tag] = new TiffEntry(tag, type, n, valuePos);
|
||||
}
|
||||
|
||||
View.TryGetUInt32(p, out nextIfd);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- accesso ai valori
|
||||
|
||||
public bool TryFind(ushort tag, out TiffEntry entry)
|
||||
{
|
||||
if (Exif.TryGetValue(tag, out entry)) return true;
|
||||
if (Ifd0.TryGetValue(tag, out entry)) return true;
|
||||
foreach (var sub in SubIfds)
|
||||
{
|
||||
if (sub.TryGetValue(tag, out entry)) return true;
|
||||
}
|
||||
entry = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public string? GetString(ushort tag)
|
||||
{
|
||||
if (!TryFind(tag, out var e)) return null;
|
||||
if (e.Type is TiffType.Ascii or TiffType.Byte or TiffType.Undefined)
|
||||
return View.GetAscii(e.ValuePosition, e.ByteLength);
|
||||
// Alcuni firmware scrivono valori numerici dove ci si aspetta testo.
|
||||
return TryGetDouble(tag, out double d) ? d.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
|
||||
}
|
||||
|
||||
public bool TryGetUInt32(ushort tag, out uint value)
|
||||
{
|
||||
value = 0;
|
||||
if (!TryFind(tag, out var e) || e.Count < 1) return false;
|
||||
return TryReadScalarUInt(e, 0, out value);
|
||||
}
|
||||
|
||||
private bool TryReadScalarUInt(in TiffEntry e, int index, out uint value)
|
||||
{
|
||||
value = 0;
|
||||
long pos = e.ValuePosition + (long)index * TiffEntry.SizeOf(e.Type);
|
||||
switch (e.Type)
|
||||
{
|
||||
case TiffType.Byte or TiffType.SByte or TiffType.Undefined:
|
||||
if (!View.TryGetByte(pos, out byte b)) return false;
|
||||
value = b; return true;
|
||||
case TiffType.Short or TiffType.SShort:
|
||||
if (!View.TryGetUInt16(pos, out ushort s)) return false;
|
||||
value = s; return true;
|
||||
case TiffType.Long or TiffType.SLong or TiffType.Ifd:
|
||||
return View.TryGetUInt32(pos, out value);
|
||||
default:
|
||||
if (!TryReadIndexedDouble(e, index, out double d)) return false;
|
||||
value = d is >= 0 and <= uint.MaxValue ? (uint)d : 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetDouble(ushort tag, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (!TryFind(tag, out var e) || e.Count < 1) return false;
|
||||
return TryReadIndexedDouble(e, 0, out value);
|
||||
}
|
||||
|
||||
private bool TryReadIndexedDouble(in TiffEntry e, int index, out double value)
|
||||
{
|
||||
value = 0;
|
||||
long pos = e.ValuePosition + (long)index * TiffEntry.SizeOf(e.Type);
|
||||
switch (e.Type)
|
||||
{
|
||||
case TiffType.Rational:
|
||||
{
|
||||
if (!View.TryGetUInt32(pos, out uint num) || !View.TryGetUInt32(pos + 4, out uint den)) return false;
|
||||
if (den == 0) { value = num == 0 ? 0 : double.PositiveInfinity; return num == 0; }
|
||||
value = (double)num / den;
|
||||
return true;
|
||||
}
|
||||
case TiffType.SRational:
|
||||
{
|
||||
if (!View.TryGetInt32(pos, out int num) || !View.TryGetInt32(pos + 4, out int den)) return false;
|
||||
if (den == 0) return false;
|
||||
value = (double)num / den;
|
||||
return true;
|
||||
}
|
||||
case TiffType.Float:
|
||||
{
|
||||
if (!View.TryGetSingle(pos, out float f)) return false;
|
||||
value = f; return true;
|
||||
}
|
||||
case TiffType.Double:
|
||||
return View.TryGetDouble(pos, out value);
|
||||
case TiffType.SShort:
|
||||
{
|
||||
if (!View.TryGetUInt16(pos, out ushort us)) return false;
|
||||
value = (short)us; return true;
|
||||
}
|
||||
case TiffType.SLong:
|
||||
{
|
||||
if (!View.TryGetInt32(pos, out int i)) return false;
|
||||
value = i; return true;
|
||||
}
|
||||
default:
|
||||
{
|
||||
if (!TryReadScalarUIntRaw(e.Type, pos, out uint u)) return false;
|
||||
value = u; return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadScalarUIntRaw(TiffType type, long pos, out uint value)
|
||||
{
|
||||
value = 0;
|
||||
switch (type)
|
||||
{
|
||||
case TiffType.Byte or TiffType.SByte or TiffType.Undefined:
|
||||
if (!View.TryGetByte(pos, out byte b)) return false;
|
||||
value = b; return true;
|
||||
case TiffType.Short:
|
||||
if (!View.TryGetUInt16(pos, out ushort s)) return false;
|
||||
value = s; return true;
|
||||
case TiffType.Long or TiffType.Ifd:
|
||||
return View.TryGetUInt32(pos, out value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Primo valore non nullo di una lista di tag (utile per ISO, presente in più varianti).</summary>
|
||||
public bool TryGetFirstUInt32(out uint value, params ushort[] tags)
|
||||
{
|
||||
foreach (ushort t in tags)
|
||||
{
|
||||
if (TryGetUInt32(t, out value) && value != 0) return true;
|
||||
}
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Numerazione dei tag TIFF/Exif effettivamente utilizzati dal motore.</summary>
|
||||
internal static class TiffTags
|
||||
{
|
||||
public const ushort ImageWidth = 0x0100;
|
||||
public const ushort ImageLength = 0x0101;
|
||||
public const ushort Make = 0x010F;
|
||||
public const ushort Model = 0x0110;
|
||||
public const ushort Orientation = 0x0112;
|
||||
public const ushort SubIfds = 0x014A;
|
||||
public const ushort DateTime = 0x0132;
|
||||
public const ushort XmpPacket = 0x02BC;
|
||||
|
||||
public const ushort ExposureTime = 0x829A;
|
||||
public const ushort FNumber = 0x829D;
|
||||
public const ushort ExifIfdPointer = 0x8769;
|
||||
public const ushort IsoSpeedRatings = 0x8827;
|
||||
public const ushort SensitivityType = 0x8830;
|
||||
public const ushort RecommendedExposureIndex = 0x8832;
|
||||
public const ushort IsoSpeed = 0x8833;
|
||||
public const ushort GpsIfdPointer = 0x8825;
|
||||
|
||||
public const ushort DateTimeOriginal = 0x9003;
|
||||
public const ushort DateTimeDigitized = 0x9004;
|
||||
public const ushort OffsetTime = 0x9010;
|
||||
public const ushort OffsetTimeOriginal = 0x9011;
|
||||
public const ushort OffsetTimeDigitized = 0x9012;
|
||||
public const ushort ShutterSpeedValue = 0x9201;
|
||||
public const ushort ApertureValue = 0x9202;
|
||||
public const ushort ExposureBiasValue = 0x9204;
|
||||
public const ushort MeteringMode = 0x9207;
|
||||
public const ushort FocalLength = 0x920A;
|
||||
public const ushort SubSecTime = 0x9290;
|
||||
public const ushort SubSecTimeOriginal = 0x9291;
|
||||
public const ushort SubSecTimeDigitized = 0x9292;
|
||||
|
||||
public const ushort PixelXDimension = 0xA002;
|
||||
public const ushort PixelYDimension = 0xA003;
|
||||
public const ushort ExposureMode = 0xA402;
|
||||
public const ushort WhiteBalance = 0xA403;
|
||||
public const ushort LensModel = 0xA434;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
namespace Titano.Metadata;
|
||||
|
||||
/// <summary>Tipi di dato definiti dalla specifica TIFF 6.0 / Exif 2.32.</summary>
|
||||
internal enum TiffType : ushort
|
||||
{
|
||||
Unknown = 0,
|
||||
Byte = 1,
|
||||
Ascii = 2,
|
||||
Short = 3,
|
||||
Long = 4,
|
||||
Rational = 5,
|
||||
SByte = 6,
|
||||
Undefined = 7,
|
||||
SShort = 8,
|
||||
SLong = 9,
|
||||
SRational = 10,
|
||||
Float = 11,
|
||||
Double = 12,
|
||||
Ifd = 13,
|
||||
}
|
||||
|
||||
/// <summary>Voce di una IFD: 12 byte nel file, con il valore inline se occupa ≤ 4 byte.</summary>
|
||||
internal readonly struct TiffEntry
|
||||
{
|
||||
public readonly ushort Tag;
|
||||
public readonly TiffType Type;
|
||||
public readonly uint Count;
|
||||
|
||||
/// <summary>Offset assoluto (rispetto all'inizio del blocco TIFF) dove risiede il valore.</summary>
|
||||
public readonly long ValuePosition;
|
||||
|
||||
public TiffEntry(ushort tag, TiffType type, uint count, long valuePosition)
|
||||
{
|
||||
Tag = tag;
|
||||
Type = type;
|
||||
Count = count;
|
||||
ValuePosition = valuePosition;
|
||||
}
|
||||
|
||||
public static int SizeOf(TiffType type) => type switch
|
||||
{
|
||||
TiffType.Byte or TiffType.Ascii or TiffType.SByte or TiffType.Undefined => 1,
|
||||
TiffType.Short or TiffType.SShort => 2,
|
||||
TiffType.Long or TiffType.SLong or TiffType.Float or TiffType.Ifd => 4,
|
||||
TiffType.Rational or TiffType.SRational or TiffType.Double => 8,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
public long ByteLength => (long)SizeOf(Type) * Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lettore binario endian-aware su uno <see cref="ReadOnlySpan{T}"/> logico (buffer immutabile).
|
||||
/// Tutti gli accessi sono limitati: un file malformato produce valori assenti, mai eccezioni.
|
||||
/// </summary>
|
||||
internal readonly struct ByteView
|
||||
{
|
||||
private readonly byte[] _data;
|
||||
private readonly int _origin;
|
||||
private readonly int _length;
|
||||
public readonly bool BigEndian;
|
||||
|
||||
public ByteView(byte[] data, int origin, int length, bool bigEndian)
|
||||
{
|
||||
_data = data;
|
||||
_origin = Math.Clamp(origin, 0, data.Length);
|
||||
_length = Math.Clamp(length, 0, data.Length - _origin);
|
||||
BigEndian = bigEndian;
|
||||
}
|
||||
|
||||
public ByteView WithEndianness(bool bigEndian) => new(_data, _origin, _length, bigEndian);
|
||||
|
||||
public int Length => _length;
|
||||
|
||||
public bool InRange(long offset, long count)
|
||||
=> offset >= 0 && count >= 0 && offset + count <= _length;
|
||||
|
||||
public bool TryGetByte(long offset, out byte value)
|
||||
{
|
||||
if (!InRange(offset, 1)) { value = 0; return false; }
|
||||
value = _data[_origin + (int)offset];
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetUInt16(long offset, out ushort value)
|
||||
{
|
||||
value = 0;
|
||||
if (!InRange(offset, 2)) return false;
|
||||
int p = _origin + (int)offset;
|
||||
value = BigEndian
|
||||
? (ushort)((_data[p] << 8) | _data[p + 1])
|
||||
: (ushort)((_data[p + 1] << 8) | _data[p]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetUInt32(long offset, out uint value)
|
||||
{
|
||||
value = 0;
|
||||
if (!InRange(offset, 4)) return false;
|
||||
int p = _origin + (int)offset;
|
||||
value = BigEndian
|
||||
? ((uint)_data[p] << 24) | ((uint)_data[p + 1] << 16) | ((uint)_data[p + 2] << 8) | _data[p + 3]
|
||||
: ((uint)_data[p + 3] << 24) | ((uint)_data[p + 2] << 16) | ((uint)_data[p + 1] << 8) | _data[p];
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetInt32(long offset, out int value)
|
||||
{
|
||||
bool ok = TryGetUInt32(offset, out uint raw);
|
||||
value = unchecked((int)raw);
|
||||
return ok;
|
||||
}
|
||||
|
||||
public bool TryGetSingle(long offset, out float value)
|
||||
{
|
||||
value = 0f;
|
||||
if (!TryGetUInt32(offset, out uint raw)) return false;
|
||||
value = BitConverter.UInt32BitsToSingle(raw);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetDouble(long offset, out double value)
|
||||
{
|
||||
value = 0d;
|
||||
if (!InRange(offset, 8)) return false;
|
||||
TryGetUInt32(offset, out uint a);
|
||||
TryGetUInt32(offset + 4, out uint b);
|
||||
ulong raw = BigEndian ? ((ulong)a << 32) | b : ((ulong)b << 32) | a;
|
||||
value = BitConverter.UInt64BitsToDouble(raw);
|
||||
return true;
|
||||
}
|
||||
|
||||
public string? GetAscii(long offset, long count)
|
||||
{
|
||||
if (!InRange(offset, count) || count <= 0) return null;
|
||||
int p = _origin + (int)offset;
|
||||
int n = (int)count;
|
||||
// Le stringhe Exif sono NUL-terminate; alcuni firmware riempiono di spazi.
|
||||
int end = n;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (_data[p + i] == 0) { end = i; break; }
|
||||
}
|
||||
while (end > 0 && (_data[p + end - 1] == ' ' || _data[p + end - 1] == '\t')) end--;
|
||||
if (end <= 0) return null;
|
||||
return System.Text.Encoding.Latin1.GetString(_data, p, end);
|
||||
}
|
||||
|
||||
/// <summary>Ricerca di un pattern di byte; -1 se assente. Usata per i fallback di scansione.</summary>
|
||||
public int IndexOf(ReadOnlySpan<byte> pattern, int startAt = 0)
|
||||
{
|
||||
if (pattern.Length == 0 || pattern.Length > _length) return -1;
|
||||
var haystack = new ReadOnlySpan<byte>(_data, _origin, _length);
|
||||
int idx = haystack[Math.Clamp(startAt, 0, _length)..].IndexOf(pattern);
|
||||
return idx < 0 ? -1 : idx + Math.Clamp(startAt, 0, _length);
|
||||
}
|
||||
|
||||
public byte[] ToArray(long offset, long count)
|
||||
{
|
||||
if (!InRange(offset, count) || count <= 0) return [];
|
||||
var result = new byte[count];
|
||||
Array.Copy(_data, _origin + (int)offset, result, 0, (int)count);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Titano.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Estrattore XMP minimale scritto in-house: non costruisce un albero XML, esegue una
|
||||
/// scansione lineare del packet cercando le proprietà utili sia in forma attributo
|
||||
/// (<c>xmp:CreateDate="..."</c>) sia in forma elemento (<c><xmp:CreateDate>...</></c>).
|
||||
/// </summary>
|
||||
internal static class XmpScanner
|
||||
{
|
||||
public readonly record struct XmpFields(
|
||||
DateTime? CaptureTime,
|
||||
TimeSpan? UtcOffset,
|
||||
double? ExposureSeconds,
|
||||
double? FNumber,
|
||||
int? Iso);
|
||||
|
||||
private static readonly string[] DateProps = ["exif:DateTimeOriginal", "photoshop:DateCreated", "xmp:CreateDate"];
|
||||
|
||||
public static XmpFields Scan(string packet)
|
||||
{
|
||||
DateTime? time = null;
|
||||
TimeSpan? offset = null;
|
||||
|
||||
foreach (string prop in DateProps)
|
||||
{
|
||||
string? raw = ReadProperty(packet, prop);
|
||||
if (raw is null) continue;
|
||||
if (TryParseIso8601(raw, out var local, out var off))
|
||||
{
|
||||
time = local;
|
||||
offset = off;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
double? exposure = ReadRational(packet, "exif:ExposureTime");
|
||||
double? fnumber = ReadRational(packet, "exif:FNumber");
|
||||
if (fnumber is null && ReadRational(packet, "exif:ApertureValue") is { } apex)
|
||||
fnumber = Math.Round(Math.Pow(2.0, apex / 2.0), 2);
|
||||
|
||||
int? iso = null;
|
||||
// ISOSpeedRatings è tipicamente una rdf:Seq: prendiamo il primo <rdf:li>.
|
||||
string? isoBlock = ReadProperty(packet, "exif:ISOSpeedRatings") ?? ReadProperty(packet, "exif:PhotographicSensitivity");
|
||||
if (isoBlock is not null)
|
||||
{
|
||||
string digits = ExtractFirstNumber(isoBlock);
|
||||
if (int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) && v > 0) iso = v;
|
||||
}
|
||||
|
||||
return new XmpFields(time, offset, exposure, fnumber, iso);
|
||||
}
|
||||
|
||||
/// <summary>Individua un packet XMP dentro un buffer generico di testo/binario.</summary>
|
||||
public static string? ExtractPacket(string text)
|
||||
{
|
||||
int start = text.IndexOf("<x:xmpmeta", StringComparison.Ordinal);
|
||||
if (start < 0) start = text.IndexOf("<rdf:RDF", StringComparison.Ordinal);
|
||||
if (start < 0) return null;
|
||||
int end = text.IndexOf("</x:xmpmeta>", start, StringComparison.Ordinal);
|
||||
if (end < 0) end = text.IndexOf("</rdf:RDF>", start, StringComparison.Ordinal);
|
||||
if (end < 0) return text[start..];
|
||||
return text[start..Math.Min(text.Length, end + 12)];
|
||||
}
|
||||
|
||||
private static string? ReadProperty(string packet, string name)
|
||||
{
|
||||
// Forma attributo: name="value"
|
||||
int idx = 0;
|
||||
while ((idx = packet.IndexOf(name, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
int after = idx + name.Length;
|
||||
if (after >= packet.Length) break;
|
||||
|
||||
char c = packet[after];
|
||||
if (c == '=')
|
||||
{
|
||||
int q = packet.IndexOfAny(['"', '\''], after);
|
||||
if (q < 0) break;
|
||||
char quote = packet[q];
|
||||
int close = packet.IndexOf(quote, q + 1);
|
||||
if (close < 0) break;
|
||||
return packet[(q + 1)..close];
|
||||
}
|
||||
if (c is '>' or ' ' or '\r' or '\n' or '\t' or '/')
|
||||
{
|
||||
int gt = packet.IndexOf('>', after);
|
||||
if (gt < 0) break;
|
||||
if (packet[gt - 1] == '/') { idx = after; continue; } // elemento vuoto
|
||||
int closeTag = packet.IndexOf("</" + name, gt, StringComparison.Ordinal);
|
||||
if (closeTag < 0) break;
|
||||
return packet[(gt + 1)..closeTag].Trim();
|
||||
}
|
||||
idx = after;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? ReadRational(string packet, string name)
|
||||
{
|
||||
string? raw = ReadProperty(packet, name);
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
raw = raw.Trim();
|
||||
|
||||
int slash = raw.IndexOf('/');
|
||||
if (slash > 0)
|
||||
{
|
||||
if (double.TryParse(raw[..slash], NumberStyles.Float, CultureInfo.InvariantCulture, out double n) &&
|
||||
double.TryParse(raw[(slash + 1)..], NumberStyles.Float, CultureInfo.InvariantCulture, out double d) &&
|
||||
d != 0)
|
||||
return n / d;
|
||||
return null;
|
||||
}
|
||||
return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double v) ? v : null;
|
||||
}
|
||||
|
||||
private static string ExtractFirstNumber(string text)
|
||||
{
|
||||
int i = 0;
|
||||
while (i < text.Length && (text[i] < '0' || text[i] > '9')) i++;
|
||||
int start = i;
|
||||
while (i < text.Length && text[i] is >= '0' and <= '9') i++;
|
||||
return start < i ? text[start..i] : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Parsing ISO-8601 ("2024-06-01T18:32:11.250+02:00") senza dipendere dal formattatore di sistema.</summary>
|
||||
internal static bool TryParseIso8601(string text, out DateTime local, out TimeSpan? offset)
|
||||
{
|
||||
local = default;
|
||||
offset = null;
|
||||
text = text.Trim();
|
||||
if (text.Length < 10) return false;
|
||||
|
||||
if (!DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto))
|
||||
{
|
||||
// Fallback per date parziali "YYYY-MM-DD".
|
||||
if (!DateTime.TryParseExact(text[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out local)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool hasOffset = text.EndsWith('Z') || text.LastIndexOfAny(['+', '-']) > 10;
|
||||
if (hasOffset) offset = dto.Offset;
|
||||
local = DateTime.SpecifyKind(dto.DateTime, DateTimeKind.Unspecified);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
TITANO - registro delle modifiche
|
||||
=================================
|
||||
|
||||
2026-08-13 Prima versione completa dell'applicazione.
|
||||
|
||||
Sviluppo da zero di Titano, applicazione desktop per time-lapse professionali,
|
||||
con il vincolo di non usare alcuna libreria o strumento di terze parti.
|
||||
Il progetto non contiene nessun PackageReference: oltre alla libreria standard
|
||||
di .NET si usano solo API native di Windows richiamate via P/Invoke scritto a mano.
|
||||
|
||||
MODULI REALIZZATI
|
||||
|
||||
1. Ingestion e parsing metadati (Metadata/)
|
||||
- Parser binario TIFF/Exif proprietario: header, catena di IFD, tutti i tipi
|
||||
di dato della specifica, sotto-directory Exif e GPS.
|
||||
- Riconoscimento dei contenitori: JPEG (percorso dei marker senza decodifica),
|
||||
TIFF e RAW derivati, PNG (percorso dei chunk), HEIF/AVIF/WebP per scansione.
|
||||
- Scanner XMP proprietario come sorgente complementare.
|
||||
- Timestamp con frazione di secondo (SubSecTimeOriginal) e fuso orario;
|
||||
tempo di posa, apertura e ISO anche nelle varianti APEX.
|
||||
- Calcolo degli intervalli reali, cadenza nominale mediana e segnalazione
|
||||
delle pause dell'intervallometro.
|
||||
|
||||
2. Deflicker e smoothing dell'esposizione (Analysis/)
|
||||
- Misura della luminanza come media logaritmica troncata su istogramma,
|
||||
calcolata in luce lineare su campionamento a griglia fissa.
|
||||
- Curva target da regressione lineare locale pesata su finestra mobile, con
|
||||
seconda passata robusta (peso di Tukey) che scarta i fotogrammi anomali.
|
||||
Le rampe reali di luce restano intatte, lo sfarfallio viene rimosso.
|
||||
- Applicazione dei guadagni con compressione dolce delle alte luci e
|
||||
stabilizzazione opzionale del bilanciamento colore.
|
||||
|
||||
3. Motion blur sintetico e interpolazione (Motion/)
|
||||
- Optical flow proprietario: piramide gaussiana e schema differenziale
|
||||
iterativo su griglia rada, con filtro mediano fra i livelli.
|
||||
- Shutter angle reale per fotogramma; sfocatura mancante composta in
|
||||
quadratura e resa con filtro di ricostruzione direzionale.
|
||||
- Interpolazione temporale con warping bidirezionale per uniformare la
|
||||
cadenza e gestire in modo adattivo la durata dei fotogrammi.
|
||||
|
||||
4. Streaming ed encoding video (Video/)
|
||||
- Multiplexer MP4 (ISO-BMFF) scritto da zero: ftyp, mdat in streaming con
|
||||
dimensione a 64 bit, moov completo con stts a durate variabili, stss,
|
||||
stsz, co64, avcC/hvcC costruiti dai parameter set del bitstream.
|
||||
- Encoder pilotato direttamente come Media Foundation Transform, con
|
||||
percorso asincrono per le trasformazioni hardware (Intel/AMD/NVIDIA)
|
||||
e ripiego automatico su quelle software.
|
||||
- Conversione RGB lineare a NV12 BT.709 scritta in-house.
|
||||
|
||||
5. Interfaccia (UI/)
|
||||
- Tema scuro con ogni controllo disegnato a mano in GDI+: pulsanti,
|
||||
interruttori, cursori, schede, barra di avanzamento.
|
||||
- Grafico vettoriale della curva di esposizione: luminanza misurata
|
||||
sovrapposta alla curva target, area di correzione, corsia dei guadagni,
|
||||
fasce delle anomalie di cadenza, zoom, spostamento e tooltip.
|
||||
- Tabella dei fotogrammi a rendering virtuale.
|
||||
- Pannello di configurazione in tre sezioni: Generale, Elaborazione
|
||||
immagini, Esportazione video.
|
||||
- Anteprima elaborata dallo stesso motore usato in esportazione.
|
||||
|
||||
ARCHITETTURA
|
||||
- Nessun file temporaneo: i dati passano fra le fasi solo in memoria, l'unica
|
||||
scrittura su disco è il flusso compresso finale.
|
||||
- Impronta di memoria costante: buffer poolati e canale a capacità limitata,
|
||||
l'occupazione non dipende dal numero di fotogrammi della sequenza.
|
||||
- Thread di calcolo separati dal thread dell'interfaccia.
|
||||
|
||||
VERIFICA
|
||||
Comando "Titano.exe --selftest": genera una sequenza sintetica dalle
|
||||
proprietà note e la fa attraversare l'intera pipeline confrontando 23
|
||||
grandezze con i valori attesi. Tutte superate, compresa la ri-decodifica
|
||||
del file prodotto con il lettore di sistema.
|
||||
@@ -0,0 +1,48 @@
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Interpolazione temporale fra due fotogrammi guidata dal campo vettoriale.
|
||||
///
|
||||
/// Si usa il warping all'indietro bidirezionale: l'istante t viene ricostruito prelevando da
|
||||
/// A lungo −t·v e da B lungo +(1−t)·v, poi i due contributi vengono miscelati con pesi
|
||||
/// complementari. Il warping all'indietro non lascia buchi (a differenza di quello in avanti)
|
||||
/// e la miscelazione incrociata attenua gli errori del campo nelle zone di occlusione.
|
||||
/// </summary>
|
||||
public static class FrameInterpolator
|
||||
{
|
||||
/// <summary>Genera il fotogramma all'istante <paramref name="t"/> ∈ [0,1] fra A e B.</summary>
|
||||
public static void Interpolate(ImageBuffer a, ImageBuffer b, MotionField flowAtoB,
|
||||
float t, ImageBuffer destination)
|
||||
{
|
||||
if (t <= 0f) { destination.CopyFrom(a); return; }
|
||||
if (t >= 1f) { destination.CopyFrom(b); return; }
|
||||
|
||||
int width = a.Width;
|
||||
int height = a.Height;
|
||||
var srcA = a.Data;
|
||||
var srcB = b.Data;
|
||||
var dst = destination.Data;
|
||||
float wa = 1f - t;
|
||||
|
||||
Parallel.For(0, height, y =>
|
||||
{
|
||||
int rowBase = y * width * ImageBuffer.Channels;
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int index = rowBase + x * ImageBuffer.Channels;
|
||||
flowAtoB.Sample(x, y, out float vx, out float vy);
|
||||
|
||||
MotionBlurRenderer.SampleBilinear(srcA, width, height, x - vx * t, y - vy * t,
|
||||
out float ar, out float ag, out float ab);
|
||||
MotionBlurRenderer.SampleBilinear(srcB, width, height, x + vx * wa, y + vy * wa,
|
||||
out float br, out float bg, out float bb);
|
||||
|
||||
dst[index] = ar * wa + br * t;
|
||||
dst[index + 1] = ag * wa + bg * t;
|
||||
dst[index + 2] = ab * wa + bb * t;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Motion;
|
||||
|
||||
/// <summary>Piano di luminanza percettiva a precisione singola, con campionamento bilineare.</summary>
|
||||
public sealed class GrayImage(int width, int height)
|
||||
{
|
||||
public int Width { get; } = width;
|
||||
public int Height { get; } = height;
|
||||
public float[] Data { get; } = new float[width * height];
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public float At(int x, int y)
|
||||
{
|
||||
x = x < 0 ? 0 : x >= Width ? Width - 1 : x;
|
||||
y = y < 0 ? 0 : y >= Height ? Height - 1 : y;
|
||||
return Data[y * Width + x];
|
||||
}
|
||||
|
||||
/// <summary>Campionamento bilineare con estensione dei bordi.</summary>
|
||||
public float Sample(float x, float y)
|
||||
{
|
||||
int x0 = (int)MathF.Floor(x);
|
||||
int y0 = (int)MathF.Floor(y);
|
||||
float fx = x - x0;
|
||||
float fy = y - y0;
|
||||
|
||||
float a = At(x0, y0);
|
||||
float b = At(x0 + 1, y0);
|
||||
float c = At(x0, y0 + 1);
|
||||
float d = At(x0 + 1, y0 + 1);
|
||||
|
||||
float top = a + (b - a) * fx;
|
||||
float bottom = c + (d - c) * fx;
|
||||
return top + (bottom - top) * fy;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Piramide gaussiana costruita in-house.
|
||||
///
|
||||
/// Il livello 0 è la luminanza dell'immagine ridotta alla risoluzione di analisi: il campo di
|
||||
/// movimento non richiede la piena risoluzione e lavorare in scala ridotta rende il costo
|
||||
/// dell'optical flow indipendente dalla dimensione dei file sorgente.
|
||||
/// La luminanza viene ricodificata in gamma percettiva, perché in luce lineare i gradienti
|
||||
/// delle zone scure sarebbero numericamente trascurabili rispetto alle alte luci.
|
||||
/// </summary>
|
||||
public sealed class GrayPyramid
|
||||
{
|
||||
public GrayImage[] Levels { get; }
|
||||
|
||||
/// <summary>Rapporto fra la risoluzione di analisi (livello 0) e quella dell'immagine originale.</summary>
|
||||
public float AnalysisScale { get; }
|
||||
|
||||
private GrayPyramid(GrayImage[] levels, float analysisScale)
|
||||
{
|
||||
Levels = levels;
|
||||
AnalysisScale = analysisScale;
|
||||
}
|
||||
|
||||
public int LevelCount => Levels.Length;
|
||||
|
||||
public static GrayPyramid Build(ImageBuffer frame, int maxAnalysisWidth, int requestedLevels)
|
||||
{
|
||||
float scale = Math.Min(1f, maxAnalysisWidth / (float)frame.Width);
|
||||
int w = Math.Max(16, (int)MathF.Round(frame.Width * scale));
|
||||
int h = Math.Max(16, (int)MathF.Round(frame.Height * scale));
|
||||
scale = w / (float)frame.Width;
|
||||
|
||||
var level0 = new GrayImage(w, h);
|
||||
Downsample(frame, level0);
|
||||
|
||||
int levels = Math.Clamp(requestedLevels, 1, 6);
|
||||
while (levels > 1 && (w >> (levels - 1)) < 24) levels--;
|
||||
|
||||
var all = new GrayImage[levels];
|
||||
all[0] = level0;
|
||||
for (int i = 1; i < levels; i++) all[i] = HalveWithBlur(all[i - 1]);
|
||||
|
||||
return new GrayPyramid(all, scale);
|
||||
}
|
||||
|
||||
/// <summary>Riduzione da RGB lineare a luminanza percettiva con media d'area (box filter).</summary>
|
||||
private static void Downsample(ImageBuffer source, GrayImage destination)
|
||||
{
|
||||
int sw = source.Width, sh = source.Height;
|
||||
int dw = destination.Width, dh = destination.Height;
|
||||
var src = source.Data;
|
||||
var dst = destination.Data;
|
||||
|
||||
float xRatio = sw / (float)dw;
|
||||
float yRatio = sh / (float)dh;
|
||||
|
||||
Parallel.For(0, dh, dy =>
|
||||
{
|
||||
int y0 = (int)(dy * yRatio);
|
||||
int y1 = Math.Max(y0 + 1, Math.Min(sh, (int)((dy + 1) * yRatio)));
|
||||
|
||||
for (int dx = 0; dx < dw; dx++)
|
||||
{
|
||||
int x0 = (int)(dx * xRatio);
|
||||
int x1 = Math.Max(x0 + 1, Math.Min(sw, (int)((dx + 1) * xRatio)));
|
||||
|
||||
float sum = 0;
|
||||
int count = 0;
|
||||
for (int y = y0; y < y1; y++)
|
||||
{
|
||||
int rowBase = y * sw * ImageBuffer.Channels;
|
||||
for (int x = x0; x < x1; x++)
|
||||
{
|
||||
int i = rowBase + x * ImageBuffer.Channels;
|
||||
sum += ColorSpace.Luminance(src[i], src[i + 1], src[i + 2]);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
dst[dy * dw + dx] = ColorSpace.ToSrgb(count > 0 ? sum / count : 0f);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dimezzamento con kernel binomiale 1-2-1 separabile.</summary>
|
||||
private static GrayImage HalveWithBlur(GrayImage source)
|
||||
{
|
||||
int w = Math.Max(1, source.Width / 2);
|
||||
int h = Math.Max(1, source.Height / 2);
|
||||
var result = new GrayImage(w, h);
|
||||
var dst = result.Data;
|
||||
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int sy = y * 2;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int sx = x * 2;
|
||||
float sum =
|
||||
source.At(sx - 1, sy - 1) + 2 * source.At(sx, sy - 1) + source.At(sx + 1, sy - 1)
|
||||
+ 2 * source.At(sx - 1, sy) + 4 * source.At(sx, sy) + 2 * source.At(sx + 1, sy)
|
||||
+ source.At(sx - 1, sy + 1) + 2 * source.At(sx, sy + 1) + source.At(sx + 1, sy + 1);
|
||||
dst[y * w + x] = sum / 16f;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Motion;
|
||||
|
||||
/// <summary>Parametri del motion blur sintetico, esposti nel pannello "Elaborazione immagini".</summary>
|
||||
public sealed class MotionBlurSettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Shutter angle desiderato: 180° è la convenzione cinematografica.</summary>
|
||||
public double TargetShutterAngle { get; set; } = 180.0;
|
||||
|
||||
/// <summary>Quota della sfocatura mancante effettivamente sintetizzata.</summary>
|
||||
public double Strength { get; set; } = 1.0;
|
||||
|
||||
/// <summary>Limite superiore della scia, in pixel: protegge dalle stime di movimento errate.</summary>
|
||||
public double MaxBlurPixels { get; set; } = 48.0;
|
||||
|
||||
/// <summary>Numero massimo di campioni per pixel lungo la scia.</summary>
|
||||
public int MaxSamples { get; set; } = 25;
|
||||
|
||||
public MotionBlurSettings Clone() => (MotionBlurSettings)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Motion blur direzionale sintetico lungo il campo vettoriale.
|
||||
///
|
||||
/// Il fattore di sfocatura mancante deriva dalla composizione delle varianze: la scia
|
||||
/// realmente incisa nel fotogramma (proporzionale allo shutter angle di scatto) e quella
|
||||
/// sintetica si sommano in quadratura, quindi per raggiungere l'apertura obiettivo serve
|
||||
/// una scia lunga √(target² − reale²) volte lo spostamento. Sommare linearmente
|
||||
/// produrrebbe un'immagine sistematicamente troppo morbida.
|
||||
///
|
||||
/// Il filtro di ricostruzione è di tipo "gather": ogni campione contribuisce al pixel
|
||||
/// centrale solo se la propria scia lo raggiunge davvero, così lo sfondo fermo non viene
|
||||
/// trascinato dentro i soggetti in movimento.
|
||||
/// </summary>
|
||||
public static class MotionBlurRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Frazione di spostamento da sintetizzare per portare <paramref name="actualAngle"/>
|
||||
/// al valore <paramref name="targetAngle"/>. Zero se il fotogramma è già abbastanza mosso.
|
||||
/// </summary>
|
||||
public static double MissingBlurFactor(double actualAngle, double targetAngle, double strength)
|
||||
{
|
||||
double target = Math.Clamp(targetAngle, 0, 360) / 360.0;
|
||||
double actual = Math.Clamp(actualAngle, 0, 360) / 360.0;
|
||||
if (target <= actual) return 0;
|
||||
return Math.Sqrt(target * target - actual * actual) * Math.Clamp(strength, 0, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applica la sfocatura da <paramref name="source"/> a <paramref name="destination"/>.
|
||||
/// Restituisce la lunghezza media della scia effettivamente resa, in pixel.
|
||||
/// </summary>
|
||||
public static double Render(ImageBuffer source, ImageBuffer destination, MotionField field,
|
||||
double missingFactor, MotionBlurSettings settings)
|
||||
{
|
||||
if (missingFactor <= 1e-4)
|
||||
{
|
||||
destination.CopyFrom(source);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int width = source.Width;
|
||||
int height = source.Height;
|
||||
float factor = (float)missingFactor;
|
||||
float maxBlur = (float)Math.Max(1.0, settings.MaxBlurPixels);
|
||||
int maxSamples = Math.Clamp(settings.MaxSamples, 3, 129);
|
||||
|
||||
var src = source.Data;
|
||||
var dst = destination.Data;
|
||||
double lengthSum = 0;
|
||||
object sumLock = new();
|
||||
|
||||
Parallel.For(0, height, () => 0.0, (y, _, localSum) =>
|
||||
{
|
||||
int rowBase = y * width * ImageBuffer.Channels;
|
||||
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
int index = rowBase + x * ImageBuffer.Channels;
|
||||
field.Sample(x, y, out float vx, out float vy);
|
||||
|
||||
float magnitude = MathF.Sqrt(vx * vx + vy * vy);
|
||||
float length = Math.Min(magnitude * factor, maxBlur);
|
||||
localSum += length;
|
||||
|
||||
if (length < 0.5f || magnitude < 1e-4f)
|
||||
{
|
||||
dst[index] = src[index];
|
||||
dst[index + 1] = src[index + 1];
|
||||
dst[index + 2] = src[index + 2];
|
||||
continue;
|
||||
}
|
||||
|
||||
float dirX = vx / magnitude;
|
||||
float dirY = vy / magnitude;
|
||||
float half = length * 0.5f;
|
||||
|
||||
int taps = Math.Min(maxSamples, Math.Max(3, (int)MathF.Ceiling(length) | 1));
|
||||
int side = taps / 2;
|
||||
float step = half / side;
|
||||
|
||||
// Il pixel centrale è sempre presente: garantisce continuità con le zone ferme.
|
||||
float accR = src[index], accG = src[index + 1], accB = src[index + 2];
|
||||
float weightSum = 1f;
|
||||
|
||||
for (int k = 1; k <= side; k++)
|
||||
{
|
||||
float offset = k * step;
|
||||
|
||||
weightSum += Accumulate(src, field, width, height, x, y,
|
||||
dirX, dirY, offset, factor, maxBlur, ref accR, ref accG, ref accB);
|
||||
weightSum += Accumulate(src, field, width, height, x, y,
|
||||
dirX, dirY, -offset, factor, maxBlur, ref accR, ref accG, ref accB);
|
||||
}
|
||||
|
||||
float inv = 1f / weightSum;
|
||||
dst[index] = accR * inv;
|
||||
dst[index + 1] = accG * inv;
|
||||
dst[index + 2] = accB * inv;
|
||||
}
|
||||
|
||||
return localSum;
|
||||
},
|
||||
localSum =>
|
||||
{
|
||||
lock (sumLock) lengthSum += localSum;
|
||||
});
|
||||
|
||||
return lengthSum / Math.Max(1, (long)width * height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiunge il contributo del campione spostato di <paramref name="offset"/> lungo la scia.
|
||||
/// Il peso vale 1 solo se il movimento proprio del campione arriva a coprire il pixel centrale.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static float Accumulate(float[] src, MotionField field, int width, int height,
|
||||
int x, int y, float dirX, float dirY, float offset,
|
||||
float factor, float maxBlur,
|
||||
ref float accR, ref float accG, ref float accB)
|
||||
{
|
||||
float sx = x + dirX * offset;
|
||||
float sy = y + dirY * offset;
|
||||
if (sx < 0 || sy < 0 || sx > width - 1 || sy > height - 1) return 0f;
|
||||
|
||||
field.Sample(sx, sy, out float tvx, out float tvy);
|
||||
float projection = MathF.Abs(tvx * dirX + tvy * dirY) * factor * 0.5f;
|
||||
projection = MathF.Min(projection, maxBlur * 0.5f);
|
||||
|
||||
// Transizione morbida su un pixel: evita i gradini sui bordi della scia.
|
||||
float weight = projection - MathF.Abs(offset) + 1f;
|
||||
if (weight <= 0f) return 0f;
|
||||
if (weight > 1f) weight = 1f;
|
||||
|
||||
SampleBilinear(src, width, height, sx, sy, out float r, out float g, out float b);
|
||||
accR += r * weight;
|
||||
accG += g * weight;
|
||||
accB += b * weight;
|
||||
return weight;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
internal static void SampleBilinear(float[] data, int width, int height, float x, float y,
|
||||
out float r, out float g, out float b)
|
||||
{
|
||||
if (x < 0) x = 0; else if (x > width - 1) x = width - 1;
|
||||
if (y < 0) y = 0; else if (y > height - 1) y = height - 1;
|
||||
|
||||
int x0 = (int)x;
|
||||
int y0 = (int)y;
|
||||
int x1 = x0 + 1 < width ? x0 + 1 : x0;
|
||||
int y1 = y0 + 1 < height ? y0 + 1 : y0;
|
||||
float fx = x - x0;
|
||||
float fy = y - y0;
|
||||
|
||||
int i00 = (y0 * width + x0) * ImageBuffer.Channels;
|
||||
int i10 = (y0 * width + x1) * ImageBuffer.Channels;
|
||||
int i01 = (y1 * width + x0) * ImageBuffer.Channels;
|
||||
int i11 = (y1 * width + x1) * ImageBuffer.Channels;
|
||||
|
||||
float w00 = (1 - fx) * (1 - fy);
|
||||
float w10 = fx * (1 - fy);
|
||||
float w01 = (1 - fx) * fy;
|
||||
float w11 = fx * fy;
|
||||
|
||||
r = data[i00] * w00 + data[i10] * w10 + data[i01] * w01 + data[i11] * w11;
|
||||
g = data[i00 + 1] * w00 + data[i10 + 1] * w10 + data[i01 + 1] * w01 + data[i11 + 1] * w11;
|
||||
b = data[i00 + 2] * w00 + data[i10 + 2] * w10 + data[i01 + 2] * w01 + data[i11 + 2] * w11;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Titano.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Campo vettoriale di movimento memorizzato su griglia rada: un nodo ogni
|
||||
/// <see cref="CellSize"/> pixel dell'immagine a piena risoluzione. I vettori sono espressi
|
||||
/// in pixel di spostamento fra il fotogramma corrente e il successivo.
|
||||
///
|
||||
/// La griglia rada mantiene l'occupazione trascurabile (per un 4K con celle da 16 px sono
|
||||
/// ~260 KB) e la lettura bilineare restituisce comunque un campo continuo per-pixel.
|
||||
/// </summary>
|
||||
public sealed class MotionField
|
||||
{
|
||||
public int GridWidth { get; }
|
||||
public int GridHeight { get; }
|
||||
|
||||
/// <summary>Passo della griglia in pixel dell'immagine a piena risoluzione (può non essere intero).</summary>
|
||||
public float CellSize { get; }
|
||||
|
||||
public int ImageWidth { get; }
|
||||
public int ImageHeight { get; }
|
||||
|
||||
public float[] Vx { get; }
|
||||
public float[] Vy { get; }
|
||||
|
||||
public MotionField(int imageWidth, int imageHeight, float cellSize)
|
||||
: this(imageWidth, imageHeight, cellSize,
|
||||
Math.Max(2, (int)Math.Ceiling(imageWidth / Math.Max(1f, cellSize)) + 1),
|
||||
Math.Max(2, (int)Math.Ceiling(imageHeight / Math.Max(1f, cellSize)) + 1))
|
||||
{
|
||||
}
|
||||
|
||||
internal MotionField(int imageWidth, int imageHeight, float cellSize, int gridWidth, int gridHeight)
|
||||
{
|
||||
ImageWidth = imageWidth;
|
||||
ImageHeight = imageHeight;
|
||||
CellSize = Math.Max(1f, cellSize);
|
||||
GridWidth = Math.Max(2, gridWidth);
|
||||
GridHeight = Math.Max(2, gridHeight);
|
||||
Vx = new float[GridWidth * GridHeight];
|
||||
Vy = new float[GridWidth * GridHeight];
|
||||
}
|
||||
|
||||
public int NodeCount => GridWidth * GridHeight;
|
||||
|
||||
/// <summary>Campionamento bilineare del campo in coordinate immagine.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Sample(float x, float y, out float vx, out float vy)
|
||||
{
|
||||
float gx = x / CellSize;
|
||||
float gy = y / CellSize;
|
||||
|
||||
int x0 = (int)gx;
|
||||
int y0 = (int)gy;
|
||||
float fx = gx - x0;
|
||||
float fy = gy - y0;
|
||||
|
||||
if (x0 < 0) { x0 = 0; fx = 0; }
|
||||
if (y0 < 0) { y0 = 0; fy = 0; }
|
||||
if (x0 >= GridWidth - 1) { x0 = GridWidth - 2; fx = 1; }
|
||||
if (y0 >= GridHeight - 1) { y0 = GridHeight - 2; fy = 1; }
|
||||
|
||||
int i00 = y0 * GridWidth + x0;
|
||||
int i10 = i00 + 1;
|
||||
int i01 = i00 + GridWidth;
|
||||
int i11 = i01 + 1;
|
||||
|
||||
float w00 = (1 - fx) * (1 - fy);
|
||||
float w10 = fx * (1 - fy);
|
||||
float w01 = (1 - fx) * fy;
|
||||
float w11 = fx * fy;
|
||||
|
||||
vx = Vx[i00] * w00 + Vx[i10] * w10 + Vx[i01] * w01 + Vx[i11] * w11;
|
||||
vy = Vy[i00] * w00 + Vy[i10] * w10 + Vy[i01] * w01 + Vy[i11] * w11;
|
||||
}
|
||||
|
||||
/// <summary>Modulo mediano dei vettori: stima robusta dell'entità del movimento.</summary>
|
||||
public double MedianMagnitude()
|
||||
{
|
||||
int n = NodeCount;
|
||||
if (n == 0) return 0;
|
||||
var magnitudes = new float[n];
|
||||
for (int i = 0; i < n; i++) magnitudes[i] = MathF.Sqrt(Vx[i] * Vx[i] + Vy[i] * Vy[i]);
|
||||
Array.Sort(magnitudes);
|
||||
return magnitudes[n / 2];
|
||||
}
|
||||
|
||||
/// <summary>Direzione dominante del campo, in gradi, pesata sul modulo dei vettori.</summary>
|
||||
public double DominantDirection()
|
||||
{
|
||||
double sx = 0, sy = 0;
|
||||
for (int i = 0; i < Vx.Length; i++)
|
||||
{
|
||||
float m = MathF.Sqrt(Vx[i] * Vx[i] + Vy[i] * Vy[i]);
|
||||
sx += Vx[i] * m;
|
||||
sy += Vy[i] * m;
|
||||
}
|
||||
if (Math.Abs(sx) < 1e-6 && Math.Abs(sy) < 1e-6) return 0;
|
||||
double deg = Math.Atan2(sy, sx) * 180.0 / Math.PI;
|
||||
return deg < 0 ? deg + 360 : deg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Motion;
|
||||
|
||||
/// <summary>Parametri del calcolo del campo vettoriale.</summary>
|
||||
public sealed class OpticalFlowSettings
|
||||
{
|
||||
/// <summary>Larghezza massima a cui viene condotta l'analisi del movimento.</summary>
|
||||
public int AnalysisWidth { get; set; } = 960;
|
||||
|
||||
/// <summary>Passo della griglia di nodi, in pixel della risoluzione di analisi.</summary>
|
||||
public int CellSize { get; set; } = 8;
|
||||
|
||||
/// <summary>Livelli della piramide: determina lo spostamento massimo inseguibile.</summary>
|
||||
public int PyramidLevels { get; set; } = 4;
|
||||
|
||||
/// <summary>Semi-lato della finestra di correlazione.</summary>
|
||||
public int WindowRadius { get; set; } = 6;
|
||||
|
||||
/// <summary>Iterazioni di raffinamento per livello.</summary>
|
||||
public int Iterations { get; set; } = 5;
|
||||
|
||||
/// <summary>Regolarizzazione del sistema normale: stabilizza le zone senza tessitura.</summary>
|
||||
public double Regularization { get; set; } = 1e-4;
|
||||
|
||||
public OpticalFlowSettings Clone() => (OpticalFlowSettings)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcolo del campo di movimento fra due fotogrammi adiacenti, implementato interamente
|
||||
/// in-house con uno schema differenziale piramidale (famiglia Lucas–Kanade).
|
||||
///
|
||||
/// Su ogni nodo della griglia si risolve iterativamente il sistema normale 2×2
|
||||
/// costruito dai gradienti spaziali del primo fotogramma e dalla differenza temporale
|
||||
/// rispetto al secondo, ricampionato secondo la stima corrente. La piramide permette di
|
||||
/// inseguire spostamenti ampi (nuvole, stelle, folla) partendo dai livelli grossolani;
|
||||
/// un filtro mediano fra un livello e l'altro elimina i vettori spuri delle zone piatte.
|
||||
/// </summary>
|
||||
public sealed class OpticalFlowEngine(OpticalFlowSettings settings)
|
||||
{
|
||||
private readonly OpticalFlowSettings _settings = settings;
|
||||
|
||||
/// <summary>
|
||||
/// Calcola il campo dal fotogramma <paramref name="current"/> verso <paramref name="next"/>.
|
||||
/// I vettori restituiti sono espressi in pixel dell'immagine a piena risoluzione.
|
||||
/// </summary>
|
||||
public MotionField Compute(ImageBuffer current, ImageBuffer next)
|
||||
{
|
||||
var a = GrayPyramid.Build(current, _settings.AnalysisWidth, _settings.PyramidLevels);
|
||||
var b = GrayPyramid.Build(next, _settings.AnalysisWidth, _settings.PyramidLevels);
|
||||
return Compute(a, b, current.Width, current.Height);
|
||||
}
|
||||
|
||||
public MotionField Compute(GrayPyramid a, GrayPyramid b, int fullWidth, int fullHeight)
|
||||
{
|
||||
int levels = Math.Min(a.LevelCount, b.LevelCount);
|
||||
int cell = Math.Max(2, _settings.CellSize);
|
||||
|
||||
var baseLevel = a.Levels[0];
|
||||
int gw = Math.Max(2, baseLevel.Width / cell + 1);
|
||||
int gh = Math.Max(2, baseLevel.Height / cell + 1);
|
||||
|
||||
var vx = new float[gw * gh];
|
||||
var vy = new float[gw * gh];
|
||||
|
||||
for (int level = levels - 1; level >= 0; level--)
|
||||
{
|
||||
float levelScale = 1f / (1 << level);
|
||||
RefineLevel(a.Levels[level], b.Levels[level], vx, vy, gw, gh, cell * levelScale);
|
||||
MedianFilter(vx, vy, gw, gh);
|
||||
|
||||
if (level > 0)
|
||||
{
|
||||
// Passando a un livello di risoluzione doppia raddoppia anche lo spostamento.
|
||||
for (int i = 0; i < vx.Length; i++) { vx[i] *= 2f; vy[i] *= 2f; }
|
||||
}
|
||||
}
|
||||
|
||||
// Conversione dalle unità della risoluzione di analisi a quelle dell'immagine piena.
|
||||
float toFull = a.AnalysisScale > 0 ? 1f / a.AnalysisScale : 1f;
|
||||
var field = new MotionField(fullWidth, fullHeight, cell * toFull, gw, gh);
|
||||
for (int i = 0; i < vx.Length; i++)
|
||||
{
|
||||
field.Vx[i] = vx[i] * toFull;
|
||||
field.Vy[i] = vy[i] * toFull;
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
/// <summary>Raffina la stima corrente su un livello della piramide.</summary>
|
||||
private void RefineLevel(GrayImage a, GrayImage b, float[] vx, float[] vy,
|
||||
int gridWidth, int gridHeight, float nodeSpacing)
|
||||
{
|
||||
int radius = Math.Max(2, _settings.WindowRadius);
|
||||
int iterations = Math.Max(1, _settings.Iterations);
|
||||
double lambda = Math.Max(0, _settings.Regularization);
|
||||
float maxStep = Math.Max(4f, radius * 1.5f);
|
||||
|
||||
Parallel.For(0, gridHeight, gy =>
|
||||
{
|
||||
float py = gy * nodeSpacing;
|
||||
|
||||
for (int gx = 0; gx < gridWidth; gx++)
|
||||
{
|
||||
float px = gx * nodeSpacing;
|
||||
int node = gy * gridWidth + gx;
|
||||
float u = vx[node];
|
||||
float v = vy[node];
|
||||
|
||||
for (int iter = 0; iter < iterations; iter++)
|
||||
{
|
||||
double sxx = lambda, sxy = 0, syy = lambda, sxt = 0, syt = 0;
|
||||
|
||||
for (int wy = -radius; wy <= radius; wy++)
|
||||
{
|
||||
float sy = py + wy;
|
||||
for (int wx = -radius; wx <= radius; wx++)
|
||||
{
|
||||
float sx = px + wx;
|
||||
|
||||
// Gradiente spaziale del primo fotogramma (differenze centrali).
|
||||
float ix = (a.Sample(sx + 1, sy) - a.Sample(sx - 1, sy)) * 0.5f;
|
||||
float iy = (a.Sample(sx, sy + 1) - a.Sample(sx, sy - 1)) * 0.5f;
|
||||
// Differenza temporale con il secondo fotogramma ricampionato.
|
||||
float it = b.Sample(sx + u, sy + v) - a.Sample(sx, sy);
|
||||
|
||||
sxx += ix * ix;
|
||||
sxy += ix * iy;
|
||||
syy += iy * iy;
|
||||
sxt += ix * it;
|
||||
syt += iy * it;
|
||||
}
|
||||
}
|
||||
|
||||
double det = sxx * syy - sxy * sxy;
|
||||
if (Math.Abs(det) < 1e-12) break;
|
||||
|
||||
// Soluzione del sistema normale: d = -A⁻¹·b
|
||||
double du = -(syy * sxt - sxy * syt) / det;
|
||||
double dv = -(sxx * syt - sxy * sxt) / det;
|
||||
|
||||
if (double.IsNaN(du) || double.IsNaN(dv)) break;
|
||||
|
||||
du = Math.Clamp(du, -maxStep, maxStep);
|
||||
dv = Math.Clamp(dv, -maxStep, maxStep);
|
||||
|
||||
u += (float)du;
|
||||
v += (float)dv;
|
||||
|
||||
if (Math.Abs(du) < 0.01 && Math.Abs(dv) < 0.01) break;
|
||||
}
|
||||
|
||||
// Vincolo fisico: nessun punto può uscire di più dell'intera immagine.
|
||||
vx[node] = Math.Clamp(u, -a.Width, a.Width);
|
||||
vy[node] = Math.Clamp(v, -a.Height, a.Height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void MedianFilter(float[] vx, float[] vy, int gridWidth, int gridHeight)
|
||||
{
|
||||
var ox = (float[])vx.Clone();
|
||||
var oy = (float[])vy.Clone();
|
||||
Span<float> wx = stackalloc float[9];
|
||||
Span<float> wy = stackalloc float[9];
|
||||
|
||||
for (int gy = 0; gy < gridHeight; gy++)
|
||||
{
|
||||
for (int gx = 0; gx < gridWidth; gx++)
|
||||
{
|
||||
int n = 0;
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
{
|
||||
int yy = gy + dy;
|
||||
if (yy < 0 || yy >= gridHeight) continue;
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
int xx = gx + dx;
|
||||
if (xx < 0 || xx >= gridWidth) continue;
|
||||
int idx = yy * gridWidth + xx;
|
||||
wx[n] = ox[idx];
|
||||
wy[n] = oy[idx];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
wx[..n].Sort();
|
||||
wy[..n].Sort();
|
||||
int center = gy * gridWidth + gx;
|
||||
vx[center] = wx[n / 2];
|
||||
vy[center] = wy[n / 2];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Titano.Pipeline;
|
||||
|
||||
public enum PipelinePhase
|
||||
{
|
||||
Ingestion,
|
||||
Analysis,
|
||||
Rendering,
|
||||
Finalizing,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>Stato d'avanzamento pubblicato verso l'interfaccia; immutabile e sicuro da marshalare.</summary>
|
||||
public readonly record struct PipelineProgress(
|
||||
PipelinePhase Phase,
|
||||
int Completed,
|
||||
int Total,
|
||||
string Message,
|
||||
double FramesPerSecond = 0,
|
||||
TimeSpan Remaining = default)
|
||||
{
|
||||
public double Fraction => Total <= 0 ? 0 : Math.Clamp(Completed / (double)Total, 0, 1);
|
||||
}
|
||||
|
||||
/// <summary>Esito di una esportazione completata.</summary>
|
||||
public sealed record RenderResult(
|
||||
string OutputPath,
|
||||
int EncodedFrames,
|
||||
long OutputBytes,
|
||||
TimeSpan Elapsed,
|
||||
string EncoderName,
|
||||
bool HardwareAccelerated,
|
||||
long PeakPixelMemoryBytes);
|
||||
@@ -0,0 +1,441 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using Titano.Analysis;
|
||||
using Titano.Core;
|
||||
using Titano.Imaging;
|
||||
using Titano.Metadata;
|
||||
using Titano.Motion;
|
||||
using Titano.Video;
|
||||
|
||||
namespace Titano.Pipeline;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrazione delle fasi di lavoro.
|
||||
///
|
||||
/// Architettura a flusso: i fotogrammi vengono decodificati da più thread e consegnati in
|
||||
/// ordine attraverso un canale a capacità limitata. Il limite del canale coincide con il
|
||||
/// numero di decodifiche simultanee, quindi il numero di buffer vivi — e con esso
|
||||
/// l'occupazione di memoria — resta costante qualunque sia la lunghezza della sequenza.
|
||||
/// Nessuna fase scrive su disco: l'unico file prodotto è il video finale.
|
||||
/// </summary>
|
||||
public sealed class RenderPipeline(TitanoProject project)
|
||||
{
|
||||
private readonly TitanoProject _project = project;
|
||||
|
||||
// ------------------------------------------------------------------ ingestion
|
||||
|
||||
/// <summary>Legge i metadati dei file indicati e costruisce la sequenza ordinata.</summary>
|
||||
public static async Task<TimelapseSequence> IngestAsync(IReadOnlyList<string> paths,
|
||||
double cadenceTolerance,
|
||||
IProgress<PipelineProgress>? progress,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
var metadata = new FrameMetadata[paths.Count];
|
||||
int done = 0;
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var options = new ParallelOptions
|
||||
{
|
||||
CancellationToken = cancellation,
|
||||
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 1, 16),
|
||||
};
|
||||
|
||||
Parallel.For(0, paths.Count, options, i =>
|
||||
{
|
||||
metadata[i] = MetadataReader.Read(paths[i]);
|
||||
int completed = Interlocked.Increment(ref done);
|
||||
if (completed % 16 == 0 || completed == paths.Count)
|
||||
{
|
||||
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, completed, paths.Count,
|
||||
"Lettura metadati…"));
|
||||
}
|
||||
});
|
||||
}, cancellation).ConfigureAwait(false);
|
||||
|
||||
var sequence = TimelapseSequence.Build(metadata);
|
||||
sequence.RecomputeTiming(cadenceTolerance);
|
||||
return sequence;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ analisi fotometrica
|
||||
|
||||
/// <summary>
|
||||
/// Passata di analisi: misura la luminanza di ogni fotogramma e calcola la curva di
|
||||
/// deflicker. La decodifica avviene a risoluzione ridotta perché la media logaritmica
|
||||
/// troncata è invariante alla scala, e questo rende la fase praticamente istantanea.
|
||||
/// </summary>
|
||||
public async Task AnalyzeAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
|
||||
{
|
||||
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
|
||||
int count = sequence.Count;
|
||||
if (count == 0) return;
|
||||
|
||||
var (workingWidth, workingHeight) = _project.ResolveWorkingSize();
|
||||
if (workingWidth <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
|
||||
|
||||
int analysisWidth = Math.Clamp(_project.General.AnalysisWidth, 128, workingWidth);
|
||||
int analysisHeight = Math.Max(2, (int)Math.Round(analysisWidth * workingHeight / (double)workingWidth));
|
||||
|
||||
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
|
||||
var pool = new FrameBufferPool(parallelism + 2);
|
||||
var stats = new LuminanceStats[count];
|
||||
var failures = new bool[count];
|
||||
int done = 0;
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var options = new ParallelOptions
|
||||
{
|
||||
CancellationToken = cancellation,
|
||||
MaxDegreeOfParallelism = parallelism,
|
||||
};
|
||||
|
||||
Parallel.For(0, count, options, i =>
|
||||
{
|
||||
var record = sequence.Frames[i];
|
||||
try
|
||||
{
|
||||
using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight,
|
||||
record.Metadata.Orientation, pool);
|
||||
stats[i] = LuminanceAnalyzer.Analyze(buffer);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
failures[i] = true;
|
||||
}
|
||||
|
||||
int completed = Interlocked.Increment(ref done);
|
||||
progress?.Report(new PipelineProgress(PipelinePhase.Analysis, completed, count,
|
||||
"Analisi della luminanza…"));
|
||||
});
|
||||
}, cancellation).ConfigureAwait(false);
|
||||
|
||||
// I fotogrammi illeggibili ereditano la misura del vicino: la curva resta continua.
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!failures[i]) continue;
|
||||
stats[i] = i > 0 ? stats[i - 1] : LuminanceStats.Empty;
|
||||
}
|
||||
|
||||
var curve = DeflickerEngine.Compute(stats, _project.Deflicker);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var record = sequence.Frames[i];
|
||||
record.MeasuredLuminance = Math.Pow(2, curve.Measured[i]);
|
||||
record.TargetLuminance = Math.Pow(2, curve.Target[i]);
|
||||
record.Gain = Math.Pow(2, curve.GainStops[i]);
|
||||
record.ClippedFraction = stats[i].ClippedFraction;
|
||||
record.LuminanceAnalyzed = !failures[i];
|
||||
}
|
||||
|
||||
_project.Stats = stats;
|
||||
_project.Curve = curve;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ricalcola solo la curva a partire dalle statistiche già misurate: permette di muovere
|
||||
/// i cursori del deflicker con riscontro immediato, senza rileggere i file.
|
||||
/// </summary>
|
||||
public void RecomputeCurve()
|
||||
{
|
||||
if (_project.Stats is not { } stats || _project.Sequence is not { } sequence) return;
|
||||
|
||||
var curve = DeflickerEngine.Compute(stats, _project.Deflicker);
|
||||
for (int i = 0; i < sequence.Count && i < curve.Count; i++)
|
||||
{
|
||||
var record = sequence.Frames[i];
|
||||
record.MeasuredLuminance = Math.Pow(2, curve.Measured[i]);
|
||||
record.TargetLuminance = Math.Pow(2, curve.Target[i]);
|
||||
record.Gain = Math.Pow(2, curve.GainStops[i]);
|
||||
}
|
||||
_project.Curve = curve;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ render ed esportazione
|
||||
|
||||
public async Task<RenderResult> RenderAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
|
||||
{
|
||||
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
|
||||
if (sequence.Count == 0) throw new InvalidOperationException("La sequenza è vuota.");
|
||||
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
|
||||
throw new InvalidOperationException("Percorso di destinazione non impostato.");
|
||||
|
||||
if (!_project.IsAnalyzed) await AnalyzeAsync(progress, cancellation).ConfigureAwait(false);
|
||||
|
||||
var (width, height) = _project.ResolveWorkingSize();
|
||||
if (width <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
|
||||
|
||||
var export = _project.Export.Clone();
|
||||
export.Width = width;
|
||||
export.Height = height;
|
||||
|
||||
return await Task.Run(() => RenderCore(sequence, export, progress, cancellation), cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private RenderResult RenderCore(TimelapseSequence sequence, ExportSettings export,
|
||||
IProgress<PipelineProgress>? progress, CancellationToken cancellation)
|
||||
{
|
||||
int width = export.Width;
|
||||
int height = export.Height;
|
||||
int count = sequence.Count;
|
||||
var curve = _project.Curve;
|
||||
|
||||
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
|
||||
var pool = new FrameBufferPool(parallelism + 8);
|
||||
var flowEngine = new OpticalFlowEngine(_project.Flow);
|
||||
var blurSettings = _project.MotionBlur;
|
||||
var deflickerSettings = _project.Deflicker;
|
||||
|
||||
uint baseUnits = (uint)Math.Max(1, Math.Round(export.Timescale / Math.Max(1.0, export.FrameRate)));
|
||||
double nominal = sequence.NominalInterval > 0 ? sequence.NominalInterval : 1.0;
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var session = new VideoEncoderSession(export, width, height);
|
||||
|
||||
// Canale a capacità limitata: al più "parallelism" decodifiche in volo.
|
||||
var channel = Channel.CreateBounded<Task<ImageBuffer?>>(new BoundedChannelOptions(parallelism)
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
});
|
||||
|
||||
var producer = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
cancellation.ThrowIfCancellationRequested();
|
||||
int index = i;
|
||||
var task = Task.Run(() => DecodeAndCorrect(sequence, index, width, height, pool,
|
||||
curve, deflickerSettings), cancellation);
|
||||
await channel.Writer.WriteAsync(task, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
channel.Writer.Complete();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
channel.Writer.TryComplete(ex);
|
||||
}
|
||||
}, cancellation);
|
||||
|
||||
ImageBuffer? current = null;
|
||||
ImageBuffer? next = null;
|
||||
var blurScratch = pool.Rent(width, height);
|
||||
var interpolated = pool.Rent(width, height);
|
||||
var interpolatedBlur = pool.Rent(width, height);
|
||||
|
||||
int encoded = 0;
|
||||
bool cancelled = false;
|
||||
|
||||
try
|
||||
{
|
||||
current = ReadNextOrSubstitute(channel, pool, width, height, null);
|
||||
next = ReadNextOrSubstitute(channel, pool, width, height, current);
|
||||
|
||||
for (int i = 0; i < count && current is not null; i++)
|
||||
{
|
||||
if (cancellation.IsCancellationRequested) { cancelled = true; break; }
|
||||
|
||||
var record = sequence.Frames[i];
|
||||
int subdivisions = ComputeSubdivisions(export, record, nominal);
|
||||
uint duration = ComputeDuration(export, record, nominal, baseUnits, subdivisions);
|
||||
|
||||
MotionField? field = null;
|
||||
bool needsFlow = blurSettings.Enabled || (export.Timing == FrameTimingMode.Interpolated && subdivisions > 1);
|
||||
if (needsFlow && next is not null)
|
||||
{
|
||||
field = flowEngine.Compute(current, next);
|
||||
record.MotionMagnitude = field.MedianMagnitude();
|
||||
record.MotionDirection = field.DominantDirection();
|
||||
}
|
||||
|
||||
// In presenza di suddivisioni l'intervallo di ciascun fotogramma d'uscita si
|
||||
// accorcia: lo shutter angle effettivo cresce e lo spostamento si riduce.
|
||||
double effectiveAngle = Math.Min(360.0, record.ShutterAngle * subdivisions);
|
||||
double missing = blurSettings.Enabled
|
||||
? MotionBlurRenderer.MissingBlurFactor(effectiveAngle, blurSettings.TargetShutterAngle,
|
||||
blurSettings.Strength) / subdivisions
|
||||
: 0;
|
||||
|
||||
record.BlurLength = EncodeFrame(session, current, field, missing, blurSettings, blurScratch, duration);
|
||||
encoded++;
|
||||
record.OutputDurationUnits = (int)duration;
|
||||
|
||||
for (int k = 1; k < subdivisions && next is not null && field is not null; k++)
|
||||
{
|
||||
if (cancellation.IsCancellationRequested) { cancelled = true; break; }
|
||||
float t = k / (float)subdivisions;
|
||||
FrameInterpolator.Interpolate(current, next, field, t, interpolated);
|
||||
EncodeFrame(session, interpolated, field, missing, blurSettings, interpolatedBlur, duration);
|
||||
encoded++;
|
||||
}
|
||||
|
||||
ReportRenderProgress(progress, i + 1, count, encoded, stopwatch);
|
||||
|
||||
current.Dispose();
|
||||
current = next;
|
||||
next = ReadNextOrSubstitute(channel, pool, width, height, current);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
current?.Dispose();
|
||||
next?.Dispose();
|
||||
blurScratch.Dispose();
|
||||
interpolated.Dispose();
|
||||
interpolatedBlur.Dispose();
|
||||
|
||||
progress?.Report(new PipelineProgress(PipelinePhase.Finalizing, count, count,
|
||||
"Chiusura del contenitore…"));
|
||||
session.Finish();
|
||||
|
||||
try { producer.Wait(TimeSpan.FromSeconds(5)); }
|
||||
catch (AggregateException) { /* già segnalato dal canale */ }
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (cancelled) cancellation.ThrowIfCancellationRequested();
|
||||
|
||||
return new RenderResult(
|
||||
export.OutputPath,
|
||||
session.EncodedFrames,
|
||||
session.OutputBytes,
|
||||
stopwatch.Elapsed,
|
||||
session.EncoderName,
|
||||
session.IsHardware,
|
||||
pool.AllocatedBytes);
|
||||
}
|
||||
|
||||
/// <summary>Applica la sfocatura, se prevista, e consegna il fotogramma all'encoder.</summary>
|
||||
private static double EncodeFrame(VideoEncoderSession session, ImageBuffer frame, MotionField? field,
|
||||
double missing, MotionBlurSettings settings, ImageBuffer scratch,
|
||||
uint duration)
|
||||
{
|
||||
double blurLength = 0;
|
||||
var toEncode = frame;
|
||||
|
||||
if (settings.Enabled && field is not null && missing > 1e-4)
|
||||
{
|
||||
blurLength = MotionBlurRenderer.Render(frame, scratch, field, missing, settings);
|
||||
toEncode = scratch;
|
||||
}
|
||||
|
||||
session.EncodeFrame(toEncode, duration);
|
||||
return blurLength;
|
||||
}
|
||||
|
||||
/// <summary>Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato.</summary>
|
||||
private static ImageBuffer? DecodeAndCorrect(TimelapseSequence sequence, int index, int width, int height,
|
||||
FrameBufferPool pool, DeflickerCurve? curve,
|
||||
DeflickerSettings settings)
|
||||
{
|
||||
var record = sequence.Frames[index];
|
||||
ImageBuffer buffer;
|
||||
try
|
||||
{
|
||||
buffer = ImageDecoder.Decode(record.FilePath, width, height, record.Metadata.Orientation, pool);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null; // il consumatore sostituirà con l'ultimo fotogramma valido
|
||||
}
|
||||
|
||||
if (curve is not null && index < curve.Count && settings.Enabled)
|
||||
{
|
||||
record.ClippedFraction = ExposureProcessor.Apply(buffer, curve.ChannelGain[index],
|
||||
settings.ProtectHighlights, settings.HighlightKnee);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preleva il prossimo fotogramma in ordine di sequenza.
|
||||
/// Restituisce false quando il flusso è terminato; un buffer nullo con esito true segnala
|
||||
/// un fotogramma illeggibile, che il chiamante sostituisce senza perdere il sincronismo.
|
||||
/// </summary>
|
||||
private static bool TryReadNext(Channel<Task<ImageBuffer?>> channel, out ImageBuffer? buffer)
|
||||
{
|
||||
buffer = null;
|
||||
while (true)
|
||||
{
|
||||
Task<ImageBuffer?> task;
|
||||
try
|
||||
{
|
||||
if (!channel.Reader.WaitToReadAsync().AsTask().GetAwaiter().GetResult()) return false;
|
||||
if (!channel.Reader.TryRead(out task!)) continue;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
buffer = task.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
buffer = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Legge il fotogramma successivo sostituendo gli illeggibili con una copia del precedente.</summary>
|
||||
private static ImageBuffer? ReadNextOrSubstitute(Channel<Task<ImageBuffer?>> channel, FrameBufferPool pool,
|
||||
int width, int height, ImageBuffer? previous)
|
||||
{
|
||||
if (!TryReadNext(channel, out var buffer)) return null;
|
||||
if (buffer is not null) return buffer;
|
||||
|
||||
return previous is not null
|
||||
? previous.CloneFromPool(pool)
|
||||
: CreateBlack(pool, width, height);
|
||||
}
|
||||
|
||||
private static ImageBuffer CreateBlack(FrameBufferPool pool, int width, int height)
|
||||
{
|
||||
var buffer = pool.Rent(width, height);
|
||||
Array.Clear(buffer.Data, 0, buffer.SampleCount);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static int ComputeSubdivisions(ExportSettings export, FrameRecord record, double nominal)
|
||||
{
|
||||
if (export.Timing != FrameTimingMode.Interpolated) return 1;
|
||||
double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal);
|
||||
int max = (int)Math.Max(1, Math.Round(export.MaxAdaptiveStretch));
|
||||
return Math.Clamp((int)Math.Round(ratio), 1, max);
|
||||
}
|
||||
|
||||
private static uint ComputeDuration(ExportSettings export, FrameRecord record, double nominal,
|
||||
uint baseUnits, int subdivisions)
|
||||
{
|
||||
if (export.Timing != FrameTimingMode.Adaptive) return baseUnits;
|
||||
|
||||
double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal);
|
||||
double limited = Math.Clamp(ratio, 1.0 / export.MaxAdaptiveStretch, export.MaxAdaptiveStretch);
|
||||
return (uint)Math.Max(1, Math.Round(baseUnits * limited));
|
||||
}
|
||||
|
||||
private static void ReportRenderProgress(IProgress<PipelineProgress>? progress, int completed, int total,
|
||||
int encoded, Stopwatch stopwatch)
|
||||
{
|
||||
if (progress is null) return;
|
||||
|
||||
double seconds = stopwatch.Elapsed.TotalSeconds;
|
||||
double fps = seconds > 0.001 ? encoded / seconds : 0;
|
||||
var remaining = fps > 0.01
|
||||
? TimeSpan.FromSeconds((total - completed) / fps)
|
||||
: TimeSpan.Zero;
|
||||
|
||||
progress.Report(new PipelineProgress(PipelinePhase.Rendering, completed, total,
|
||||
"Elaborazione e codifica…", fps, remaining));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Titano.Analysis;
|
||||
using Titano.Core;
|
||||
using Titano.Motion;
|
||||
using Titano.Video;
|
||||
|
||||
namespace Titano.Pipeline;
|
||||
|
||||
/// <summary>Impostazioni della sezione "Generale".</summary>
|
||||
public sealed class GeneralSettings
|
||||
{
|
||||
/// <summary>Larghezza massima di lavoro; 0 = risoluzione nativa del primo fotogramma.</summary>
|
||||
public int WorkingWidth { get; set; }
|
||||
|
||||
/// <summary>Larghezza usata nella passata di analisi fotometrica: incide solo sulla velocità.</summary>
|
||||
public int AnalysisWidth { get; set; } = 1024;
|
||||
|
||||
/// <summary>Decodifiche simultanee. Limita anche i buffer in volo, quindi la memoria occupata.</summary>
|
||||
public int DecodeParallelism { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 2, 8);
|
||||
|
||||
/// <summary>Tolleranza sulla cadenza oltre la quale un intervallo è segnalato come anomalo.</summary>
|
||||
public double CadenceTolerance { get; set; } = 0.35;
|
||||
|
||||
public GeneralSettings Clone() => (GeneralSettings)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stato completo di un progetto: sequenza caricata e tutti i parametri dei tre pannelli
|
||||
/// di configurazione. È l'unico oggetto che l'interfaccia scambia con il motore.
|
||||
/// </summary>
|
||||
public sealed class TitanoProject
|
||||
{
|
||||
public GeneralSettings General { get; set; } = new();
|
||||
public DeflickerSettings Deflicker { get; set; } = new();
|
||||
public MotionBlurSettings MotionBlur { get; set; } = new();
|
||||
public OpticalFlowSettings Flow { get; set; } = new();
|
||||
public ExportSettings Export { get; set; } = new();
|
||||
|
||||
public TimelapseSequence? Sequence { get; set; }
|
||||
|
||||
/// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary>
|
||||
public DeflickerCurve? Curve { get; set; }
|
||||
|
||||
/// <summary>Statistiche fotometriche per fotogramma dell'ultima analisi.</summary>
|
||||
public IReadOnlyList<LuminanceStats>? Stats { get; set; }
|
||||
|
||||
public bool HasSequence => Sequence is { Count: > 0 };
|
||||
public bool IsAnalyzed => Curve is not null && Stats is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Risoluzione di lavoro effettiva: parte dal primo fotogramma, applica l'eventuale limite
|
||||
/// dell'utente e arrotonda a valori pari, richiesti dal sottocampionamento cromatico 4:2:0.
|
||||
/// </summary>
|
||||
public (int Width, int Height) ResolveWorkingSize()
|
||||
{
|
||||
if (Sequence is not { Count: > 0 }) return (0, 0);
|
||||
|
||||
var first = Sequence.Frames[0].Metadata;
|
||||
int sourceWidth = first.PixelWidth;
|
||||
int sourceHeight = first.PixelHeight;
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0)
|
||||
{
|
||||
(sourceWidth, sourceHeight) = Imaging.ImageDecoder.ProbeDisplaySize(first.FilePath, first.Orientation);
|
||||
}
|
||||
else if (Imaging.ImageDecoder.SwapsAxes(first.Orientation))
|
||||
{
|
||||
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
|
||||
}
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) return (0, 0);
|
||||
|
||||
int targetWidth = Export.Width > 0 ? Export.Width
|
||||
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
|
||||
: sourceWidth;
|
||||
|
||||
double aspect = sourceHeight / (double)sourceWidth;
|
||||
int targetHeight = Export.Height > 0 ? Export.Height : (int)Math.Round(targetWidth * aspect);
|
||||
|
||||
targetWidth = Math.Max(2, targetWidth & ~1);
|
||||
targetHeight = Math.Max(2, targetHeight & ~1);
|
||||
return (targetWidth, targetHeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Titano.Diagnostics;
|
||||
|
||||
namespace Titano;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private const int AttachParentProcess = -1;
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool AttachConsole(int processId);
|
||||
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
if (args.Length > 0 && args[0] is "--selftest" or "-t")
|
||||
{
|
||||
AttachConsole(AttachParentProcess);
|
||||
string directory = args.Length > 1
|
||||
? args[1]
|
||||
: Path.Combine(Path.GetTempPath(), "Titano.SelfTest");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.Write(SelfTest.DescribeEnvironment());
|
||||
return SelfTest.Run(directory, Console.Out);
|
||||
}
|
||||
|
||||
if (args.Length > 1 && args[0] == "--capture")
|
||||
{
|
||||
AttachConsole(AttachParentProcess);
|
||||
ApplicationConfiguration.Initialize();
|
||||
return Capture(args[1], args.Length > 2 ? args[2] : null,
|
||||
args.Length > 3 && int.TryParse(args[3], out int tab) ? tab : 0);
|
||||
}
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new UI.MainForm());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apre l'interfaccia, opzionalmente vi carica una sequenza, e ne salva un'immagine.
|
||||
/// Serve a verificare la resa dei controlli disegnati a mano senza intervento manuale.
|
||||
/// </summary>
|
||||
private static int Capture(string outputPath, string? sequenceDirectory, int settingsTab)
|
||||
{
|
||||
using var form = new UI.MainForm();
|
||||
form.Show();
|
||||
Pump(200);
|
||||
|
||||
if (sequenceDirectory is not null && Directory.Exists(sequenceDirectory))
|
||||
{
|
||||
string[] files = [.. Directory.EnumerateFiles(sequenceDirectory)
|
||||
.Where(Metadata.MetadataReader.IsSupported)];
|
||||
var work = form.PrepareForCaptureAsync(files);
|
||||
while (!work.IsCompleted) Pump(30);
|
||||
Pump(2500); // attesa del rendering asincrono dell'anteprima
|
||||
}
|
||||
|
||||
if (settingsTab > 0)
|
||||
{
|
||||
form.SelectSettingsTab(settingsTab);
|
||||
Pump(200);
|
||||
}
|
||||
|
||||
using var bitmap = new Bitmap(form.Width, form.Height);
|
||||
form.DrawToBitmap(bitmap, new Rectangle(0, 0, form.Width, form.Height));
|
||||
bitmap.Save(outputPath, System.Drawing.Imaging.ImageFormat.Png);
|
||||
|
||||
Console.WriteLine($"Interfaccia catturata in {outputPath} ({bitmap.Width}×{bitmap.Height}).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void Pump(int milliseconds)
|
||||
{
|
||||
var deadline = Environment.TickCount64 + milliseconds;
|
||||
do
|
||||
{
|
||||
Application.DoEvents();
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
while (Environment.TickCount64 < deadline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# Titano
|
||||
|
||||
Applicazione desktop per la creazione e l'ottimizzazione di time-lapse di livello
|
||||
professionale, sviluppata interamente in-house.
|
||||
|
||||
## Il vincolo che definisce il progetto
|
||||
|
||||
**Zero librerie di terze parti.** Il file di progetto non contiene alcun
|
||||
`<PackageReference>`: tutto ciò che non è la libreria standard di .NET è scritto qui dentro
|
||||
oppure ottenuto tramite P/Invoke diretto verso componenti del sistema operativo.
|
||||
|
||||
| Ambito | Come è risolto |
|
||||
|---|---|
|
||||
| Parsing EXIF / XMP | Parser binario proprietario (`Metadata/`) |
|
||||
| Analisi fotometrica e deflicker | Algoritmi proprietari (`Analysis/`) |
|
||||
| Campo vettoriale e motion blur | Schema differenziale piramidale proprietario (`Motion/`) |
|
||||
| Contenitore MP4 | Multiplexer ISO-BMFF proprietario (`Video/Mp4Muxer.cs`) |
|
||||
| Decodifica immagini | WIC, componente di Windows, via COM interop scritto a mano |
|
||||
| Codifica video | Media Foundation Transform, encoder hardware di sistema |
|
||||
| Interfaccia grafica | WinForms + GDI+, ogni controllo disegnato a mano (`UI/`) |
|
||||
|
||||
Non viene invocato nessun processo esterno: niente FFmpeg, niente ExifTool.
|
||||
|
||||
## Principi architetturali
|
||||
|
||||
**Nessun file temporaneo.** I dati passano fra le fasi solo attraverso buffer in RAM. L'unica
|
||||
scrittura su disco è il flusso compresso finale, prodotto in streaming: `ftyp` e l'header
|
||||
`mdat` vengono scritti all'apertura, i pacchetti dell'encoder scorrono direttamente nel file
|
||||
e `moov` viene aggiunto in chiusura.
|
||||
|
||||
**Impronta di memoria costante.** I fotogrammi vivono in array float presi da un pool
|
||||
(`Imaging/ImageBuffer.cs`) e la decodifica è governata da un canale a capacità limitata: al
|
||||
più *N* fotogrammi sono vivi contemporaneamente, dove *N* è il numero di decodifiche
|
||||
simultanee scelto dall'utente. L'occupazione non dipende dalla lunghezza della sequenza —
|
||||
la verifica automatica lo misura esplicitamente.
|
||||
|
||||
**Interfaccia reattiva.** Ingestion, analisi e render girano su thread di lavoro; il thread
|
||||
dell'interfaccia riceve solo aggiornamenti di stato immutabili tramite `IProgress<T>`.
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
Metadata/ parser binario TIFF/Exif, scanner XMP, riconoscimento contenitori
|
||||
Core/ sequenza, intervalli reali, cadenza nominale, shutter angle
|
||||
Imaging/ buffer poolati, spazio colore lineare, decodifica via WIC
|
||||
Analysis/ misura di luminanza, curva di deflicker, applicazione dei guadagni
|
||||
Motion/ piramide gaussiana, optical flow, motion blur, interpolazione
|
||||
Video/ conversione NV12, encoder Media Foundation, multiplexer MP4
|
||||
Pipeline/ orchestrazione delle fasi, progetto e impostazioni
|
||||
UI/ tema scuro, grafico vettoriale, tabella virtuale, pannelli
|
||||
Diagnostics/ sequenza sintetica, verifica end-to-end, ispettore MP4
|
||||
```
|
||||
|
||||
## Note sugli algoritmi
|
||||
|
||||
**Deflicker.** Per ogni fotogramma si esegue una regressione lineare locale pesata sulla
|
||||
finestra mobile. I pesi combinano una gaussiana sulla distanza temporale e, in seconda
|
||||
passata, un peso di robustezza di Tukey che neutralizza i fotogrammi anomali. La componente
|
||||
lineare segue senza ritardo le rampe reali di luce (alba, tramonto) e rimuove solo la
|
||||
componente ad alta frequenza dovuta alle micro-variazioni del diaframma. La misura è la media
|
||||
logaritmica troncata: invariante alla scala, insensibile a cieli bruciati e ombre chiuse.
|
||||
|
||||
**Motion blur.** Lo shutter angle reale è `360 × posa / intervallo`. La scia già incisa nel
|
||||
fotogramma e quella sintetica si compongono in quadratura, quindi per raggiungere l'apertura
|
||||
obiettivo serve una scia di `√(obiettivo² − reale²)` volte lo spostamento: sommare
|
||||
linearmente produrrebbe un'immagine sistematicamente troppo morbida. Il filtro di
|
||||
ricostruzione è di tipo *gather*: un campione contribuisce al pixel centrale solo se la
|
||||
propria scia lo raggiunge davvero, così lo sfondo fermo non viene trascinato nei soggetti in
|
||||
movimento.
|
||||
|
||||
**Optical flow.** Schema differenziale piramidale: su ogni nodo di una griglia rada si risolve
|
||||
iterativamente il sistema normale 2×2 costruito dai gradienti spaziali e dalla differenza
|
||||
temporale. Un filtro mediano fra un livello e l'altro elimina i vettori spuri delle zone
|
||||
piatte. L'analisi avviene a risoluzione ridotta e i vettori vengono riportati in scala piena:
|
||||
il costo non dipende dalla dimensione dei file sorgente.
|
||||
|
||||
## Compilazione ed esecuzione
|
||||
|
||||
```
|
||||
dotnet build -c Release
|
||||
dotnet run -c Release
|
||||
```
|
||||
|
||||
Richiede .NET 10 SDK su Windows x64.
|
||||
|
||||
## Verifica automatica
|
||||
|
||||
```
|
||||
Titano.exe --selftest [cartella]
|
||||
```
|
||||
|
||||
Genera una sequenza sintetica dalle proprietà note — traslazione, sfarfallio e rampa di luce
|
||||
imposti, pausa dell'intervallometro inclusa — e la fa attraversare l'intera pipeline,
|
||||
confrontando 23 grandezze misurate con i valori attesi: campi Exif, cadenza, shutter angle,
|
||||
riduzione dello sfarfallio, conservazione della rampa, modulo e direzione del campo
|
||||
vettoriale, attenuazione del dettaglio dovuta alla sfocatura, struttura del contenitore
|
||||
prodotto e — prova conclusiva — la ri-decodifica del file con il lettore di sistema.
|
||||
|
||||
```
|
||||
Titano.exe --capture <file.png> [cartella-sequenza] [scheda]
|
||||
```
|
||||
|
||||
Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile.
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Titano - motore di time-lapse professionale.
|
||||
|
||||
VINCOLO ARCHITETTURALE: questo progetto non contiene NESSUN <PackageReference>.
|
||||
Tutto ciò che non è BCL è implementato in-house oppure ottenuto tramite
|
||||
P/Invoke diretto verso API native del sistema operativo (WIC, Media Foundation,
|
||||
GDI+, DWM, Kernel32), che il brief consente esplicitamente.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Platforms>x64</Platforms>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<AssemblyName>Titano</AssemblyName>
|
||||
<RootNamespace>Titano</RootNamespace>
|
||||
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||
<ApplicationDefaultFont>Segoe UI, 9pt</ApplicationDefaultFont>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
<Product>Titano</Product>
|
||||
<Company>Titano</Company>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Version>1.0.0</Version>
|
||||
<!--
|
||||
CA1416: le API Windows-only sono intenzionali, il progetto è vincolato a win-x64.
|
||||
WFO1000: riguarda la serializzazione dei controlli nel designer visuale. Qui l'intera
|
||||
interfaccia è costruita in codice e nessun controllo viene mai serializzato.
|
||||
-->
|
||||
<NoWarn>$(NoWarn);CA1416;WFO1000</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="Titano.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,488 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Pulsante disegnato interamente a mano, con varianti primaria e secondaria.</summary>
|
||||
internal sealed class DarkButton : Control
|
||||
{
|
||||
private bool _hover;
|
||||
private bool _pressed;
|
||||
|
||||
public bool Primary { get; set; }
|
||||
public bool Danger { get; set; }
|
||||
|
||||
public DarkButton()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 32;
|
||||
Font = Theme.Body;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hover = false; _pressed = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
protected override void OnMouseDown(MouseEventArgs e) { _pressed = true; Invalidate(); base.OnMouseDown(e); }
|
||||
protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; Invalidate(); base.OnMouseUp(e); }
|
||||
protected override void OnEnabledChanged(EventArgs e) { Invalidate(); base.OnEnabledChanged(e); }
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
|
||||
Color fill, stroke, text;
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
fill = Theme.SurfaceAlt;
|
||||
stroke = Theme.Border;
|
||||
text = Theme.TextFaint;
|
||||
}
|
||||
else if (Primary)
|
||||
{
|
||||
fill = _pressed ? Theme.AccentDim : _hover ? Theme.Mix(Theme.Accent, Color.White, 0.12) : Theme.Accent;
|
||||
stroke = fill;
|
||||
text = Color.FromArgb(0x0B, 0x12, 0x1C);
|
||||
}
|
||||
else if (Danger)
|
||||
{
|
||||
fill = _pressed ? Theme.SurfaceAlt : _hover ? Theme.Mix(Theme.Danger, Theme.Surface, 0.75) : Theme.Surface;
|
||||
stroke = Theme.Danger;
|
||||
text = Theme.Danger;
|
||||
}
|
||||
else
|
||||
{
|
||||
fill = _pressed ? Theme.Surface : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
|
||||
stroke = _hover ? Theme.BorderStrong : Theme.Border;
|
||||
text = Theme.Text;
|
||||
}
|
||||
|
||||
Theme.FillAndStroke(g, bounds, 6f, fill, stroke);
|
||||
|
||||
TextRenderer.DrawText(g, Text, Font, new Rectangle(0, 0, Width, Height), text,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Interruttore a due stati con etichetta, disegnato a mano.</summary>
|
||||
internal sealed class DarkCheckBox : Control
|
||||
{
|
||||
private bool _checked;
|
||||
private bool _hover;
|
||||
|
||||
public event EventHandler? CheckedChanged;
|
||||
|
||||
public bool Checked
|
||||
{
|
||||
get => _checked;
|
||||
set
|
||||
{
|
||||
if (_checked == value) return;
|
||||
_checked = value;
|
||||
Invalidate();
|
||||
CheckedChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public DarkCheckBox()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 26;
|
||||
Font = Theme.Body;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
protected override void OnClick(EventArgs e) { Checked = !Checked; base.OnClick(e); }
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
const int size = 17;
|
||||
int top = (Height - size) / 2;
|
||||
var box = new RectangleF(0.5f, top + 0.5f, size, size);
|
||||
|
||||
Color fill = _checked ? Theme.Accent : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
|
||||
Color stroke = _checked ? Theme.Accent : _hover ? Theme.BorderStrong : Theme.Border;
|
||||
Theme.FillAndStroke(g, box, 4f, fill, stroke);
|
||||
|
||||
if (_checked)
|
||||
{
|
||||
using var pen = new Pen(Color.FromArgb(0x0B, 0x12, 0x1C), 2f)
|
||||
{
|
||||
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
};
|
||||
g.DrawLines(pen,
|
||||
[
|
||||
new PointF(box.Left + 4f, box.Top + 8.5f),
|
||||
new PointF(box.Left + 7f, box.Top + 11.5f),
|
||||
new PointF(box.Left + 13f, box.Top + 5f),
|
||||
]);
|
||||
}
|
||||
|
||||
var textRect = new Rectangle(size + 9, 0, Width - size - 9, Height);
|
||||
TextRenderer.DrawText(g, Text, Font, textRect, Enabled ? Theme.Text : Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cursore continuo con didascalia e valore, unità di misura opzionale e tacche.
|
||||
/// Sostituisce il TrackBar di sistema, che non è tematizzabile.
|
||||
/// </summary>
|
||||
internal sealed class ParameterSlider : Control
|
||||
{
|
||||
private double _value;
|
||||
private bool _dragging;
|
||||
private bool _hover;
|
||||
|
||||
public string Caption { get; set; } = string.Empty;
|
||||
public string Unit { get; set; } = string.Empty;
|
||||
public string ValueFormat { get; set; } = "0.##";
|
||||
public double Minimum { get; set; }
|
||||
public double Maximum { get; set; } = 1;
|
||||
public double Step { get; set; }
|
||||
|
||||
public event EventHandler? ValueChanged;
|
||||
|
||||
public double Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
double clamped = Math.Clamp(value, Minimum, Maximum);
|
||||
if (Step > 0) clamped = Math.Round(clamped / Step) * Step;
|
||||
if (Math.Abs(clamped - _value) < 1e-9) return;
|
||||
_value = clamped;
|
||||
Invalidate();
|
||||
ValueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Imposta il valore senza sollevare l'evento: usata al caricamento delle impostazioni.</summary>
|
||||
public void SetValueSilently(double value)
|
||||
{
|
||||
_value = Math.Clamp(value, Minimum, Maximum);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public ParameterSlider()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 46;
|
||||
Font = Theme.Body;
|
||||
}
|
||||
|
||||
private Rectangle TrackBounds => new(2, Height - 20, Width - 4, 12);
|
||||
|
||||
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 OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left || !Enabled) return;
|
||||
_dragging = true;
|
||||
UpdateFromMouse(e.X);
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
if (_dragging) UpdateFromMouse(e.X);
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
_dragging = false;
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
double increment = Step > 0 ? Step : (Maximum - Minimum) / 50.0;
|
||||
Value += Math.Sign(e.Delta) * increment;
|
||||
}
|
||||
|
||||
private void UpdateFromMouse(int x)
|
||||
{
|
||||
var track = TrackBounds;
|
||||
double fraction = Math.Clamp((x - track.Left) / (double)Math.Max(1, track.Width), 0, 1);
|
||||
Value = Minimum + fraction * (Maximum - Minimum);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
var track = TrackBounds;
|
||||
float centerY = track.Top + track.Height / 2f;
|
||||
var groove = new RectangleF(track.Left, centerY - 2f, track.Width, 4f);
|
||||
Theme.FillRounded(g, groove, 2f, Enabled ? Theme.SurfaceAlt : Theme.Surface);
|
||||
|
||||
double span = Maximum - Minimum;
|
||||
float fraction = span <= 0 ? 0 : (float)((_value - Minimum) / span);
|
||||
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);
|
||||
|
||||
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))
|
||||
g.DrawEllipse(pen, RectangleF.Inflate(knob, -1f, -1f));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Etichetta e menu a discesa affiancati, con lo stile del tema.</summary>
|
||||
internal sealed class LabeledCombo : Panel
|
||||
{
|
||||
public ComboBox Combo { get; }
|
||||
|
||||
public LabeledCombo(string caption)
|
||||
{
|
||||
Height = 46;
|
||||
BackColor = Theme.Surface;
|
||||
|
||||
var label = new Label
|
||||
{
|
||||
Text = caption,
|
||||
Font = Theme.Small,
|
||||
ForeColor = Theme.TextMuted,
|
||||
Dock = DockStyle.Top,
|
||||
Height = 16,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
Combo = new ComboBox
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.SurfaceAlt,
|
||||
ForeColor = Theme.Text,
|
||||
Font = Theme.Body,
|
||||
Dock = DockStyle.Top,
|
||||
DrawMode = DrawMode.OwnerDrawFixed,
|
||||
ItemHeight = 20,
|
||||
};
|
||||
Combo.DrawItem += DrawItem;
|
||||
|
||||
Controls.Add(Combo);
|
||||
Controls.Add(label);
|
||||
}
|
||||
|
||||
private void DrawItem(object? sender, DrawItemEventArgs e)
|
||||
{
|
||||
if (e.Index < 0) return;
|
||||
bool selected = (e.State & DrawItemState.Selected) != 0;
|
||||
e.Graphics.FillRectangle(new SolidBrush(selected ? Theme.AccentDim : Theme.SurfaceAlt), e.Bounds);
|
||||
TextRenderer.DrawText(e.Graphics, Combo.Items[e.Index]?.ToString() ?? string.Empty, Theme.Body,
|
||||
Rectangle.Inflate(e.Bounds, -4, 0), Theme.Text,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Intestazione di sezione con filetto di separazione.</summary>
|
||||
internal sealed class SectionHeader : Control
|
||||
{
|
||||
public SectionHeader(string text)
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Text = text;
|
||||
Height = 30;
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var size = TextRenderer.MeasureText(g, Text, Theme.SmallBold);
|
||||
TextRenderer.DrawText(g, Text.ToUpperInvariant(), Theme.SmallBold,
|
||||
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
|
||||
int lineStart = size.Width + 14;
|
||||
if (lineStart < Width - 4)
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
int y = Height / 2;
|
||||
g.DrawLine(pen, lineStart, y, Width - 2, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Selettore a schede orizzontali usato dal pannello di configurazione.</summary>
|
||||
internal sealed class TabStrip : Control
|
||||
{
|
||||
private readonly List<string> _tabs = [];
|
||||
private int _selected;
|
||||
private int _hovered = -1;
|
||||
|
||||
public event EventHandler? SelectedChanged;
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
int clamped = Math.Clamp(value, 0, Math.Max(0, _tabs.Count - 1));
|
||||
if (clamped == _selected) return;
|
||||
_selected = clamped;
|
||||
Invalidate();
|
||||
SelectedChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public TabStrip(params string[] tabs)
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
_tabs.AddRange(tabs);
|
||||
Height = 36;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Larghezze proporzionali al testo: le etichette lunghe non vengono troncate solo
|
||||
/// perché condividono la barra con etichette corte.
|
||||
/// </summary>
|
||||
private float[] TabWidths()
|
||||
{
|
||||
var widths = new float[_tabs.Count];
|
||||
float total = 0;
|
||||
|
||||
using (var graphics = CreateGraphics())
|
||||
{
|
||||
for (int i = 0; i < _tabs.Count; i++)
|
||||
{
|
||||
widths[i] = TextRenderer.MeasureText(graphics, _tabs[i], Theme.SmallBold).Width + 22;
|
||||
total += widths[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (total <= 0) return widths;
|
||||
float scale = Width / total;
|
||||
for (int i = 0; i < widths.Length; i++) widths[i] *= scale;
|
||||
return widths;
|
||||
}
|
||||
|
||||
private int IndexAt(int x)
|
||||
{
|
||||
if (_tabs.Count == 0) return -1;
|
||||
var widths = TabWidths();
|
||||
float cursor = 0;
|
||||
for (int i = 0; i < widths.Length; i++)
|
||||
{
|
||||
cursor += widths[i];
|
||||
if (x < cursor) return i;
|
||||
}
|
||||
return _tabs.Count - 1;
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
int index = IndexAt(e.X);
|
||||
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) { SelectedIndex = IndexAt(e.X); base.OnMouseDown(e); }
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
if (_tabs.Count == 0) return;
|
||||
|
||||
var widths = TabWidths();
|
||||
float offset = 0;
|
||||
|
||||
for (int i = 0; i < _tabs.Count; i++)
|
||||
{
|
||||
var bounds = new RectangleF(offset, 0, widths[i], Height);
|
||||
offset += widths[i];
|
||||
bool active = i == _selected;
|
||||
|
||||
if (active) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.SurfaceAlt);
|
||||
else if (i == _hovered) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.Surface);
|
||||
|
||||
TextRenderer.DrawText(g, _tabs[i], active ? Theme.SmallBold : Theme.Small,
|
||||
Rectangle.Round(bounds), active ? Theme.Text : Theme.TextMuted,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.EndEllipsis);
|
||||
|
||||
if (active)
|
||||
{
|
||||
using var brush = new SolidBrush(Theme.Accent);
|
||||
g.FillRectangle(brush, bounds.X + bounds.Width / 2 - 12, Height - 3, 24, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Barra di avanzamento sottile con etichetta interna.</summary>
|
||||
internal sealed class DarkProgressBar : Control
|
||||
{
|
||||
private double _fraction;
|
||||
|
||||
public double Fraction
|
||||
{
|
||||
get => _fraction;
|
||||
set { _fraction = Math.Clamp(value, 0, 1); Invalidate(); }
|
||||
}
|
||||
|
||||
public DarkProgressBar()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
Height = 22;
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var bounds = new RectangleF(0, (Height - 8) / 2f, Width, 8);
|
||||
Theme.FillRounded(g, bounds, 4f, Theme.SurfaceAlt);
|
||||
|
||||
if (_fraction > 0.0005)
|
||||
{
|
||||
var filled = new RectangleF(bounds.X, bounds.Y, (float)(bounds.Width * _fraction), bounds.Height);
|
||||
Theme.FillRounded(g, filled, 4f, Theme.Accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using Titano.Core;
|
||||
using Titano.Metadata;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Tabella dei fotogrammi a rendering virtuale: disegna soltanto le righe visibili, quindi
|
||||
/// regge sequenze da decine di migliaia di scatti senza creare un controllo per riga.
|
||||
/// Barra di scorrimento, intestazioni e selezione sono disegnate a mano nel tema scuro.
|
||||
/// </summary>
|
||||
internal sealed class FrameTable : Control
|
||||
{
|
||||
private sealed record Column(string Title, int Width, bool RightAligned, Func<FrameRecord, string> Value);
|
||||
|
||||
private const int RowHeight = 24;
|
||||
private const int HeaderHeight = 30;
|
||||
private const int ScrollWidth = 12;
|
||||
|
||||
private readonly Column[] _columns;
|
||||
private TimelapseSequence? _sequence;
|
||||
private int _scroll;
|
||||
private int _hoverRow = -1;
|
||||
private int _selectedIndex = -1;
|
||||
private bool _draggingScroll;
|
||||
private int _dragOffset;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (_selectedIndex == value) return;
|
||||
_selectedIndex = value;
|
||||
EnsureVisible(value);
|
||||
Invalidate();
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public FrameTable()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
TabStop = true;
|
||||
|
||||
_columns =
|
||||
[
|
||||
new Column("#", 52, true, r => (r.Index + 1).ToString()),
|
||||
new Column("File", 178, false, r => r.FileName),
|
||||
new Column("Ora di scatto", 104, false, r => r.Metadata.CaptureTime?.ToString("HH:mm:ss.ff") ?? "—"),
|
||||
new Column("Δt", 68, true, r => r.CadenceText),
|
||||
new Column("Posa", 62, true, r => r.Metadata.ExposureText),
|
||||
new Column("Apertura", 68, true, r => r.Metadata.ApertureText),
|
||||
new Column("ISO", 52, true, r => r.Metadata.IsoText),
|
||||
new Column("Otturatore", 74, true, r => r.ShutterAngle > 0 ? $"{r.ShutterAngle:0.#}°" : "—"),
|
||||
new Column("Luminanza", 80, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.MeasuredLuminance, 1e-9)):0.00}" : "—"),
|
||||
new Column("Target", 72, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.TargetLuminance, 1e-9)):0.00}" : "—"),
|
||||
new Column("Guadagno", 76, true, r => r.LuminanceAnalyzed ? $"{r.GainStops:+0.00;-0.00;0.00}" : "—"),
|
||||
new Column("Blur", 62, true, r => r.BlurLength > 0.01 ? $"{r.BlurLength:0.0} px" : "—"),
|
||||
new Column("Movimento", 82, true, r => r.MotionMagnitude > 0.01 ? $"{r.MotionMagnitude:0.0} px" : "—"),
|
||||
];
|
||||
}
|
||||
|
||||
public void SetSequence(TimelapseSequence? sequence)
|
||||
{
|
||||
_sequence = sequence;
|
||||
_scroll = 0;
|
||||
_hoverRow = -1;
|
||||
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void Refresh(TimelapseSequence? sequence)
|
||||
{
|
||||
_sequence = sequence;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private int VisibleRows => Math.Max(1, (Height - HeaderHeight) / RowHeight);
|
||||
private int RowCount => _sequence?.Count ?? 0;
|
||||
private int MaxScroll => Math.Max(0, RowCount - VisibleRows);
|
||||
|
||||
private void EnsureVisible(int index)
|
||||
{
|
||||
if (index < 0) return;
|
||||
if (index < _scroll) _scroll = index;
|
||||
else if (index >= _scroll + VisibleRows) _scroll = index - VisibleRows + 1;
|
||||
_scroll = Math.Clamp(_scroll, 0, MaxScroll);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) => keyData is Keys.Up or Keys.Down or Keys.PageUp or Keys.PageDown;
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (RowCount == 0) return;
|
||||
switch (e.KeyCode)
|
||||
{
|
||||
case Keys.Up: SelectedIndex = Math.Max(0, _selectedIndex - 1); break;
|
||||
case Keys.Down: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + 1); break;
|
||||
case Keys.PageUp: SelectedIndex = Math.Max(0, _selectedIndex - VisibleRows); break;
|
||||
case Keys.PageDown: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + VisibleRows); break;
|
||||
case Keys.Home: SelectedIndex = 0; break;
|
||||
case Keys.End: SelectedIndex = RowCount - 1; break;
|
||||
default: base.OnKeyDown(e); return;
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
_scroll = Math.Clamp(_scroll - Math.Sign(e.Delta) * 3, 0, MaxScroll);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
|
||||
if (e.X >= Width - ScrollWidth && RowCount > VisibleRows)
|
||||
{
|
||||
var thumb = ThumbBounds();
|
||||
if (thumb.Contains(e.Location)) { _draggingScroll = true; _dragOffset = e.Y - thumb.Top; }
|
||||
else ScrollToThumb(e.Y - thumb.Height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
int row = (e.Y - HeaderHeight) / RowHeight;
|
||||
int index = _scroll + row;
|
||||
if (e.Y >= HeaderHeight && index >= 0 && index < RowCount) SelectedIndex = index;
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
if (_draggingScroll) { ScrollToThumb(e.Y - _dragOffset); return; }
|
||||
|
||||
int row = e.Y >= HeaderHeight ? (e.Y - HeaderHeight) / RowHeight : -1;
|
||||
if (row != _hoverRow) { _hoverRow = row; Invalidate(); }
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e) { _draggingScroll = false; base.OnMouseUp(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hoverRow = -1; Invalidate(); base.OnMouseLeave(e); }
|
||||
|
||||
private Rectangle ThumbBounds()
|
||||
{
|
||||
int trackHeight = Height - HeaderHeight;
|
||||
if (RowCount <= VisibleRows) return new Rectangle(Width - ScrollWidth, HeaderHeight, ScrollWidth, trackHeight);
|
||||
|
||||
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / RowCount);
|
||||
int available = trackHeight - thumbHeight;
|
||||
int offset = MaxScroll == 0 ? 0 : available * _scroll / MaxScroll;
|
||||
return new Rectangle(Width - ScrollWidth + 2, HeaderHeight + offset, ScrollWidth - 4, thumbHeight);
|
||||
}
|
||||
|
||||
private void ScrollToThumb(int top)
|
||||
{
|
||||
int trackHeight = Height - HeaderHeight;
|
||||
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / Math.Max(1, RowCount));
|
||||
int available = Math.Max(1, trackHeight - thumbHeight);
|
||||
double fraction = Math.Clamp((top - HeaderHeight) / (double)available, 0, 1);
|
||||
_scroll = (int)Math.Round(fraction * MaxScroll);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Surface);
|
||||
|
||||
int contentWidth = Width - (RowCount > VisibleRows ? ScrollWidth : 0);
|
||||
DrawHeader(g, contentWidth);
|
||||
|
||||
if (_sequence is not { Count: > 0 })
|
||||
{
|
||||
TextRenderer.DrawText(g, "Trascina qui le immagini della sequenza, oppure usa «Aggiungi cartella»",
|
||||
Theme.Body, new Rectangle(0, HeaderHeight, Width, Height - HeaderHeight),
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
int last = Math.Min(RowCount, _scroll + VisibleRows + 1);
|
||||
for (int index = _scroll; index < last; index++)
|
||||
{
|
||||
int y = HeaderHeight + (index - _scroll) * RowHeight;
|
||||
if (y > Height) break;
|
||||
DrawRow(g, _sequence.Frames[index], index, y, contentWidth);
|
||||
}
|
||||
|
||||
DrawScrollBar(g);
|
||||
}
|
||||
|
||||
private void DrawHeader(Graphics g, int contentWidth)
|
||||
{
|
||||
using (var brush = new SolidBrush(Theme.Background))
|
||||
g.FillRectangle(brush, 0, 0, Width, HeaderHeight);
|
||||
using (var pen = new Pen(Theme.Border))
|
||||
g.DrawLine(pen, 0, HeaderHeight - 1, Width, HeaderHeight - 1);
|
||||
|
||||
int x = 8;
|
||||
foreach (var column in _columns)
|
||||
{
|
||||
if (x > contentWidth) break;
|
||||
var bounds = new Rectangle(x, 0, Math.Min(column.Width - 8, contentWidth - x), HeaderHeight);
|
||||
TextRenderer.DrawText(g, column.Title, Theme.SmallBold, bounds, Theme.TextMuted,
|
||||
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
|
||||
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
x += column.Width;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRow(Graphics g, FrameRecord record, int index, int y, int contentWidth)
|
||||
{
|
||||
bool selected = index == _selectedIndex;
|
||||
bool hovered = _hoverRow == index - _scroll;
|
||||
|
||||
if (selected)
|
||||
{
|
||||
using var brush = new SolidBrush(Color.FromArgb(58, Theme.Accent));
|
||||
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
|
||||
using var edge = new SolidBrush(Theme.Accent);
|
||||
g.FillRectangle(edge, 0, y, 2, RowHeight);
|
||||
}
|
||||
else if (hovered)
|
||||
{
|
||||
using var brush = new SolidBrush(Theme.SurfaceAlt);
|
||||
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
|
||||
}
|
||||
else if ((index & 1) == 1)
|
||||
{
|
||||
using var brush = new SolidBrush(Color.FromArgb(0x1F, 0x22, 0x29));
|
||||
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
|
||||
}
|
||||
|
||||
int x = 8;
|
||||
for (int c = 0; c < _columns.Length; c++)
|
||||
{
|
||||
var column = _columns[c];
|
||||
if (x > contentWidth) break;
|
||||
|
||||
Color color = c switch
|
||||
{
|
||||
0 => Theme.TextFaint,
|
||||
1 => selected ? Theme.Text : Theme.Text,
|
||||
10 => GainColor(record),
|
||||
_ => Theme.TextMuted,
|
||||
};
|
||||
|
||||
// Un intervallo anomalo va segnalato dove si legge: sulla colonna Δt.
|
||||
if (c == 3 && record.IsCadenceAnomaly) color = Theme.Warning;
|
||||
if (c == 2 && record.Metadata.CaptureSource == TimestampSource.FileSystem) color = Theme.Warning;
|
||||
|
||||
var bounds = new Rectangle(x, y, Math.Min(column.Width - 8, Math.Max(0, contentWidth - x)), RowHeight);
|
||||
TextRenderer.DrawText(g, column.Value(record), c == 1 ? Theme.Body : Theme.Small, bounds, color,
|
||||
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
|
||||
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
x += column.Width;
|
||||
}
|
||||
}
|
||||
|
||||
private static Color GainColor(FrameRecord record)
|
||||
{
|
||||
if (!record.LuminanceAnalyzed) return Theme.TextFaint;
|
||||
double stops = Math.Abs(record.GainStops);
|
||||
if (stops < 0.02) return Theme.TextMuted;
|
||||
return record.GainStops > 0 ? Theme.Success : Theme.Danger;
|
||||
}
|
||||
|
||||
private void DrawScrollBar(Graphics g)
|
||||
{
|
||||
if (RowCount <= VisibleRows) return;
|
||||
|
||||
using (var track = new SolidBrush(Theme.Background))
|
||||
g.FillRectangle(track, Width - ScrollWidth, HeaderHeight, ScrollWidth, Height - HeaderHeight);
|
||||
|
||||
var thumb = ThumbBounds();
|
||||
Theme.FillRounded(g, thumb, 3f, _draggingScroll ? Theme.Accent : Theme.BorderStrong);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using Titano.Analysis;
|
||||
using Titano.Core;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Sistema di rendering vettoriale per la curva di esposizione.
|
||||
///
|
||||
/// Sovrappone la luminanza misurata (lo sfarfallio, in ambra) alla curva target calcolata
|
||||
/// dal deflicker (in blu), riempiendo lo scarto fra le due — che è esattamente la correzione
|
||||
/// applicata. Una corsia inferiore mostra il guadagno in stop per fotogramma e le anomalie
|
||||
/// di cadenza dell'intervallometro. Tutto è disegnato con primitive vettoriali: zoom e
|
||||
/// spostamento non degradano la resa.
|
||||
/// </summary>
|
||||
internal sealed class LuminanceChart : Control
|
||||
{
|
||||
private TimelapseSequence? _sequence;
|
||||
private DeflickerCurve? _curve;
|
||||
|
||||
private double _viewStart;
|
||||
private double _viewEnd = 1;
|
||||
private int _hoverIndex = -1;
|
||||
private int _selectedIndex = -1;
|
||||
private Point _mousePosition;
|
||||
private bool _panning;
|
||||
private double _panAnchor;
|
||||
private int _panOriginX;
|
||||
|
||||
private const int GutterLeft = 62;
|
||||
private const int GutterBottom = 22;
|
||||
private const int GutterTop = 26;
|
||||
private const int GainLaneHeight = 62;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (_selectedIndex == value) return;
|
||||
_selectedIndex = value;
|
||||
Invalidate();
|
||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public LuminanceChart()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
}
|
||||
|
||||
public void SetData(TimelapseSequence? sequence, DeflickerCurve? curve)
|
||||
{
|
||||
_sequence = sequence;
|
||||
_curve = curve;
|
||||
_viewStart = 0;
|
||||
_viewEnd = Math.Max(1, (sequence?.Count ?? 1) - 1);
|
||||
_hoverIndex = -1;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Aggiorna la sola curva conservando zoom e selezione (cursori del deflicker).</summary>
|
||||
public void UpdateCurve(DeflickerCurve? curve)
|
||||
{
|
||||
_curve = curve;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private Rectangle PlotArea
|
||||
{
|
||||
get
|
||||
{
|
||||
int height = Math.Max(40, Height - GutterTop - GutterBottom - GainLaneHeight);
|
||||
return new Rectangle(GutterLeft, GutterTop, Math.Max(10, Width - GutterLeft - 12), height);
|
||||
}
|
||||
}
|
||||
|
||||
private Rectangle GainArea
|
||||
{
|
||||
get
|
||||
{
|
||||
var plot = PlotArea;
|
||||
return new Rectangle(plot.Left, plot.Bottom + 6, plot.Width, GainLaneHeight - 12);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
_mousePosition = e.Location;
|
||||
|
||||
if (_panning)
|
||||
{
|
||||
var plot = PlotArea;
|
||||
double span = _viewEnd - _viewStart;
|
||||
double delta = (e.X - _panOriginX) / (double)Math.Max(1, plot.Width) * span;
|
||||
double start = _panAnchor - delta;
|
||||
int max = Math.Max(0, (_sequence?.Count ?? 1) - 1);
|
||||
start = Math.Clamp(start, 0, Math.Max(0, max - span));
|
||||
_viewStart = start;
|
||||
_viewEnd = start + span;
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
int index = IndexAt(e.X);
|
||||
if (index != _hoverIndex) { _hoverIndex = index; Invalidate(); }
|
||||
else if (index >= 0) Invalidate();
|
||||
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hoverIndex = -1;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
Focus();
|
||||
if (e.Button == MouseButtons.Left)
|
||||
{
|
||||
int index = IndexAt(e.X);
|
||||
if (index >= 0) SelectedIndex = index;
|
||||
}
|
||||
else if (e.Button == MouseButtons.Right)
|
||||
{
|
||||
_panning = true;
|
||||
_panAnchor = _viewStart;
|
||||
_panOriginX = e.X;
|
||||
Cursor = Cursors.SizeWE;
|
||||
}
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
_panning = false;
|
||||
Cursor = Cursors.Default;
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
if (_sequence is not { Count: > 1 }) return;
|
||||
|
||||
int last = _sequence.Count - 1;
|
||||
var plot = PlotArea;
|
||||
double fraction = Math.Clamp((e.X - plot.Left) / (double)Math.Max(1, plot.Width), 0, 1);
|
||||
double focus = _viewStart + fraction * (_viewEnd - _viewStart);
|
||||
|
||||
double factor = e.Delta > 0 ? 0.8 : 1.25;
|
||||
double span = Math.Clamp((_viewEnd - _viewStart) * factor, 4, last);
|
||||
|
||||
_viewStart = Math.Clamp(focus - fraction * span, 0, Math.Max(0, last - span));
|
||||
_viewEnd = _viewStart + span;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseDoubleClick(MouseEventArgs e)
|
||||
{
|
||||
_viewStart = 0;
|
||||
_viewEnd = Math.Max(1, (_sequence?.Count ?? 1) - 1);
|
||||
Invalidate();
|
||||
base.OnMouseDoubleClick(e);
|
||||
}
|
||||
|
||||
private int IndexAt(int x)
|
||||
{
|
||||
if (_sequence is not { Count: > 0 }) return -1;
|
||||
var plot = PlotArea;
|
||||
if (x < plot.Left - 4 || x > plot.Right + 4) return -1;
|
||||
|
||||
double fraction = (x - plot.Left) / (double)Math.Max(1, plot.Width);
|
||||
double position = _viewStart + fraction * (_viewEnd - _viewStart);
|
||||
return Math.Clamp((int)Math.Round(position), 0, _sequence.Count - 1);
|
||||
}
|
||||
|
||||
private float XFor(double index, Rectangle plot)
|
||||
{
|
||||
double span = Math.Max(1e-6, _viewEnd - _viewStart);
|
||||
return plot.Left + (float)((index - _viewStart) / span * plot.Width);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Surface);
|
||||
|
||||
var plot = PlotArea;
|
||||
|
||||
if (_sequence is not { Count: > 1 } || _curve is null || _curve.Count < 2)
|
||||
{
|
||||
DrawEmptyState(g);
|
||||
return;
|
||||
}
|
||||
|
||||
// Estensione verticale: unione delle due curve con un margine costante.
|
||||
double minValue = double.MaxValue, maxValue = double.MinValue;
|
||||
int from = Math.Max(0, (int)Math.Floor(_viewStart));
|
||||
int to = Math.Min(_curve.Count - 1, (int)Math.Ceiling(_viewEnd));
|
||||
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
minValue = Math.Min(minValue, Math.Min(_curve.Measured[i], _curve.Target[i]));
|
||||
maxValue = Math.Max(maxValue, Math.Max(_curve.Measured[i], _curve.Target[i]));
|
||||
}
|
||||
if (minValue > maxValue) { minValue = -4; maxValue = -1; }
|
||||
|
||||
double padding = Math.Max(0.12, (maxValue - minValue) * 0.15);
|
||||
minValue -= padding;
|
||||
maxValue += padding;
|
||||
|
||||
DrawGrid(g, plot, minValue, maxValue);
|
||||
DrawCadenceMarkers(g, plot);
|
||||
DrawCorrectionBand(g, plot, from, to, minValue, maxValue);
|
||||
DrawCurve(g, plot, _curve.Measured, from, to, minValue, maxValue, Theme.Measured, 1.4f);
|
||||
DrawCurve(g, plot, _curve.Target, from, to, minValue, maxValue, Theme.Accent, 2.1f);
|
||||
DrawGainLane(g, from, to);
|
||||
DrawSelection(g, plot);
|
||||
DrawLegend(g);
|
||||
DrawHover(g, plot, minValue, maxValue);
|
||||
}
|
||||
|
||||
private void DrawEmptyState(Graphics g)
|
||||
{
|
||||
string message = _sequence is null
|
||||
? "Nessuna sequenza caricata"
|
||||
: "Esegui l'analisi per visualizzare la curva di esposizione";
|
||||
|
||||
TextRenderer.DrawText(g, message, Theme.Body, new Rectangle(0, 0, Width, Height),
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
private float YFor(double value, Rectangle plot, double min, double max)
|
||||
{
|
||||
double span = Math.Max(1e-6, max - min);
|
||||
return plot.Bottom - (float)((value - min) / span * plot.Height);
|
||||
}
|
||||
|
||||
private void DrawGrid(Graphics g, Rectangle plot, double min, double max)
|
||||
{
|
||||
using var gridPen = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
|
||||
using var axisPen = new Pen(Theme.BorderStrong);
|
||||
|
||||
// Linee orizzontali a passo di stop intero (o mezzo stop se l'intervallo è stretto).
|
||||
double step = (max - min) > 4 ? 1.0 : (max - min) > 1.6 ? 0.5 : 0.25;
|
||||
double first = Math.Ceiling(min / step) * step;
|
||||
|
||||
for (double value = first; value <= max; value += step)
|
||||
{
|
||||
float y = YFor(value, plot, min, max);
|
||||
g.DrawLine(gridPen, plot.Left, y, plot.Right, y);
|
||||
TextRenderer.DrawText(g, value.ToString("0.##") + " EV", Theme.Small,
|
||||
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
g.DrawLine(axisPen, plot.Left, plot.Top, plot.Left, plot.Bottom);
|
||||
g.DrawLine(axisPen, plot.Left, plot.Bottom, plot.Right, plot.Bottom);
|
||||
|
||||
// Etichette dei fotogrammi lungo l'asse orizzontale.
|
||||
int count = _sequence!.Count;
|
||||
double span = _viewEnd - _viewStart;
|
||||
double labelStep = NiceStep(span / 8.0);
|
||||
double firstLabel = Math.Ceiling(_viewStart / labelStep) * labelStep;
|
||||
|
||||
for (double index = firstLabel; index <= _viewEnd; index += labelStep)
|
||||
{
|
||||
float x = XFor(index, plot);
|
||||
if (x < plot.Left - 1 || x > plot.Right + 1) continue;
|
||||
g.DrawLine(gridPen, x, plot.Top, x, plot.Bottom);
|
||||
|
||||
int frame = Math.Clamp((int)Math.Round(index), 0, count - 1);
|
||||
TextRenderer.DrawText(g, (frame + 1).ToString(), Theme.Small,
|
||||
new Rectangle((int)x - 30, Height - GutterBottom + 2, 60, 16),
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
|
||||
private static double NiceStep(double raw)
|
||||
{
|
||||
if (raw <= 1) return 1;
|
||||
double magnitude = Math.Pow(10, Math.Floor(Math.Log10(raw)));
|
||||
double normalized = raw / magnitude;
|
||||
double nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
/// <summary>Tacche verticali sugli intervalli che si discostano dalla cadenza nominale.</summary>
|
||||
private void DrawCadenceMarkers(Graphics g, Rectangle plot)
|
||||
{
|
||||
if (_sequence is null) return;
|
||||
using var brush = new SolidBrush(Color.FromArgb(60, Theme.Warning));
|
||||
|
||||
for (int i = Math.Max(0, (int)_viewStart); i <= Math.Min(_sequence.Count - 1, (int)Math.Ceiling(_viewEnd)); i++)
|
||||
{
|
||||
if (!_sequence.Frames[i].IsCadenceAnomaly) continue;
|
||||
float x = XFor(i, plot);
|
||||
float next = XFor(i + 1, plot);
|
||||
g.FillRectangle(brush, x, plot.Top, Math.Max(1.5f, next - x), plot.Height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Area fra misurato e target: è la correzione che verrà applicata.</summary>
|
||||
private void DrawCorrectionBand(Graphics g, Rectangle plot, int from, int to, double min, double max)
|
||||
{
|
||||
if (to - from < 1) return;
|
||||
|
||||
var points = new List<PointF>((to - from + 1) * 2);
|
||||
for (int i = from; i <= to; i++) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Measured[i], plot, min, max)));
|
||||
for (int i = to; i >= from; i--) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Target[i], plot, min, max)));
|
||||
|
||||
using var path = new GraphicsPath();
|
||||
path.AddPolygon(points.ToArray());
|
||||
using var brush = new SolidBrush(Color.FromArgb(38, Theme.Accent));
|
||||
|
||||
var clip = g.Clip;
|
||||
g.SetClip(plot);
|
||||
g.FillPath(brush, path);
|
||||
g.Clip = clip;
|
||||
}
|
||||
|
||||
private void DrawCurve(Graphics g, Rectangle plot, double[] values, int from, int to,
|
||||
double min, double max, Color color, float width)
|
||||
{
|
||||
if (to - from < 1) return;
|
||||
|
||||
var clip = g.Clip;
|
||||
g.SetClip(Rectangle.Inflate(plot, 2, 2));
|
||||
|
||||
using var pen = new Pen(color, width)
|
||||
{
|
||||
LineJoin = LineJoin.Round,
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round,
|
||||
};
|
||||
|
||||
int visible = to - from + 1;
|
||||
if (visible > plot.Width * 2)
|
||||
{
|
||||
// Più campioni che pixel: si traccia l'inviluppo min/max per colonna,
|
||||
// preservando l'ampiezza reale dello sfarfallio invece di alias arbitrari.
|
||||
DrawEnvelope(g, plot, values, from, to, min, max, color);
|
||||
}
|
||||
else
|
||||
{
|
||||
var points = new PointF[visible];
|
||||
for (int i = 0; i < visible; i++)
|
||||
{
|
||||
points[i] = new PointF(XFor(from + i, plot), YFor(values[from + i], plot, min, max));
|
||||
}
|
||||
if (points.Length >= 2) g.DrawLines(pen, points);
|
||||
}
|
||||
|
||||
// Punti singoli quando lo zoom è sufficiente a distinguerli.
|
||||
if (visible <= 90)
|
||||
{
|
||||
using var dot = new SolidBrush(color);
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
float x = XFor(i, plot);
|
||||
float y = YFor(values[i], plot, min, max);
|
||||
g.FillEllipse(dot, x - 2f, y - 2f, 4f, 4f);
|
||||
}
|
||||
}
|
||||
|
||||
g.Clip = clip;
|
||||
}
|
||||
|
||||
private void DrawEnvelope(Graphics g, Rectangle plot, double[] values, int from, int to,
|
||||
double min, double max, Color color)
|
||||
{
|
||||
using var pen = new Pen(Color.FromArgb(200, color), 1f);
|
||||
double perPixel = (to - from + 1) / (double)plot.Width;
|
||||
|
||||
for (int px = 0; px < plot.Width; px++)
|
||||
{
|
||||
int start = from + (int)(px * perPixel);
|
||||
int end = Math.Min(to, from + (int)((px + 1) * perPixel));
|
||||
if (start > end) continue;
|
||||
|
||||
double lo = double.MaxValue, hi = double.MinValue;
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
lo = Math.Min(lo, values[i]);
|
||||
hi = Math.Max(hi, values[i]);
|
||||
}
|
||||
|
||||
float x = plot.Left + px;
|
||||
g.DrawLine(pen, x, YFor(hi, plot, min, max), x, YFor(lo, plot, min, max) + 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Corsia inferiore: guadagno per fotogramma in stop, con lo zero al centro.</summary>
|
||||
private void DrawGainLane(Graphics g, int from, int to)
|
||||
{
|
||||
var lane = GainArea;
|
||||
if (lane.Height < 12) return;
|
||||
|
||||
double peak = 0.05;
|
||||
for (int i = from; i <= to; i++) peak = Math.Max(peak, Math.Abs(_curve!.GainStops[i]));
|
||||
peak = Math.Max(peak, 0.05);
|
||||
|
||||
float zero = lane.Top + lane.Height / 2f;
|
||||
using (var basePen = new Pen(Theme.Border)) g.DrawLine(basePen, lane.Left, zero, lane.Right, zero);
|
||||
|
||||
TextRenderer.DrawText(g, "guadagno", Theme.Small, new Rectangle(0, (int)zero - 8, GutterLeft - 6, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
TextRenderer.DrawText(g, $"fondoscala ±{peak:0.##} EV", Theme.Small,
|
||||
new Rectangle(Width - 172, 4, 160, 16), Theme.TextFaint,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
|
||||
double perColumn = (to - from + 1) / (double)Math.Max(1, lane.Width);
|
||||
float barWidth = Math.Max(1f, (float)(lane.Width / (double)Math.Max(1, to - from + 1)) - 1f);
|
||||
|
||||
using var positive = new SolidBrush(Color.FromArgb(190, Theme.Success));
|
||||
using var negative = new SolidBrush(Color.FromArgb(190, Theme.Danger));
|
||||
|
||||
if (perColumn <= 1.0)
|
||||
{
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
double gain = _curve!.GainStops[i];
|
||||
float x = XFor(i, lane) - barWidth / 2f;
|
||||
float height = (float)(Math.Abs(gain) / peak * (lane.Height / 2f - 2));
|
||||
if (height < 0.6f) continue;
|
||||
var rect = gain >= 0
|
||||
? new RectangleF(x, zero - height, barWidth, height)
|
||||
: new RectangleF(x, zero, barWidth, height);
|
||||
g.FillRectangle(gain >= 0 ? positive : negative, rect);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int px = 0; px < lane.Width; px++)
|
||||
{
|
||||
int start = from + (int)(px * perColumn);
|
||||
int end = Math.Min(to, from + (int)((px + 1) * perColumn));
|
||||
if (start > end) continue;
|
||||
|
||||
double lo = 0, hi = 0;
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
lo = Math.Min(lo, _curve!.GainStops[i]);
|
||||
hi = Math.Max(hi, _curve!.GainStops[i]);
|
||||
}
|
||||
|
||||
float x = lane.Left + px;
|
||||
float top = zero - (float)(hi / peak * (lane.Height / 2f - 2));
|
||||
float bottom = zero - (float)(lo / peak * (lane.Height / 2f - 2));
|
||||
g.FillRectangle(hi >= -lo ? positive : negative, x, top, 1f, Math.Max(1f, bottom - top));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSelection(Graphics g, Rectangle plot)
|
||||
{
|
||||
if (_selectedIndex < 0 || _sequence is null || _selectedIndex >= _sequence.Count) return;
|
||||
float x = XFor(_selectedIndex, plot);
|
||||
if (x < plot.Left || x > plot.Right) return;
|
||||
|
||||
using var pen = new Pen(Color.FromArgb(150, Theme.Text), 1f) { DashStyle = DashStyle.Dash };
|
||||
g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
|
||||
}
|
||||
|
||||
private void DrawLegend(Graphics g)
|
||||
{
|
||||
var entries = new (Color Color, string Label)[]
|
||||
{
|
||||
(Theme.Measured, "luminanza misurata"),
|
||||
(Theme.Accent, "curva target"),
|
||||
(Theme.Warning, "cadenza anomala"),
|
||||
};
|
||||
|
||||
int x = GutterLeft;
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
var (color, label) = entries[i];
|
||||
// L'ultima voce indica una fascia di sfondo, non una curva: si disegna come tale.
|
||||
bool band = i == entries.Length - 1;
|
||||
using (var brush = new SolidBrush(band ? Color.FromArgb(90, color) : color))
|
||||
g.FillRectangle(brush, x, band ? 5 : 10, 14, band ? 12 : 3);
|
||||
var size = TextRenderer.MeasureText(g, label, Theme.Small);
|
||||
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 19, 4, size.Width + 4, 16),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
x += 19 + size.Width + 18;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawHover(Graphics g, Rectangle plot, double min, double max)
|
||||
{
|
||||
if (_hoverIndex < 0 || _sequence is null || _hoverIndex >= _sequence.Count || _curve is null) return;
|
||||
|
||||
float x = XFor(_hoverIndex, plot);
|
||||
if (x < plot.Left - 2 || x > plot.Right + 2) return;
|
||||
|
||||
using (var pen = new Pen(Color.FromArgb(90, Theme.Text), 1f)) g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
|
||||
|
||||
float measuredY = YFor(_curve.Measured[_hoverIndex], plot, min, max);
|
||||
float targetY = YFor(_curve.Target[_hoverIndex], plot, min, max);
|
||||
|
||||
using (var brush = new SolidBrush(Theme.Measured)) g.FillEllipse(brush, x - 3.5f, measuredY - 3.5f, 7, 7);
|
||||
using (var brush = new SolidBrush(Theme.Accent)) g.FillEllipse(brush, x - 3.5f, targetY - 3.5f, 7, 7);
|
||||
|
||||
var record = _sequence.Frames[_hoverIndex];
|
||||
string[] lines =
|
||||
[
|
||||
$"#{_hoverIndex + 1} {record.FileName}",
|
||||
$"misurata {_curve.Measured[_hoverIndex]:0.00} EV",
|
||||
$"target {_curve.Target[_hoverIndex]:0.00} EV",
|
||||
$"guadagno {_curve.GainStops[_hoverIndex]:+0.00;-0.00;0.00} EV",
|
||||
$"intervallo {record.CadenceText} otturatore {record.ShutterAngle:0.#}°",
|
||||
];
|
||||
|
||||
int widthNeeded = 0;
|
||||
foreach (string line in lines)
|
||||
widthNeeded = Math.Max(widthNeeded, TextRenderer.MeasureText(g, line, Theme.Small).Width);
|
||||
|
||||
int boxWidth = widthNeeded + 18;
|
||||
int boxHeight = lines.Length * 15 + 12;
|
||||
int boxX = (int)(x + 14);
|
||||
if (boxX + boxWidth > Width - 6) boxX = (int)(x - 14 - boxWidth);
|
||||
int boxY = Math.Clamp(_mousePosition.Y - boxHeight / 2, plot.Top, Math.Max(plot.Top, Height - boxHeight - 4));
|
||||
|
||||
var box = new RectangleF(boxX, boxY, boxWidth, boxHeight);
|
||||
Theme.FillAndStroke(g, box, 6f, Color.FromArgb(242, Theme.Background), Theme.BorderStrong);
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
TextRenderer.DrawText(g, lines[i], i == 0 ? Theme.SmallBold : Theme.Small,
|
||||
new Rectangle(boxX + 9, boxY + 6 + i * 15, boxWidth - 18, 15),
|
||||
i == 0 ? Theme.Text : Theme.TextMuted,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
using Titano.Core;
|
||||
using Titano.Metadata;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>Finestra principale: collega i controlli disegnati a mano al motore di elaborazione.</summary>
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
private readonly TitanoProject _project = new();
|
||||
|
||||
private readonly LuminanceChart _chart;
|
||||
private readonly FrameTable _table;
|
||||
private readonly PreviewPanel _preview;
|
||||
private readonly SettingsPanel _settings;
|
||||
private readonly DarkProgressBar _progress;
|
||||
private readonly Label _status;
|
||||
private readonly Label _summary;
|
||||
|
||||
private readonly DarkButton _addFilesButton;
|
||||
private readonly DarkButton _addFolderButton;
|
||||
private readonly DarkButton _clearButton;
|
||||
private readonly DarkButton _analyzeButton;
|
||||
private readonly DarkButton _exportButton;
|
||||
private readonly DarkButton _cancelButton;
|
||||
|
||||
private CancellationTokenSource? _operation;
|
||||
private bool _busy;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "Titano — time-lapse";
|
||||
MinimumSize = new Size(1180, 720);
|
||||
Size = new Size(1560, 950);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
BackColor = Theme.Background;
|
||||
ForeColor = Theme.Text;
|
||||
Font = Theme.Body;
|
||||
AllowDrop = true;
|
||||
DoubleBuffered = true;
|
||||
|
||||
_chart = new LuminanceChart { Dock = DockStyle.Fill };
|
||||
_table = new FrameTable { Dock = DockStyle.Fill };
|
||||
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
|
||||
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
|
||||
_progress = new DarkProgressBar { Dock = DockStyle.Fill };
|
||||
|
||||
_status = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ForeColor = Theme.TextMuted,
|
||||
Font = Theme.Small,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
Text = "Pronto. Trascina qui una sequenza di immagini per iniziare.",
|
||||
};
|
||||
|
||||
_summary = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ForeColor = Theme.TextFaint,
|
||||
Font = Theme.Small,
|
||||
TextAlign = ContentAlignment.MiddleRight,
|
||||
Text = string.Empty,
|
||||
};
|
||||
|
||||
_addFilesButton = new DarkButton { Text = "Aggiungi file…", Width = 130 };
|
||||
_addFolderButton = new DarkButton { Text = "Aggiungi cartella…", Width = 150 };
|
||||
_clearButton = new DarkButton { Text = "Svuota", Width = 84 };
|
||||
_analyzeButton = new DarkButton { Text = "Analizza sequenza", Width = 160 };
|
||||
_exportButton = new DarkButton { Text = "Esporta video", Width = 140, Primary = true };
|
||||
_cancelButton = new DarkButton { Text = "Annulla", Width = 96, Danger = true, Visible = false };
|
||||
|
||||
BuildLayout();
|
||||
WireEvents();
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
Theme.ApplyDarkTitleBar(this);
|
||||
}
|
||||
|
||||
protected override void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
// La cornice viene ridisegnata dal sistema alla comparsa: l'attributo va riconfermato.
|
||||
Theme.ApplyDarkTitleBar(this);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ struttura
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var toolbar = BuildToolbar();
|
||||
var statusBar = BuildStatusBar();
|
||||
|
||||
var settingsHost = new Panel
|
||||
{
|
||||
Dock = DockStyle.Right,
|
||||
Width = 372,
|
||||
BackColor = Theme.Surface,
|
||||
Padding = new Padding(1, 0, 0, 0),
|
||||
};
|
||||
settingsHost.Controls.Add(_settings);
|
||||
settingsHost.Paint += (_, e) =>
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
e.Graphics.DrawLine(pen, 0, 0, 0, settingsHost.Height);
|
||||
};
|
||||
|
||||
var center = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
|
||||
|
||||
var tableHost = Card(_table, "Fotogrammi", DockStyle.Fill);
|
||||
var chartHost = Card(_chart, "Curva di esposizione", DockStyle.Top, 268);
|
||||
var previewHost = Card(_preview, "Anteprima", DockStyle.Top, 300);
|
||||
|
||||
var chartSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
|
||||
var previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
|
||||
|
||||
center.Controls.Add(tableHost);
|
||||
center.Controls.Add(chartSplitter);
|
||||
center.Controls.Add(chartHost);
|
||||
center.Controls.Add(previewSplitter);
|
||||
center.Controls.Add(previewHost);
|
||||
|
||||
Controls.Add(center);
|
||||
Controls.Add(settingsHost);
|
||||
Controls.Add(statusBar);
|
||||
Controls.Add(toolbar);
|
||||
}
|
||||
|
||||
/// <summary>Riquadro con intestazione: unità visiva ricorrente dell'interfaccia.</summary>
|
||||
private static Panel Card(Control content, string title, DockStyle dock, int height = 0)
|
||||
{
|
||||
var host = new Panel
|
||||
{
|
||||
Dock = dock,
|
||||
BackColor = Theme.Surface,
|
||||
Padding = new Padding(1, 30, 1, 1),
|
||||
Margin = new Padding(0),
|
||||
};
|
||||
if (height > 0) host.Height = height;
|
||||
|
||||
host.Controls.Add(content);
|
||||
host.Paint += (_, e) =>
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
using (var brush = new SolidBrush(Theme.Background))
|
||||
g.FillRectangle(brush, 0, 0, host.Width, 30);
|
||||
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(12, 0, host.Width - 24, 30),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
using var pen = new Pen(Theme.Border);
|
||||
g.DrawRectangle(pen, 0, 0, host.Width - 1, host.Height - 1);
|
||||
g.DrawLine(pen, 0, 29, host.Width, 29);
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
private Panel BuildToolbar()
|
||||
{
|
||||
var bar = new Panel { Dock = DockStyle.Top, Height = 58, BackColor = Theme.Background };
|
||||
|
||||
var title = new Label
|
||||
{
|
||||
Text = "TITANO",
|
||||
Font = Theme.Title,
|
||||
ForeColor = Theme.Text,
|
||||
AutoSize = false,
|
||||
Bounds = new Rectangle(16, 12, 110, 32),
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
var subtitle = new Label
|
||||
{
|
||||
Text = "elaborazione time-lapse",
|
||||
Font = Theme.Small,
|
||||
ForeColor = Theme.TextFaint,
|
||||
AutoSize = false,
|
||||
Bounds = new Rectangle(112, 20, 160, 18),
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
int x = 288;
|
||||
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
|
||||
{
|
||||
button.Bounds = new Rectangle(x, 13, button.Width, 32);
|
||||
bar.Controls.Add(button);
|
||||
x += button.Width + 8;
|
||||
}
|
||||
|
||||
_analyzeButton.Bounds = new Rectangle(x + 16, 13, _analyzeButton.Width, 32);
|
||||
bar.Controls.Add(_analyzeButton);
|
||||
x += _analyzeButton.Width + 24;
|
||||
|
||||
_exportButton.Bounds = new Rectangle(x, 13, _exportButton.Width, 32);
|
||||
bar.Controls.Add(_exportButton);
|
||||
|
||||
bar.Controls.Add(title);
|
||||
bar.Controls.Add(subtitle);
|
||||
|
||||
bar.Paint += (_, e) =>
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
e.Graphics.DrawLine(pen, 0, bar.Height - 1, bar.Width, bar.Height - 1);
|
||||
};
|
||||
return bar;
|
||||
}
|
||||
|
||||
private Panel BuildStatusBar()
|
||||
{
|
||||
var bar = new Panel { Dock = DockStyle.Bottom, Height = 52, BackColor = Theme.Background };
|
||||
|
||||
var layout = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 3,
|
||||
RowCount = 2,
|
||||
BackColor = Theme.Background,
|
||||
Padding = new Padding(16, 6, 16, 6),
|
||||
};
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 60));
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40));
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 104));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
|
||||
|
||||
layout.Controls.Add(_status, 0, 0);
|
||||
layout.Controls.Add(_summary, 1, 0);
|
||||
layout.Controls.Add(_progress, 0, 1);
|
||||
layout.SetColumnSpan(_progress, 2);
|
||||
|
||||
_cancelButton.Dock = DockStyle.Fill;
|
||||
_cancelButton.Margin = new Padding(8, 4, 0, 4);
|
||||
layout.Controls.Add(_cancelButton, 2, 0);
|
||||
layout.SetRowSpan(_cancelButton, 2);
|
||||
|
||||
bar.Controls.Add(layout);
|
||||
bar.Paint += (_, e) =>
|
||||
{
|
||||
using var pen = new Pen(Theme.Border);
|
||||
e.Graphics.DrawLine(pen, 0, 0, bar.Width, 0);
|
||||
};
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ eventi
|
||||
|
||||
private void WireEvents()
|
||||
{
|
||||
_addFilesButton.Click += (_, _) => AddFiles();
|
||||
_addFolderButton.Click += (_, _) => AddFolder();
|
||||
_clearButton.Click += (_, _) => ClearSequence();
|
||||
_analyzeButton.Click += async (_, _) => await AnalyzeAsync();
|
||||
_exportButton.Click += async (_, _) => await ExportAsync();
|
||||
_cancelButton.Click += (_, _) => _operation?.Cancel();
|
||||
|
||||
_chart.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (_chart.SelectedIndex >= 0) _table.SelectedIndex = _chart.SelectedIndex;
|
||||
ShowPreview(_chart.SelectedIndex);
|
||||
};
|
||||
|
||||
_table.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (_table.SelectedIndex >= 0) _chart.SelectedIndex = _table.SelectedIndex;
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
};
|
||||
|
||||
_settings.DeflickerChanged += (_, _) => RecomputeCurve();
|
||||
_settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis();
|
||||
_settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex);
|
||||
_settings.BrowseOutputRequested += (_, _) => BrowseOutput();
|
||||
|
||||
DragEnter += (_, e) =>
|
||||
{
|
||||
if (e.Data?.GetDataPresent(DataFormats.FileDrop) == true) e.Effect = DragDropEffects.Copy;
|
||||
};
|
||||
DragDrop += (_, e) =>
|
||||
{
|
||||
if (e.Data?.GetData(DataFormats.FileDrop) is string[] items) _ = LoadAsync(ExpandPaths(items));
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ comandi
|
||||
|
||||
private void AddFiles()
|
||||
{
|
||||
using var dialog = new OpenFileDialog
|
||||
{
|
||||
Multiselect = true,
|
||||
Title = "Seleziona i fotogrammi della sequenza",
|
||||
Filter = "Immagini (" + string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + ")|" +
|
||||
string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + "|Tutti i file|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(dialog.FileNames);
|
||||
}
|
||||
|
||||
private void AddFolder()
|
||||
{
|
||||
using var dialog = new FolderBrowserDialog { Description = "Seleziona la cartella della sequenza" };
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(ExpandPaths([dialog.SelectedPath]));
|
||||
}
|
||||
|
||||
private static string[] ExpandPaths(IEnumerable<string> items)
|
||||
{
|
||||
var files = new List<string>();
|
||||
foreach (string item in items)
|
||||
{
|
||||
if (Directory.Exists(item))
|
||||
{
|
||||
files.AddRange(Directory.EnumerateFiles(item).Where(MetadataReader.IsSupported));
|
||||
}
|
||||
else if (File.Exists(item) && MetadataReader.IsSupported(item))
|
||||
{
|
||||
files.Add(item);
|
||||
}
|
||||
}
|
||||
return [.. files];
|
||||
}
|
||||
|
||||
private async Task LoadAsync(IReadOnlyList<string> paths)
|
||||
{
|
||||
if (_busy || paths.Count == 0)
|
||||
{
|
||||
if (paths.Count == 0) SetStatus("Nessun file d'immagine riconosciuto fra quelli indicati.");
|
||||
return;
|
||||
}
|
||||
|
||||
BeginOperation("Lettura dei metadati…");
|
||||
try
|
||||
{
|
||||
var progress = new Progress<PipelineProgress>(ReportProgress);
|
||||
var sequence = await RenderPipeline.IngestAsync(paths, _project.General.CadenceTolerance,
|
||||
progress, _operation!.Token);
|
||||
_project.Sequence = sequence;
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
|
||||
_table.SetSequence(sequence);
|
||||
_chart.SetData(sequence, null);
|
||||
SuggestOutputPath(sequence);
|
||||
UpdateSummary();
|
||||
ShowPreview(0);
|
||||
|
||||
SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " +
|
||||
$"{sequence.CadenceAnomalies} intervalli anomali.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SetStatus("Caricamento annullato.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus("Caricamento non riuscito: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOperation();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearSequence()
|
||||
{
|
||||
if (_busy) return;
|
||||
_project.Sequence = null;
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
_table.SetSequence(null);
|
||||
_chart.SetData(null, null);
|
||||
_preview.Clear();
|
||||
_summary.Text = string.Empty;
|
||||
SetStatus("Sequenza svuotata.");
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private async Task AnalyzeAsync()
|
||||
{
|
||||
if (_busy || !_project.HasSequence) return;
|
||||
|
||||
BeginOperation("Analisi della luminanza…");
|
||||
try
|
||||
{
|
||||
var pipeline = new RenderPipeline(_project);
|
||||
var progress = new Progress<PipelineProgress>(ReportProgress);
|
||||
await pipeline.AnalyzeAsync(progress, _operation!.Token);
|
||||
|
||||
_chart.SetData(_project.Sequence, _project.Curve);
|
||||
_table.Refresh(_project.Sequence);
|
||||
UpdateSummary();
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
|
||||
var curve = _project.Curve!;
|
||||
double before = Analysis.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 = Analysis.DeflickerCurve.FlickerIndex(corrected);
|
||||
|
||||
SetStatus($"Analisi completata. Sfarfallio {before:0.000} EV → {after:0.000} EV " +
|
||||
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione).");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SetStatus("Analisi annullata.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus("Analisi non riuscita: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOperation();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
if (_busy || !_project.HasSequence) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
|
||||
{
|
||||
BrowseOutput();
|
||||
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath)) return;
|
||||
}
|
||||
|
||||
BeginOperation("Elaborazione e codifica…");
|
||||
try
|
||||
{
|
||||
var pipeline = new RenderPipeline(_project);
|
||||
var progress = new Progress<PipelineProgress>(ReportProgress);
|
||||
var result = await pipeline.RenderAsync(progress, _operation!.Token);
|
||||
|
||||
_table.Refresh(_project.Sequence);
|
||||
SetStatus($"Esportazione completata: {result.EncodedFrames} fotogrammi, " +
|
||||
$"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.Elapsed.TotalSeconds:0.0} s " +
|
||||
$"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SetStatus("Esportazione interrotta. Il file contiene i fotogrammi già codificati.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus("Esportazione non riuscita: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOperation();
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseOutput()
|
||||
{
|
||||
using var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Destinazione del video",
|
||||
Filter = "Video MP4|*.mp4",
|
||||
DefaultExt = "mp4",
|
||||
FileName = Path.GetFileName(_project.Export.OutputPath) is { Length: > 0 } name ? name : "timelapse.mp4",
|
||||
};
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return;
|
||||
|
||||
_project.Export.OutputPath = dialog.FileName;
|
||||
_settings.OutputPathBox.Text = dialog.FileName;
|
||||
}
|
||||
|
||||
private void SuggestOutputPath(TimelapseSequence sequence)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_project.Export.OutputPath) || sequence.Count == 0) return;
|
||||
|
||||
string? directory = Path.GetDirectoryName(sequence.Frames[0].FilePath);
|
||||
if (directory is null) return;
|
||||
|
||||
string suggestion = Path.Combine(directory, "timelapse.mp4");
|
||||
_project.Export.OutputPath = suggestion;
|
||||
_settings.OutputPathBox.Text = suggestion;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ aggiornamenti
|
||||
|
||||
private void RecomputeCurve()
|
||||
{
|
||||
if (!_project.IsAnalyzed) return;
|
||||
new RenderPipeline(_project).RecomputeCurve();
|
||||
_chart.UpdateCurve(_project.Curve);
|
||||
_table.Refresh(_project.Sequence);
|
||||
UpdateSummary();
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
}
|
||||
|
||||
private void InvalidateAnalysis()
|
||||
{
|
||||
if (_project.Sequence is { } sequence) sequence.RecomputeTiming(_project.General.CadenceTolerance);
|
||||
_project.Curve = null;
|
||||
_project.Stats = null;
|
||||
_chart.SetData(_project.Sequence, null);
|
||||
_table.Refresh(_project.Sequence);
|
||||
UpdateSummary();
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private void ShowPreview(int index)
|
||||
{
|
||||
if (_project.Sequence is not { Count: > 0 } sequence || index < 0) { _preview.Clear(); return; }
|
||||
_preview.Show(sequence, Math.Clamp(index, 0, sequence.Count - 1));
|
||||
}
|
||||
|
||||
private void UpdateSummary()
|
||||
{
|
||||
if (_project.Sequence is not { Count: > 0 } sequence)
|
||||
{
|
||||
_summary.Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var (width, height) = _project.ResolveWorkingSize();
|
||||
double outputSeconds = sequence.Count / Math.Max(1.0, _project.Export.FrameRate);
|
||||
|
||||
_summary.Text = $"{sequence.Count} scatti · {sequence.TotalDuration:hh\\:mm\\:ss} di ripresa · " +
|
||||
$"{width}×{height} · {outputSeconds:0.0} s di video" +
|
||||
(_project.IsAnalyzed ? " · analizzata" : string.Empty);
|
||||
}
|
||||
|
||||
private void ReportProgress(PipelineProgress progress)
|
||||
{
|
||||
_progress.Fraction = progress.Fraction;
|
||||
string detail = progress.Total > 0 ? $" {progress.Completed}/{progress.Total}" : string.Empty;
|
||||
string speed = progress.FramesPerSecond > 0.01
|
||||
? $" · {progress.FramesPerSecond:0.0} fps · {progress.Remaining:hh\\:mm\\:ss} rimanenti"
|
||||
: string.Empty;
|
||||
_status.Text = progress.Message + detail + speed;
|
||||
}
|
||||
|
||||
private void SetStatus(string message)
|
||||
{
|
||||
_status.Text = message;
|
||||
_progress.Fraction = 0;
|
||||
}
|
||||
|
||||
private void BeginOperation(string message)
|
||||
{
|
||||
_busy = true;
|
||||
_operation?.Dispose();
|
||||
_operation = new CancellationTokenSource();
|
||||
_status.Text = message;
|
||||
_progress.Fraction = 0;
|
||||
_cancelButton.Visible = true;
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private void EndOperation()
|
||||
{
|
||||
_busy = false;
|
||||
_cancelButton.Visible = false;
|
||||
_progress.Fraction = 0;
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
private void UpdateCommandState()
|
||||
{
|
||||
bool hasSequence = _project.HasSequence;
|
||||
_addFilesButton.Enabled = !_busy;
|
||||
_addFolderButton.Enabled = !_busy;
|
||||
_clearButton.Enabled = !_busy && hasSequence;
|
||||
_analyzeButton.Enabled = !_busy && hasSequence;
|
||||
_exportButton.Enabled = !_busy && hasSequence;
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
_operation?.Cancel();
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carica e analizza una sequenza senza interazione: usata dalla modalità di cattura
|
||||
/// dell'interfaccia, che serve a verificare la resa grafica in modo riproducibile.
|
||||
/// </summary>
|
||||
internal void SelectSettingsTab(int index) => _settings.SelectPage(index);
|
||||
|
||||
internal async Task PrepareForCaptureAsync(IReadOnlyList<string> paths)
|
||||
{
|
||||
await LoadAsync(paths);
|
||||
await AnalyzeAsync();
|
||||
_table.SelectedIndex = Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Drawing.Imaging;
|
||||
using Titano.Core;
|
||||
using Titano.Imaging;
|
||||
using Titano.Motion;
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
internal sealed class PreviewPanel : Control
|
||||
{
|
||||
private readonly TitanoProject _project;
|
||||
private Bitmap? _bitmap;
|
||||
private string _caption = string.Empty;
|
||||
private string _status = "Nessun fotogramma selezionato";
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _requestId;
|
||||
private bool _busy;
|
||||
|
||||
public PreviewPanel(TitanoProject project)
|
||||
{
|
||||
_project = project;
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Background;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Interlocked.Increment(ref _requestId);
|
||||
_pending?.Cancel();
|
||||
SwapBitmap(null);
|
||||
_caption = string.Empty;
|
||||
_status = "Nessun fotogramma selezionato";
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.</summary>
|
||||
public void Show(TimelapseSequence sequence, int index)
|
||||
{
|
||||
if (index < 0 || index >= sequence.Count) { Clear(); return; }
|
||||
|
||||
int requestId = Interlocked.Increment(ref _requestId);
|
||||
_pending?.Cancel();
|
||||
var source = new CancellationTokenSource();
|
||||
_pending = source;
|
||||
|
||||
var record = sequence.Frames[index];
|
||||
_caption = $"#{index + 1} {record.FileName}";
|
||||
_busy = true;
|
||||
Invalidate();
|
||||
|
||||
var project = _project;
|
||||
var token = source.Token;
|
||||
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var (bitmap, status) = Render(project, sequence, index, PreviewSize(), token);
|
||||
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; }
|
||||
SwapBitmap(bitmap);
|
||||
_status = status;
|
||||
_busy = false;
|
||||
Invalidate();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
try
|
||||
{
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
SwapBitmap(null);
|
||||
_status = "Anteprima non disponibile: " + ex.Message;
|
||||
_busy = false;
|
||||
Invalidate();
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException) { /* finestra già chiusa */ }
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
private Size PreviewSize()
|
||||
{
|
||||
int width = Math.Clamp(Width - 24, 160, 1600);
|
||||
int height = Math.Clamp(Height - 46, 120, 1200);
|
||||
return new Size(width, height);
|
||||
}
|
||||
|
||||
private void SwapBitmap(Bitmap? bitmap)
|
||||
{
|
||||
var previous = _bitmap;
|
||||
_bitmap = bitmap;
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rendering
|
||||
|
||||
private static (Bitmap? Bitmap, string Status) Render(TitanoProject project, TimelapseSequence sequence,
|
||||
int index, Size available, CancellationToken token)
|
||||
{
|
||||
var record = sequence.Frames[index];
|
||||
var metadata = record.Metadata;
|
||||
|
||||
int sourceWidth = metadata.PixelWidth;
|
||||
int sourceHeight = metadata.PixelHeight;
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0)
|
||||
(sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation);
|
||||
else if (ImageDecoder.SwapsAxes(metadata.Orientation))
|
||||
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) return (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);
|
||||
using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, metadata.Orientation, pool);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var status = new System.Text.StringBuilder();
|
||||
status.Append($"{sourceWidth}×{sourceHeight}");
|
||||
|
||||
var curve = project.Curve;
|
||||
if (project.Deflicker.Enabled && curve is not null && index < curve.Count)
|
||||
{
|
||||
Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index],
|
||||
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
|
||||
status.Append($" guadagno {curve.GainStops[index]:+0.00;-0.00;0.00} EV");
|
||||
}
|
||||
|
||||
ImageBuffer result = frame;
|
||||
ImageBuffer? blurred = null;
|
||||
|
||||
if (project.MotionBlur.Enabled && index + 1 < sequence.Count)
|
||||
{
|
||||
var nextMetadata = sequence.Frames[index + 1].Metadata;
|
||||
using var next = ImageDecoder.Decode(nextMetadata.FilePath, width, height, nextMetadata.Orientation, pool);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count)
|
||||
{
|
||||
Analysis.ExposureProcessor.Apply(next, curve.ChannelGain[index + 1],
|
||||
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
var bitmap = ToBitmap(result);
|
||||
blurred?.Dispose();
|
||||
return (bitmap, status.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
|
||||
var frame = new Rectangle(0, 0, Width, Height - 22);
|
||||
|
||||
if (_bitmap is null)
|
||||
{
|
||||
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, frame,
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
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);
|
||||
|
||||
g.DrawImage(_bitmap, target);
|
||||
using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, 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,
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
|
||||
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);
|
||||
TextRenderer.DrawText(g, _status, Theme.Small, footer, Theme.TextMuted,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_pending?.Cancel();
|
||||
_pending?.Dispose();
|
||||
_bitmap?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using Titano.Pipeline;
|
||||
using Titano.Video;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pannello di configurazione avanzata, suddiviso nelle tre sezioni richieste:
|
||||
/// Generale, Elaborazione immagini (deflicker, motion blur, campo vettoriale) ed
|
||||
/// Esportazione video. Ogni controllo scrive direttamente nel progetto e segnala
|
||||
/// quale parte della pipeline va ricalcolata.
|
||||
/// </summary>
|
||||
internal sealed class SettingsPanel : Panel
|
||||
{
|
||||
private readonly TitanoProject _project;
|
||||
private readonly TabStrip _tabs;
|
||||
private readonly Panel[] _pages;
|
||||
|
||||
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</summary>
|
||||
public event EventHandler? DeflickerChanged;
|
||||
|
||||
/// <summary>È cambiato un parametro che invalida l'analisi già svolta.</summary>
|
||||
public event EventHandler? AnalysisInvalidated;
|
||||
|
||||
/// <summary>È cambiato un parametro che modifica solo l'anteprima o l'esportazione.</summary>
|
||||
public event EventHandler? PreviewInvalidated;
|
||||
|
||||
public event EventHandler? BrowseOutputRequested;
|
||||
|
||||
public TextBox OutputPathBox { get; }
|
||||
|
||||
public SettingsPanel(TitanoProject project)
|
||||
{
|
||||
_project = project;
|
||||
BackColor = Theme.Surface;
|
||||
Padding = new Padding(0);
|
||||
|
||||
_tabs = new TabStrip("Generale", "Elaborazione immagini", "Esportazione") { Dock = DockStyle.Top };
|
||||
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
|
||||
|
||||
OutputPathBox = new TextBox
|
||||
{
|
||||
BackColor = Theme.SurfaceAlt,
|
||||
ForeColor = Theme.Text,
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
Font = Theme.Body,
|
||||
Dock = DockStyle.Top,
|
||||
};
|
||||
OutputPathBox.TextChanged += (_, _) =>
|
||||
{
|
||||
_project.Export.OutputPath = OutputPathBox.Text;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
};
|
||||
|
||||
// L'ordine di inserimento determina l'ordine di ancoraggio: i controlli in coda alla
|
||||
// collezione vengono disposti per primi, quindi la barra delle schede va aggiunta
|
||||
// dopo le pagine per riservarsi la propria fascia in alto.
|
||||
_pages = [BuildGeneralPage(), BuildImagePage(), BuildExportPage()];
|
||||
foreach (var page in _pages)
|
||||
{
|
||||
page.Dock = DockStyle.Fill;
|
||||
page.Visible = false;
|
||||
Controls.Add(page);
|
||||
}
|
||||
Controls.Add(_tabs);
|
||||
|
||||
ShowPage(0);
|
||||
}
|
||||
|
||||
/// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary>
|
||||
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
|
||||
|
||||
private void ShowPage(int index)
|
||||
{
|
||||
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ pagine
|
||||
|
||||
private Panel BuildGeneralPage()
|
||||
{
|
||||
var stack = NewStack();
|
||||
|
||||
stack.Add(new SectionHeader("Sequenza"));
|
||||
stack.Add(Combo("Risoluzione di lavoro",
|
||||
["Nativa (piena risoluzione)", "3840 px (4K UHD)", "2560 px", "1920 px (Full HD)", "1280 px"],
|
||||
WorkingWidthToIndex(_project.General.WorkingWidth),
|
||||
index =>
|
||||
{
|
||||
_project.General.WorkingWidth = index switch { 1 => 3840, 2 => 2560, 3 => 1920, 4 => 1280, _ => 0 };
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
|
||||
value =>
|
||||
{
|
||||
_project.General.CadenceTolerance = value;
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(new SectionHeader("Prestazioni"));
|
||||
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
|
||||
value =>
|
||||
{
|
||||
_project.General.AnalysisWidth = (int)value;
|
||||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Decodifiche simultanee", 1, 16, _project.General.DecodeParallelism, 1, "0", "thread",
|
||||
value =>
|
||||
{
|
||||
_project.General.DecodeParallelism = (int)value;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Note("Le decodifiche simultanee determinano anche quanti fotogrammi restano " +
|
||||
"contemporaneamente in memoria: l'occupazione non dipende dalla lunghezza della sequenza."));
|
||||
return stack.Panel;
|
||||
}
|
||||
|
||||
private Panel BuildImagePage()
|
||||
{
|
||||
var stack = NewStack();
|
||||
|
||||
// ---- Deflicker
|
||||
stack.Add(new SectionHeader("Deflicker"));
|
||||
|
||||
var deflickerEnabled = Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
|
||||
{
|
||||
_project.Deflicker.Enabled = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
});
|
||||
stack.Add(deflickerEnabled);
|
||||
|
||||
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
|
||||
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
|
||||
value => { _project.Deflicker.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Correzione massima", 0.1, 3.0, _project.Deflicker.MaxCorrectionStops, 0.1, "0.0", "EV",
|
||||
value => { _project.Deflicker.MaxCorrectionStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers, value =>
|
||||
{
|
||||
_project.Deflicker.RejectOutliers = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Check("Stabilizza il bilanciamento colore", _project.Deflicker.StabilizeColor, value =>
|
||||
{
|
||||
_project.Deflicker.StabilizeColor = value;
|
||||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
|
||||
{
|
||||
_project.Deflicker.ProtectHighlights = value;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty,
|
||||
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
// ---- Motion blur
|
||||
stack.Add(new SectionHeader("Motion blur sintetico"));
|
||||
|
||||
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
|
||||
{
|
||||
_project.MotionBlur.Enabled = value;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Shutter angle obiettivo", 0, 360, _project.MotionBlur.TargetShutterAngle, 5, "0", "°",
|
||||
value => { _project.MotionBlur.TargetShutterAngle = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Intensità", 0, 1, _project.MotionBlur.Strength, 0.05, "0.00", string.Empty,
|
||||
value => { _project.MotionBlur.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Lunghezza massima della scia", 4, 160, _project.MotionBlur.MaxBlurPixels, 2, "0", "px",
|
||||
value => { _project.MotionBlur.MaxBlurPixels = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Campioni per pixel", 3, 65, _project.MotionBlur.MaxSamples, 2, "0", string.Empty,
|
||||
value => { _project.MotionBlur.MaxSamples = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " +
|
||||
"√(obiettivo² − reale²). A 180° si ottiene la resa cinematografica."));
|
||||
|
||||
// ---- Optical flow
|
||||
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
|
||||
|
||||
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
|
||||
value => { _project.Flow.AnalysisWidth = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Passo della griglia", 4, 32, _project.Flow.CellSize, 1, "0", "px",
|
||||
value => { _project.Flow.CellSize = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Livelli della piramide", 1, 6, _project.Flow.PyramidLevels, 1, "0", string.Empty,
|
||||
value => { _project.Flow.PyramidLevels = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Raggio della finestra", 2, 12, _project.Flow.WindowRadius, 1, "0", "px",
|
||||
value => { _project.Flow.WindowRadius = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
|
||||
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
return stack.Panel;
|
||||
}
|
||||
|
||||
private Panel BuildExportPage()
|
||||
{
|
||||
var stack = NewStack();
|
||||
|
||||
stack.Add(new SectionHeader("Formato"));
|
||||
stack.Add(Combo("Codec", ["H.264 / AVC", "H.265 / HEVC"], _project.Export.Codec == VideoCodec.H264 ? 0 : 1,
|
||||
index =>
|
||||
{
|
||||
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
|
||||
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
|
||||
index => _project.Export.Profile = index switch
|
||||
{
|
||||
0 => H264Profile.Baseline,
|
||||
1 => H264Profile.Main,
|
||||
_ => H264Profile.High,
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
|
||||
value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||||
|
||||
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
|
||||
value => _project.Export.BitrateMbps = value));
|
||||
|
||||
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
|
||||
value => _project.Export.KeyframeIntervalSeconds = (int)value));
|
||||
|
||||
stack.Add(new SectionHeader("Andamento temporale"));
|
||||
stack.Add(Combo("Durata dei fotogrammi",
|
||||
["Costante — un fotogramma per scatto",
|
||||
"Adattiva — durata proporzionale all'intervallo",
|
||||
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
|
||||
(int)_project.Export.Timing,
|
||||
index =>
|
||||
{
|
||||
_project.Export.Timing = (FrameTimingMode)index;
|
||||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}));
|
||||
|
||||
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
|
||||
value => _project.Export.MaxAdaptiveStretch = value));
|
||||
|
||||
stack.Add(new SectionHeader("Codifica"));
|
||||
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
|
||||
value => _project.Export.PreferHardware = value));
|
||||
|
||||
stack.Add(new SectionHeader("Destinazione"));
|
||||
stack.Add(OutputPathBox);
|
||||
|
||||
var browse = new DarkButton { Text = "Scegli il file di destinazione…", Height = 32, Dock = DockStyle.Top };
|
||||
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
|
||||
stack.Add(browse);
|
||||
|
||||
stack.Add(Note("Il video viene scritto in un unico flusso continuo: l'elaborazione non " +
|
||||
"genera alcun file temporaneo su disco."));
|
||||
|
||||
return stack.Panel;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ costruttori di controlli
|
||||
|
||||
private sealed class Stack(Panel panel)
|
||||
{
|
||||
public Panel Panel { get; } = panel;
|
||||
private int _y;
|
||||
|
||||
public void Add(Control control)
|
||||
{
|
||||
control.Dock = DockStyle.None;
|
||||
control.Left = 14;
|
||||
control.Top = _y;
|
||||
control.Width = Panel.ClientSize.Width - 34;
|
||||
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right;
|
||||
Panel.Controls.Add(control);
|
||||
_y += control.Height + 6;
|
||||
}
|
||||
}
|
||||
|
||||
private static Stack NewStack()
|
||||
{
|
||||
var panel = new Panel
|
||||
{
|
||||
BackColor = Theme.Surface,
|
||||
Padding = new Padding(0, 8, 0, 12),
|
||||
Width = 360,
|
||||
};
|
||||
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 360 };
|
||||
panel.Controls.Add(host);
|
||||
return new Stack(host);
|
||||
}
|
||||
|
||||
private static ParameterSlider Slider(string caption, double min, double max, double value, double step,
|
||||
string format, string unit, Action<double> onChange)
|
||||
{
|
||||
var slider = new ParameterSlider
|
||||
{
|
||||
Caption = caption,
|
||||
Minimum = min,
|
||||
Maximum = max,
|
||||
Step = step,
|
||||
ValueFormat = format,
|
||||
Unit = unit,
|
||||
};
|
||||
slider.SetValueSilently(value);
|
||||
slider.ValueChanged += (_, _) => onChange(slider.Value);
|
||||
return slider;
|
||||
}
|
||||
|
||||
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
|
||||
{
|
||||
var box = new DarkCheckBox { Text = caption, Checked = value };
|
||||
box.CheckedChanged += (_, _) => onChange(box.Checked);
|
||||
return box;
|
||||
}
|
||||
|
||||
private static LabeledCombo Combo(string caption, string[] items, int selected, Action<int> onChange)
|
||||
{
|
||||
var row = new LabeledCombo(caption);
|
||||
row.Combo.Items.AddRange(items);
|
||||
row.Combo.SelectedIndex = Math.Clamp(selected, 0, items.Length - 1);
|
||||
row.Combo.SelectedIndexChanged += (_, _) => onChange(row.Combo.SelectedIndex);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Label Note(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
Font = Theme.Small,
|
||||
ForeColor = Theme.TextFaint,
|
||||
AutoSize = false,
|
||||
Height = 52,
|
||||
BackColor = Theme.Surface,
|
||||
};
|
||||
|
||||
private static int WorkingWidthToIndex(int width) => width switch
|
||||
{
|
||||
3840 => 1,
|
||||
2560 => 2,
|
||||
1920 => 3,
|
||||
1280 => 4,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Tavolozza e primitive di disegno del tema scuro. Tutti i controlli dell'applicazione
|
||||
/// sono resi con GDI+ a partire da questi valori: nessun tema di terze parti.
|
||||
/// </summary>
|
||||
internal static class Theme
|
||||
{
|
||||
public static readonly Color Background = Color.FromArgb(0x14, 0x16, 0x1A);
|
||||
public static readonly Color Surface = Color.FromArgb(0x1B, 0x1E, 0x24);
|
||||
public static readonly Color SurfaceAlt = Color.FromArgb(0x22, 0x26, 0x2E);
|
||||
public static readonly Color SurfaceHover = Color.FromArgb(0x2A, 0x2F, 0x39);
|
||||
public static readonly Color Border = Color.FromArgb(0x2E, 0x33, 0x3D);
|
||||
public static readonly Color BorderStrong = Color.FromArgb(0x3C, 0x43, 0x50);
|
||||
|
||||
public static readonly Color Text = Color.FromArgb(0xE6, 0xE9, 0xEF);
|
||||
public static readonly Color TextMuted = Color.FromArgb(0x98, 0xA0, 0xAE);
|
||||
public static readonly Color TextFaint = Color.FromArgb(0x6B, 0x73, 0x82);
|
||||
|
||||
public static readonly Color Accent = Color.FromArgb(0x4C, 0x9A, 0xFF);
|
||||
public static readonly Color AccentDim = Color.FromArgb(0x2F, 0x6C, 0xC2);
|
||||
public static readonly Color Measured = Color.FromArgb(0xF5, 0xA5, 0x24);
|
||||
public static readonly Color Success = Color.FromArgb(0x35, 0xC4, 0x8F);
|
||||
public static readonly Color Warning = Color.FromArgb(0xE8, 0xB3, 0x39);
|
||||
public static readonly Color Danger = Color.FromArgb(0xF0, 0x57, 0x5A);
|
||||
|
||||
public static readonly Font Body = new("Segoe UI", 9f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font BodyBold = new("Segoe UI", 9f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Small = new("Segoe UI", 8f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font SmallBold = new("Segoe UI", 8f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Title = new("Segoe UI Semibold", 14f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
|
||||
private const int DwmUseImmersiveDarkMode = 20;
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
|
||||
|
||||
/// <summary>Estende il tema scuro alla barra del titolo, disegnata dal sistema.</summary>
|
||||
public static void ApplyDarkTitleBar(Form form)
|
||||
{
|
||||
if (!form.IsHandleCreated) return;
|
||||
int enabled = 1;
|
||||
DwmSetWindowAttribute(form.Handle, DwmUseImmersiveDarkMode, ref enabled, sizeof(int));
|
||||
}
|
||||
|
||||
/// <summary>Rettangolo con angoli arrotondati, primitiva di base dell'interfaccia.</summary>
|
||||
public static GraphicsPath RoundedRect(RectangleF bounds, float radius)
|
||||
{
|
||||
var path = new GraphicsPath();
|
||||
if (radius <= 0.5f)
|
||||
{
|
||||
path.AddRectangle(bounds);
|
||||
return path;
|
||||
}
|
||||
|
||||
float diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height));
|
||||
var arc = new RectangleF(bounds.X, bounds.Y, diameter, diameter);
|
||||
|
||||
path.AddArc(arc, 180, 90);
|
||||
arc.X = bounds.Right - diameter;
|
||||
path.AddArc(arc, 270, 90);
|
||||
arc.Y = bounds.Bottom - diameter;
|
||||
path.AddArc(arc, 0, 90);
|
||||
arc.X = bounds.X;
|
||||
path.AddArc(arc, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
public static void FillRounded(Graphics g, RectangleF bounds, float radius, Color fill)
|
||||
{
|
||||
using var path = RoundedRect(bounds, radius);
|
||||
using var brush = new SolidBrush(fill);
|
||||
g.FillPath(brush, path);
|
||||
}
|
||||
|
||||
public static void DrawRounded(Graphics g, RectangleF bounds, float radius, Color stroke, float width = 1f)
|
||||
{
|
||||
using var path = RoundedRect(bounds, radius);
|
||||
using var pen = new Pen(stroke, width);
|
||||
g.DrawPath(pen, path);
|
||||
}
|
||||
|
||||
public static void FillAndStroke(Graphics g, RectangleF bounds, float radius, Color fill, Color stroke)
|
||||
{
|
||||
FillRounded(g, bounds, radius, fill);
|
||||
DrawRounded(g, RectangleF.Inflate(bounds, -0.5f, -0.5f), radius, stroke);
|
||||
}
|
||||
|
||||
public static void HighQuality(Graphics g)
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
|
||||
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
|
||||
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
}
|
||||
|
||||
public static Color Mix(Color a, Color b, double t)
|
||||
{
|
||||
t = Math.Clamp(t, 0, 1);
|
||||
return Color.FromArgb(
|
||||
(int)(a.A + (b.A - a.A) * t),
|
||||
(int)(a.R + (b.R - a.R) * t),
|
||||
(int)(a.G + (b.G - a.G) * t),
|
||||
(int)(a.B + (b.B - a.B) * t));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Titano.Video;
|
||||
|
||||
/// <summary>
|
||||
/// Scansione di un bitstream Annex-B (quello prodotto dagli encoder di sistema): individua
|
||||
/// le NAL unit delimitate dai codici di avvio 00 00 01 / 00 00 00 01, senza copie di memoria.
|
||||
/// </summary>
|
||||
internal static class AnnexBParser
|
||||
{
|
||||
internal readonly record struct NalRange(int Start, int Length);
|
||||
|
||||
public static NalEnumerator EnumerateNals(ReadOnlySpan<byte> data) => new(data);
|
||||
|
||||
internal ref struct NalEnumerator(ReadOnlySpan<byte> data)
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _data = data;
|
||||
private int _position = 0;
|
||||
private NalRange _current = default;
|
||||
|
||||
public readonly NalEnumerator GetEnumerator() => this;
|
||||
|
||||
public readonly NalRange Current => _current;
|
||||
|
||||
public bool MoveNext()
|
||||
{
|
||||
int start = FindStartCode(_data, _position, out int codeLength);
|
||||
if (start < 0) return false;
|
||||
|
||||
int payloadStart = start + codeLength;
|
||||
int next = FindStartCode(_data, payloadStart, out _);
|
||||
int end = next < 0 ? _data.Length : next;
|
||||
|
||||
// Gli zeri finali non fanno parte della NAL (trailing_zero_8bits).
|
||||
while (end > payloadStart && _data[end - 1] == 0) end--;
|
||||
|
||||
_position = next < 0 ? _data.Length : next;
|
||||
_current = new NalRange(payloadStart, end - payloadStart);
|
||||
return _current.Length > 0 || next >= 0;
|
||||
}
|
||||
|
||||
private static int FindStartCode(ReadOnlySpan<byte> data, int from, out int codeLength)
|
||||
{
|
||||
for (int i = Math.Max(0, from); i + 2 < data.Length; i++)
|
||||
{
|
||||
if (data[i] != 0 || data[i + 1] != 0) continue;
|
||||
|
||||
if (data[i + 2] == 1)
|
||||
{
|
||||
codeLength = 3;
|
||||
return i;
|
||||
}
|
||||
if (data[i + 2] == 0 && i + 3 < data.Length && data[i + 3] == 1)
|
||||
{
|
||||
codeLength = 4;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
codeLength = 0;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Titano.Video;
|
||||
|
||||
/// <summary>
|
||||
/// Scrittore di box ISO-BMFF. Ogni box riserva quattro byte per la propria dimensione e li
|
||||
/// corregge alla chiusura: i box possono così essere annidati scrivendo direttamente sul
|
||||
/// flusso di uscita, senza costruire alberi in memoria.
|
||||
/// </summary>
|
||||
internal sealed class BoxWriter : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly long _start;
|
||||
private bool _closed;
|
||||
|
||||
public BoxWriter(Stream stream, string type)
|
||||
{
|
||||
_stream = stream;
|
||||
_start = stream.Position;
|
||||
|
||||
Span<byte> header = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(header, 0); // segnaposto
|
||||
Encoding.ASCII.GetBytes(type, header[4..]);
|
||||
_stream.Write(header);
|
||||
}
|
||||
|
||||
public BoxWriter Child(string type) => new(_stream, type);
|
||||
|
||||
public void WriteFullBoxHeader(byte version, uint flags)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
buffer[0] = version;
|
||||
buffer[1] = (byte)((flags >> 16) & 0xFF);
|
||||
buffer[2] = (byte)((flags >> 8) & 0xFF);
|
||||
buffer[3] = (byte)(flags & 0xFF);
|
||||
_stream.Write(buffer);
|
||||
}
|
||||
|
||||
public void WriteByte(byte value) => _stream.WriteByte(value);
|
||||
|
||||
public void WriteBytes(ReadOnlySpan<byte> value) => _stream.Write(value);
|
||||
|
||||
public void WriteUInt16(ushort value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buffer, value);
|
||||
_stream.Write(buffer);
|
||||
}
|
||||
|
||||
public void WriteUInt32(uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buffer, value);
|
||||
_stream.Write(buffer);
|
||||
}
|
||||
|
||||
public void WriteUInt64(ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer, value);
|
||||
_stream.Write(buffer);
|
||||
}
|
||||
|
||||
public void WriteFourCc(string value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
buffer.Fill((byte)' ');
|
||||
Encoding.ASCII.GetBytes(value.AsSpan(0, Math.Min(4, value.Length)), buffer);
|
||||
_stream.Write(buffer);
|
||||
}
|
||||
|
||||
/// <summary>Stringa terminata da NUL, come richiesto dal box hdlr.</summary>
|
||||
public void WriteCString(string value)
|
||||
{
|
||||
_stream.Write(Encoding.UTF8.GetBytes(value));
|
||||
_stream.WriteByte(0);
|
||||
}
|
||||
|
||||
/// <summary>Stringa Pascal in campo fisso da 32 byte (compressorname del sample entry).</summary>
|
||||
public void WritePascalString32(string value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
buffer.Clear();
|
||||
int length = Math.Min(31, Encoding.ASCII.GetByteCount(value));
|
||||
buffer[0] = (byte)length;
|
||||
Encoding.ASCII.GetBytes(value.AsSpan(0, length), buffer[1..]);
|
||||
_stream.Write(buffer);
|
||||
}
|
||||
|
||||
/// <summary>Matrice di trasformazione identità in virgola fissa 16.16 / 2.30.</summary>
|
||||
public void WriteMatrix()
|
||||
{
|
||||
WriteUInt32(0x00010000);
|
||||
WriteUInt32(0);
|
||||
WriteUInt32(0);
|
||||
WriteUInt32(0);
|
||||
WriteUInt32(0x00010000);
|
||||
WriteUInt32(0);
|
||||
WriteUInt32(0);
|
||||
WriteUInt32(0);
|
||||
WriteUInt32(0x40000000);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
|
||||
long end = _stream.Position;
|
||||
long size = end - _start;
|
||||
|
||||
_stream.Position = _start;
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)size);
|
||||
_stream.Write(buffer);
|
||||
_stream.Position = end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace Titano.Video;
|
||||
|
||||
/// <summary>Come viene tradotta in durate di fotogramma la cadenza reale della sequenza.</summary>
|
||||
public enum FrameTimingMode
|
||||
{
|
||||
/// <summary>Ogni scatto dura esattamente un fotogramma: il time-lapse classico.</summary>
|
||||
Constant,
|
||||
|
||||
/// <summary>La durata segue l'intervallo reale: le pause dell'intervallometro restano visibili.</summary>
|
||||
Adaptive,
|
||||
|
||||
/// <summary>La sequenza viene riportata su una griglia temporale uniforme sintetizzando i fotogrammi mancanti.</summary>
|
||||
Interpolated,
|
||||
}
|
||||
|
||||
public enum H264Profile
|
||||
{
|
||||
Baseline = 66,
|
||||
Main = 77,
|
||||
High = 100,
|
||||
}
|
||||
|
||||
/// <summary>Parametri del pannello "Esportazione video".</summary>
|
||||
public sealed class ExportSettings
|
||||
{
|
||||
public string OutputPath { get; set; } = string.Empty;
|
||||
|
||||
public VideoCodec Codec { get; set; } = VideoCodec.H264;
|
||||
public H264Profile Profile { get; set; } = H264Profile.High;
|
||||
|
||||
/// <summary>Larghezza del video; 0 = dedotta dal primo fotogramma.</summary>
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
|
||||
public double FrameRate { get; set; } = 30.0;
|
||||
|
||||
/// <summary>Bitrate medio in megabit al secondo.</summary>
|
||||
public double BitrateMbps { get; set; } = 60.0;
|
||||
|
||||
public FrameTimingMode Timing { get; set; } = FrameTimingMode.Constant;
|
||||
|
||||
/// <summary>Fattore di dilatazione applicato in modalità adattiva, limitato per non congelare la scena.</summary>
|
||||
public double MaxAdaptiveStretch { get; set; } = 4.0;
|
||||
|
||||
public bool PreferHardware { get; set; } = true;
|
||||
|
||||
public int KeyframeIntervalSeconds { get; set; } = 2;
|
||||
|
||||
/// <summary>Unità temporali della traccia video: 90 kHz consente durate variabili precise.</summary>
|
||||
public uint Timescale { get; set; } = 90000;
|
||||
|
||||
public uint AverageBitrate => (uint)Math.Clamp(BitrateMbps * 1_000_000.0, 1_000_000.0, 800_000_000.0);
|
||||
|
||||
public ExportSettings Clone() => (ExportSettings)MemberwiseClone();
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Titano.Video;
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Binding manuale verso Media Foundation (mfplat.dll / mfreadwrite.dll), lo stack di
|
||||
// codifica nativo di Windows che espone gli encoder hardware di Intel, AMD e NVIDIA.
|
||||
// Le interfacce sono dichiarate con l'ordine di vtable esatto; le voci non utilizzate
|
||||
// sono comunque presenti per non alterare gli offset.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
[ComImport, Guid("2cd2d921-c447-44a7-a13c-4adabfc247e3"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFAttributes
|
||||
{
|
||||
void GetItem(ref Guid key, IntPtr value);
|
||||
void GetItemType(ref Guid key, out int type);
|
||||
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
|
||||
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
|
||||
[PreserveSig] int GetDouble(ref Guid key, out double value);
|
||||
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
|
||||
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
|
||||
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
|
||||
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
|
||||
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
|
||||
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
|
||||
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
|
||||
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
|
||||
void SetItem(ref Guid key, IntPtr value);
|
||||
void DeleteItem(ref Guid key);
|
||||
void DeleteAllItems();
|
||||
void SetUINT32(ref Guid key, uint value);
|
||||
void SetUINT64(ref Guid key, ulong value);
|
||||
void SetDouble(ref Guid key, double value);
|
||||
void SetGUID(ref Guid key, ref Guid value);
|
||||
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
|
||||
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
|
||||
void SetUnknown(ref Guid key, IntPtr unknown);
|
||||
void LockStore();
|
||||
void UnlockStore();
|
||||
void GetCount(out uint count);
|
||||
void GetItemByIndex(uint index, out Guid key, IntPtr value);
|
||||
void CopyAllItems(IMFAttributes destination);
|
||||
}
|
||||
|
||||
[ComImport, Guid("44ae0fa8-ea31-4109-8d2e-4cae4997c555"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFMediaType
|
||||
{
|
||||
// --- IMFAttributes
|
||||
void GetItem(ref Guid key, IntPtr value);
|
||||
void GetItemType(ref Guid key, out int type);
|
||||
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
|
||||
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
|
||||
[PreserveSig] int GetDouble(ref Guid key, out double value);
|
||||
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
|
||||
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
|
||||
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
|
||||
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
|
||||
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
|
||||
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
|
||||
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
|
||||
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
|
||||
void SetItem(ref Guid key, IntPtr value);
|
||||
void DeleteItem(ref Guid key);
|
||||
void DeleteAllItems();
|
||||
void SetUINT32(ref Guid key, uint value);
|
||||
void SetUINT64(ref Guid key, ulong value);
|
||||
void SetDouble(ref Guid key, double value);
|
||||
void SetGUID(ref Guid key, ref Guid value);
|
||||
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
|
||||
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
|
||||
void SetUnknown(ref Guid key, IntPtr unknown);
|
||||
void LockStore();
|
||||
void UnlockStore();
|
||||
void GetCount(out uint count);
|
||||
void GetItemByIndex(uint index, out Guid key, IntPtr value);
|
||||
void CopyAllItems(IMFAttributes destination);
|
||||
// --- IMFMediaType
|
||||
void GetMajorType(out Guid majorType);
|
||||
void IsCompressedFormat([MarshalAs(UnmanagedType.Bool)] out bool compressed);
|
||||
[PreserveSig] int IsEqual(IMFMediaType type, out uint flags);
|
||||
void GetRepresentation(Guid representation, out IntPtr data);
|
||||
void FreeRepresentation(Guid representation, IntPtr data);
|
||||
}
|
||||
|
||||
[ComImport, Guid("045fa593-8799-42b8-bc8d-8968c6453507"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFMediaBuffer
|
||||
{
|
||||
void Lock(out IntPtr buffer, out uint maxLength, out uint currentLength);
|
||||
void Unlock();
|
||||
void GetCurrentLength(out uint length);
|
||||
void SetCurrentLength(uint length);
|
||||
void GetMaxLength(out uint length);
|
||||
}
|
||||
|
||||
[ComImport, Guid("c40a00f2-b93a-4d80-ae8c-5a1c634f58e4"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFSample
|
||||
{
|
||||
// --- IMFAttributes
|
||||
void GetItem(ref Guid key, IntPtr value);
|
||||
void GetItemType(ref Guid key, out int type);
|
||||
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
|
||||
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
|
||||
[PreserveSig] int GetDouble(ref Guid key, out double value);
|
||||
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
|
||||
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
|
||||
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
|
||||
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
|
||||
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
|
||||
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
|
||||
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
|
||||
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
|
||||
void SetItem(ref Guid key, IntPtr value);
|
||||
void DeleteItem(ref Guid key);
|
||||
void DeleteAllItems();
|
||||
void SetUINT32(ref Guid key, uint value);
|
||||
void SetUINT64(ref Guid key, ulong value);
|
||||
void SetDouble(ref Guid key, double value);
|
||||
void SetGUID(ref Guid key, ref Guid value);
|
||||
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
|
||||
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
|
||||
void SetUnknown(ref Guid key, IntPtr unknown);
|
||||
void LockStore();
|
||||
void UnlockStore();
|
||||
void GetCount(out uint count);
|
||||
void GetItemByIndex(uint index, out Guid key, IntPtr value);
|
||||
void CopyAllItems(IMFAttributes destination);
|
||||
// --- IMFSample
|
||||
void GetSampleFlags(out uint flags);
|
||||
void SetSampleFlags(uint flags);
|
||||
[PreserveSig] int GetSampleTime(out long time);
|
||||
void SetSampleTime(long time);
|
||||
[PreserveSig] int GetSampleDuration(out long duration);
|
||||
void SetSampleDuration(long duration);
|
||||
void GetBufferCount(out uint count);
|
||||
void GetBufferByIndex(uint index, out IMFMediaBuffer buffer);
|
||||
void ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
|
||||
void AddBuffer(IMFMediaBuffer buffer);
|
||||
void RemoveBufferByIndex(uint index);
|
||||
void RemoveAllBuffers();
|
||||
void GetTotalLength(out uint length);
|
||||
void CopyToBuffer(IMFMediaBuffer buffer);
|
||||
}
|
||||
|
||||
[ComImport, Guid("bf94c121-5b05-4e6f-8000-ba598961414d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFTransform
|
||||
{
|
||||
void GetStreamLimits(out uint inputMin, out uint inputMax, out uint outputMin, out uint outputMax);
|
||||
void GetStreamCount(out uint inputs, out uint outputs);
|
||||
[PreserveSig] int GetStreamIDs(uint inputSize, [Out, MarshalAs(UnmanagedType.LPArray)] uint[] inputIds,
|
||||
uint outputSize, [Out, MarshalAs(UnmanagedType.LPArray)] uint[] outputIds);
|
||||
void GetInputStreamInfo(uint streamId, out MftInputStreamInfo info);
|
||||
void GetOutputStreamInfo(uint streamId, out MftOutputStreamInfo info);
|
||||
[PreserveSig] int GetAttributes(out IMFAttributes attributes);
|
||||
[PreserveSig] int GetInputStreamAttributes(uint streamId, out IMFAttributes attributes);
|
||||
[PreserveSig] int GetOutputStreamAttributes(uint streamId, out IMFAttributes attributes);
|
||||
[PreserveSig] int DeleteInputStream(uint streamId);
|
||||
[PreserveSig] int AddInputStreams(uint count, [MarshalAs(UnmanagedType.LPArray)] uint[] ids);
|
||||
[PreserveSig] int GetInputAvailableType(uint streamId, uint index, out IMFMediaType type);
|
||||
[PreserveSig] int GetOutputAvailableType(uint streamId, uint index, out IMFMediaType type);
|
||||
[PreserveSig] int SetInputType(uint streamId, IMFMediaType? type, uint flags);
|
||||
[PreserveSig] int SetOutputType(uint streamId, IMFMediaType? type, uint flags);
|
||||
[PreserveSig] int GetInputCurrentType(uint streamId, out IMFMediaType type);
|
||||
[PreserveSig] int GetOutputCurrentType(uint streamId, out IMFMediaType type);
|
||||
[PreserveSig] int GetInputStatus(uint streamId, out uint flags);
|
||||
[PreserveSig] int GetOutputStatus(out uint flags);
|
||||
[PreserveSig] int SetOutputBounds(long lower, long upper);
|
||||
[PreserveSig] int ProcessEvent(uint streamId, IntPtr mediaEvent);
|
||||
[PreserveSig] int ProcessMessage(int message, IntPtr param);
|
||||
[PreserveSig] int ProcessInput(uint streamId, IMFSample sample, uint flags);
|
||||
[PreserveSig] int ProcessOutput(uint flags, uint count, ref MftOutputDataBuffer buffers, out uint status);
|
||||
}
|
||||
|
||||
[ComImport, Guid("7fee9e9a-4a89-47a6-899c-b6a53a70fb67"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFActivate
|
||||
{
|
||||
// --- IMFAttributes
|
||||
void GetItem(ref Guid key, IntPtr value);
|
||||
void GetItemType(ref Guid key, out int type);
|
||||
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
|
||||
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
|
||||
[PreserveSig] int GetDouble(ref Guid key, out double value);
|
||||
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
|
||||
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
|
||||
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
|
||||
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
|
||||
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
|
||||
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
|
||||
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
|
||||
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
|
||||
void SetItem(ref Guid key, IntPtr value);
|
||||
void DeleteItem(ref Guid key);
|
||||
void DeleteAllItems();
|
||||
void SetUINT32(ref Guid key, uint value);
|
||||
void SetUINT64(ref Guid key, ulong value);
|
||||
void SetDouble(ref Guid key, double value);
|
||||
void SetGUID(ref Guid key, ref Guid value);
|
||||
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
|
||||
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
|
||||
void SetUnknown(ref Guid key, IntPtr unknown);
|
||||
void LockStore();
|
||||
void UnlockStore();
|
||||
void GetCount(out uint count);
|
||||
void GetItemByIndex(uint index, out Guid key, IntPtr value);
|
||||
void CopyAllItems(IMFAttributes destination);
|
||||
// --- IMFActivate
|
||||
[PreserveSig] int ActivateObject(ref Guid riid, out IntPtr instance);
|
||||
[PreserveSig] int ShutdownObject();
|
||||
[PreserveSig] int DetachObject();
|
||||
}
|
||||
|
||||
[ComImport, Guid("2cd0bd52-bcd5-4b89-b62c-eadc0c031e7d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFMediaEventGenerator
|
||||
{
|
||||
[PreserveSig] int GetEvent(uint flags, out IMFMediaEvent mediaEvent);
|
||||
[PreserveSig] int BeginGetEvent(IntPtr callback, IntPtr state);
|
||||
[PreserveSig] int EndGetEvent(IntPtr result, out IMFMediaEvent mediaEvent);
|
||||
[PreserveSig] int QueueEvent(uint met, ref Guid extendedType, int status, IntPtr value);
|
||||
}
|
||||
|
||||
[ComImport, Guid("df598932-f10c-4e39-bba2-c308f101daa3"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface IMFMediaEvent
|
||||
{
|
||||
// --- IMFAttributes (le prime 30 voci)
|
||||
void GetItem(ref Guid key, IntPtr value);
|
||||
void GetItemType(ref Guid key, out int type);
|
||||
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
|
||||
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
|
||||
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
|
||||
[PreserveSig] int GetDouble(ref Guid key, out double value);
|
||||
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
|
||||
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
|
||||
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
|
||||
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
|
||||
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
|
||||
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
|
||||
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
|
||||
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
|
||||
void SetItem(ref Guid key, IntPtr value);
|
||||
void DeleteItem(ref Guid key);
|
||||
void DeleteAllItems();
|
||||
void SetUINT32(ref Guid key, uint value);
|
||||
void SetUINT64(ref Guid key, ulong value);
|
||||
void SetDouble(ref Guid key, double value);
|
||||
void SetGUID(ref Guid key, ref Guid value);
|
||||
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
|
||||
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
|
||||
void SetUnknown(ref Guid key, IntPtr unknown);
|
||||
void LockStore();
|
||||
void UnlockStore();
|
||||
void GetCount(out uint count);
|
||||
void GetItemByIndex(uint index, out Guid key, IntPtr value);
|
||||
void CopyAllItems(IMFAttributes destination);
|
||||
// --- IMFMediaEvent
|
||||
[PreserveSig] int GetEventType(out uint met);
|
||||
[PreserveSig] int GetExtendedType(out Guid extendedType);
|
||||
[PreserveSig] int GetStatus(out int status);
|
||||
[PreserveSig] int GetValue(IntPtr value);
|
||||
}
|
||||
|
||||
[ComImport, Guid("901db4c7-31ce-41a2-85dc-8fa0bf41b8da"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
internal interface ICodecAPI
|
||||
{
|
||||
[PreserveSig] int IsSupported(ref Guid api);
|
||||
[PreserveSig] int IsModifiable(ref Guid api);
|
||||
[PreserveSig] int GetParameterRange(ref Guid api, out PropVariant min, out PropVariant max, out PropVariant delta);
|
||||
[PreserveSig] int GetParameterValues(ref Guid api, out IntPtr values, out uint count);
|
||||
[PreserveSig] int GetDefaultValue(ref Guid api, out PropVariant value);
|
||||
[PreserveSig] int GetValue(ref Guid api, out PropVariant value);
|
||||
[PreserveSig] int SetValue(ref Guid api, ref PropVariant value);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MftInputStreamInfo
|
||||
{
|
||||
public long MaxLatency;
|
||||
public uint Flags;
|
||||
public uint Size;
|
||||
public uint MaxLookahead;
|
||||
public uint Alignment;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MftOutputStreamInfo
|
||||
{
|
||||
public uint Flags;
|
||||
public uint Size;
|
||||
public uint Alignment;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MftOutputDataBuffer
|
||||
{
|
||||
public uint StreamId;
|
||||
public IntPtr Sample;
|
||||
public uint Status;
|
||||
public IntPtr Events;
|
||||
}
|
||||
|
||||
/// <summary>PROPVARIANT ridotta ai soli tipi scalari usati per la configurazione dell'encoder.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Size = 24)]
|
||||
internal struct PropVariant
|
||||
{
|
||||
public ushort Type;
|
||||
public ushort Reserved1;
|
||||
public ushort Reserved2;
|
||||
public ushort Reserved3;
|
||||
public ulong Value;
|
||||
|
||||
public const ushort VtUI4 = 19;
|
||||
public const ushort VtBool = 11;
|
||||
|
||||
public static PropVariant FromUInt32(uint value) => new() { Type = VtUI4, Value = value };
|
||||
}
|
||||
|
||||
internal static class MediaFoundation
|
||||
{
|
||||
public const uint Version = 0x00020070;
|
||||
public const uint StartupLite = 1;
|
||||
|
||||
// Categorie e formati
|
||||
public static Guid CategoryVideoEncoder = new("f79eac7d-e545-4387-bdee-d647d7bde42a");
|
||||
public static Guid MajorTypeVideo = new("73646976-0000-0010-8000-00aa00389b71");
|
||||
public static Guid VideoFormatH264 = new("34363248-0000-0010-8000-00aa00389b71");
|
||||
public static Guid VideoFormatHevc = new("43564548-0000-0010-8000-00aa00389b71");
|
||||
public static Guid VideoFormatNv12 = new("3231564e-0000-0010-8000-00aa00389b71");
|
||||
|
||||
// Attributi dei media type
|
||||
public static Guid MtMajorType = new("48eba18e-f8c9-4687-bf11-0a74c9f96a8f");
|
||||
public static Guid MtSubtype = new("f7e34c9a-42e8-4714-b74b-cb29d72c35e5");
|
||||
public static Guid MtAvgBitrate = new("20332624-fb0d-4d9e-bd0d-cbf6786c102e");
|
||||
public static Guid MtFrameSize = new("1652c33d-d6b2-4012-b834-72030849a37d");
|
||||
public static Guid MtFrameRate = new("c459a2e8-3d2c-4e44-b132-fee5156c7bb0");
|
||||
public static Guid MtPixelAspectRatio = new("c6376a1e-8d0a-4027-be45-6d9a0ad39bb6");
|
||||
public static Guid MtInterlaceMode = new("e2724bb8-e676-4806-b4b2-a8d6efb44ccd");
|
||||
public static Guid MtMpeg2Profile = new("ad76a80b-2d5c-4e0b-b375-64e520137036");
|
||||
public static Guid MtMpegSequenceHeader = new("3c036de7-3ad0-4c9e-9216-ee6d6ac21cb3");
|
||||
public static Guid MtAllSamplesIndependent = new("c9173739-5e56-461c-b713-46fb995cb95f");
|
||||
public static Guid MtYuvMatrix = new("3e23d450-2c75-4d25-a00e-b91670d12327");
|
||||
public static Guid MtVideoPrimaries = new("dbfbe4d7-0740-4ee0-8192-850ab0e21935");
|
||||
public static Guid MtTransferFunction = new("5fb0fce9-be5c-4935-a811-ec838f8eed93");
|
||||
public static Guid MtVideoNominalRange = new("c21b8ee5-b956-4071-8daf-325edf5cab11");
|
||||
|
||||
// Attributi delle trasformazioni
|
||||
public static Guid TransformAsync = new("f81a699a-649a-497d-8c73-29f8fed6ad7a");
|
||||
public static Guid TransformAsyncUnlock = new("e5666d6b-3422-4eb6-a421-da7db1f8e207");
|
||||
public static Guid LowLatency = new("9c27891a-ed7a-40e1-88e8-b22727a024ee");
|
||||
public static Guid MftFriendlyName = new("314ffbae-5b41-4c95-9c19-4e7d586face3");
|
||||
public static Guid MftEnumHardwareUrl = new("2fb866ac-b078-4942-ab6c-003d05cda674");
|
||||
|
||||
// Attributi dei campioni
|
||||
public static Guid SampleCleanPoint = new("9cdf01d8-a0f0-43ba-b077-eaa06cbd728a");
|
||||
|
||||
// Parametri ICodecAPI
|
||||
public static Guid AvEncCommonRateControlMode = new("1c0608e9-370c-4710-8a58-cb6181c42423");
|
||||
public static Guid AvEncCommonMeanBitRate = new("f7222374-2144-4815-b550-a37f8e12ee52");
|
||||
public static Guid AvEncMpvDefaultBPictureCount = new("8d390aac-dc5c-4200-b57f-814d04babab2");
|
||||
public static Guid AvEncMpvgopSize = new("95f31b26-95a4-41aa-9303-246a7fc6eef1");
|
||||
|
||||
// Messaggi e stati
|
||||
public const int MessageCommandFlush = 0x00000000;
|
||||
public const int MessageCommandDrain = 0x00000001;
|
||||
public const int MessageNotifyBeginStreaming = 0x10000000;
|
||||
public const int MessageNotifyEndStreaming = 0x10000001;
|
||||
public const int MessageNotifyEndOfStream = 0x10000002;
|
||||
public const int MessageNotifyStartOfStream = 0x10000003;
|
||||
|
||||
public const uint EventTransformNeedInput = 601;
|
||||
public const uint EventTransformHaveOutput = 602;
|
||||
public const uint EventTransformDrainComplete = 603;
|
||||
|
||||
public const uint EnumFlagSyncMft = 0x00000001;
|
||||
public const uint EnumFlagAsyncMft = 0x00000002;
|
||||
public const uint EnumFlagHardware = 0x00000004;
|
||||
public const uint EnumFlagTranscodeOnly = 0x00000010;
|
||||
public const uint EnumFlagSortAndFilter = 0x00000040;
|
||||
|
||||
public const uint OutputStreamProvidesSamples = 0x00000100;
|
||||
public const uint InterlaceModeProgressive = 2;
|
||||
|
||||
public const int ErrorTransformNeedMoreInput = unchecked((int)0xC00D6D72);
|
||||
public const int ErrorTransformStreamChange = unchecked((int)0xC00D6D61);
|
||||
public const int ErrorNoMoreTypes = unchecked((int)0xC00D36B9);
|
||||
public const int ErrorAttributeNotFound = unchecked((int)0xC00D36E6);
|
||||
|
||||
[DllImport("mfplat.dll", ExactSpelling = true)]
|
||||
public static extern int MFStartup(uint version, uint flags);
|
||||
|
||||
[DllImport("mfplat.dll", ExactSpelling = true)]
|
||||
public static extern int MFShutdown();
|
||||
|
||||
[DllImport("mfplat.dll", ExactSpelling = true)]
|
||||
public static extern int MFCreateMediaType(out IMFMediaType type);
|
||||
|
||||
[DllImport("mfplat.dll", ExactSpelling = true)]
|
||||
public static extern int MFCreateSample(out IMFSample sample);
|
||||
|
||||
[DllImport("mfplat.dll", ExactSpelling = true)]
|
||||
public static extern int MFCreateMemoryBuffer(uint maxLength, out IMFMediaBuffer buffer);
|
||||
|
||||
[DllImport("mfplat.dll", ExactSpelling = true)]
|
||||
public static extern int MFTEnumEx(Guid category, uint flags, IntPtr inputType, IntPtr outputType,
|
||||
out IntPtr activateArray, out uint count);
|
||||
|
||||
[DllImport("ole32.dll", ExactSpelling = true)]
|
||||
public static extern void CoTaskMemFree(IntPtr memory);
|
||||
|
||||
/// <summary>Impacchetta due valori a 32 bit in un attributo UINT64 (dimensioni, frame rate).</summary>
|
||||
public static ulong Pack(uint high, uint low) => ((ulong)high << 32) | low;
|
||||
|
||||
public static void Check(int hr, string what)
|
||||
{
|
||||
if (hr < 0) throw new VideoEncoderException($"{what} non riuscita (HRESULT 0x{hr:X8}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Errore riconducibile allo stack di codifica di sistema.</summary>
|
||||
public sealed class VideoEncoderException(string message) : Exception(message);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MftRegisterTypeInfo
|
||||
{
|
||||
public Guid MajorType;
|
||||
public Guid Subtype;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Titano.Video;
|
||||
|
||||
public enum VideoCodec
|
||||
{
|
||||
H264,
|
||||
Hevc,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multiplexer ISO Base Media File Format (MP4) scritto interamente in-house.
|
||||
///
|
||||
/// Struttura generata: ftyp → mdat (in streaming, con dimensione a 64 bit corretta a
|
||||
/// posteriori) → moov. I campioni vengono scritti sul file di uscita mentre arrivano
|
||||
/// dall'encoder: nessun file temporaneo, nessuna copia intermedia dell'intero flusso.
|
||||
///
|
||||
/// Il muxer accetta bitstream in formato Annex-B (quello prodotto dagli encoder di sistema),
|
||||
/// ne estrae i parameter set per la configurazione del codec e converte le NAL nel formato
|
||||
/// a lunghezza prefissata richiesto dal contenitore.
|
||||
/// </summary>
|
||||
public sealed class Mp4Muxer : IDisposable
|
||||
{
|
||||
private readonly Stream _output;
|
||||
private readonly VideoCodec _codec;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly uint _timescale;
|
||||
|
||||
private readonly List<uint> _sampleSizes = [];
|
||||
private readonly List<uint> _sampleDurations = [];
|
||||
private readonly List<int> _syncSamples = [];
|
||||
|
||||
private readonly List<byte[]> _vps = [];
|
||||
private readonly List<byte[]> _sps = [];
|
||||
private readonly List<byte[]> _pps = [];
|
||||
|
||||
private long _mdatHeaderPosition;
|
||||
private long _mdatPayloadStart;
|
||||
private long _mediaDuration;
|
||||
private bool _finished;
|
||||
|
||||
/// <summary>Buffer di lavoro riusato per la conversione Annex-B → lunghezza prefissata.</summary>
|
||||
private byte[] _scratch = new byte[1 << 20];
|
||||
|
||||
public int SampleCount => _sampleSizes.Count;
|
||||
public long BytesWritten { get; private set; }
|
||||
public bool HasParameterSets => _sps.Count > 0;
|
||||
|
||||
public Mp4Muxer(Stream output, VideoCodec codec, int width, int height, uint timescale)
|
||||
{
|
||||
if (!output.CanSeek) throw new ArgumentException("Il flusso di uscita deve essere posizionabile.", nameof(output));
|
||||
_output = output;
|
||||
_codec = codec;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_timescale = timescale == 0 ? 90000u : timescale;
|
||||
|
||||
WriteFileTypeBox();
|
||||
BeginMediaData();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra parameter set forniti fuori banda dall'encoder (blob MF_MT_MPEG_SEQUENCE_HEADER).
|
||||
/// </summary>
|
||||
public void AddParameterSets(ReadOnlySpan<byte> annexB)
|
||||
{
|
||||
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
|
||||
{
|
||||
ClassifyAndStore(annexB, nal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrive un campione codificato. <paramref name="duration"/> è espressa nella timescale
|
||||
/// della traccia, così ogni fotogramma può avere durata propria (playback adattivo).
|
||||
/// </summary>
|
||||
public void WriteSample(ReadOnlySpan<byte> annexB, uint duration)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_finished, this);
|
||||
|
||||
int written = 0;
|
||||
bool keyframe = false;
|
||||
|
||||
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
|
||||
{
|
||||
var payload = annexB.Slice(nal.Start, nal.Length);
|
||||
if (payload.Length == 0) continue;
|
||||
|
||||
if (IsParameterSet(payload[0]))
|
||||
{
|
||||
ClassifyAndStore(annexB, nal);
|
||||
continue; // i parameter set vivono in stsd, non in mdat
|
||||
}
|
||||
|
||||
if (IsKeyframeNal(payload[0])) keyframe = true;
|
||||
|
||||
EnsureScratch(written + payload.Length + 4);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(_scratch.AsSpan(written), (uint)payload.Length);
|
||||
payload.CopyTo(_scratch.AsSpan(written + 4));
|
||||
written += payload.Length + 4;
|
||||
}
|
||||
|
||||
if (written == 0) return;
|
||||
|
||||
_output.Write(_scratch, 0, written);
|
||||
BytesWritten += written;
|
||||
|
||||
if (keyframe) _syncSamples.Add(_sampleSizes.Count + 1); // gli indici in stss partono da 1
|
||||
_sampleSizes.Add((uint)written);
|
||||
_sampleDurations.Add(duration);
|
||||
_mediaDuration += duration;
|
||||
}
|
||||
|
||||
/// <summary>Chiude mdat, scrive moov e finalizza il file.</summary>
|
||||
public void Finish()
|
||||
{
|
||||
if (_finished) return;
|
||||
_finished = true;
|
||||
|
||||
long mdatEnd = _output.Position;
|
||||
long mdatSize = mdatEnd - _mdatHeaderPosition;
|
||||
|
||||
// Correzione della dimensione a 64 bit riservata all'apertura del box.
|
||||
_output.Position = _mdatHeaderPosition + 8;
|
||||
Span<byte> size = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteInt64BigEndian(size, mdatSize);
|
||||
_output.Write(size);
|
||||
|
||||
_output.Position = mdatEnd;
|
||||
WriteMovieBox();
|
||||
_output.Flush();
|
||||
}
|
||||
|
||||
public void Dispose() => Finish();
|
||||
|
||||
// ------------------------------------------------------------------ box di apertura
|
||||
|
||||
private void WriteFileTypeBox()
|
||||
{
|
||||
using var box = new BoxWriter(_output, "ftyp");
|
||||
box.WriteFourCc("isom");
|
||||
box.WriteUInt32(0x200);
|
||||
box.WriteFourCc("isom");
|
||||
box.WriteFourCc("iso2");
|
||||
box.WriteFourCc(_codec == VideoCodec.H264 ? "avc1" : "hvc1");
|
||||
box.WriteFourCc("mp41");
|
||||
}
|
||||
|
||||
private void BeginMediaData()
|
||||
{
|
||||
_mdatHeaderPosition = _output.Position;
|
||||
Span<byte> header = stackalloc byte[16];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(header, 1); // size = 1 → largesize a 64 bit
|
||||
Encoding.ASCII.GetBytes("mdat", header[4..]);
|
||||
BinaryPrimitives.WriteInt64BigEndian(header[8..], 16); // valore provvisorio
|
||||
_output.Write(header);
|
||||
_mdatPayloadStart = _output.Position;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ moov
|
||||
|
||||
private void WriteMovieBox()
|
||||
{
|
||||
uint movieTimescale = 1000;
|
||||
long movieDuration = _timescale == 0 ? 0 : _mediaDuration * movieTimescale / _timescale;
|
||||
|
||||
using var moov = new BoxWriter(_output, "moov");
|
||||
|
||||
using (var mvhd = moov.Child("mvhd"))
|
||||
{
|
||||
mvhd.WriteFullBoxHeader(0, 0);
|
||||
mvhd.WriteUInt32(0); // creation_time
|
||||
mvhd.WriteUInt32(0); // modification_time
|
||||
mvhd.WriteUInt32(movieTimescale);
|
||||
mvhd.WriteUInt32((uint)movieDuration);
|
||||
mvhd.WriteUInt32(0x00010000); // rate 1.0
|
||||
mvhd.WriteUInt16(0x0100); // volume 1.0
|
||||
mvhd.WriteUInt16(0); // reserved
|
||||
mvhd.WriteUInt32(0);
|
||||
mvhd.WriteUInt32(0);
|
||||
mvhd.WriteMatrix();
|
||||
for (int i = 0; i < 6; i++) mvhd.WriteUInt32(0); // pre_defined
|
||||
mvhd.WriteUInt32(2); // next_track_ID
|
||||
}
|
||||
|
||||
using (var trak = moov.Child("trak"))
|
||||
{
|
||||
using (var tkhd = trak.Child("tkhd"))
|
||||
{
|
||||
tkhd.WriteFullBoxHeader(0, 0x000007); // enabled | in movie | in preview
|
||||
tkhd.WriteUInt32(0);
|
||||
tkhd.WriteUInt32(0);
|
||||
tkhd.WriteUInt32(1); // track_ID
|
||||
tkhd.WriteUInt32(0); // reserved
|
||||
tkhd.WriteUInt32((uint)movieDuration);
|
||||
tkhd.WriteUInt32(0);
|
||||
tkhd.WriteUInt32(0);
|
||||
tkhd.WriteUInt16(0); // layer
|
||||
tkhd.WriteUInt16(0); // alternate_group
|
||||
tkhd.WriteUInt16(0); // volume (0 per il video)
|
||||
tkhd.WriteUInt16(0);
|
||||
tkhd.WriteMatrix();
|
||||
tkhd.WriteUInt32((uint)_width << 16);
|
||||
tkhd.WriteUInt32((uint)_height << 16);
|
||||
}
|
||||
|
||||
using var mdia = trak.Child("mdia");
|
||||
|
||||
using (var mdhd = mdia.Child("mdhd"))
|
||||
{
|
||||
mdhd.WriteFullBoxHeader(0, 0);
|
||||
mdhd.WriteUInt32(0);
|
||||
mdhd.WriteUInt32(0);
|
||||
mdhd.WriteUInt32(_timescale);
|
||||
mdhd.WriteUInt32((uint)_mediaDuration);
|
||||
mdhd.WriteUInt16(0x55C4); // lingua "und" impacchettata a 5 bit
|
||||
mdhd.WriteUInt16(0);
|
||||
}
|
||||
|
||||
using (var hdlr = mdia.Child("hdlr"))
|
||||
{
|
||||
hdlr.WriteFullBoxHeader(0, 0);
|
||||
hdlr.WriteUInt32(0); // pre_defined
|
||||
hdlr.WriteFourCc("vide");
|
||||
hdlr.WriteUInt32(0);
|
||||
hdlr.WriteUInt32(0);
|
||||
hdlr.WriteUInt32(0);
|
||||
hdlr.WriteCString("Titano Video Handler");
|
||||
}
|
||||
|
||||
using var minf = mdia.Child("minf");
|
||||
|
||||
using (var vmhd = minf.Child("vmhd"))
|
||||
{
|
||||
vmhd.WriteFullBoxHeader(0, 1);
|
||||
vmhd.WriteUInt16(0); // graphicsmode
|
||||
vmhd.WriteUInt16(0); // opcolor
|
||||
vmhd.WriteUInt16(0);
|
||||
vmhd.WriteUInt16(0);
|
||||
}
|
||||
|
||||
using (var dinf = minf.Child("dinf"))
|
||||
using (var dref = dinf.Child("dref"))
|
||||
{
|
||||
dref.WriteFullBoxHeader(0, 0);
|
||||
dref.WriteUInt32(1); // entry_count
|
||||
using var url = dref.Child("url ");
|
||||
url.WriteFullBoxHeader(0, 1); // flag 1 = dati nello stesso file
|
||||
}
|
||||
|
||||
using var stbl = minf.Child("stbl");
|
||||
WriteSampleDescription(stbl);
|
||||
WriteTimeToSample(stbl);
|
||||
WriteSyncSamples(stbl);
|
||||
WriteSampleToChunk(stbl);
|
||||
WriteSampleSizes(stbl);
|
||||
WriteChunkOffsets(stbl);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteSampleDescription(BoxWriter stbl)
|
||||
{
|
||||
using var stsd = stbl.Child("stsd");
|
||||
stsd.WriteFullBoxHeader(0, 0);
|
||||
stsd.WriteUInt32(1); // entry_count
|
||||
|
||||
string entryName = _codec == VideoCodec.H264 ? "avc1" : "hvc1";
|
||||
using var entry = stsd.Child(entryName);
|
||||
|
||||
for (int i = 0; i < 6; i++) entry.WriteByte(0); // reserved
|
||||
entry.WriteUInt16(1); // data_reference_index
|
||||
entry.WriteUInt16(0); // pre_defined
|
||||
entry.WriteUInt16(0); // reserved
|
||||
for (int i = 0; i < 3; i++) entry.WriteUInt32(0);
|
||||
entry.WriteUInt16((ushort)_width);
|
||||
entry.WriteUInt16((ushort)_height);
|
||||
entry.WriteUInt32(0x00480000); // 72 dpi orizzontali
|
||||
entry.WriteUInt32(0x00480000); // 72 dpi verticali
|
||||
entry.WriteUInt32(0); // reserved
|
||||
entry.WriteUInt16(1); // frame_count
|
||||
entry.WritePascalString32("Titano"); // compressorname
|
||||
entry.WriteUInt16(0x0018); // profondità 24 bit
|
||||
entry.WriteUInt16(0xFFFF); // pre_defined = -1
|
||||
|
||||
if (_codec == VideoCodec.H264) WriteAvcConfiguration(entry);
|
||||
else WriteHevcConfiguration(entry);
|
||||
|
||||
using (var colr = entry.Child("colr"))
|
||||
{
|
||||
colr.WriteFourCc("nclx");
|
||||
colr.WriteUInt16(1); // primarie BT.709
|
||||
colr.WriteUInt16(1); // funzione di trasferimento BT.709
|
||||
colr.WriteUInt16(1); // matrice BT.709
|
||||
colr.WriteByte(0); // range televisivo (16-235)
|
||||
}
|
||||
|
||||
using var pasp = entry.Child("pasp");
|
||||
pasp.WriteUInt32(1); // pixel quadrati
|
||||
pasp.WriteUInt32(1);
|
||||
}
|
||||
|
||||
private void WriteAvcConfiguration(BoxWriter entry)
|
||||
{
|
||||
using var avcc = entry.Child("avcC");
|
||||
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
|
||||
|
||||
avcc.WriteByte(1); // configurationVersion
|
||||
avcc.WriteByte(sps.Length > 1 ? sps[1] : (byte)0x64); // AVCProfileIndication
|
||||
avcc.WriteByte(sps.Length > 2 ? sps[2] : (byte)0x00); // profile_compatibility
|
||||
avcc.WriteByte(sps.Length > 3 ? sps[3] : (byte)0x28); // AVCLevelIndication
|
||||
avcc.WriteByte(0xFF); // 6 bit riservati + lengthSizeMinusOne = 3
|
||||
avcc.WriteByte((byte)(0xE0 | Math.Min(_sps.Count, 31))); // 3 bit riservati + numOfSPS
|
||||
|
||||
foreach (var set in _sps)
|
||||
{
|
||||
avcc.WriteUInt16((ushort)set.Length);
|
||||
avcc.WriteBytes(set);
|
||||
}
|
||||
|
||||
avcc.WriteByte((byte)Math.Min(_pps.Count, 255));
|
||||
foreach (var set in _pps)
|
||||
{
|
||||
avcc.WriteUInt16((ushort)set.Length);
|
||||
avcc.WriteBytes(set);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHevcConfiguration(BoxWriter entry)
|
||||
{
|
||||
using var hvcc = entry.Child("hvcC");
|
||||
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
|
||||
|
||||
// profile_tier_level occupa 12 byte subito dopo i due byte di header NAL e il byte
|
||||
// che contiene sps_video_parameter_set_id / sps_max_sub_layers_minus1.
|
||||
Span<byte> ptl = stackalloc byte[12];
|
||||
if (sps.Length >= 15) sps.AsSpan(3, 12).CopyTo(ptl);
|
||||
else ptl[0] = 0x01; // Main profile come ripiego
|
||||
|
||||
hvcc.WriteByte(1); // configurationVersion
|
||||
hvcc.WriteByte(ptl[0]); // profile_space/tier/profile_idc
|
||||
hvcc.WriteBytes(ptl[1..5]); // general_profile_compatibility_flags
|
||||
hvcc.WriteBytes(ptl[5..11]); // general_constraint_indicator_flags
|
||||
hvcc.WriteByte(ptl[11]); // general_level_idc
|
||||
hvcc.WriteUInt16(0xF000); // min_spatial_segmentation = 0
|
||||
hvcc.WriteByte(0xFC); // parallelismType sconosciuto
|
||||
hvcc.WriteByte(0xFD); // chromaFormat 4:2:0
|
||||
hvcc.WriteByte(0xF8); // bitDepthLumaMinus8 = 0
|
||||
hvcc.WriteByte(0xF8); // bitDepthChromaMinus8 = 0
|
||||
hvcc.WriteUInt16(0); // avgFrameRate (non dichiarato)
|
||||
hvcc.WriteByte(0x0F); // costantFrameRate/temporalId + lengthSizeMinusOne
|
||||
hvcc.WriteByte((byte)((_vps.Count > 0 ? 1 : 0) + (_sps.Count > 0 ? 1 : 0) + (_pps.Count > 0 ? 1 : 0)));
|
||||
|
||||
WriteHevcArray(hvcc, 32, _vps);
|
||||
WriteHevcArray(hvcc, 33, _sps);
|
||||
WriteHevcArray(hvcc, 34, _pps);
|
||||
}
|
||||
|
||||
private static void WriteHevcArray(BoxWriter hvcc, byte nalType, List<byte[]> sets)
|
||||
{
|
||||
if (sets.Count == 0) return;
|
||||
hvcc.WriteByte((byte)(0x80 | nalType)); // array_completeness + NAL_unit_type
|
||||
hvcc.WriteUInt16((ushort)sets.Count);
|
||||
foreach (var set in sets)
|
||||
{
|
||||
hvcc.WriteUInt16((ushort)set.Length);
|
||||
hvcc.WriteBytes(set);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteTimeToSample(BoxWriter stbl)
|
||||
{
|
||||
// Codifica a corse: le durate uguali consecutive occupano una sola voce.
|
||||
var runs = new List<(uint Count, uint Delta)>();
|
||||
foreach (uint delta in _sampleDurations)
|
||||
{
|
||||
if (runs.Count > 0 && runs[^1].Delta == delta) runs[^1] = (runs[^1].Count + 1, delta);
|
||||
else runs.Add((1, delta));
|
||||
}
|
||||
|
||||
using var stts = stbl.Child("stts");
|
||||
stts.WriteFullBoxHeader(0, 0);
|
||||
stts.WriteUInt32((uint)runs.Count);
|
||||
foreach (var (count, delta) in runs)
|
||||
{
|
||||
stts.WriteUInt32(count);
|
||||
stts.WriteUInt32(delta);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteSyncSamples(BoxWriter stbl)
|
||||
{
|
||||
// Se ogni campione è un punto di sincronizzazione la tabella si omette per convenzione.
|
||||
if (_syncSamples.Count == _sampleSizes.Count || _syncSamples.Count == 0) return;
|
||||
|
||||
using var stss = stbl.Child("stss");
|
||||
stss.WriteFullBoxHeader(0, 0);
|
||||
stss.WriteUInt32((uint)_syncSamples.Count);
|
||||
foreach (int index in _syncSamples) stss.WriteUInt32((uint)index);
|
||||
}
|
||||
|
||||
private void WriteSampleToChunk(BoxWriter stbl)
|
||||
{
|
||||
using var stsc = stbl.Child("stsc");
|
||||
stsc.WriteFullBoxHeader(0, 0);
|
||||
stsc.WriteUInt32(1); // una sola voce: tutti i campioni in un chunk
|
||||
stsc.WriteUInt32(1); // first_chunk
|
||||
stsc.WriteUInt32((uint)Math.Max(1, _sampleSizes.Count));
|
||||
stsc.WriteUInt32(1); // sample_description_index
|
||||
}
|
||||
|
||||
private void WriteSampleSizes(BoxWriter stbl)
|
||||
{
|
||||
using var stsz = stbl.Child("stsz");
|
||||
stsz.WriteFullBoxHeader(0, 0);
|
||||
stsz.WriteUInt32(0); // dimensione variabile
|
||||
stsz.WriteUInt32((uint)_sampleSizes.Count);
|
||||
foreach (uint size in _sampleSizes) stsz.WriteUInt32(size);
|
||||
}
|
||||
|
||||
private void WriteChunkOffsets(BoxWriter stbl)
|
||||
{
|
||||
using var co64 = stbl.Child("co64");
|
||||
co64.WriteFullBoxHeader(0, 0);
|
||||
co64.WriteUInt32(1);
|
||||
co64.WriteUInt64((ulong)_mdatPayloadStart);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ NAL
|
||||
|
||||
private bool IsParameterSet(byte header)
|
||||
{
|
||||
if (_codec == VideoCodec.H264)
|
||||
{
|
||||
int type = header & 0x1F;
|
||||
return type is 7 or 8; // SPS, PPS
|
||||
}
|
||||
int hevcType = (header >> 1) & 0x3F;
|
||||
return hevcType is 32 or 33 or 34; // VPS, SPS, PPS
|
||||
}
|
||||
|
||||
private bool IsKeyframeNal(byte header)
|
||||
{
|
||||
if (_codec == VideoCodec.H264) return (header & 0x1F) == 5; // IDR
|
||||
int type = (header >> 1) & 0x3F;
|
||||
return type is >= 16 and <= 21; // BLA/IDR/CRA
|
||||
}
|
||||
|
||||
private void ClassifyAndStore(ReadOnlySpan<byte> source, AnnexBParser.NalRange nal)
|
||||
{
|
||||
if (nal.Length <= 0) return;
|
||||
var payload = source.Slice(nal.Start, nal.Length);
|
||||
byte header = payload[0];
|
||||
|
||||
List<byte[]> target;
|
||||
if (_codec == VideoCodec.H264)
|
||||
{
|
||||
int type = header & 0x1F;
|
||||
if (type == 7) target = _sps;
|
||||
else if (type == 8) target = _pps;
|
||||
else return;
|
||||
}
|
||||
else
|
||||
{
|
||||
int type = (header >> 1) & 0x3F;
|
||||
if (type == 32) target = _vps;
|
||||
else if (type == 33) target = _sps;
|
||||
else if (type == 34) target = _pps;
|
||||
else return;
|
||||
}
|
||||
|
||||
var copy = payload.ToArray();
|
||||
foreach (var existing in target)
|
||||
{
|
||||
if (existing.AsSpan().SequenceEqual(copy)) return;
|
||||
}
|
||||
target.Add(copy);
|
||||
}
|
||||
|
||||
private void EnsureScratch(int required)
|
||||
{
|
||||
if (_scratch.Length >= required) return;
|
||||
int size = _scratch.Length;
|
||||
while (size < required) size *= 2;
|
||||
_scratch = new byte[size];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Video;
|
||||
|
||||
/// <summary>
|
||||
/// Conversione da RGB lineare a NV12 (Y a piena risoluzione + CbCr interlacciato a metà
|
||||
/// risoluzione), il formato d'ingresso atteso dagli encoder hardware.
|
||||
///
|
||||
/// La matrice è quella BT.709 in range televisivo (Y 16-235, C 16-240): è la convenzione
|
||||
/// dichiarata nel box "colr" del contenitore, quindi il colore resta coerente in riproduzione.
|
||||
/// La codifica di gamma viene riapplicata qui, all'ultimo passaggio utile: tutta l'elaborazione
|
||||
/// a monte è avvenuta in luce lineare.
|
||||
/// </summary>
|
||||
public static class Nv12Converter
|
||||
{
|
||||
public static int RequiredSize(int width, int height) => width * height * 3 / 2;
|
||||
|
||||
public static unsafe void Convert(ImageBuffer source, byte* destination)
|
||||
{
|
||||
int width = source.Width;
|
||||
int height = source.Height;
|
||||
var data = source.Data;
|
||||
|
||||
byte* yPlane = destination;
|
||||
byte* uvPlane = destination + (long)width * height;
|
||||
int chromaRows = height / 2;
|
||||
|
||||
Parallel.For(0, chromaRows, cy =>
|
||||
{
|
||||
int y0 = cy * 2;
|
||||
int y1 = Math.Min(y0 + 1, height - 1);
|
||||
|
||||
for (int cx = 0; cx < width / 2; cx++)
|
||||
{
|
||||
int x0 = cx * 2;
|
||||
int x1 = Math.Min(x0 + 1, width - 1);
|
||||
|
||||
double cbSum = 0, crSum = 0;
|
||||
|
||||
cbSum += Encode(data, width, x0, y0, yPlane, out double cr00); crSum += cr00;
|
||||
cbSum += Encode(data, width, x1, y0, yPlane, out double cr10); crSum += cr10;
|
||||
cbSum += Encode(data, width, x0, y1, yPlane, out double cr01); crSum += cr01;
|
||||
cbSum += Encode(data, width, x1, y1, yPlane, out double cr11); crSum += cr11;
|
||||
|
||||
long uvIndex = (long)cy * width + cx * 2;
|
||||
uvPlane[uvIndex] = Quantize(128.0 + 224.0 * (cbSum * 0.25));
|
||||
uvPlane[uvIndex + 1] = Quantize(128.0 + 224.0 * (crSum * 0.25));
|
||||
}
|
||||
});
|
||||
|
||||
// Riga o colonna dispari residua: la luma va comunque scritta.
|
||||
if ((height & 1) != 0)
|
||||
{
|
||||
int y = height - 1;
|
||||
for (int x = 0; x < width; x++) Encode(data, width, x, y, yPlane, out _);
|
||||
}
|
||||
if ((width & 1) != 0)
|
||||
{
|
||||
int x = width - 1;
|
||||
for (int y = 0; y < height; y++) Encode(data, width, x, y, yPlane, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scrive la luma del pixel indicato e restituisce le due crominanze normalizzate.</summary>
|
||||
private static unsafe double Encode(float[] data, int width, int x, int y, byte* yPlane, out double cr)
|
||||
{
|
||||
int index = (y * width + x) * ImageBuffer.Channels;
|
||||
float r = ColorSpace.ToSrgb(data[index]);
|
||||
float g = ColorSpace.ToSrgb(data[index + 1]);
|
||||
float b = ColorSpace.ToSrgb(data[index + 2]);
|
||||
|
||||
double luma = ColorSpace.LumaR * r + ColorSpace.LumaG * g + ColorSpace.LumaB * b;
|
||||
yPlane[(long)y * width + x] = Quantize(16.0 + 219.0 * luma);
|
||||
|
||||
cr = (r - luma) / 1.5748;
|
||||
return (b - luma) / 1.8556;
|
||||
}
|
||||
|
||||
private static byte Quantize(double value)
|
||||
=> (byte)Math.Clamp((int)(value + 0.5), 0, 255);
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Titano.Imaging;
|
||||
|
||||
namespace Titano.Video;
|
||||
|
||||
/// <summary>
|
||||
/// Sessione di codifica: pilota direttamente la Media Foundation Transform dell'encoder di
|
||||
/// sistema (hardware se disponibile) e riversa le NAL prodotte nel multiplexer MP4 in-house.
|
||||
///
|
||||
/// I fotogrammi entrano come buffer in memoria, escono come pacchetti compressi e finiscono
|
||||
/// nel file di destinazione: nessun passaggio intermedio su disco.
|
||||
/// </summary>
|
||||
public sealed class VideoEncoderSession : IDisposable
|
||||
{
|
||||
private readonly ExportSettings _settings;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly Stream _output;
|
||||
private readonly Mp4Muxer _muxer;
|
||||
|
||||
private IMFTransform? _transform;
|
||||
private IMFMediaEventGenerator? _events;
|
||||
private uint _inputStreamId;
|
||||
private uint _outputStreamId;
|
||||
private bool _async;
|
||||
private MftOutputStreamInfo _outputInfo;
|
||||
|
||||
private readonly Queue<uint> _pendingDurations = new();
|
||||
private long _presentationTime;
|
||||
private byte[] _packetBuffer = new byte[1 << 20];
|
||||
private int _pendingNeedInput;
|
||||
private bool _finished;
|
||||
|
||||
public string EncoderName { get; private set; } = "sconosciuto";
|
||||
public bool IsHardware { get; private set; }
|
||||
public int EncodedFrames { get; private set; }
|
||||
public long OutputBytes => _muxer.BytesWritten;
|
||||
|
||||
public VideoEncoderSession(ExportSettings settings, int width, int height)
|
||||
{
|
||||
if ((width & 1) != 0 || (height & 1) != 0)
|
||||
throw new ArgumentException("La codifica 4:2:0 richiede dimensioni pari.");
|
||||
|
||||
_settings = settings;
|
||||
_width = width;
|
||||
_height = height;
|
||||
|
||||
MediaFoundationRuntime.Startup();
|
||||
|
||||
_output = new FileStream(settings.OutputPath, FileMode.Create, FileAccess.ReadWrite,
|
||||
FileShare.Read, 1 << 20, FileOptions.SequentialScan);
|
||||
try
|
||||
{
|
||||
_muxer = new Mp4Muxer(_output, settings.Codec, width, height, settings.Timescale);
|
||||
CreateTransform();
|
||||
ConfigureTypes();
|
||||
StartStreaming();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_output.Dispose();
|
||||
TryDeletePartialFile();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ configurazione
|
||||
|
||||
private void CreateTransform()
|
||||
{
|
||||
Guid subtype = _settings.Codec == VideoCodec.H264
|
||||
? MediaFoundation.VideoFormatH264
|
||||
: MediaFoundation.VideoFormatHevc;
|
||||
|
||||
var outputInfo = new MftRegisterTypeInfo
|
||||
{
|
||||
MajorType = MediaFoundation.MajorTypeVideo,
|
||||
Subtype = subtype,
|
||||
};
|
||||
|
||||
// Prima gli encoder hardware, poi quelli software: stessa interfaccia, stesso codice.
|
||||
if (_settings.PreferHardware &&
|
||||
TryActivate(outputInfo, MediaFoundation.EnumFlagHardware | MediaFoundation.EnumFlagSortAndFilter, true))
|
||||
return;
|
||||
|
||||
if (TryActivate(outputInfo, MediaFoundation.EnumFlagSyncMft | MediaFoundation.EnumFlagAsyncMft |
|
||||
MediaFoundation.EnumFlagSortAndFilter, false))
|
||||
return;
|
||||
|
||||
throw new VideoEncoderException(
|
||||
$"Nessun encoder {( _settings.Codec == VideoCodec.H264 ? "H.264" : "HEVC")} disponibile nel sistema. " +
|
||||
"Su alcune edizioni di Windows occorre installare il Media Feature Pack.");
|
||||
}
|
||||
|
||||
private unsafe bool TryActivate(MftRegisterTypeInfo outputInfo, uint flags, bool hardware)
|
||||
{
|
||||
IntPtr array = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
// I parametri sono già su stack: se ne può prendere l'indirizzo senza "fixed".
|
||||
MftRegisterTypeInfo* pOutput = &outputInfo;
|
||||
int hr = MediaFoundation.MFTEnumEx(MediaFoundation.CategoryVideoEncoder, flags,
|
||||
IntPtr.Zero, (IntPtr)pOutput, out array, out uint count);
|
||||
if (hr < 0 || count == 0 || array == IntPtr.Zero) return false;
|
||||
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
IntPtr activatePtr = Marshal.ReadIntPtr(array, (int)i * IntPtr.Size);
|
||||
if (activatePtr == IntPtr.Zero) continue;
|
||||
|
||||
var activate = (IMFActivate)Marshal.GetObjectForIUnknown(activatePtr);
|
||||
try
|
||||
{
|
||||
if (_transform is null && TryActivateOne(activate, hardware)) { /* trovato */ }
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(activate);
|
||||
Marshal.Release(activatePtr);
|
||||
}
|
||||
|
||||
if (_transform is not null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (array != IntPtr.Zero) MediaFoundation.CoTaskMemFree(array);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryActivateOne(IMFActivate activate, bool hardware)
|
||||
{
|
||||
var iid = typeof(IMFTransform).GUID;
|
||||
if (activate.ActivateObject(ref iid, out IntPtr instance) < 0 || instance == IntPtr.Zero) return false;
|
||||
|
||||
IMFTransform transform;
|
||||
try
|
||||
{
|
||||
transform = (IMFTransform)Marshal.GetObjectForIUnknown(instance);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.Release(instance);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string name = ReadFriendlyName(activate);
|
||||
|
||||
// Le trasformazioni hardware sono asincrone e vanno sbloccate esplicitamente.
|
||||
bool isAsync = false;
|
||||
if (transform.GetAttributes(out var attributes) >= 0 && attributes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var asyncKey = MediaFoundation.TransformAsync;
|
||||
if (attributes.GetUINT32(ref asyncKey, out uint asyncFlag) >= 0 && asyncFlag != 0)
|
||||
{
|
||||
isAsync = true;
|
||||
var unlockKey = MediaFoundation.TransformAsyncUnlock;
|
||||
attributes.SetUINT32(ref unlockKey, 1);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(attributes);
|
||||
}
|
||||
}
|
||||
|
||||
_transform = transform;
|
||||
_async = isAsync;
|
||||
IsHardware = hardware;
|
||||
EncoderName = name;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Marshal.ReleaseComObject(transform);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadFriendlyName(IMFActivate activate)
|
||||
{
|
||||
var key = MediaFoundation.MftFriendlyName;
|
||||
if (activate.GetStringLength(ref key, out uint length) < 0 || length == 0) return "encoder di sistema";
|
||||
|
||||
var builder = new StringBuilder((int)length + 1);
|
||||
uint written = 0;
|
||||
return activate.GetString(ref key, builder, length + 1, ref written) < 0
|
||||
? "encoder di sistema"
|
||||
: builder.ToString();
|
||||
}
|
||||
|
||||
private void ConfigureTypes()
|
||||
{
|
||||
var transform = _transform!;
|
||||
|
||||
transform.GetStreamCount(out uint inputs, out uint outputs);
|
||||
var inputIds = new uint[Math.Max(1, inputs)];
|
||||
var outputIds = new uint[Math.Max(1, outputs)];
|
||||
if (transform.GetStreamIDs((uint)inputIds.Length, inputIds, (uint)outputIds.Length, outputIds) >= 0)
|
||||
{
|
||||
_inputStreamId = inputIds[0];
|
||||
_outputStreamId = outputIds[0];
|
||||
}
|
||||
|
||||
uint fpsNumerator = (uint)Math.Round(_settings.FrameRate * 1000.0);
|
||||
const uint fpsDenominator = 1000;
|
||||
|
||||
// L'ordine è vincolante: prima il tipo di uscita (compresso), poi quello d'ingresso.
|
||||
MediaFoundation.Check(MediaFoundation.MFCreateMediaType(out IMFMediaType outputType), "Creazione del tipo di uscita");
|
||||
try
|
||||
{
|
||||
SetGuid(outputType, MediaFoundation.MtMajorType, MediaFoundation.MajorTypeVideo);
|
||||
SetGuid(outputType, MediaFoundation.MtSubtype,
|
||||
_settings.Codec == VideoCodec.H264 ? MediaFoundation.VideoFormatH264 : MediaFoundation.VideoFormatHevc);
|
||||
SetUInt32(outputType, MediaFoundation.MtAvgBitrate, _settings.AverageBitrate);
|
||||
SetUInt32(outputType, MediaFoundation.MtInterlaceMode, MediaFoundation.InterlaceModeProgressive);
|
||||
SetUInt64(outputType, MediaFoundation.MtFrameSize, MediaFoundation.Pack((uint)_width, (uint)_height));
|
||||
SetUInt64(outputType, MediaFoundation.MtFrameRate, MediaFoundation.Pack(fpsNumerator, fpsDenominator));
|
||||
SetUInt64(outputType, MediaFoundation.MtPixelAspectRatio, MediaFoundation.Pack(1, 1));
|
||||
if (_settings.Codec == VideoCodec.H264)
|
||||
SetUInt32(outputType, MediaFoundation.MtMpeg2Profile, (uint)_settings.Profile);
|
||||
|
||||
int hr = transform.SetOutputType(_outputStreamId, outputType, 0);
|
||||
MediaFoundation.Check(hr, "Impostazione del formato compresso");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(outputType);
|
||||
}
|
||||
|
||||
MediaFoundation.Check(MediaFoundation.MFCreateMediaType(out IMFMediaType inputType), "Creazione del tipo d'ingresso");
|
||||
try
|
||||
{
|
||||
SetGuid(inputType, MediaFoundation.MtMajorType, MediaFoundation.MajorTypeVideo);
|
||||
SetGuid(inputType, MediaFoundation.MtSubtype, MediaFoundation.VideoFormatNv12);
|
||||
SetUInt32(inputType, MediaFoundation.MtInterlaceMode, MediaFoundation.InterlaceModeProgressive);
|
||||
SetUInt64(inputType, MediaFoundation.MtFrameSize, MediaFoundation.Pack((uint)_width, (uint)_height));
|
||||
SetUInt64(inputType, MediaFoundation.MtFrameRate, MediaFoundation.Pack(fpsNumerator, fpsDenominator));
|
||||
SetUInt64(inputType, MediaFoundation.MtPixelAspectRatio, MediaFoundation.Pack(1, 1));
|
||||
SetUInt32(inputType, MediaFoundation.MtYuvMatrix, 2); // BT.709
|
||||
SetUInt32(inputType, MediaFoundation.MtVideoPrimaries, 3); // BT.709
|
||||
SetUInt32(inputType, MediaFoundation.MtTransferFunction, 5); // BT.709
|
||||
SetUInt32(inputType, MediaFoundation.MtVideoNominalRange, 2); // 16-235
|
||||
|
||||
int hr = transform.SetInputType(_inputStreamId, inputType, 0);
|
||||
MediaFoundation.Check(hr, "Impostazione del formato d'ingresso NV12");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(inputType);
|
||||
}
|
||||
|
||||
ConfigureCodecApi();
|
||||
|
||||
transform.GetOutputStreamInfo(_outputStreamId, out _outputInfo);
|
||||
ReadSequenceHeader();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configurazione fine dell'encoder. Le B-frame vengono azzerate: senza riordino,
|
||||
/// l'ordine di decodifica coincide con quello di presentazione e il muxer può usare
|
||||
/// direttamente le durate della sequenza, comprese quelle variabili.
|
||||
/// </summary>
|
||||
private void ConfigureCodecApi()
|
||||
{
|
||||
if (_transform is not ICodecAPI codec) return;
|
||||
|
||||
TrySet(codec, MediaFoundation.AvEncMpvDefaultBPictureCount, 0);
|
||||
TrySet(codec, MediaFoundation.AvEncCommonRateControlMode, 0); // bitrate costante
|
||||
TrySet(codec, MediaFoundation.AvEncCommonMeanBitRate, _settings.AverageBitrate);
|
||||
|
||||
uint gop = (uint)Math.Clamp(_settings.KeyframeIntervalSeconds * _settings.FrameRate, 1, 600);
|
||||
TrySet(codec, MediaFoundation.AvEncMpvgopSize, gop);
|
||||
|
||||
static void TrySet(ICodecAPI codec, Guid parameter, uint value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var variant = PropVariant.FromUInt32(value);
|
||||
codec.SetValue(ref parameter, ref variant);
|
||||
}
|
||||
catch (COMException)
|
||||
{
|
||||
// Parametro non supportato dall'encoder: si prosegue con il valore predefinito.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Preleva i parameter set fuori banda, se l'encoder li espone già.</summary>
|
||||
private void ReadSequenceHeader()
|
||||
{
|
||||
if (_transform!.GetOutputCurrentType(_outputStreamId, out IMFMediaType current) < 0) return;
|
||||
try
|
||||
{
|
||||
var key = MediaFoundation.MtMpegSequenceHeader;
|
||||
if (current.GetBlobSize(ref key, out uint size) < 0 || size == 0) return;
|
||||
|
||||
var blob = new byte[size];
|
||||
uint actual = 0;
|
||||
if (current.GetBlob(ref key, blob, size, ref actual) < 0) return;
|
||||
_muxer.AddParameterSets(blob.AsSpan(0, (int)Math.Min(actual == 0 ? size : actual, size)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(current);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartStreaming()
|
||||
{
|
||||
var transform = _transform!;
|
||||
|
||||
if (_async)
|
||||
{
|
||||
var generator = transform as IMFMediaEventGenerator
|
||||
?? throw new VideoEncoderException("L'encoder asincrono non espone la coda eventi.");
|
||||
_events = generator;
|
||||
}
|
||||
|
||||
MediaFoundation.Check(transform.ProcessMessage(MediaFoundation.MessageNotifyBeginStreaming, IntPtr.Zero),
|
||||
"Avvio dello streaming");
|
||||
MediaFoundation.Check(transform.ProcessMessage(MediaFoundation.MessageNotifyStartOfStream, IntPtr.Zero),
|
||||
"Notifica di inizio flusso");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ codifica
|
||||
|
||||
/// <summary>
|
||||
/// Codifica un fotogramma. <paramref name="durationUnits"/> è la durata nella timescale
|
||||
/// della traccia, quindi ogni fotogramma può restare a schermo per un tempo diverso.
|
||||
/// </summary>
|
||||
public unsafe void EncodeFrame(ImageBuffer frame, uint durationUnits)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_finished, this);
|
||||
if (frame.Width != _width || frame.Height != _height)
|
||||
throw new ArgumentException("Il fotogramma non corrisponde alla risoluzione della sessione.");
|
||||
|
||||
long durationHns = durationUnits * 10_000_000L / _settings.Timescale;
|
||||
|
||||
if (_async) WaitForInputSlot();
|
||||
|
||||
int size = Nv12Converter.RequiredSize(_width, _height);
|
||||
MediaFoundation.Check(MediaFoundation.MFCreateMemoryBuffer((uint)size, out IMFMediaBuffer buffer),
|
||||
"Allocazione del buffer d'ingresso");
|
||||
IMFSample? sample = null;
|
||||
try
|
||||
{
|
||||
buffer.Lock(out IntPtr pointer, out _, out _);
|
||||
try
|
||||
{
|
||||
Nv12Converter.Convert(frame, (byte*)pointer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
buffer.Unlock();
|
||||
}
|
||||
buffer.SetCurrentLength((uint)size);
|
||||
|
||||
MediaFoundation.Check(MediaFoundation.MFCreateSample(out sample), "Creazione del campione");
|
||||
sample.AddBuffer(buffer);
|
||||
sample.SetSampleTime(_presentationTime);
|
||||
sample.SetSampleDuration(durationHns);
|
||||
|
||||
_pendingDurations.Enqueue(durationUnits);
|
||||
int hr = _transform!.ProcessInput(_inputStreamId, sample, 0);
|
||||
if (hr < 0)
|
||||
{
|
||||
_pendingDurations.Dequeue();
|
||||
MediaFoundation.Check(hr, "Invio del fotogramma all'encoder");
|
||||
}
|
||||
|
||||
_presentationTime += durationHns;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (sample is not null) Marshal.ReleaseComObject(sample);
|
||||
Marshal.ReleaseComObject(buffer);
|
||||
}
|
||||
|
||||
if (!_async)
|
||||
{
|
||||
while (TryDrainOutput()) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chiude il flusso, svuota l'encoder e finalizza il contenitore.</summary>
|
||||
public void Finish()
|
||||
{
|
||||
if (_finished) return;
|
||||
_finished = true;
|
||||
|
||||
try
|
||||
{
|
||||
var transform = _transform;
|
||||
if (transform is not null)
|
||||
{
|
||||
transform.ProcessMessage(MediaFoundation.MessageNotifyEndOfStream, IntPtr.Zero);
|
||||
transform.ProcessMessage(MediaFoundation.MessageCommandDrain, IntPtr.Zero);
|
||||
|
||||
if (_async) PumpUntilDrained();
|
||||
else
|
||||
{
|
||||
while (TryDrainOutput()) { }
|
||||
}
|
||||
|
||||
transform.ProcessMessage(MediaFoundation.MessageNotifyEndStreaming, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_muxer.Finish();
|
||||
_output.Dispose();
|
||||
ReleaseTransform();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ pompa eventi (MFT asincrone)
|
||||
|
||||
private void WaitForInputSlot()
|
||||
{
|
||||
while (_pendingNeedInput == 0)
|
||||
{
|
||||
if (!ProcessNextEvent()) throw new VideoEncoderException("L'encoder ha chiuso la coda eventi.");
|
||||
}
|
||||
_pendingNeedInput--;
|
||||
}
|
||||
|
||||
private void PumpUntilDrained()
|
||||
{
|
||||
int guard = 0;
|
||||
while (guard++ < 1_000_000)
|
||||
{
|
||||
if (!ProcessNextEvent()) return;
|
||||
if (_drainComplete) return;
|
||||
}
|
||||
}
|
||||
|
||||
private bool _drainComplete;
|
||||
|
||||
private bool ProcessNextEvent()
|
||||
{
|
||||
var generator = _events;
|
||||
if (generator is null) return false;
|
||||
|
||||
int hr = generator.GetEvent(0, out IMFMediaEvent mediaEvent);
|
||||
if (hr < 0 || mediaEvent is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (mediaEvent.GetEventType(out uint type) < 0) return true;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case MediaFoundation.EventTransformNeedInput:
|
||||
_pendingNeedInput++;
|
||||
break;
|
||||
case MediaFoundation.EventTransformHaveOutput:
|
||||
TryDrainOutput();
|
||||
break;
|
||||
case MediaFoundation.EventTransformDrainComplete:
|
||||
_drainComplete = true;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(mediaEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Estrae un pacchetto compresso dall'encoder e lo consegna al multiplexer.</summary>
|
||||
private bool TryDrainOutput()
|
||||
{
|
||||
var transform = _transform;
|
||||
if (transform is null) return false;
|
||||
|
||||
bool providesSamples = (_outputInfo.Flags & MediaFoundation.OutputStreamProvidesSamples) != 0;
|
||||
|
||||
IMFSample? ownSample = null;
|
||||
IMFMediaBuffer? ownBuffer = null;
|
||||
IntPtr providedPointer = IntPtr.Zero;
|
||||
|
||||
var descriptor = new MftOutputDataBuffer { StreamId = _outputStreamId };
|
||||
|
||||
try
|
||||
{
|
||||
if (!providesSamples)
|
||||
{
|
||||
uint size = Math.Max(_outputInfo.Size, (uint)(_width * _height));
|
||||
MediaFoundation.Check(MediaFoundation.MFCreateMemoryBuffer(size, out ownBuffer),
|
||||
"Allocazione del buffer di uscita");
|
||||
MediaFoundation.Check(MediaFoundation.MFCreateSample(out ownSample), "Creazione del campione di uscita");
|
||||
ownSample.AddBuffer(ownBuffer);
|
||||
providedPointer = Marshal.GetIUnknownForObject(ownSample);
|
||||
descriptor.Sample = providedPointer;
|
||||
}
|
||||
|
||||
int hr = transform.ProcessOutput(0, 1, ref descriptor, out _);
|
||||
|
||||
if (hr == MediaFoundation.ErrorTransformNeedMoreInput) return false;
|
||||
if (hr == MediaFoundation.ErrorTransformStreamChange)
|
||||
{
|
||||
RenegotiateOutputType();
|
||||
return true;
|
||||
}
|
||||
MediaFoundation.Check(hr, "Estrazione del pacchetto compresso");
|
||||
|
||||
IMFSample? produced = providesSamples
|
||||
? (descriptor.Sample != IntPtr.Zero ? (IMFSample)Marshal.GetObjectForIUnknown(descriptor.Sample) : null)
|
||||
: ownSample;
|
||||
|
||||
if (produced is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
ConsumeSample(produced);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (providesSamples) Marshal.ReleaseComObject(produced);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (descriptor.Events != IntPtr.Zero) Marshal.Release(descriptor.Events);
|
||||
if (providesSamples && descriptor.Sample != IntPtr.Zero) Marshal.Release(descriptor.Sample);
|
||||
if (providedPointer != IntPtr.Zero) Marshal.Release(providedPointer);
|
||||
if (ownSample is not null) Marshal.ReleaseComObject(ownSample);
|
||||
if (ownBuffer is not null) Marshal.ReleaseComObject(ownBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void ConsumeSample(IMFSample sample)
|
||||
{
|
||||
sample.ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
|
||||
try
|
||||
{
|
||||
uint length;
|
||||
buffer.Lock(out IntPtr pointer, out _, out length);
|
||||
try
|
||||
{
|
||||
if (length == 0) return;
|
||||
if (_packetBuffer.Length < length) _packetBuffer = new byte[Math.Max(length, (uint)_packetBuffer.Length * 2)];
|
||||
Marshal.Copy(pointer, _packetBuffer, 0, (int)length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
buffer.Unlock();
|
||||
}
|
||||
|
||||
uint duration = _pendingDurations.Count > 0
|
||||
? _pendingDurations.Dequeue()
|
||||
: (uint)Math.Max(1, _settings.Timescale / Math.Max(1.0, _settings.FrameRate));
|
||||
|
||||
_muxer.WriteSample(_packetBuffer.AsSpan(0, (int)length), duration);
|
||||
EncodedFrames++;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>L'encoder può richiedere di riconfermare il tipo di uscita dopo la negoziazione iniziale.</summary>
|
||||
private void RenegotiateOutputType()
|
||||
{
|
||||
var transform = _transform!;
|
||||
if (transform.GetOutputAvailableType(_outputStreamId, 0, out IMFMediaType type) < 0) return;
|
||||
try
|
||||
{
|
||||
transform.SetOutputType(_outputStreamId, type, 0);
|
||||
transform.GetOutputStreamInfo(_outputStreamId, out _outputInfo);
|
||||
ReadSequenceHeader();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(type);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ utilità
|
||||
|
||||
private static void SetGuid(IMFMediaType type, Guid key, Guid value) => type.SetGUID(ref key, ref value);
|
||||
private static void SetUInt32(IMFMediaType type, Guid key, uint value) => type.SetUINT32(ref key, value);
|
||||
private static void SetUInt64(IMFMediaType type, Guid key, ulong value) => type.SetUINT64(ref key, value);
|
||||
|
||||
private void ReleaseTransform()
|
||||
{
|
||||
if (_events is not null && !ReferenceEquals(_events, _transform))
|
||||
{
|
||||
Marshal.ReleaseComObject(_events);
|
||||
}
|
||||
_events = null;
|
||||
|
||||
if (_transform is not null)
|
||||
{
|
||||
Marshal.ReleaseComObject(_transform);
|
||||
_transform = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeletePartialFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_settings.OutputPath)) File.Delete(_settings.OutputPath);
|
||||
}
|
||||
catch (IOException) { /* il file resta, verrà sovrascritto al tentativo successivo */ }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
public void Dispose() => Finish();
|
||||
}
|
||||
|
||||
/// <summary>Inizializzazione una tantum della piattaforma Media Foundation.</summary>
|
||||
internal static class MediaFoundationRuntime
|
||||
{
|
||||
private static int _started;
|
||||
|
||||
public static void Startup()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) != 0) return;
|
||||
int hr = MediaFoundation.MFStartup(MediaFoundation.Version, MediaFoundation.StartupLite);
|
||||
if (hr < 0)
|
||||
{
|
||||
Interlocked.Exchange(ref _started, 0);
|
||||
throw new VideoEncoderException($"Inizializzazione di Media Foundation non riuscita (HRESULT 0x{hr:X8}).");
|
||||
}
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => MediaFoundation.MFShutdown();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user