diff --git a/Titano/Analysis/DeflickerEngine.cs b/Titano/Analysis/DeflickerEngine.cs
new file mode 100644
index 0000000..f76965e
--- /dev/null
+++ b/Titano/Analysis/DeflickerEngine.cs
@@ -0,0 +1,241 @@
+namespace Titano.Analysis;
+
+/// Parametri del motore di deflicker, esposti nel pannello "Elaborazione immagini".
+public sealed class DeflickerSettings
+{
+ public bool Enabled { get; set; } = true;
+
+ /// Ampiezza della finestra mobile in fotogrammi (viene resa dispari internamente).
+ public int WindowFrames { get; set; } = 15;
+
+ /// Quota della correzione applicata: 1 = curva target piena, 0 = nessuna correzione.
+ public double Strength { get; set; } = 1.0;
+
+ /// Limite di sicurezza della correzione, in stop.
+ public double MaxCorrectionStops { get; set; } = 1.5;
+
+ /// Scarta i fotogrammi anomali (lampi, passaggi di persone) dal calcolo della curva.
+ public bool RejectOutliers { get; set; } = true;
+
+ /// Applica lo stesso smoothing ai singoli canali per stabilizzare il bilanciamento colore.
+ public bool StabilizeColor { get; set; }
+
+ /// Comprime dolcemente le alte luci quando il guadagno è maggiore di 1.
+ public bool ProtectHighlights { get; set; } = true;
+
+ /// Punto d'innesco della compressione, in luce lineare.
+ public double HighlightKnee { get; set; } = 0.75;
+
+ public DeflickerSettings Clone() => (DeflickerSettings)MemberwiseClone();
+}
+
+/// Risultato del calcolo della curva: valori per fotogramma, in log2.
+public sealed class DeflickerCurve
+{
+ public required double[] Measured { get; init; }
+ public required double[] Target { get; init; }
+ public required double[] GainStops { get; init; }
+
+ /// Guadagni per canale (R,G,B); pari a quello di luminanza se la stabilizzazione colore è spenta.
+ public required double[][] ChannelGain { get; init; }
+
+ public int Count => Measured.Length;
+
+ /// Deviazione standard delle differenze fra fotogrammi adiacenti, in stop: misura lo sfarfallio.
+ public static double FlickerIndex(IReadOnlyList 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);
+ }
+}
+
+///
+/// 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.
+///
+public static class DeflickerEngine
+{
+ private const double TukeyConstant = 4.685;
+
+ public static DeflickerCurve Compute(IReadOnlyList 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,
+ };
+ }
+
+ /// Regressione lineare locale pesata, con seconda passata robusta agli outlier.
+ 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);
+ }
+ }
+}
diff --git a/Titano/Analysis/ExposureProcessor.cs b/Titano/Analysis/ExposureProcessor.cs
new file mode 100644
index 0000000..26304ac
--- /dev/null
+++ b/Titano/Analysis/ExposureProcessor.cs
@@ -0,0 +1,112 @@
+using System.Numerics;
+using Titano.Imaging;
+
+namespace Titano.Analysis;
+
+///
+/// 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.
+///
+public static class ExposureProcessor
+{
+ ///
+ /// Moltiplica il buffer per i tre guadagni indicati, in-place.
+ /// Restituisce la frazione stimata di campioni saturati dopo la correzione.
+ ///
+ public static double Apply(ImageBuffer frame, ReadOnlySpan 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.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(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[] BuildGainPhases(float gr, float gg, float gb, int width, out int phaseCount)
+ {
+ phaseCount = width % 3 == 0 ? 1 : 3;
+ var phases = new Vector[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(scratch);
+ }
+ return phases;
+ }
+
+ private static float GainFor(int sampleIndex, float gr, float gg, float gb)
+ => (sampleIndex % 3) switch { 0 => gr, 1 => gg, _ => gb };
+
+ /// Stima del clipping su campionamento regolare: costo indipendente dalla risoluzione.
+ 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;
+ }
+}
diff --git a/Titano/Analysis/LuminanceAnalyzer.cs b/Titano/Analysis/LuminanceAnalyzer.cs
new file mode 100644
index 0000000..d709d7c
--- /dev/null
+++ b/Titano/Analysis/LuminanceAnalyzer.cs
@@ -0,0 +1,144 @@
+using Titano.Imaging;
+
+namespace Titano.Analysis;
+
+/// Statistiche fotometriche di un fotogramma, calcolate in luce lineare.
+public readonly struct LuminanceStats
+{
+ /// Media logaritmica (base 2) della luminanza sui pixel non estremi.
+ public double Log2Average { get; init; }
+
+ /// Media geometrica in scala lineare: 2^.
+ public double Linear => Math.Pow(2.0, Log2Average);
+
+ public double Log2AverageR { get; init; }
+ public double Log2AverageG { get; init; }
+ public double Log2AverageB { get; init; }
+
+ /// Percentili della distribuzione di luminanza, in scala lineare.
+ public double Percentile01 { get; init; }
+ public double Percentile50 { get; init; }
+ public double Percentile99 { get; init; }
+
+ /// Frazione di pixel già saturati nel fotogramma sorgente.
+ public double ClippedFraction { get; init; }
+
+ public double BlackFraction { get; init; }
+
+ public static LuminanceStats Empty => new() { Log2Average = -8 };
+}
+
+///
+/// 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.
+///
+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;
+
+ /// Numero massimo di pixel campionati per fotogramma: fissa il costo dell'analisi.
+ 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 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,
+ };
+ }
+
+ /// Passo di campionamento a griglia: deterministico, quindi identico per ogni fotogramma.
+ private static int ComputeStep(int pixelCount)
+ => Math.Max(1, pixelCount / TargetSamples);
+
+ /// Logaritmo base 2 con saturazione al nero: evita -infinito sui pixel spenti.
+ private static double Log2Safe(double value)
+ => value <= 0 ? LogMin : Math.Max(LogMin, Math.Log2(value));
+}
diff --git a/Titano/Core/FrameRecord.cs b/Titano/Core/FrameRecord.cs
new file mode 100644
index 0000000..b7f8488
--- /dev/null
+++ b/Titano/Core/FrameRecord.cs
@@ -0,0 +1,65 @@
+using Titano.Metadata;
+
+namespace Titano.Core;
+
+///
+/// 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).
+///
+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;
+
+ /// Secondi trascorsi dal primo scatto della sequenza.
+ public double ElapsedSeconds { get; internal set; }
+
+ /// Intervallo verso lo scatto successivo, in secondi (l'ultimo eredita il precedente).
+ public double IntervalSeconds { get; internal set; }
+
+ /// True se l'intervallo devia in modo significativo dalla cadenza nominale.
+ public bool IsCadenceAnomaly { get; internal set; }
+
+ /// Shutter angle reale: 360 * tempo di posa / intervallo.
+ public double ShutterAngle { get; internal set; }
+
+ // ---- analisi luminanza -------------------------------------------------
+ /// Luminanza media geometrica misurata (log-average), in scala lineare 0..1.
+ public double MeasuredLuminance { get; internal set; }
+
+ /// Luminanza obiettivo dopo lo smoothing temporale.
+ public double TargetLuminance { get; internal set; }
+
+ /// Guadagno lineare applicato ai canali colore.
+ public double Gain { get; internal set; } = 1.0;
+
+ /// Correzione espressa in stop (log2 del guadagno), per la lettura in UI.
+ public double GainStops => Gain > 0 ? Math.Log2(Gain) : 0;
+
+ /// Percentuale di pixel oltre la soglia di clipping dopo la correzione.
+ public double ClippedFraction { get; internal set; }
+
+ public bool LuminanceAnalyzed { get; internal set; }
+
+ // ---- motion ------------------------------------------------------------
+ /// Modulo mediano del vettore di movimento verso il fotogramma successivo, in pixel.
+ public double MotionMagnitude { get; internal set; }
+
+ /// Direzione dominante del movimento, in gradi.
+ public double MotionDirection { get; internal set; }
+
+ /// Lunghezza della scia di motion blur sintetico effettivamente applicata, in pixel.
+ public double BlurLength { get; internal set; }
+
+ /// Durata del fotogramma nel video finale, in unità di timescale (playback adattivo).
+ public int OutputDurationUnits { get; internal set; }
+
+ public string CadenceText => IntervalSeconds <= 0
+ ? "—"
+ : IntervalSeconds < 1
+ ? $"{IntervalSeconds * 1000:0} ms"
+ : $"{IntervalSeconds:0.###} s";
+}
diff --git a/Titano/Core/TimelapseSequence.cs b/Titano/Core/TimelapseSequence.cs
new file mode 100644
index 0000000..9881be0
--- /dev/null
+++ b/Titano/Core/TimelapseSequence.cs
@@ -0,0 +1,169 @@
+using Titano.Metadata;
+
+namespace Titano.Core;
+
+///
+/// Sequenza ordinata di fotogrammi con la relativa analisi temporale: intervalli reali,
+/// cadenza nominale dell'intervallometro, pause e shutter angle per singolo scatto.
+///
+public sealed class TimelapseSequence
+{
+ private readonly List _frames = [];
+
+ public IReadOnlyList Frames => _frames;
+ public int Count => _frames.Count;
+
+ /// Cadenza nominale (mediana degli intervalli), in secondi.
+ public double NominalInterval { get; private set; }
+
+ /// Numero di intervalli che deviano oltre la tolleranza dalla cadenza nominale.
+ public int CadenceAnomalies { get; private set; }
+
+ public TimeSpan TotalDuration { get; private set; }
+
+ /// True se almeno un fotogramma ha un timestamp con precisione al sotto-secondo.
+ public bool HasSubSecondPrecision { get; private set; }
+
+ /// Costruisce la sequenza ordinando per timestamp e, a parità, per nome file naturale.
+ public static TimelapseSequence Build(IEnumerable 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;
+ }
+
+ ///
+ /// Ricalcola intervalli reali, cadenza nominale e shutter angle.
+ /// Gli intervalli assenti (timestamp mancanti) ereditano la cadenza nominale.
+ ///
+ 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;
+ }
+
+ ///
+ /// Shutter angle cinematografico: la frazione dell'intervallo effettivamente esposta,
+ /// espressa in gradi su un giro completo dell'otturatore rotante.
+ ///
+ 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);
+ }
+
+ /// Sotto-insieme contiguo di indici, usato dalle anteprime e dai render parziali.
+ public IEnumerable 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];
+ }
+}
+
+///
+/// Ordinamento "naturale" dei nomi file: IMG_2.jpg precede IMG_10.jpg.
+/// Necessario perché molte sequenze condividono lo stesso timestamp al secondo.
+///
+public sealed class NaturalFileNameComparer : IComparer
+{
+ 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);
+ }
+}
diff --git a/Titano/Diagnostics/ExifWriter.cs b/Titano/Diagnostics/ExifWriter.cs
new file mode 100644
index 0000000..d41d1ba
--- /dev/null
+++ b/Titano/Diagnostics/ExifWriter.cs
@@ -0,0 +1,168 @@
+using System.Buffers.Binary;
+using System.Text;
+using Titano.Metadata;
+
+namespace Titano.Diagnostics;
+
+///
+/// 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.
+///
+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
+ {
+ Ascii(TiffTags.Make, make),
+ Ascii(TiffTags.Model, model),
+ Short(TiffTags.Orientation, 1),
+ };
+
+ var exif = new List
+ {
+ 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(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();
+ }
+
+ /// Inserisce il segmento APP1 in un JPEG, subito dopo l'eventuale APP0/JFIF.
+ 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 entries, MemoryStream data, int dataOffset)
+ {
+ WriteUInt16(body, (ushort)entries.Count);
+ Span 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 buffer = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
+ stream.Write(buffer);
+ }
+
+ private static void WriteUInt32(Stream stream, uint value)
+ {
+ Span buffer = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
+ stream.Write(buffer);
+ }
+}
diff --git a/Titano/Diagnostics/Mp4Inspector.cs b/Titano/Diagnostics/Mp4Inspector.cs
new file mode 100644
index 0000000..e922e37
--- /dev/null
+++ b/Titano/Diagnostics/Mp4Inspector.cs
@@ -0,0 +1,133 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace Titano.Diagnostics;
+
+///
+/// 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).
+///
+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 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();
+ int sampleCount = 0, width = 0, height = 0, configBytes = 0;
+ uint timescale = 0;
+ long mediaDuration = 0;
+ var problems = new List();
+
+ 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 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 buffer = stackalloc byte[4];
+ return file.Read(buffer) == 4 ? BinaryPrimitives.ReadUInt32BigEndian(buffer) : 0;
+ }
+
+ static ushort ReadUInt16(FileStream file)
+ {
+ Span buffer = stackalloc byte[2];
+ return file.Read(buffer) == 2 ? BinaryPrimitives.ReadUInt16BigEndian(buffer) : (ushort)0;
+ }
+ }
+}
diff --git a/Titano/Diagnostics/Mp4Playback.cs b/Titano/Diagnostics/Mp4Playback.cs
new file mode 100644
index 0000000..b3c61bb
--- /dev/null
+++ b/Titano/Diagnostics/Mp4Playback.cs
@@ -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);
+}
+
+///
+/// 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.
+///
+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 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();
+ 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);
+ }
+ }
+}
diff --git a/Titano/Diagnostics/SelfTest.cs b/Titano/Diagnostics/SelfTest.cs
new file mode 100644
index 0000000..0da3a8f
--- /dev/null
+++ b/Titano/Diagnostics/SelfTest.cs
@@ -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;
+
+///
+/// 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.
+///
+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;
+ }
+
+ ///
+ /// 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ù.
+ ///
+ 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;
+ }
+
+ /// Riepilogo testuale usato anche dal pannello diagnostica dell'interfaccia.
+ 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.Count} float per registro");
+ return builder.ToString();
+ }
+}
diff --git a/Titano/Diagnostics/SyntheticSequence.cs b/Titano/Diagnostics/SyntheticSequence.cs
new file mode 100644
index 0000000..105f950
--- /dev/null
+++ b/Titano/Diagnostics/SyntheticSequence.cs
@@ -0,0 +1,159 @@
+using System.Drawing;
+using System.Drawing.Imaging;
+
+namespace Titano.Diagnostics;
+
+///
+/// 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).
+///
+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);
+
+ /// Scrive la sequenza nella cartella indicata e restituisce i percorsi generati.
+ public static List Write(string directory, Definition definition)
+ {
+ Directory.CreateDirectory(directory);
+ var paths = new List(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);
+
+ /// Sfarfallio deterministico ma non periodico rispetto alla finestra di smoothing.
+ 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();
+ }
+
+ /// Rumore di valore bilineare su reticolo intero, deterministico.
+ 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;
+ }
+ }
+}
diff --git a/Titano/Imaging/ColorSpace.cs b/Titano/Imaging/ColorSpace.cs
new file mode 100644
index 0000000..d726209
--- /dev/null
+++ b/Titano/Imaging/ColorSpace.cs
@@ -0,0 +1,88 @@
+using System.Runtime.CompilerServices;
+
+namespace Titano.Imaging;
+
+///
+/// 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.
+///
+public static class ColorSpace
+{
+ /// LUT sRGB→lineare a 8 bit.
+ private static readonly float[] SrgbToLinear8 = BuildLut(256);
+
+ /// LUT sRGB→lineare a 16 bit, campionata a 4096 punti con interpolazione lineare.
+ private static readonly float[] SrgbToLinear16 = BuildLut(4096);
+
+ /// LUT inversa lineare→sRGB a 12 bit, per la scrittura verso l'encoder.
+ 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);
+ }
+
+ /// Lineare → sRGB con LUT interpolata; valori fuori range vengono saturati.
+ [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;
+}
diff --git a/Titano/Imaging/ImageBuffer.cs b/Titano/Imaging/ImageBuffer.cs
new file mode 100644
index 0000000..a29d2bd
--- /dev/null
+++ b/Titano/Imaging/ImageBuffer.cs
@@ -0,0 +1,116 @@
+using System.Collections.Concurrent;
+
+namespace Titano.Imaging;
+
+///
+/// Fotogramma in memoria: RGB impacchettato, float32, luce lineare (gamma rimossa).
+/// L'array proviene sempre da un : nessuna allocazione
+/// per fotogramma dopo il riscaldamento, quindi impronta di memoria stazionaria.
+///
+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;
+ }
+
+ /// Indice del primo campione (canale R) del pixel indicato.
+ 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;
+ }
+
+ /// Restituisce il buffer al pool. Idempotente.
+ 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;
+ }
+}
+
+///
+/// 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.
+///
+public sealed class FrameBufferPool(int maxRetained = 8)
+{
+ private readonly ConcurrentDictionary> _bins = new();
+ private readonly int _maxRetained = Math.Max(2, maxRetained);
+ private int _retained;
+ private long _allocatedBytes;
+
+ /// Numero di array attualmente trattenuti dal pool.
+ public int Retained => Volatile.Read(ref _retained);
+
+ ///
+ /// 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.
+ ///
+ 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(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);
+ }
+
+ /// Svuota il pool: usato al cambio di risoluzione di lavoro.
+ public void Clear()
+ {
+ _bins.Clear();
+ Interlocked.Exchange(ref _retained, 0);
+ Interlocked.Exchange(ref _allocatedBytes, 0);
+ }
+}
diff --git a/Titano/Imaging/ImageDecoder.cs b/Titano/Imaging/ImageDecoder.cs
new file mode 100644
index 0000000..852ccad
--- /dev/null
+++ b/Titano/Imaging/ImageDecoder.cs
@@ -0,0 +1,234 @@
+using System.Buffers;
+using System.Runtime.InteropServices;
+
+namespace Titano.Imaging;
+
+///
+/// Decodifica un file immagine direttamente in un 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.
+///
+public static class ImageDecoder
+{
+ /// Dimensioni dell'immagine così come verrà mostrata (orientamento già applicato).
+ 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);
+ }
+ }
+
+ ///
+ /// Decodifica il file producendo un buffer di esattamente ×
+ /// pixel (misure già nell'orientamento finale).
+ ///
+ 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.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.Shared.Return(scratch);
+ }
+ }
+ finally
+ {
+ Wic.Release(scaler);
+ Wic.Release(converter);
+ Wic.Release(frame);
+ Wic.Release(decoder);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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]);
+ }
+ }
+ }
+ }
+
+ /// Applica la trasformazione Exif (valori 1..8) restituendo l'offset di destinazione.
+ 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;
+ }
+}
diff --git a/Titano/Imaging/WicInterop.cs b/Titano/Imaging/WicInterop.cs
new file mode 100644
index 0000000..bf1692b
--- /dev/null
+++ b/Titano/Imaging/WicInterop.cs
@@ -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);
+
+ ///
+ /// 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.
+ ///
+ 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 */ }
+ }
+ }
+}
diff --git a/Titano/Metadata/FrameMetadata.cs b/Titano/Metadata/FrameMetadata.cs
new file mode 100644
index 0000000..5d3eb59
--- /dev/null
+++ b/Titano/Metadata/FrameMetadata.cs
@@ -0,0 +1,138 @@
+using System.Globalization;
+
+namespace Titano.Metadata;
+
+/// Origine del timestamp di scatto, usata per segnalare in UI la qualità del dato.
+public enum TimestampSource
+{
+ None = 0,
+ Exif,
+ ExifSubSecond,
+ Xmp,
+ FileSystem,
+}
+
+///
+/// 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.
+///
+public sealed class FrameMetadata
+{
+ public required string FilePath { get; init; }
+ public required string FileName { get; init; }
+ public long FileSize { get; init; }
+
+ /// Istante di scatto, comprensivo di frazione di secondo quando disponibile.
+ 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; }
+
+ /// Errori non fatali incontrati durante il parsing (file troncato, IFD corrotta...).
+ 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) : "—";
+
+ /// Costruisce l'istante completo a partire dai campi Exif grezzi.
+ 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 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;
+ }
+
+ /// Interpreta un offset fuso orario Exif nella forma "+02:00".
+ 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);
+ }
+}
diff --git a/Titano/Metadata/MetadataReader.cs b/Titano/Metadata/MetadataReader.cs
new file mode 100644
index 0000000..9108df0
--- /dev/null
+++ b/Titano/Metadata/MetadataReader.cs
@@ -0,0 +1,388 @@
+using System.Text;
+
+namespace Titano.Metadata;
+
+///
+/// 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.
+///
+public static class MetadataReader
+{
+ /// Quantità massima di header letta per i contenitori TIFF/RAW (le IFD stanno all'inizio).
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 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 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)[[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("
+/// 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...).
+///
+internal sealed class TiffBlock
+{
+ public ByteView View { get; }
+ public Dictionary Ifd0 { get; } = [];
+ public Dictionary Exif { get; } = [];
+ public Dictionary Gps { get; } = [];
+ public List> SubIfds { get; } = [];
+
+ private TiffBlock(ByteView view) => View = view;
+
+ /// Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory.
+ 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();
+ 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 target, Dictionary 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? 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(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;
+ }
+ }
+
+ /// Primo valore non nullo di una lista di tag (utile per ISO, presente in più varianti).
+ 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;
+ }
+}
+
+/// Numerazione dei tag TIFF/Exif effettivamente utilizzati dal motore.
+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;
+}
diff --git a/Titano/Metadata/TiffPrimitives.cs b/Titano/Metadata/TiffPrimitives.cs
new file mode 100644
index 0000000..58e5fd8
--- /dev/null
+++ b/Titano/Metadata/TiffPrimitives.cs
@@ -0,0 +1,165 @@
+namespace Titano.Metadata;
+
+/// Tipi di dato definiti dalla specifica TIFF 6.0 / Exif 2.32.
+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,
+}
+
+/// Voce di una IFD: 12 byte nel file, con il valore inline se occupa ≤ 4 byte.
+internal readonly struct TiffEntry
+{
+ public readonly ushort Tag;
+ public readonly TiffType Type;
+ public readonly uint Count;
+
+ /// Offset assoluto (rispetto all'inizio del blocco TIFF) dove risiede il valore.
+ 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;
+}
+
+///
+/// Lettore binario endian-aware su uno logico (buffer immutabile).
+/// Tutti gli accessi sono limitati: un file malformato produce valori assenti, mai eccezioni.
+///
+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);
+ }
+
+ /// Ricerca di un pattern di byte; -1 se assente. Usata per i fallback di scansione.
+ public int IndexOf(ReadOnlySpan pattern, int startAt = 0)
+ {
+ if (pattern.Length == 0 || pattern.Length > _length) return -1;
+ var haystack = new ReadOnlySpan(_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;
+ }
+}
diff --git a/Titano/Metadata/XmpScanner.cs b/Titano/Metadata/XmpScanner.cs
new file mode 100644
index 0000000..451c22b
--- /dev/null
+++ b/Titano/Metadata/XmpScanner.cs
@@ -0,0 +1,149 @@
+using System.Globalization;
+
+namespace Titano.Metadata;
+
+///
+/// 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
+/// (xmp:CreateDate="...") sia in forma elemento (<xmp:CreateDate>...</>).
+///
+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 .
+ 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);
+ }
+
+ /// Individua un packet XMP dentro un buffer generico di testo/binario.
+ public static string? ExtractPacket(string text)
+ {
+ int start = text.IndexOf("", start, StringComparison.Ordinal);
+ if (end < 0) end = text.IndexOf("", 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;
+ }
+
+ /// Parsing ISO-8601 ("2024-06-01T18:32:11.250+02:00") senza dipendere dal formattatore di sistema.
+ 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;
+ }
+}
diff --git a/Titano/Modifiche.txt b/Titano/Modifiche.txt
new file mode 100644
index 0000000..1c506eb
--- /dev/null
+++ b/Titano/Modifiche.txt
@@ -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.
diff --git a/Titano/Motion/FrameInterpolator.cs b/Titano/Motion/FrameInterpolator.cs
new file mode 100644
index 0000000..c6b4618
--- /dev/null
+++ b/Titano/Motion/FrameInterpolator.cs
@@ -0,0 +1,48 @@
+using Titano.Imaging;
+
+namespace Titano.Motion;
+
+///
+/// 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.
+///
+public static class FrameInterpolator
+{
+ /// Genera il fotogramma all'istante ∈ [0,1] fra A e B.
+ 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;
+ }
+ });
+ }
+}
diff --git a/Titano/Motion/GrayPyramid.cs b/Titano/Motion/GrayPyramid.cs
new file mode 100644
index 0000000..ca8e1c6
--- /dev/null
+++ b/Titano/Motion/GrayPyramid.cs
@@ -0,0 +1,145 @@
+using System.Runtime.CompilerServices;
+using Titano.Imaging;
+
+namespace Titano.Motion;
+
+/// Piano di luminanza percettiva a precisione singola, con campionamento bilineare.
+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];
+ }
+
+ /// Campionamento bilineare con estensione dei bordi.
+ 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;
+ }
+}
+
+///
+/// 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.
+///
+public sealed class GrayPyramid
+{
+ public GrayImage[] Levels { get; }
+
+ /// Rapporto fra la risoluzione di analisi (livello 0) e quella dell'immagine originale.
+ 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);
+ }
+
+ /// Riduzione da RGB lineare a luminanza percettiva con media d'area (box filter).
+ 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);
+ }
+ });
+ }
+
+ /// Dimezzamento con kernel binomiale 1-2-1 separabile.
+ 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;
+ }
+}
diff --git a/Titano/Motion/MotionBlurRenderer.cs b/Titano/Motion/MotionBlurRenderer.cs
new file mode 100644
index 0000000..9a38591
--- /dev/null
+++ b/Titano/Motion/MotionBlurRenderer.cs
@@ -0,0 +1,194 @@
+using System.Runtime.CompilerServices;
+using Titano.Imaging;
+
+namespace Titano.Motion;
+
+/// Parametri del motion blur sintetico, esposti nel pannello "Elaborazione immagini".
+public sealed class MotionBlurSettings
+{
+ public bool Enabled { get; set; } = true;
+
+ /// Shutter angle desiderato: 180° è la convenzione cinematografica.
+ public double TargetShutterAngle { get; set; } = 180.0;
+
+ /// Quota della sfocatura mancante effettivamente sintetizzata.
+ public double Strength { get; set; } = 1.0;
+
+ /// Limite superiore della scia, in pixel: protegge dalle stime di movimento errate.
+ public double MaxBlurPixels { get; set; } = 48.0;
+
+ /// Numero massimo di campioni per pixel lungo la scia.
+ public int MaxSamples { get; set; } = 25;
+
+ public MotionBlurSettings Clone() => (MotionBlurSettings)MemberwiseClone();
+}
+
+///
+/// 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.
+///
+public static class MotionBlurRenderer
+{
+ ///
+ /// Frazione di spostamento da sintetizzare per portare
+ /// al valore . Zero se il fotogramma è già abbastanza mosso.
+ ///
+ 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);
+ }
+
+ ///
+ /// Applica la sfocatura da a .
+ /// Restituisce la lunghezza media della scia effettivamente resa, in pixel.
+ ///
+ 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);
+ }
+
+ ///
+ /// Aggiunge il contributo del campione spostato di lungo la scia.
+ /// Il peso vale 1 solo se il movimento proprio del campione arriva a coprire il pixel centrale.
+ ///
+ [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;
+ }
+}
diff --git a/Titano/Motion/MotionField.cs b/Titano/Motion/MotionField.cs
new file mode 100644
index 0000000..989b92f
--- /dev/null
+++ b/Titano/Motion/MotionField.cs
@@ -0,0 +1,103 @@
+using System.Runtime.CompilerServices;
+
+namespace Titano.Motion;
+
+///
+/// Campo vettoriale di movimento memorizzato su griglia rada: un nodo ogni
+/// 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.
+///
+public sealed class MotionField
+{
+ public int GridWidth { get; }
+ public int GridHeight { get; }
+
+ /// Passo della griglia in pixel dell'immagine a piena risoluzione (può non essere intero).
+ 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;
+
+ /// Campionamento bilineare del campo in coordinate immagine.
+ [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;
+ }
+
+ /// Modulo mediano dei vettori: stima robusta dell'entità del movimento.
+ 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];
+ }
+
+ /// Direzione dominante del campo, in gradi, pesata sul modulo dei vettori.
+ 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;
+ }
+}
diff --git a/Titano/Motion/OpticalFlowEngine.cs b/Titano/Motion/OpticalFlowEngine.cs
new file mode 100644
index 0000000..f6d7207
--- /dev/null
+++ b/Titano/Motion/OpticalFlowEngine.cs
@@ -0,0 +1,194 @@
+using Titano.Imaging;
+
+namespace Titano.Motion;
+
+/// Parametri del calcolo del campo vettoriale.
+public sealed class OpticalFlowSettings
+{
+ /// Larghezza massima a cui viene condotta l'analisi del movimento.
+ public int AnalysisWidth { get; set; } = 960;
+
+ /// Passo della griglia di nodi, in pixel della risoluzione di analisi.
+ public int CellSize { get; set; } = 8;
+
+ /// Livelli della piramide: determina lo spostamento massimo inseguibile.
+ public int PyramidLevels { get; set; } = 4;
+
+ /// Semi-lato della finestra di correlazione.
+ public int WindowRadius { get; set; } = 6;
+
+ /// Iterazioni di raffinamento per livello.
+ public int Iterations { get; set; } = 5;
+
+ /// Regolarizzazione del sistema normale: stabilizza le zone senza tessitura.
+ public double Regularization { get; set; } = 1e-4;
+
+ public OpticalFlowSettings Clone() => (OpticalFlowSettings)MemberwiseClone();
+}
+
+///
+/// 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.
+///
+public sealed class OpticalFlowEngine(OpticalFlowSettings settings)
+{
+ private readonly OpticalFlowSettings _settings = settings;
+
+ ///
+ /// Calcola il campo dal fotogramma verso .
+ /// I vettori restituiti sono espressi in pixel dell'immagine a piena risoluzione.
+ ///
+ 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;
+ }
+
+ /// Raffina la stima corrente su un livello della piramide.
+ 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 wx = stackalloc float[9];
+ Span 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];
+ }
+ }
+ }
+}
diff --git a/Titano/Pipeline/PipelineProgress.cs b/Titano/Pipeline/PipelineProgress.cs
new file mode 100644
index 0000000..7fe843b
--- /dev/null
+++ b/Titano/Pipeline/PipelineProgress.cs
@@ -0,0 +1,33 @@
+namespace Titano.Pipeline;
+
+public enum PipelinePhase
+{
+ Ingestion,
+ Analysis,
+ Rendering,
+ Finalizing,
+ Completed,
+ Failed,
+}
+
+/// Stato d'avanzamento pubblicato verso l'interfaccia; immutabile e sicuro da marshalare.
+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);
+}
+
+/// Esito di una esportazione completata.
+public sealed record RenderResult(
+ string OutputPath,
+ int EncodedFrames,
+ long OutputBytes,
+ TimeSpan Elapsed,
+ string EncoderName,
+ bool HardwareAccelerated,
+ long PeakPixelMemoryBytes);
diff --git a/Titano/Pipeline/RenderPipeline.cs b/Titano/Pipeline/RenderPipeline.cs
new file mode 100644
index 0000000..6c36365
--- /dev/null
+++ b/Titano/Pipeline/RenderPipeline.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed class RenderPipeline(TitanoProject project)
+{
+ private readonly TitanoProject _project = project;
+
+ // ------------------------------------------------------------------ ingestion
+
+ /// Legge i metadati dei file indicati e costruisce la sequenza ordinata.
+ public static async Task IngestAsync(IReadOnlyList paths,
+ double cadenceTolerance,
+ IProgress? 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
+
+ ///
+ /// 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.
+ ///
+ public async Task AnalyzeAsync(IProgress? 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;
+ }
+
+ ///
+ /// Ricalcola solo la curva a partire dalle statistiche già misurate: permette di muovere
+ /// i cursori del deflicker con riscontro immediato, senza rileggere i file.
+ ///
+ 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 RenderAsync(IProgress? 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? 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>(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);
+ }
+
+ /// Applica la sfocatura, se prevista, e consegna il fotogramma all'encoder.
+ 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;
+ }
+
+ /// Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato.
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static bool TryReadNext(Channel> channel, out ImageBuffer? buffer)
+ {
+ buffer = null;
+ while (true)
+ {
+ Task 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;
+ }
+ }
+
+ /// Legge il fotogramma successivo sostituendo gli illeggibili con una copia del precedente.
+ private static ImageBuffer? ReadNextOrSubstitute(Channel> 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? 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));
+ }
+}
diff --git a/Titano/Pipeline/TitanoProject.cs b/Titano/Pipeline/TitanoProject.cs
new file mode 100644
index 0000000..c9f972d
--- /dev/null
+++ b/Titano/Pipeline/TitanoProject.cs
@@ -0,0 +1,83 @@
+using Titano.Analysis;
+using Titano.Core;
+using Titano.Motion;
+using Titano.Video;
+
+namespace Titano.Pipeline;
+
+/// Impostazioni della sezione "Generale".
+public sealed class GeneralSettings
+{
+ /// Larghezza massima di lavoro; 0 = risoluzione nativa del primo fotogramma.
+ public int WorkingWidth { get; set; }
+
+ /// Larghezza usata nella passata di analisi fotometrica: incide solo sulla velocità.
+ public int AnalysisWidth { get; set; } = 1024;
+
+ /// Decodifiche simultanee. Limita anche i buffer in volo, quindi la memoria occupata.
+ public int DecodeParallelism { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 2, 8);
+
+ /// Tolleranza sulla cadenza oltre la quale un intervallo è segnalato come anomalo.
+ public double CadenceTolerance { get; set; } = 0.35;
+
+ public GeneralSettings Clone() => (GeneralSettings)MemberwiseClone();
+}
+
+///
+/// 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.
+///
+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; }
+
+ /// Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.
+ public DeflickerCurve? Curve { get; set; }
+
+ /// Statistiche fotometriche per fotogramma dell'ultima analisi.
+ public IReadOnlyList? Stats { get; set; }
+
+ public bool HasSequence => Sequence is { Count: > 0 };
+ public bool IsAnalyzed => Curve is not null && Stats is not null;
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/Titano/Program.cs b/Titano/Program.cs
new file mode 100644
index 0000000..3bfeb26
--- /dev/null
+++ b/Titano/Program.cs
@@ -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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/Titano/README.md b/Titano/README.md
new file mode 100644
index 0000000..2e9cd0c
--- /dev/null
+++ b/Titano/README.md
@@ -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
+``: 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`.
+
+## 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 [cartella-sequenza] [scheda]
+```
+
+Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile.
diff --git a/Titano/Titano.csproj b/Titano/Titano.csproj
new file mode 100644
index 0000000..ac297ec
--- /dev/null
+++ b/Titano/Titano.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+
+ WinExe
+ net10.0-windows
+ true
+ enable
+ enable
+ latest
+ true
+ x64
+ x64
+ Titano
+ Titano
+ PerMonitorV2
+ Segoe UI, 9pt
+ true
+ true
+ true
+ false
+ Titano
+ Titano
+ 1.0.0.0
+ 1.0.0.0
+ 1.0.0
+
+ $(NoWarn);CA1416;WFO1000
+
+
+
diff --git a/Titano/Titano.slnx b/Titano/Titano.slnx
new file mode 100644
index 0000000..59b39cc
--- /dev/null
+++ b/Titano/Titano.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Titano/UI/Controls.cs b/Titano/UI/Controls.cs
new file mode 100644
index 0000000..0211b93
--- /dev/null
+++ b/Titano/UI/Controls.cs
@@ -0,0 +1,488 @@
+using System.Globalization;
+
+namespace Titano.UI;
+
+/// Pulsante disegnato interamente a mano, con varianti primaria e secondaria.
+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);
+ }
+}
+
+/// Interruttore a due stati con etichetta, disegnato a mano.
+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);
+ }
+}
+
+///
+/// Cursore continuo con didascalia e valore, unità di misura opzionale e tacche.
+/// Sostituisce il TrackBar di sistema, che non è tematizzabile.
+///
+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);
+ }
+ }
+
+ /// Imposta il valore senza sollevare l'evento: usata al caricamento delle impostazioni.
+ 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));
+ }
+}
+
+/// Etichetta e menu a discesa affiancati, con lo stile del tema.
+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);
+ }
+}
+
+/// Intestazione di sezione con filetto di separazione.
+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);
+ }
+ }
+}
+
+/// Selettore a schede orizzontali usato dal pannello di configurazione.
+internal sealed class TabStrip : Control
+{
+ private readonly List _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;
+ }
+
+ ///
+ /// Larghezze proporzionali al testo: le etichette lunghe non vengono troncate solo
+ /// perché condividono la barra con etichette corte.
+ ///
+ 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);
+ }
+ }
+ }
+}
+
+/// Barra di avanzamento sottile con etichetta interna.
+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);
+ }
+ }
+}
diff --git a/Titano/UI/FrameTable.cs b/Titano/UI/FrameTable.cs
new file mode 100644
index 0000000..49f45a6
--- /dev/null
+++ b/Titano/UI/FrameTable.cs
@@ -0,0 +1,288 @@
+using Titano.Core;
+using Titano.Metadata;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+internal sealed class FrameTable : Control
+{
+ private sealed record Column(string Title, int Width, bool RightAligned, Func 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);
+ }
+}
diff --git a/Titano/UI/LuminanceChart.cs b/Titano/UI/LuminanceChart.cs
new file mode 100644
index 0000000..258e5fd
--- /dev/null
+++ b/Titano/UI/LuminanceChart.cs
@@ -0,0 +1,549 @@
+using System.Drawing.Drawing2D;
+using Titano.Analysis;
+using Titano.Core;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+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();
+ }
+
+ /// Aggiorna la sola curva conservando zoom e selezione (cursori del deflicker).
+ 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;
+ }
+
+ /// Tacche verticali sugli intervalli che si discostano dalla cadenza nominale.
+ 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);
+ }
+ }
+
+ /// Area fra misurato e target: è la correzione che verrà applicata.
+ private void DrawCorrectionBand(Graphics g, Rectangle plot, int from, int to, double min, double max)
+ {
+ if (to - from < 1) return;
+
+ var points = new List((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);
+ }
+ }
+
+ /// Corsia inferiore: guadagno per fotogramma in stop, con lo zero al centro.
+ 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);
+ }
+ }
+}
diff --git a/Titano/UI/MainForm.cs b/Titano/UI/MainForm.cs
new file mode 100644
index 0000000..865715d
--- /dev/null
+++ b/Titano/UI/MainForm.cs
@@ -0,0 +1,588 @@
+using Titano.Core;
+using Titano.Metadata;
+using Titano.Pipeline;
+
+namespace Titano.UI;
+
+/// Finestra principale: collega i controlli disegnati a mano al motore di elaborazione.
+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);
+ }
+
+ /// Riquadro con intestazione: unità visiva ricorrente dell'interfaccia.
+ 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 items)
+ {
+ var files = new List();
+ 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 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(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(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(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);
+ }
+
+ ///
+ /// Carica e analizza una sequenza senza interazione: usata dalla modalità di cattura
+ /// dell'interfaccia, che serve a verificare la resa grafica in modo riproducibile.
+ ///
+ internal void SelectSettingsTab(int index) => _settings.SelectPage(index);
+
+ internal async Task PrepareForCaptureAsync(IReadOnlyList paths)
+ {
+ await LoadAsync(paths);
+ await AnalyzeAsync();
+ _table.SelectedIndex = Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1));
+ }
+}
diff --git a/Titano/UI/PreviewPanel.cs b/Titano/UI/PreviewPanel.cs
new file mode 100644
index 0000000..b4de450
--- /dev/null
+++ b/Titano/UI/PreviewPanel.cs
@@ -0,0 +1,272 @@
+using System.Drawing.Imaging;
+using Titano.Core;
+using Titano.Imaging;
+using Titano.Motion;
+using Titano.Pipeline;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+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();
+ }
+
+ /// Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.
+ 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());
+ }
+
+ /// Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.
+ 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);
+ }
+}
diff --git a/Titano/UI/SettingsPanel.cs b/Titano/UI/SettingsPanel.cs
new file mode 100644
index 0000000..a3c3177
--- /dev/null
+++ b/Titano/UI/SettingsPanel.cs
@@ -0,0 +1,354 @@
+using Titano.Pipeline;
+using Titano.Video;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+internal sealed class SettingsPanel : Panel
+{
+ private readonly TitanoProject _project;
+ private readonly TabStrip _tabs;
+ private readonly Panel[] _pages;
+
+ /// Un parametro del deflicker è cambiato: basta ricalcolare la curva.
+ public event EventHandler? DeflickerChanged;
+
+ /// È cambiato un parametro che invalida l'analisi già svolta.
+ public event EventHandler? AnalysisInvalidated;
+
+ /// È cambiato un parametro che modifica solo l'anteprima o l'esportazione.
+ 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);
+ }
+
+ /// Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.
+ 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 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 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 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,
+ };
+}
diff --git a/Titano/UI/Theme.cs b/Titano/UI/Theme.cs
new file mode 100644
index 0000000..e9fe3fc
--- /dev/null
+++ b/Titano/UI/Theme.cs
@@ -0,0 +1,110 @@
+using System.Drawing.Drawing2D;
+using System.Runtime.InteropServices;
+
+namespace Titano.UI;
+
+///
+/// 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.
+///
+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);
+
+ /// Estende il tema scuro alla barra del titolo, disegnata dal sistema.
+ public static void ApplyDarkTitleBar(Form form)
+ {
+ if (!form.IsHandleCreated) return;
+ int enabled = 1;
+ DwmSetWindowAttribute(form.Handle, DwmUseImmersiveDarkMode, ref enabled, sizeof(int));
+ }
+
+ /// Rettangolo con angoli arrotondati, primitiva di base dell'interfaccia.
+ 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));
+ }
+}
diff --git a/Titano/Video/AnnexBParser.cs b/Titano/Video/AnnexBParser.cs
new file mode 100644
index 0000000..4394de2
--- /dev/null
+++ b/Titano/Video/AnnexBParser.cs
@@ -0,0 +1,61 @@
+namespace Titano.Video;
+
+///
+/// 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.
+///
+internal static class AnnexBParser
+{
+ internal readonly record struct NalRange(int Start, int Length);
+
+ public static NalEnumerator EnumerateNals(ReadOnlySpan data) => new(data);
+
+ internal ref struct NalEnumerator(ReadOnlySpan data)
+ {
+ private readonly ReadOnlySpan _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 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;
+ }
+ }
+}
diff --git a/Titano/Video/BoxWriter.cs b/Titano/Video/BoxWriter.cs
new file mode 100644
index 0000000..c6c5d03
--- /dev/null
+++ b/Titano/Video/BoxWriter.cs
@@ -0,0 +1,119 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace Titano.Video;
+
+///
+/// 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.
+///
+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 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 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 value) => _stream.Write(value);
+
+ public void WriteUInt16(ushort value)
+ {
+ Span buffer = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(buffer, value);
+ _stream.Write(buffer);
+ }
+
+ public void WriteUInt32(uint value)
+ {
+ Span buffer = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(buffer, value);
+ _stream.Write(buffer);
+ }
+
+ public void WriteUInt64(ulong value)
+ {
+ Span buffer = stackalloc byte[8];
+ BinaryPrimitives.WriteUInt64BigEndian(buffer, value);
+ _stream.Write(buffer);
+ }
+
+ public void WriteFourCc(string value)
+ {
+ Span buffer = stackalloc byte[4];
+ buffer.Fill((byte)' ');
+ Encoding.ASCII.GetBytes(value.AsSpan(0, Math.Min(4, value.Length)), buffer);
+ _stream.Write(buffer);
+ }
+
+ /// Stringa terminata da NUL, come richiesto dal box hdlr.
+ public void WriteCString(string value)
+ {
+ _stream.Write(Encoding.UTF8.GetBytes(value));
+ _stream.WriteByte(0);
+ }
+
+ /// Stringa Pascal in campo fisso da 32 byte (compressorname del sample entry).
+ public void WritePascalString32(string value)
+ {
+ Span 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);
+ }
+
+ /// Matrice di trasformazione identità in virgola fissa 16.16 / 2.30.
+ 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 buffer = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)size);
+ _stream.Write(buffer);
+ _stream.Position = end;
+ }
+}
diff --git a/Titano/Video/ExportSettings.cs b/Titano/Video/ExportSettings.cs
new file mode 100644
index 0000000..5b177a3
--- /dev/null
+++ b/Titano/Video/ExportSettings.cs
@@ -0,0 +1,55 @@
+namespace Titano.Video;
+
+/// Come viene tradotta in durate di fotogramma la cadenza reale della sequenza.
+public enum FrameTimingMode
+{
+ /// Ogni scatto dura esattamente un fotogramma: il time-lapse classico.
+ Constant,
+
+ /// La durata segue l'intervallo reale: le pause dell'intervallometro restano visibili.
+ Adaptive,
+
+ /// La sequenza viene riportata su una griglia temporale uniforme sintetizzando i fotogrammi mancanti.
+ Interpolated,
+}
+
+public enum H264Profile
+{
+ Baseline = 66,
+ Main = 77,
+ High = 100,
+}
+
+/// Parametri del pannello "Esportazione video".
+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;
+
+ /// Larghezza del video; 0 = dedotta dal primo fotogramma.
+ public int Width { get; set; }
+ public int Height { get; set; }
+
+ public double FrameRate { get; set; } = 30.0;
+
+ /// Bitrate medio in megabit al secondo.
+ public double BitrateMbps { get; set; } = 60.0;
+
+ public FrameTimingMode Timing { get; set; } = FrameTimingMode.Constant;
+
+ /// Fattore di dilatazione applicato in modalità adattiva, limitato per non congelare la scena.
+ public double MaxAdaptiveStretch { get; set; } = 4.0;
+
+ public bool PreferHardware { get; set; } = true;
+
+ public int KeyframeIntervalSeconds { get; set; } = 2;
+
+ /// Unità temporali della traccia video: 90 kHz consente durate variabili precise.
+ 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();
+}
diff --git a/Titano/Video/MediaFoundationInterop.cs b/Titano/Video/MediaFoundationInterop.cs
new file mode 100644
index 0000000..ecc27cd
--- /dev/null
+++ b/Titano/Video/MediaFoundationInterop.cs
@@ -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;
+}
+
+/// PROPVARIANT ridotta ai soli tipi scalari usati per la configurazione dell'encoder.
+[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);
+
+ /// Impacchetta due valori a 32 bit in un attributo UINT64 (dimensioni, frame rate).
+ 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}).");
+ }
+}
+
+/// Errore riconducibile allo stack di codifica di sistema.
+public sealed class VideoEncoderException(string message) : Exception(message);
+
+[StructLayout(LayoutKind.Sequential)]
+internal struct MftRegisterTypeInfo
+{
+ public Guid MajorType;
+ public Guid Subtype;
+}
diff --git a/Titano/Video/Mp4Muxer.cs b/Titano/Video/Mp4Muxer.cs
new file mode 100644
index 0000000..d69f58c
--- /dev/null
+++ b/Titano/Video/Mp4Muxer.cs
@@ -0,0 +1,488 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace Titano.Video;
+
+public enum VideoCodec
+{
+ H264,
+ Hevc,
+}
+
+///
+/// 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.
+///
+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 _sampleSizes = [];
+ private readonly List _sampleDurations = [];
+ private readonly List _syncSamples = [];
+
+ private readonly List _vps = [];
+ private readonly List _sps = [];
+ private readonly List _pps = [];
+
+ private long _mdatHeaderPosition;
+ private long _mdatPayloadStart;
+ private long _mediaDuration;
+ private bool _finished;
+
+ /// Buffer di lavoro riusato per la conversione Annex-B → lunghezza prefissata.
+ 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();
+ }
+
+ ///
+ /// Registra parameter set forniti fuori banda dall'encoder (blob MF_MT_MPEG_SEQUENCE_HEADER).
+ ///
+ public void AddParameterSets(ReadOnlySpan annexB)
+ {
+ foreach (var nal in AnnexBParser.EnumerateNals(annexB))
+ {
+ ClassifyAndStore(annexB, nal);
+ }
+ }
+
+ ///
+ /// Scrive un campione codificato. è espressa nella timescale
+ /// della traccia, così ogni fotogramma può avere durata propria (playback adattivo).
+ ///
+ public void WriteSample(ReadOnlySpan 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;
+ }
+
+ /// Chiude mdat, scrive moov e finalizza il file.
+ 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 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 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 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 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 source, AnnexBParser.NalRange nal)
+ {
+ if (nal.Length <= 0) return;
+ var payload = source.Slice(nal.Start, nal.Length);
+ byte header = payload[0];
+
+ List 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];
+ }
+}
diff --git a/Titano/Video/Nv12Converter.cs b/Titano/Video/Nv12Converter.cs
new file mode 100644
index 0000000..fdbaab9
--- /dev/null
+++ b/Titano/Video/Nv12Converter.cs
@@ -0,0 +1,81 @@
+using Titano.Imaging;
+
+namespace Titano.Video;
+
+///
+/// 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.
+///
+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 _);
+ }
+ }
+
+ /// Scrive la luma del pixel indicato e restituisce le due crominanze normalizzate.
+ 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);
+}
diff --git a/Titano/Video/VideoEncoderSession.cs b/Titano/Video/VideoEncoderSession.cs
new file mode 100644
index 0000000..5ad4a20
--- /dev/null
+++ b/Titano/Video/VideoEncoderSession.cs
@@ -0,0 +1,638 @@
+using System.Runtime.InteropServices;
+using System.Text;
+using Titano.Imaging;
+
+namespace Titano.Video;
+
+///
+/// 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.
+///
+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 _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();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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.
+ }
+ }
+ }
+
+ /// Preleva i parameter set fuori banda, se l'encoder li espone già.
+ 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
+
+ ///
+ /// Codifica un fotogramma. è la durata nella timescale
+ /// della traccia, quindi ogni fotogramma può restare a schermo per un tempo diverso.
+ ///
+ 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()) { }
+ }
+ }
+
+ /// Chiude il flusso, svuota l'encoder e finalizza il contenitore.
+ 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);
+ }
+ }
+
+ /// Estrae un pacchetto compresso dall'encoder e lo consegna al multiplexer.
+ 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);
+ }
+ }
+
+ /// L'encoder può richiedere di riconfermare il tipo di uscita dopo la negoziazione iniziale.
+ 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();
+}
+
+/// Inizializzazione una tantum della piattaforma Media Foundation.
+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();
+ }
+}