From cc1d040ac0ab43cfaaef0a3c1c9334e02dabf67a Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Thu, 13 Aug 2026 21:40:26 +0200 Subject: [PATCH] Titano: motore e interfaccia per time-lapse, senza dipendenze di terze parti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applicazione desktop completa per creazione e ottimizzazione di time-lapse. Il vincolo che ne definisce l'architettura è l'assenza totale di componenti di terze parti: il progetto non ha alcun PackageReference e non invoca processi esterni. Oltre alla libreria standard di .NET si usano solo API native di Windows (WIC, Media Foundation, GDI+, DWM) richiamate via P/Invoke scritto a mano. Sono in-house tutte le parti che di norma si delegherebbero a una libreria: il parser binario EXIF/XMP, la misura di luminanza e la curva di deflicker, il calcolo del campo vettoriale di movimento con il motion blur sintetico, il multiplexer MP4 e ogni controllo dell'interfaccia. Scelte algoritmiche che meritano una nota: - il deflicker usa una regressione lineare locale pesata con seconda passata robusta, così le rampe reali di luce (alba, tramonto) sopravvivono mentre lo sfarfallio del diaframma viene rimosso; una media mobile semplice le appiattirebbe entrambe; - la sfocatura mancante si compone in quadratura con quella già incisa nello scatto, perché sommarla linearmente renderebbe l'immagine troppo morbida; - la luminanza si misura come media logaritmica troncata, invariante alla scala e insensibile a cieli bruciati e ombre chiuse. L'elaborazione non produce file temporanei e mantiene un'occupazione di memoria stazionaria: buffer poolati e canale a capacità limitata rendono i fotogrammi vivi indipendenti dalla lunghezza della sequenza. Verificato con "Titano.exe --selftest": 23 controlli su una sequenza sintetica dalle proprietà note, incluse la struttura del contenitore prodotto e la sua ri-decodifica con il lettore di sistema. Tutti superati. Co-Authored-By: Claude Opus 5 --- Titano/Analysis/DeflickerEngine.cs | 241 +++++++++ Titano/Analysis/ExposureProcessor.cs | 112 +++++ Titano/Analysis/LuminanceAnalyzer.cs | 144 ++++++ Titano/Core/FrameRecord.cs | 65 +++ Titano/Core/TimelapseSequence.cs | 169 +++++++ Titano/Diagnostics/ExifWriter.cs | 168 +++++++ Titano/Diagnostics/Mp4Inspector.cs | 133 +++++ Titano/Diagnostics/Mp4Playback.cs | 177 +++++++ Titano/Diagnostics/SelfTest.cs | 268 ++++++++++ Titano/Diagnostics/SyntheticSequence.cs | 159 ++++++ Titano/Imaging/ColorSpace.cs | 88 ++++ Titano/Imaging/ImageBuffer.cs | 116 +++++ Titano/Imaging/ImageDecoder.cs | 234 +++++++++ Titano/Imaging/WicInterop.cs | 164 ++++++ Titano/Metadata/FrameMetadata.cs | 138 +++++ Titano/Metadata/MetadataReader.cs | 388 ++++++++++++++ Titano/Metadata/TiffDirectory.cs | 304 +++++++++++ Titano/Metadata/TiffPrimitives.cs | 165 ++++++ Titano/Metadata/XmpScanner.cs | 149 ++++++ Titano/Modifiche.txt | 72 +++ Titano/Motion/FrameInterpolator.cs | 48 ++ Titano/Motion/GrayPyramid.cs | 145 ++++++ Titano/Motion/MotionBlurRenderer.cs | 194 +++++++ Titano/Motion/MotionField.cs | 103 ++++ Titano/Motion/OpticalFlowEngine.cs | 194 +++++++ Titano/Pipeline/PipelineProgress.cs | 33 ++ Titano/Pipeline/RenderPipeline.cs | 441 ++++++++++++++++ Titano/Pipeline/TitanoProject.cs | 83 +++ Titano/Program.cs | 85 ++++ Titano/README.md | 103 ++++ Titano/Titano.csproj | 43 ++ Titano/Titano.slnx | 3 + Titano/UI/Controls.cs | 488 ++++++++++++++++++ Titano/UI/FrameTable.cs | 288 +++++++++++ Titano/UI/LuminanceChart.cs | 549 ++++++++++++++++++++ Titano/UI/MainForm.cs | 588 ++++++++++++++++++++++ Titano/UI/PreviewPanel.cs | 272 ++++++++++ Titano/UI/SettingsPanel.cs | 354 +++++++++++++ Titano/UI/Theme.cs | 110 ++++ Titano/Video/AnnexBParser.cs | 61 +++ Titano/Video/BoxWriter.cs | 119 +++++ Titano/Video/ExportSettings.cs | 55 ++ Titano/Video/MediaFoundationInterop.cs | 433 ++++++++++++++++ Titano/Video/Mp4Muxer.cs | 488 ++++++++++++++++++ Titano/Video/Nv12Converter.cs | 81 +++ Titano/Video/VideoEncoderSession.cs | 638 ++++++++++++++++++++++++ 46 files changed, 9453 insertions(+) create mode 100644 Titano/Analysis/DeflickerEngine.cs create mode 100644 Titano/Analysis/ExposureProcessor.cs create mode 100644 Titano/Analysis/LuminanceAnalyzer.cs create mode 100644 Titano/Core/FrameRecord.cs create mode 100644 Titano/Core/TimelapseSequence.cs create mode 100644 Titano/Diagnostics/ExifWriter.cs create mode 100644 Titano/Diagnostics/Mp4Inspector.cs create mode 100644 Titano/Diagnostics/Mp4Playback.cs create mode 100644 Titano/Diagnostics/SelfTest.cs create mode 100644 Titano/Diagnostics/SyntheticSequence.cs create mode 100644 Titano/Imaging/ColorSpace.cs create mode 100644 Titano/Imaging/ImageBuffer.cs create mode 100644 Titano/Imaging/ImageDecoder.cs create mode 100644 Titano/Imaging/WicInterop.cs create mode 100644 Titano/Metadata/FrameMetadata.cs create mode 100644 Titano/Metadata/MetadataReader.cs create mode 100644 Titano/Metadata/TiffDirectory.cs create mode 100644 Titano/Metadata/TiffPrimitives.cs create mode 100644 Titano/Metadata/XmpScanner.cs create mode 100644 Titano/Modifiche.txt create mode 100644 Titano/Motion/FrameInterpolator.cs create mode 100644 Titano/Motion/GrayPyramid.cs create mode 100644 Titano/Motion/MotionBlurRenderer.cs create mode 100644 Titano/Motion/MotionField.cs create mode 100644 Titano/Motion/OpticalFlowEngine.cs create mode 100644 Titano/Pipeline/PipelineProgress.cs create mode 100644 Titano/Pipeline/RenderPipeline.cs create mode 100644 Titano/Pipeline/TitanoProject.cs create mode 100644 Titano/Program.cs create mode 100644 Titano/README.md create mode 100644 Titano/Titano.csproj create mode 100644 Titano/Titano.slnx create mode 100644 Titano/UI/Controls.cs create mode 100644 Titano/UI/FrameTable.cs create mode 100644 Titano/UI/LuminanceChart.cs create mode 100644 Titano/UI/MainForm.cs create mode 100644 Titano/UI/PreviewPanel.cs create mode 100644 Titano/UI/SettingsPanel.cs create mode 100644 Titano/UI/Theme.cs create mode 100644 Titano/Video/AnnexBParser.cs create mode 100644 Titano/Video/BoxWriter.cs create mode 100644 Titano/Video/ExportSettings.cs create mode 100644 Titano/Video/MediaFoundationInterop.cs create mode 100644 Titano/Video/Mp4Muxer.cs create mode 100644 Titano/Video/Nv12Converter.cs create mode 100644 Titano/Video/VideoEncoderSession.cs 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(" 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(); + } +}