Titano: motore e interfaccia per time-lapse, senza dipendenze di terze parti

Applicazione desktop completa per creazione e ottimizzazione di time-lapse.
Il vincolo che ne definisce l'architettura è l'assenza totale di componenti
di terze parti: il progetto non ha alcun PackageReference e non invoca processi
esterni. Oltre alla libreria standard di .NET si usano solo API native di
Windows (WIC, Media Foundation, GDI+, DWM) richiamate via P/Invoke scritto a mano.

Sono in-house tutte le parti che di norma si delegherebbero a una libreria:
il parser binario EXIF/XMP, la misura di luminanza e la curva di deflicker,
il calcolo del campo vettoriale di movimento con il motion blur sintetico,
il multiplexer MP4 e ogni controllo dell'interfaccia.

Scelte algoritmiche che meritano una nota:
- il deflicker usa una regressione lineare locale pesata con seconda passata
  robusta, così le rampe reali di luce (alba, tramonto) sopravvivono mentre
  lo sfarfallio del diaframma viene rimosso; una media mobile semplice le
  appiattirebbe entrambe;
- la sfocatura mancante si compone in quadratura con quella già incisa nello
  scatto, perché sommarla linearmente renderebbe l'immagine troppo morbida;
- la luminanza si misura come media logaritmica troncata, invariante alla
  scala e insensibile a cieli bruciati e ombre chiuse.

L'elaborazione non produce file temporanei e mantiene un'occupazione di memoria
stazionaria: buffer poolati e canale a capacità limitata rendono i fotogrammi
vivi indipendenti dalla lunghezza della sequenza.

Verificato con "Titano.exe --selftest": 23 controlli su una sequenza sintetica
dalle proprietà note, incluse la struttura del contenitore prodotto e la sua
ri-decodifica con il lettore di sistema. Tutti superati.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 21:40:26 +02:00
co-authored by Claude Opus 5
parent 165eff627b
commit cc1d040ac0
46 changed files with 9453 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
namespace Titano.Analysis;
/// <summary>Parametri del motore di deflicker, esposti nel pannello "Elaborazione immagini".</summary>
public sealed class DeflickerSettings
{
public bool Enabled { get; set; } = true;
/// <summary>Ampiezza della finestra mobile in fotogrammi (viene resa dispari internamente).</summary>
public int WindowFrames { get; set; } = 15;
/// <summary>Quota della correzione applicata: 1 = curva target piena, 0 = nessuna correzione.</summary>
public double Strength { get; set; } = 1.0;
/// <summary>Limite di sicurezza della correzione, in stop.</summary>
public double MaxCorrectionStops { get; set; } = 1.5;
/// <summary>Scarta i fotogrammi anomali (lampi, passaggi di persone) dal calcolo della curva.</summary>
public bool RejectOutliers { get; set; } = true;
/// <summary>Applica lo stesso smoothing ai singoli canali per stabilizzare il bilanciamento colore.</summary>
public bool StabilizeColor { get; set; }
/// <summary>Comprime dolcemente le alte luci quando il guadagno è maggiore di 1.</summary>
public bool ProtectHighlights { get; set; } = true;
/// <summary>Punto d'innesco della compressione, in luce lineare.</summary>
public double HighlightKnee { get; set; } = 0.75;
public DeflickerSettings Clone() => (DeflickerSettings)MemberwiseClone();
}
/// <summary>Risultato del calcolo della curva: valori per fotogramma, in log2.</summary>
public sealed class DeflickerCurve
{
public required double[] Measured { get; init; }
public required double[] Target { get; init; }
public required double[] GainStops { get; init; }
/// <summary>Guadagni per canale (R,G,B); pari a quello di luminanza se la stabilizzazione colore è spenta.</summary>
public required double[][] ChannelGain { get; init; }
public int Count => Measured.Length;
/// <summary>Deviazione standard delle differenze fra fotogrammi adiacenti, in stop: misura lo sfarfallio.</summary>
public static double FlickerIndex(IReadOnlyList<double> log2Series)
{
if (log2Series.Count < 3) return 0;
double mean = 0;
int n = log2Series.Count - 1;
var deltas = new double[n];
for (int i = 0; i < n; i++)
{
deltas[i] = log2Series[i + 1] - log2Series[i];
mean += deltas[i];
}
mean /= n;
double variance = 0;
for (int i = 0; i < n; i++)
{
double d = deltas[i] - mean;
variance += d * d;
}
return Math.Sqrt(variance / n);
}
}
/// <summary>
/// Algoritmo proprietario di smoothing temporale dell'esposizione.
///
/// Per ogni fotogramma si esegue una regressione lineare locale pesata sulla finestra mobile:
/// i pesi combinano una gaussiana sulla distanza temporale e, in seconda passata, un peso di
/// robustezza di Tukey che neutralizza i fotogrammi anomali. Rispetto a una media mobile
/// semplice, la componente lineare segue senza ritardo le rampe reali di luce (alba, tramonto)
/// e rimuove solo la componente ad alta frequenza generata dalle micro-variazioni di diaframma.
/// </summary>
public static class DeflickerEngine
{
private const double TukeyConstant = 4.685;
public static DeflickerCurve Compute(IReadOnlyList<LuminanceStats> stats, DeflickerSettings settings)
{
int n = stats.Count;
var measured = new double[n];
for (int i = 0; i < n; i++) measured[i] = stats[i].Log2Average;
var target = Smooth(measured, settings);
var gainStops = new double[n];
var channelGain = new double[n][];
double limit = Math.Max(0, settings.MaxCorrectionStops);
double strength = Math.Clamp(settings.Strength, 0, 1);
double[]? targetR = null, targetG = null, targetB = null;
if (settings.StabilizeColor && n > 0)
{
var r = new double[n];
var g = new double[n];
var b = new double[n];
for (int i = 0; i < n; i++)
{
r[i] = stats[i].Log2AverageR;
g[i] = stats[i].Log2AverageG;
b[i] = stats[i].Log2AverageB;
}
targetR = Smooth(r, settings);
targetG = Smooth(g, settings);
targetB = Smooth(b, settings);
}
for (int i = 0; i < n; i++)
{
double delta = settings.Enabled ? Math.Clamp((target[i] - measured[i]) * strength, -limit, limit) : 0;
gainStops[i] = delta;
if (settings.Enabled && targetR is not null && targetG is not null && targetB is not null)
{
// Il canale verde definisce il livello, gli altri due lo inseguono: si corregge
// la deriva cromatica senza spostare la luminanza complessiva.
double dr = Math.Clamp((targetR[i] - stats[i].Log2AverageR) * strength, -limit, limit);
double dg = Math.Clamp((targetG[i] - stats[i].Log2AverageG) * strength, -limit, limit);
double db = Math.Clamp((targetB[i] - stats[i].Log2AverageB) * strength, -limit, limit);
channelGain[i] =
[
Math.Pow(2, delta + (dr - dg)),
Math.Pow(2, delta),
Math.Pow(2, delta + (db - dg)),
];
}
else
{
double gain = Math.Pow(2, delta);
channelGain[i] = [gain, gain, gain];
}
}
return new DeflickerCurve
{
Measured = measured,
Target = target,
GainStops = gainStops,
ChannelGain = channelGain,
};
}
/// <summary>Regressione lineare locale pesata, con seconda passata robusta agli outlier.</summary>
public static double[] Smooth(double[] series, DeflickerSettings settings)
{
int n = series.Length;
var result = new double[n];
if (n == 0) return result;
if (n <= 2)
{
Array.Copy(series, result, n);
return result;
}
int radius = Math.Clamp((Math.Max(3, settings.WindowFrames) - 1) / 2, 1, Math.Max(1, n - 1));
double sigma = Math.Max(radius / 2.0, 0.5);
var robust = new double[n];
Array.Fill(robust, 1.0);
FitAll(series, result, radius, sigma, robust);
if (settings.RejectOutliers)
{
UpdateRobustWeights(series, result, robust);
FitAll(series, result, radius, sigma, robust);
}
return result;
}
private static void FitAll(double[] y, double[] output, int radius, double sigma, double[] robust)
{
int n = y.Length;
double twoSigmaSq = 2.0 * sigma * sigma;
for (int i = 0; i < n; i++)
{
int from = Math.Max(0, i - radius);
int to = Math.Min(n - 1, i + radius);
// Sistema normale della retta pesata y = a + b·t, con t = j - i.
double sw = 0, swt = 0, swt2 = 0, swy = 0, swty = 0;
for (int j = from; j <= to; j++)
{
double t = j - i;
double w = Math.Exp(-(t * t) / twoSigmaSq) * robust[j];
if (w <= 1e-9) continue;
sw += w;
swt += w * t;
swt2 += w * t * t;
swy += w * y[j];
swty += w * t * y[j];
}
if (sw <= 1e-9)
{
output[i] = y[i];
continue;
}
double det = sw * swt2 - swt * swt;
if (Math.Abs(det) < 1e-12)
{
output[i] = swy / sw; // finestra degenere: media pesata
continue;
}
double a = (swt2 * swy - swt * swty) / det; // intercetta = valore stimato in t = 0
output[i] = a;
}
}
private static void UpdateRobustWeights(double[] y, double[] fit, double[] robust)
{
int n = y.Length;
var residuals = new double[n];
for (int i = 0; i < n; i++) residuals[i] = Math.Abs(y[i] - fit[i]);
var sorted = (double[])residuals.Clone();
Array.Sort(sorted);
double mad = sorted[n / 2];
double scale = 1.4826 * mad;
// Se la sequenza è già pulita non si scarta nulla: evita di amplificare il rumore numerico.
if (scale < 1e-4)
{
Array.Fill(robust, 1.0);
return;
}
for (int i = 0; i < n; i++)
{
double u = residuals[i] / (TukeyConstant * scale);
robust[i] = u >= 1.0 ? 0.0 : Math.Pow(1.0 - u * u, 2.0);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
using System.Numerics;
using Titano.Imaging;
namespace Titano.Analysis;
/// <summary>
/// Applicazione dei guadagni di esposizione sui canali colore.
///
/// L'operazione avviene in luce lineare (moltiplicazione pura, fisicamente corretta) e,
/// se richiesto, con una compressione dolce delle alte luci: la parte di segnale che
/// supererebbe il bianco viene ripiegata con una tangente iperbolica invece di essere
/// troncata, evitando le classiche macchie piatte e le derive di tinta sui bordi bruciati.
/// </summary>
public static class ExposureProcessor
{
/// <summary>
/// Moltiplica il buffer per i tre guadagni indicati, in-place.
/// Restituisce la frazione stimata di campioni saturati dopo la correzione.
/// </summary>
public static double Apply(ImageBuffer frame, ReadOnlySpan<double> channelGain,
bool protectHighlights, double knee)
{
float gr = (float)channelGain[0];
float gg = (float)channelGain[1];
float gb = (float)channelGain[2];
bool identity = Math.Abs(gr - 1f) < 1e-5f && Math.Abs(gg - 1f) < 1e-5f && Math.Abs(gb - 1f) < 1e-5f;
bool rolloff = protectHighlights && Math.Max(gr, Math.Max(gg, gb)) > 1.0001f;
var data = frame.Data;
int count = frame.SampleCount;
if (!identity)
{
if (rolloff) ApplyWithRolloff(data, count, gr, gg, gb, (float)Math.Clamp(knee, 0.05, 0.98));
else ApplyLinear(data, count, gr, gg, gb);
}
return MeasureClipping(data, count);
}
private static void ApplyLinear(float[] data, int count, float gr, float gg, float gb)
{
int width = Vector<float>.Count;
if (width >= 4 && count >= width * 3)
{
// I guadagni si ripetono ogni 3 campioni: con vettori di larghezza non multipla di 3
// il pattern si richiude su 3 vettori consecutivi (lcm(3, width) / width == 3).
var phases = BuildGainPhases(gr, gg, gb, width, out int phaseCount);
int blocks = count / width;
int i = 0;
for (int block = 0; block < blocks; block++, i += width)
{
var v = new Vector<float>(data, i);
(v * phases[block % phaseCount]).CopyTo(data, i);
}
for (; i < count; i++) data[i] *= GainFor(i, gr, gg, gb);
return;
}
for (int i = 0; i < count; i++) data[i] *= GainFor(i, gr, gg, gb);
}
private static void ApplyWithRolloff(float[] data, int count, float gr, float gg, float gb, float knee)
{
float span = 1f - knee;
for (int i = 0; i < count; i++)
{
float v = data[i] * GainFor(i, gr, gg, gb);
data[i] = v <= knee ? v : knee + span * MathF.Tanh((v - knee) / span);
}
}
private static Vector<float>[] BuildGainPhases(float gr, float gg, float gb, int width, out int phaseCount)
{
phaseCount = width % 3 == 0 ? 1 : 3;
var phases = new Vector<float>[phaseCount];
var scratch = new float[width];
for (int phase = 0; phase < phaseCount; phase++)
{
for (int k = 0; k < width; k++)
{
scratch[k] = GainFor(phase * width + k, gr, gg, gb);
}
phases[phase] = new Vector<float>(scratch);
}
return phases;
}
private static float GainFor(int sampleIndex, float gr, float gg, float gb)
=> (sampleIndex % 3) switch { 0 => gr, 1 => gg, _ => gb };
/// <summary>Stima del clipping su campionamento regolare: costo indipendente dalla risoluzione.</summary>
private static double MeasureClipping(float[] data, int count)
{
if (count == 0) return 0;
int step = Math.Max(3, (count / 3 / 200_000) * 3);
int clipped = 0, samples = 0;
for (int i = 0; i + 2 < count; i += step)
{
float luma = ColorSpace.Luminance(data[i], data[i + 1], data[i + 2]);
if (luma >= 0.995f) clipped++;
samples++;
}
return samples == 0 ? 0 : (double)clipped / samples;
}
}
+144
View File
@@ -0,0 +1,144 @@
using Titano.Imaging;
namespace Titano.Analysis;
/// <summary>Statistiche fotometriche di un fotogramma, calcolate in luce lineare.</summary>
public readonly struct LuminanceStats
{
/// <summary>Media logaritmica (base 2) della luminanza sui pixel non estremi.</summary>
public double Log2Average { get; init; }
/// <summary>Media geometrica in scala lineare: 2^<see cref="Log2Average"/>.</summary>
public double Linear => Math.Pow(2.0, Log2Average);
public double Log2AverageR { get; init; }
public double Log2AverageG { get; init; }
public double Log2AverageB { get; init; }
/// <summary>Percentili della distribuzione di luminanza, in scala lineare.</summary>
public double Percentile01 { get; init; }
public double Percentile50 { get; init; }
public double Percentile99 { get; init; }
/// <summary>Frazione di pixel già saturati nel fotogramma sorgente.</summary>
public double ClippedFraction { get; init; }
public double BlackFraction { get; init; }
public static LuminanceStats Empty => new() { Log2Average = -8 };
}
/// <summary>
/// Misura la luminanza dei buffer in-house, senza dipendenze esterne.
///
/// La stima usa la media logaritmica (media geometrica) su un campionamento a griglia fissa,
/// con esclusione delle code della distribuzione: è la metrica che segue la variazione di
/// esposizione reale senza farsi trascinare da cieli bruciati o ombre chiuse.
/// </summary>
public static class LuminanceAnalyzer
{
private const int Bins = 1024;
private const double LogMin = -16.0; // 2^-16 ≈ nero assoluto
private const double LogMax = 2.0; // headroom sopra il bianco
private const double Epsilon = 1.0 / 65536.0;
/// <summary>Numero massimo di pixel campionati per fotogramma: fissa il costo dell'analisi.</summary>
private const int TargetSamples = 262_144;
public static LuminanceStats Analyze(ImageBuffer frame, double trimLow = 0.02, double trimHigh = 0.02)
{
int step = ComputeStep(frame.PixelCount);
Span<int> histogram = stackalloc int[Bins];
histogram.Clear();
double sumR = 0, sumG = 0, sumB = 0;
int samples = 0, clipped = 0, black = 0;
var data = frame.Data;
int totalPixels = frame.PixelCount;
for (int p = 0; p < totalPixels; p += step)
{
int i = p * ImageBuffer.Channels;
float r = data[i], g = data[i + 1], b = data[i + 2];
double luma = ColorSpace.Luminance(r, g, b);
if (double.IsNaN(luma)) continue;
if (luma >= 0.995) clipped++;
if (luma <= 0.0008) black++;
sumR += Log2Safe(r + Epsilon);
sumG += Log2Safe(g + Epsilon);
sumB += Log2Safe(b + Epsilon);
double l = Log2Safe(luma + Epsilon);
int bin = (int)((l - LogMin) / (LogMax - LogMin) * (Bins - 1) + 0.5);
histogram[Math.Clamp(bin, 0, Bins - 1)]++;
samples++;
}
if (samples == 0) return LuminanceStats.Empty;
// Media troncata calcolata direttamente sull'istogramma: le code (cielo bruciato,
// ombre chiuse) non influenzano la stima dell'esposizione media.
int lowCut = (int)(samples * trimLow);
int highCut = (int)(samples * (1.0 - trimHigh));
double weighted = 0;
long counted = 0;
int running = 0;
double p01 = LogMin, p50 = LogMin, p99 = LogMin;
int q01 = (int)(samples * 0.01), q50 = samples / 2, q99 = (int)(samples * 0.99);
bool got01 = false, got50 = false, got99 = false;
for (int bin = 0; bin < Bins; bin++)
{
int count = histogram[bin];
if (count == 0) continue;
double value = LogMin + bin * (LogMax - LogMin) / (Bins - 1);
int before = running;
running += count;
if (!got01 && running >= q01) { p01 = value; got01 = true; }
if (!got50 && running >= q50) { p50 = value; got50 = true; }
if (!got99 && running >= q99) { p99 = value; got99 = true; }
int from = Math.Max(before, lowCut);
int to = Math.Min(running, highCut);
if (to > from)
{
weighted += value * (to - from);
counted += to - from;
}
}
if (counted == 0)
{
// Distribuzione degenere (immagine uniforme): ricadiamo sulla mediana.
weighted = p50;
counted = 1;
}
return new LuminanceStats
{
Log2Average = weighted / counted,
Log2AverageR = sumR / samples,
Log2AverageG = sumG / samples,
Log2AverageB = sumB / samples,
Percentile01 = Math.Pow(2.0, p01),
Percentile50 = Math.Pow(2.0, p50),
Percentile99 = Math.Pow(2.0, p99),
ClippedFraction = (double)clipped / samples,
BlackFraction = (double)black / samples,
};
}
/// <summary>Passo di campionamento a griglia: deterministico, quindi identico per ogni fotogramma.</summary>
private static int ComputeStep(int pixelCount)
=> Math.Max(1, pixelCount / TargetSamples);
/// <summary>Logaritmo base 2 con saturazione al nero: evita -infinito sui pixel spenti.</summary>
private static double Log2Safe(double value)
=> value <= 0 ? LogMin : Math.Max(LogMin, Math.Log2(value));
}
+65
View File
@@ -0,0 +1,65 @@
using Titano.Metadata;
namespace Titano.Core;
/// <summary>
/// Stato mutabile di un fotogramma lungo tutta la pipeline. Contiene solo scalari:
/// i pixel non vivono mai qui, restano nei buffer poolati (impronta di memoria costante).
/// </summary>
public sealed class FrameRecord(int index, FrameMetadata metadata)
{
public int Index { get; } = index;
public FrameMetadata Metadata { get; } = metadata;
public string FileName => Metadata.FileName;
public string FilePath => Metadata.FilePath;
/// <summary>Secondi trascorsi dal primo scatto della sequenza.</summary>
public double ElapsedSeconds { get; internal set; }
/// <summary>Intervallo verso lo scatto successivo, in secondi (l'ultimo eredita il precedente).</summary>
public double IntervalSeconds { get; internal set; }
/// <summary>True se l'intervallo devia in modo significativo dalla cadenza nominale.</summary>
public bool IsCadenceAnomaly { get; internal set; }
/// <summary>Shutter angle reale: 360 * tempo di posa / intervallo.</summary>
public double ShutterAngle { get; internal set; }
// ---- analisi luminanza -------------------------------------------------
/// <summary>Luminanza media geometrica misurata (log-average), in scala lineare 0..1.</summary>
public double MeasuredLuminance { get; internal set; }
/// <summary>Luminanza obiettivo dopo lo smoothing temporale.</summary>
public double TargetLuminance { get; internal set; }
/// <summary>Guadagno lineare applicato ai canali colore.</summary>
public double Gain { get; internal set; } = 1.0;
/// <summary>Correzione espressa in stop (log2 del guadagno), per la lettura in UI.</summary>
public double GainStops => Gain > 0 ? Math.Log2(Gain) : 0;
/// <summary>Percentuale di pixel oltre la soglia di clipping dopo la correzione.</summary>
public double ClippedFraction { get; internal set; }
public bool LuminanceAnalyzed { get; internal set; }
// ---- motion ------------------------------------------------------------
/// <summary>Modulo mediano del vettore di movimento verso il fotogramma successivo, in pixel.</summary>
public double MotionMagnitude { get; internal set; }
/// <summary>Direzione dominante del movimento, in gradi.</summary>
public double MotionDirection { get; internal set; }
/// <summary>Lunghezza della scia di motion blur sintetico effettivamente applicata, in pixel.</summary>
public double BlurLength { get; internal set; }
/// <summary>Durata del fotogramma nel video finale, in unità di timescale (playback adattivo).</summary>
public int OutputDurationUnits { get; internal set; }
public string CadenceText => IntervalSeconds <= 0
? "—"
: IntervalSeconds < 1
? $"{IntervalSeconds * 1000:0} ms"
: $"{IntervalSeconds:0.###} s";
}
+169
View File
@@ -0,0 +1,169 @@
using Titano.Metadata;
namespace Titano.Core;
/// <summary>
/// Sequenza ordinata di fotogrammi con la relativa analisi temporale: intervalli reali,
/// cadenza nominale dell'intervallometro, pause e shutter angle per singolo scatto.
/// </summary>
public sealed class TimelapseSequence
{
private readonly List<FrameRecord> _frames = [];
public IReadOnlyList<FrameRecord> Frames => _frames;
public int Count => _frames.Count;
/// <summary>Cadenza nominale (mediana degli intervalli), in secondi.</summary>
public double NominalInterval { get; private set; }
/// <summary>Numero di intervalli che deviano oltre la tolleranza dalla cadenza nominale.</summary>
public int CadenceAnomalies { get; private set; }
public TimeSpan TotalDuration { get; private set; }
/// <summary>True se almeno un fotogramma ha un timestamp con precisione al sotto-secondo.</summary>
public bool HasSubSecondPrecision { get; private set; }
/// <summary>Costruisce la sequenza ordinando per timestamp e, a parità, per nome file naturale.</summary>
public static TimelapseSequence Build(IEnumerable<FrameMetadata> metadata)
{
var ordered = metadata
.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
.ToList();
var sequence = new TimelapseSequence();
for (int i = 0; i < ordered.Count; i++)
{
sequence._frames.Add(new FrameRecord(i, ordered[i]));
}
sequence.RecomputeTiming();
return sequence;
}
/// <summary>
/// Ricalcola intervalli reali, cadenza nominale e shutter angle.
/// Gli intervalli assenti (timestamp mancanti) ereditano la cadenza nominale.
/// </summary>
public void RecomputeTiming(double cadenceTolerance = 0.35)
{
if (_frames.Count == 0)
{
NominalInterval = 0;
TotalDuration = TimeSpan.Zero;
CadenceAnomalies = 0;
return;
}
HasSubSecondPrecision = _frames.Any(f => f.Metadata.CaptureSource == TimestampSource.ExifSubSecond);
DateTime? origin = _frames[0].Metadata.CaptureTime;
var raw = new double[_frames.Count];
for (int i = 0; i < _frames.Count; i++)
{
var current = _frames[i].Metadata.CaptureTime;
var next = i + 1 < _frames.Count ? _frames[i + 1].Metadata.CaptureTime : null;
_frames[i].ElapsedSeconds = origin is { } o && current is { } c ? (c - o).TotalSeconds : i;
raw[i] = current is { } a && next is { } b ? (b - a).TotalSeconds : double.NaN;
}
// Cadenza nominale = mediana degli intervalli validi: robusta a pause e scatti doppi.
var valid = raw.Where(v => !double.IsNaN(v) && v > 0).ToArray();
if (valid.Length > 0)
{
Array.Sort(valid);
NominalInterval = valid[valid.Length / 2];
}
else
{
NominalInterval = 1.0;
}
if (NominalInterval <= 0 || double.IsNaN(NominalInterval)) NominalInterval = 1.0;
CadenceAnomalies = 0;
for (int i = 0; i < _frames.Count; i++)
{
double interval = raw[i];
if (double.IsNaN(interval) || interval <= 0)
{
interval = i > 0 ? _frames[i - 1].IntervalSeconds : NominalInterval;
}
_frames[i].IntervalSeconds = interval;
bool anomaly = Math.Abs(interval - NominalInterval) > NominalInterval * cadenceTolerance;
_frames[i].IsCadenceAnomaly = anomaly && i < _frames.Count - 1;
if (_frames[i].IsCadenceAnomaly) CadenceAnomalies++;
_frames[i].ShutterAngle = ComputeShutterAngle(_frames[i].Metadata.ExposureSeconds, interval);
}
TotalDuration = _frames.Count > 1
? TimeSpan.FromSeconds(Math.Max(0, _frames[^1].ElapsedSeconds))
: TimeSpan.Zero;
}
/// <summary>
/// Shutter angle cinematografico: la frazione dell'intervallo effettivamente esposta,
/// espressa in gradi su un giro completo dell'otturatore rotante.
/// </summary>
public static double ComputeShutterAngle(double? exposureSeconds, double intervalSeconds)
{
if (exposureSeconds is not { } e || e <= 0 || intervalSeconds <= 0) return 0;
return Math.Min(360.0, 360.0 * e / intervalSeconds);
}
/// <summary>Sotto-insieme contiguo di indici, usato dalle anteprime e dai render parziali.</summary>
public IEnumerable<FrameRecord> Range(int start, int count)
{
int from = Math.Clamp(start, 0, Math.Max(0, _frames.Count - 1));
int to = Math.Clamp(from + count, from, _frames.Count);
for (int i = from; i < to; i++) yield return _frames[i];
}
}
/// <summary>
/// Ordinamento "naturale" dei nomi file: IMG_2.jpg precede IMG_10.jpg.
/// Necessario perché molte sequenze condividono lo stesso timestamp al secondo.
/// </summary>
public sealed class NaturalFileNameComparer : IComparer<string>
{
public static readonly NaturalFileNameComparer Instance = new();
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
int i = 0, j = 0;
while (i < x.Length && j < y.Length)
{
char cx = x[i], cy = y[j];
bool dx = cx is >= '0' and <= '9';
bool dy = cy is >= '0' and <= '9';
if (dx && dy)
{
int si = i, sj = j;
while (i < x.Length && x[i] is >= '0' and <= '9') i++;
while (j < y.Length && y[j] is >= '0' and <= '9') j++;
var nx = x.AsSpan(si, i - si).TrimStart('0');
var ny = y.AsSpan(sj, j - sj).TrimStart('0');
if (nx.Length != ny.Length) return nx.Length - ny.Length;
int cmp = nx.SequenceCompareTo(ny);
if (cmp != 0) return cmp;
}
else
{
int cmp = char.ToUpperInvariant(cx).CompareTo(char.ToUpperInvariant(cy));
if (cmp != 0) return cmp;
i++;
j++;
}
}
return (x.Length - i) - (y.Length - j);
}
}
+168
View File
@@ -0,0 +1,168 @@
using System.Buffers.Binary;
using System.Text;
using Titano.Metadata;
namespace Titano.Diagnostics;
/// <summary>
/// Generatore di blocchi Exif usato dalla diagnostica interna: costruisce un APP1 completo
/// (header TIFF, IFD0, Exif IFD, area dati) e lo inserisce in un JPEG esistente.
/// Serve a verificare il parser binario su dati di forma nota.
/// </summary>
internal static class ExifWriter
{
private sealed record Entry(ushort Tag, TiffType Type, uint Count, byte[] Data);
public static byte[] BuildExifBlock(DateTime capture, int subSecond, double exposureSeconds,
double fNumber, int iso, int width, int height,
string make, string model)
{
var ifd0 = new List<Entry>
{
Ascii(TiffTags.Make, make),
Ascii(TiffTags.Model, model),
Short(TiffTags.Orientation, 1),
};
var exif = new List<Entry>
{
Rational(TiffTags.ExposureTime, exposureSeconds),
Rational(TiffTags.FNumber, fNumber),
Short(TiffTags.IsoSpeedRatings, (ushort)Math.Clamp(iso, 0, ushort.MaxValue)),
Ascii(TiffTags.DateTimeOriginal, capture.ToString("yyyy:MM:dd HH:mm:ss")),
Ascii(TiffTags.SubSecTimeOriginal, subSecond.ToString("D2")),
Ascii(TiffTags.OffsetTimeOriginal, "+02:00"),
Long(TiffTags.PixelXDimension, (uint)width),
Long(TiffTags.PixelYDimension, (uint)height),
};
// Le voci di ogni IFD devono essere ordinate per tag crescente.
ifd0.Sort((a, b) => a.Tag.CompareTo(b.Tag));
exif.Sort((a, b) => a.Tag.CompareTo(b.Tag));
const int headerSize = 8;
int ifd0Size = 2 + 12 * (ifd0.Count + 1) + 4; // + puntatore all'Exif IFD
int exifSize = 2 + 12 * exif.Count + 4;
int ifd0Offset = headerSize;
int exifOffset = ifd0Offset + ifd0Size;
int dataOffset = exifOffset + exifSize;
var data = new MemoryStream();
var body = new MemoryStream();
// Header TIFF little-endian.
WriteUInt16(body, 0x4949);
WriteUInt16(body, 42);
WriteUInt32(body, (uint)ifd0Offset);
var pointerEntry = new Entry(TiffTags.ExifIfdPointer, TiffType.Long, 1, BitConverter.GetBytes((uint)exifOffset));
var ifd0WithPointer = new List<Entry>(ifd0) { pointerEntry };
ifd0WithPointer.Sort((a, b) => a.Tag.CompareTo(b.Tag));
WriteDirectory(body, ifd0WithPointer, data, dataOffset);
WriteUInt32(body, 0); // nessuna IFD successiva
WriteDirectory(body, exif, data, dataOffset);
WriteUInt32(body, 0);
body.Write(data.GetBuffer(), 0, (int)data.Length);
return body.ToArray();
}
/// <summary>Inserisce il segmento APP1 in un JPEG, subito dopo l'eventuale APP0/JFIF.</summary>
public static byte[] InsertIntoJpeg(byte[] jpeg, byte[] exifBlock)
{
int insertAt = 2;
if (jpeg.Length > 4 && jpeg[2] == 0xFF && jpeg[3] == 0xE0)
{
int app0Length = (jpeg[4] << 8) | jpeg[5];
insertAt = 4 + app0Length;
}
byte[] signature = "Exif\0\0"u8.ToArray();
int payload = signature.Length + exifBlock.Length;
int segmentLength = payload + 2;
var result = new byte[jpeg.Length + 4 + payload];
int position = 0;
Array.Copy(jpeg, 0, result, position, insertAt);
position += insertAt;
result[position++] = 0xFF;
result[position++] = 0xE1;
result[position++] = (byte)(segmentLength >> 8);
result[position++] = (byte)(segmentLength & 0xFF);
Array.Copy(signature, 0, result, position, signature.Length);
position += signature.Length;
Array.Copy(exifBlock, 0, result, position, exifBlock.Length);
position += exifBlock.Length;
Array.Copy(jpeg, insertAt, result, position, jpeg.Length - insertAt);
return result;
}
// ------------------------------------------------------------------ scrittura IFD
private static void WriteDirectory(Stream body, List<Entry> entries, MemoryStream data, int dataOffset)
{
WriteUInt16(body, (ushort)entries.Count);
Span<byte> inline = stackalloc byte[4];
foreach (var entry in entries)
{
WriteUInt16(body, entry.Tag);
WriteUInt16(body, (ushort)entry.Type);
WriteUInt32(body, entry.Count);
if (entry.Data.Length <= 4)
{
inline.Clear();
entry.Data.CopyTo(inline);
body.Write(inline);
}
else
{
WriteUInt32(body, (uint)(dataOffset + data.Length));
data.Write(entry.Data);
if ((data.Length & 1) != 0) data.WriteByte(0); // le aree dati restano allineate
}
}
}
private static Entry Ascii(ushort tag, string value)
{
byte[] bytes = Encoding.ASCII.GetBytes(value + "\0");
return new Entry(tag, TiffType.Ascii, (uint)bytes.Length, bytes);
}
private static Entry Short(ushort tag, ushort value)
=> new(tag, TiffType.Short, 1, BitConverter.GetBytes(value));
private static Entry Long(ushort tag, uint value)
=> new(tag, TiffType.Long, 1, BitConverter.GetBytes(value));
private static Entry Rational(ushort tag, double value)
{
// Approssimazione con denominatore fisso: sufficiente e priva di ambiguità.
uint denominator = 1_000_000;
uint numerator = (uint)Math.Round(value * denominator);
var bytes = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, numerator);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), denominator);
return new Entry(tag, TiffType.Rational, 1, bytes);
}
private static void WriteUInt16(Stream stream, ushort value)
{
Span<byte> buffer = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
stream.Write(buffer);
}
private static void WriteUInt32(Stream stream, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
stream.Write(buffer);
}
}
+133
View File
@@ -0,0 +1,133 @@
using System.Buffers.Binary;
using System.Text;
namespace Titano.Diagnostics;
/// <summary>
/// Verificatore della struttura ISO-BMFF prodotta dal multiplexer: percorre l'albero dei box
/// e ne estrae i valori che devono risultare coerenti (numero di campioni, dimensioni,
/// presenza della configurazione del codec).
/// </summary>
internal static class Mp4Inspector
{
public sealed record Report(
bool Valid,
string Summary,
int SampleCount,
int Width,
int Height,
uint Timescale,
long MediaDuration,
int CodecConfigBytes,
List<string> TopLevelBoxes);
private static readonly string[] Containers =
["moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "avc1", "hvc1"];
public static Report Inspect(string path)
{
var boxes = new List<string>();
int sampleCount = 0, width = 0, height = 0, configBytes = 0;
uint timescale = 0;
long mediaDuration = 0;
var problems = new List<string>();
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
long length = stream.Length;
Walk(stream, 0, length, 0);
bool hasFtyp = boxes.Contains("ftyp");
bool hasMdat = boxes.Contains("mdat");
bool hasMoov = boxes.Contains("moov");
if (!hasFtyp) problems.Add("box ftyp assente");
if (!hasMdat) problems.Add("box mdat assente");
if (!hasMoov) problems.Add("box moov assente");
if (sampleCount == 0) problems.Add("tabella stsz vuota");
if (configBytes == 0) problems.Add("configurazione del codec assente");
string summary = problems.Count == 0
? "struttura conforme"
: string.Join(", ", problems);
return new Report(problems.Count == 0, summary, sampleCount, width, height,
timescale, mediaDuration, configBytes, boxes);
void Walk(FileStream file, long start, long end, int depth)
{
Span<byte> header = stackalloc byte[8];
long position = start;
while (position + 8 <= end && depth < 8)
{
file.Position = position;
if (file.Read(header) != 8) return;
long size = BinaryPrimitives.ReadUInt32BigEndian(header);
string type = Encoding.ASCII.GetString(header[4..]);
long payload = position + 8;
if (size == 1)
{
if (file.Read(header) != 8) return;
size = BinaryPrimitives.ReadInt64BigEndian(header);
payload += 8;
}
else if (size == 0)
{
size = end - position;
}
if (size < 8 || position + size > end) return;
if (depth == 0) boxes.Add(type);
switch (type)
{
case "stsz":
file.Position = payload + 8; // versione/flag + sample_size
sampleCount = (int)ReadUInt32(file);
break;
case "mdhd":
file.Position = payload + 12; // versione/flag + due timestamp
timescale = ReadUInt32(file);
mediaDuration = ReadUInt32(file);
break;
case "avcC" or "hvcC":
configBytes = (int)(size - (payload - position));
break;
case "avc1" or "hvc1":
file.Position = payload + 24;
width = ReadUInt16(file);
height = ReadUInt16(file);
Walk(file, payload + 78, position + size, depth + 1);
break;
}
if (Containers.Contains(type) && type is not ("avc1" or "hvc1"))
{
long childStart = type == "stsd" ? payload + 8 : payload;
Walk(file, childStart, position + size, depth + 1);
}
else if (type == "stsd")
{
Walk(file, payload + 8, position + size, depth + 1);
}
position += size;
}
}
static uint ReadUInt32(FileStream file)
{
Span<byte> buffer = stackalloc byte[4];
return file.Read(buffer) == 4 ? BinaryPrimitives.ReadUInt32BigEndian(buffer) : 0;
}
static ushort ReadUInt16(FileStream file)
{
Span<byte> buffer = stackalloc byte[2];
return file.Read(buffer) == 2 ? BinaryPrimitives.ReadUInt16BigEndian(buffer) : (ushort)0;
}
}
}
+177
View File
@@ -0,0 +1,177 @@
using System.Runtime.InteropServices;
using Titano.Video;
namespace Titano.Diagnostics;
[ComImport, Guid("70ae66f2-c809-4e4f-8915-bdcb406b7993"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFSourceReader
{
[PreserveSig] int GetStreamSelection(uint streamIndex, [MarshalAs(UnmanagedType.Bool)] out bool selected);
[PreserveSig] int SetStreamSelection(uint streamIndex, [MarshalAs(UnmanagedType.Bool)] bool selected);
[PreserveSig] int GetNativeMediaType(uint streamIndex, uint typeIndex, out IMFMediaType type);
[PreserveSig] int GetCurrentMediaType(uint streamIndex, out IMFMediaType type);
[PreserveSig] int SetCurrentMediaType(uint streamIndex, IntPtr reserved, IMFMediaType type);
[PreserveSig] int SetCurrentPosition(ref Guid timeFormat, ref PropVariant position);
[PreserveSig] int ReadSample(uint streamIndex, uint controlFlags, out uint actualStreamIndex,
out uint streamFlags, out long timestamp, out IMFSample? sample);
[PreserveSig] int Flush(uint streamIndex);
[PreserveSig] int GetServiceForStream(uint streamIndex, ref Guid service, ref Guid riid, out IntPtr instance);
[PreserveSig] int GetPresentationAttribute(uint streamIndex, ref Guid attribute, IntPtr value);
}
/// <summary>
/// Rilegge un MP4 prodotto dal multiplexer usando il lettore di sistema: è la prova che il
/// contenitore non è soltanto ben formato sulla carta, ma effettivamente riproducibile.
/// </summary>
internal static class Mp4Playback
{
private const uint FirstVideoStream = 0xFFFFFFFC;
private const uint AllStreams = 0xFFFFFFFE;
private const uint EndOfStream = 0x00000002;
private static Guid VideoFormatRgb32 = new("00000016-0000-0010-8000-00aa00389b71");
private static Guid EnableVideoProcessing = new("fb394f3d-ccf1-42ee-bbb3-f9b845d5681d");
[DllImport("mfreadwrite.dll", ExactSpelling = true)]
private static extern int MFCreateSourceReaderFromURL([MarshalAs(UnmanagedType.LPWStr)] string url,
IMFAttributes? attributes, out IMFSourceReader reader);
[DllImport("mfplat.dll", ExactSpelling = true)]
private static extern int MFCreateAttributes(out IMFAttributes attributes, uint initialSize);
public sealed record Playback(int FrameCount, int Width, int Height, List<double> MeanLuma, string? Error);
public static Playback Read(string path, int maxFrames = 4096)
{
MediaFoundationRuntime.Startup();
IMFAttributes? attributes = null;
IMFSourceReader? reader = null;
try
{
if (MFCreateAttributes(out attributes, 1) >= 0)
{
var key = EnableVideoProcessing;
attributes.SetUINT32(ref key, 1);
}
int hr = MFCreateSourceReaderFromURL(path, attributes, out reader);
if (hr < 0) return new Playback(0, 0, 0, [], $"apertura non riuscita (HRESULT 0x{hr:X8})");
reader.SetStreamSelection(AllStreams, false);
reader.SetStreamSelection(FirstVideoStream, true);
int width = 0, height = 0;
if (reader.GetNativeMediaType(FirstVideoStream, 0, out IMFMediaType native) >= 0)
{
try
{
var sizeKey = MediaFoundation.MtFrameSize;
if (native.GetUINT64(ref sizeKey, out ulong packed) >= 0)
{
width = (int)(packed >> 32);
height = (int)(packed & 0xFFFFFFFF);
}
}
finally
{
Marshal.ReleaseComObject(native);
}
}
// Si richiede RGB a 8 bit: il lettore inserisce da sé il convertitore di formato.
if (MediaFoundation.MFCreateMediaType(out IMFMediaType target) >= 0)
{
try
{
var majorKey = MediaFoundation.MtMajorType;
var majorValue = MediaFoundation.MajorTypeVideo;
target.SetGUID(ref majorKey, ref majorValue);
var subKey = MediaFoundation.MtSubtype;
target.SetGUID(ref subKey, ref VideoFormatRgb32);
reader.SetCurrentMediaType(FirstVideoStream, IntPtr.Zero, target);
}
finally
{
Marshal.ReleaseComObject(target);
}
}
var luma = new List<double>();
int frames = 0;
while (frames < maxFrames)
{
hr = reader.ReadSample(FirstVideoStream, 0, out _, out uint flags, out _, out IMFSample? sample);
if (hr < 0) return new Playback(frames, width, height, luma, $"lettura interrotta (HRESULT 0x{hr:X8})");
if (sample is not null)
{
try
{
luma.Add(MeasureMeanLuma(sample, width, height));
frames++;
}
finally
{
Marshal.ReleaseComObject(sample);
}
}
if ((flags & EndOfStream) != 0) break;
}
return new Playback(frames, width, height, luma, null);
}
catch (Exception ex)
{
return new Playback(0, 0, 0, [], ex.Message);
}
finally
{
if (reader is not null) Marshal.ReleaseComObject(reader);
if (attributes is not null) Marshal.ReleaseComObject(attributes);
}
}
private static unsafe double MeasureMeanLuma(IMFSample sample, int width, int height)
{
sample.ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
try
{
buffer.Lock(out IntPtr pointer, out _, out uint length);
try
{
if (length == 0 || width <= 0 || height <= 0) return 0;
int stride = (int)(length / Math.Max(1, height));
if (stride < width * 4) return 0;
byte* data = (byte*)pointer;
double sum = 0;
int count = 0;
for (int y = 0; y < height; y += 4)
{
byte* row = data + (long)y * stride;
for (int x = 0; x < width; x += 4)
{
byte* pixel = row + x * 4; // BGRX
sum += 0.2126 * pixel[2] + 0.7152 * pixel[1] + 0.0722 * pixel[0];
count++;
}
}
return count == 0 ? 0 : sum / count / 255.0;
}
finally
{
buffer.Unlock();
}
}
finally
{
Marshal.ReleaseComObject(buffer);
}
}
}
+268
View File
@@ -0,0 +1,268 @@
using System.Globalization;
using System.Text;
using Titano.Analysis;
using Titano.Imaging;
using Titano.Metadata;
using Titano.Motion;
using Titano.Pipeline;
using Titano.Video;
namespace Titano.Diagnostics;
/// <summary>
/// Verifica end-to-end del motore su una sequenza sintetica dalle proprietà note:
/// parser Exif, analisi di cadenza, deflicker, optical flow, motion blur, encoder e
/// multiplexer vengono esercitati nell'ordine in cui lavorano in produzione.
/// </summary>
public static class SelfTest
{
public static int Run(string workingDirectory, TextWriter output)
{
var checks = new List<(string Name, bool Passed, string Detail)>();
var definition = new SyntheticSequence.Definition();
output.WriteLine("Titano — verifica del motore");
output.WriteLine(new string('-', 74));
string sequenceDirectory = Path.Combine(workingDirectory, "sequenza");
output.WriteLine($"Generazione di {definition.FrameCount} fotogrammi sintetici in {sequenceDirectory}");
var paths = SyntheticSequence.Write(sequenceDirectory, definition);
// ---------------------------------------------------------------- 1. metadati
var metadata = paths.Select(MetadataReader.Read).ToList();
int withSubSecond = metadata.Count(m => m.CaptureSource == TimestampSource.ExifSubSecond);
Add(checks, "Parser Exif — timestamp con frazione di secondo",
withSubSecond == metadata.Count, $"{withSubSecond}/{metadata.Count}");
bool exposureOk = metadata.All(m => m.ExposureSeconds is { } e && Math.Abs(e - definition.ExposureSeconds) < 1e-6);
Add(checks, "Parser Exif — tempo di posa", exposureOk,
Format(metadata[0].ExposureSeconds) + " s");
bool apertureOk = metadata.All(m => m.FNumber is { } f && Math.Abs(f - 8.0) < 1e-6);
bool isoOk = metadata.All(m => m.Iso == 200);
Add(checks, "Parser Exif — apertura e ISO", apertureOk && isoOk,
$"f/{Format(metadata[0].FNumber)}, ISO {metadata[0].Iso}");
bool offsetOk = metadata.All(m => m.UtcOffset == TimeSpan.FromHours(2));
Add(checks, "Parser Exif — fuso orario", offsetOk, metadata[0].UtcOffset?.ToString() ?? "assente");
bool sizeOk = metadata.All(m => m.PixelWidth == definition.Width && m.PixelHeight == definition.Height);
Add(checks, "Parser Exif — dimensioni dichiarate", sizeOk,
$"{metadata[0].PixelWidth}×{metadata[0].PixelHeight}");
// ---------------------------------------------------------------- 2. cadenza
var project = new TitanoProject();
project.Sequence = Core.TimelapseSequence.Build(metadata);
project.Sequence.RecomputeTiming(project.General.CadenceTolerance);
double expectedInterval = definition.IntervalSeconds + 0.37;
bool cadenceOk = Math.Abs(project.Sequence.NominalInterval - expectedInterval) < 0.05;
Add(checks, "Cadenza nominale rilevata", cadenceOk,
$"{project.Sequence.NominalInterval:0.###} s (attesa {expectedInterval:0.###} s)");
bool pauseOk = project.Sequence.CadenceAnomalies >= definition.PauseFrames;
Add(checks, "Pause dell'intervallometro individuate", pauseOk,
$"{project.Sequence.CadenceAnomalies} intervalli anomali");
double expectedAngle = 360.0 * definition.ExposureSeconds / expectedInterval;
double measuredAngle = project.Sequence.Frames[0].ShutterAngle;
bool angleOk = Math.Abs(measuredAngle - expectedAngle) < 0.2;
Add(checks, "Shutter angle reale", angleOk,
$"{measuredAngle:0.00}° (atteso {expectedAngle:0.00}°)");
// ---------------------------------------------------------------- 3. deflicker
project.Export.OutputPath = Path.Combine(workingDirectory, "titano-selftest.mp4");
project.Export.FrameRate = 24;
project.Export.BitrateMbps = 20;
project.General.DecodeParallelism = 4;
var pipeline = new RenderPipeline(project);
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
var curve = project.Curve!;
double flickerBefore = DeflickerCurve.FlickerIndex(curve.Measured);
var corrected = new double[curve.Count];
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
double flickerAfter = DeflickerCurve.FlickerIndex(corrected);
Add(checks, "Sfarfallio misurato prima della correzione", flickerBefore > 0.04,
$"{flickerBefore:0.0000} stop RMS");
Add(checks, "Sfarfallio residuo dopo la correzione", flickerAfter < flickerBefore * 0.35,
$"{flickerAfter:0.0000} stop RMS ({100 * (1 - flickerAfter / flickerBefore):0.#}% di riduzione)");
// La rampa di luce deve sopravvivere: è un cambio reale, non sfarfallio.
double rampBefore = curve.Measured[^1] - curve.Measured[0];
double rampAfter = corrected[^1] - corrected[0];
bool rampOk = Math.Abs(rampAfter - rampBefore) < 0.25 && Math.Abs(rampAfter) > 0.4;
Add(checks, "Rampa di luce preservata", rampOk,
$"{rampBefore:0.00} stop → {rampAfter:0.00} stop");
// ---------------------------------------------------------------- 4. optical flow
var pool = new FrameBufferPool(6);
using var frameA = ImageDecoder.Decode(paths[10], definition.Width, definition.Height, 1, pool);
using var frameB = ImageDecoder.Decode(paths[11], definition.Width, definition.Height, 1, pool);
var flowEngine = new OpticalFlowEngine(project.Flow);
var field = flowEngine.Compute(frameA, frameB);
double expectedMagnitude = Math.Sqrt(definition.ShiftX * definition.ShiftX +
definition.ShiftY * definition.ShiftY);
double measuredMagnitude = field.MedianMagnitude();
bool flowOk = Math.Abs(measuredMagnitude - expectedMagnitude) < expectedMagnitude * 0.25;
Add(checks, "Campo vettoriale — modulo del movimento", flowOk,
$"{measuredMagnitude:0.00} px (atteso {expectedMagnitude:0.00} px)");
// Il generatore avanza le coordinate di campionamento della tessitura, quindi il
// contenuto visibile scorre nel verso opposto: il flusso atteso è l'opposto dello shift.
double expectedDirection = Math.Atan2(-definition.ShiftY, -definition.ShiftX) * 180 / Math.PI;
double measuredDirection = field.DominantDirection();
double directionError = Math.Abs(NormalizeAngle(measuredDirection - expectedDirection));
Add(checks, "Campo vettoriale — direzione dominante", directionError < 12,
$"{measuredDirection:0.0}° (atteso {expectedDirection:0.0}°)");
// ---------------------------------------------------------------- 5. motion blur
double missing = MotionBlurRenderer.MissingBlurFactor(measuredAngle, 180.0, 1.0);
using var blurred = pool.Rent(definition.Width, definition.Height);
double blurLength = MotionBlurRenderer.Render(frameA, blurred, field, missing, project.MotionBlur);
bool blurOk = blurLength > 1.5 && blurLength < 8.0;
Add(checks, "Motion blur sintetico — lunghezza della scia", blurOk,
$"{blurLength:0.00} px con fattore mancante {missing:0.000}");
double detailBefore = MeasureDetail(frameA);
double detailAfter = MeasureDetail(blurred);
Add(checks, "Motion blur sintetico — dettaglio ridotto lungo il moto",
detailAfter < detailBefore * 0.85,
$"{detailBefore:0.0000} → {detailAfter:0.0000} " +
$"({100 * (1 - detailAfter / detailBefore):0.#}% di attenuazione)");
// ---------------------------------------------------------------- 6. encoder + muxer
RenderResult? result = null;
string encodeDetail;
try
{
result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
encodeDetail = $"{result.EncodedFrames} fotogrammi, {result.OutputBytes / 1024} KiB, " +
$"{result.Elapsed.TotalSeconds:0.0} s, {result.EncoderName}" +
(result.HardwareAccelerated ? " (hardware)" : " (software)");
}
catch (Exception ex)
{
encodeDetail = ex.Message;
}
Add(checks, "Codifica video tramite encoder di sistema",
result is { EncodedFrames: > 0 }, encodeDetail);
if (result is not null && File.Exists(result.OutputPath))
{
var report = Mp4Inspector.Inspect(result.OutputPath);
Add(checks, "Contenitore MP4 — struttura dei box", report.Valid,
$"{report.Summary}; box radice: {string.Join(", ", report.TopLevelBoxes)}");
Add(checks, "Contenitore MP4 — numero di campioni",
report.SampleCount == result.EncodedFrames,
$"{report.SampleCount} campioni per {result.EncodedFrames} fotogrammi codificati");
Add(checks, "Contenitore MP4 — risoluzione dichiarata",
report.Width == definition.Width && report.Height == definition.Height,
$"{report.Width}×{report.Height}");
double declaredSeconds = report.Timescale > 0 ? report.MediaDuration / (double)report.Timescale : 0;
double expectedSeconds = result.EncodedFrames / project.Export.FrameRate;
Add(checks, "Contenitore MP4 — durata dichiarata",
Math.Abs(declaredSeconds - expectedSeconds) < 0.2,
$"{declaredSeconds:0.00} s (attesa {expectedSeconds:0.00} s)");
// Riproduzione effettiva con il lettore di sistema: prova che il contenitore
// scritto in-house è leggibile da un decoder indipendente.
var playback = Mp4Playback.Read(result.OutputPath);
Add(checks, "Riproduzione con il lettore di sistema",
playback.Error is null && playback.FrameCount == result.EncodedFrames,
playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati a {playback.Width}×{playback.Height}");
if (playback.MeanLuma.Count > 4)
{
// La luminanza riletta dal file deve essere già stabilizzata: il deflicker
// sopravvive a codifica e contenitore.
var log2 = playback.MeanLuma.Select(v => Math.Log2(Math.Max(v, 1e-4))).ToList();
double residual = DeflickerCurve.FlickerIndex(log2);
Add(checks, "Sfarfallio residuo nel video finale", residual < flickerBefore * 0.5,
$"{residual:0.0000} stop RMS contro {flickerBefore:0.0000} in origine");
}
}
// ---------------------------------------------------------------- 7. impronta di memoria
long expectedBytes = (long)definition.Width * definition.Height * 3 * sizeof(float);
bool memoryOk = result is null || result.PeakPixelMemoryBytes < expectedBytes * 24;
Add(checks, "Impronta di memoria del pool",
memoryOk,
result is null ? "non misurata" :
$"{result.PeakPixelMemoryBytes / (1024 * 1024.0):0.0} MiB " +
$"({result.PeakPixelMemoryBytes / (double)expectedBytes:0.0} fotogrammi)");
// ---------------------------------------------------------------- esito
output.WriteLine();
foreach (var (name, passed, detail) in checks)
{
output.WriteLine($" [{(passed ? "OK " : "FALLITO")}] {name}");
output.WriteLine($" {detail}");
}
int failed = checks.Count(c => !c.Passed);
output.WriteLine();
output.WriteLine(new string('-', 74));
output.WriteLine(failed == 0
? $"Tutte le {checks.Count} verifiche superate."
: $"{failed} verifiche fallite su {checks.Count}.");
return failed == 0 ? 0 : 1;
}
private static void Add(List<(string, bool, string)> checks, string name, bool passed, string detail)
=> checks.Add((name, passed, detail));
private static string Format(double? value)
=> value?.ToString("0.######", CultureInfo.InvariantCulture) ?? "—";
private static double NormalizeAngle(double degrees)
{
while (degrees > 180) degrees -= 360;
while (degrees < -180) degrees += 360;
return degrees;
}
/// <summary>
/// Dettaglio fine orizzontale: differenza fra pixel adiacenti sul canale verde.
/// Si usa la differenza a distanza 1 e non quella centrata, perché quest'ultima ha uno
/// zero esatto alla frequenza di Nyquist, proprio dove la sfocatura agisce di più.
/// </summary>
private static double MeasureDetail(ImageBuffer frame)
{
double sum = 0;
int count = 0;
var data = frame.Data;
for (int y = 4; y < frame.Height - 4; y += 3)
{
int rowBase = y * frame.Width * ImageBuffer.Channels;
for (int x = 4; x < frame.Width - 4; x += 3)
{
int i = rowBase + x * ImageBuffer.Channels;
sum += Math.Abs(data[i + ImageBuffer.Channels + 1] - data[i + 1]);
count++;
}
}
return count == 0 ? 0 : sum / count;
}
/// <summary>Riepilogo testuale usato anche dal pannello diagnostica dell'interfaccia.</summary>
public static string DescribeEnvironment()
{
var builder = new StringBuilder();
builder.AppendLine($"Sistema: {Environment.OSVersion.VersionString} ({(Environment.Is64BitProcess ? "x64" : "x86")})");
builder.AppendLine($"Processori logici: {Environment.ProcessorCount}");
builder.AppendLine($"Runtime: {Environment.Version}");
builder.AppendLine($"Vettori SIMD: {System.Numerics.Vector<float>.Count} float per registro");
return builder.ToString();
}
}
+159
View File
@@ -0,0 +1,159 @@
using System.Drawing;
using System.Drawing.Imaging;
namespace Titano.Diagnostics;
/// <summary>
/// Generatore di sequenze sintetiche per la diagnostica: produce JPEG con Exif completo,
/// una traslazione nota del contenuto (verifica dell'optical flow), uno sfarfallio noto
/// sovrapposto a una rampa di luce (verifica del deflicker) e una pausa dell'intervallometro
/// (verifica dell'analisi di cadenza).
/// </summary>
internal static class SyntheticSequence
{
public sealed record Definition(
int FrameCount = 48,
int Width = 640,
int Height = 360,
double IntervalSeconds = 2.0,
double ExposureSeconds = 0.02,
double FlickerStops = 0.12,
double RampStops = -0.9,
int PauseAtFrame = 20,
int PauseFrames = 4,
double PauseMultiplier = 3.0,
int ShiftX = 6,
int ShiftY = 2);
/// <summary>Scrive la sequenza nella cartella indicata e restituisce i percorsi generati.</summary>
public static List<string> Write(string directory, Definition definition)
{
Directory.CreateDirectory(directory);
var paths = new List<string>(definition.FrameCount);
var origin = new DateTime(2026, 6, 1, 18, 30, 0, DateTimeKind.Unspecified);
double elapsed = 0;
for (int i = 0; i < definition.FrameCount; i++)
{
double exposureScale = Math.Pow(2.0, Ramp(definition, i) + Flicker(definition, i));
byte[] jpeg = RenderJpeg(definition, i, exposureScale);
var timestamp = origin.AddSeconds(elapsed);
int subSecond = (int)Math.Round((elapsed - Math.Floor(elapsed)) * 100) % 100;
byte[] exif = ExifWriter.BuildExifBlock(timestamp, subSecond, definition.ExposureSeconds,
8.0, 200, definition.Width, definition.Height,
"TITANO", "TITANO Synthetic");
string path = Path.Combine(directory, $"TITANO_{i:D4}.jpg");
File.WriteAllBytes(path, ExifWriter.InsertIntoJpeg(jpeg, exif));
paths.Add(path);
bool inPause = i >= definition.PauseAtFrame && i < definition.PauseAtFrame + definition.PauseFrames;
elapsed += definition.IntervalSeconds * (inPause ? definition.PauseMultiplier : 1.0) + 0.37;
}
return paths;
}
public static double Ramp(Definition definition, int index)
=> definition.RampStops * index / Math.Max(1, definition.FrameCount - 1);
/// <summary>Sfarfallio deterministico ma non periodico rispetto alla finestra di smoothing.</summary>
public static double Flicker(Definition definition, int index)
{
int pattern = (index * 7 + (index * index) % 5) % 7;
return definition.FlickerStops * ((pattern - 3) / 3.0);
}
private static byte[] RenderJpeg(Definition definition, int index, double exposureScale)
{
int width = definition.Width;
int height = definition.Height;
double offsetX = index * definition.ShiftX;
double offsetY = index * definition.ShiftY;
using var bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb);
var rect = new Rectangle(0, 0, width, height);
var locked = bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
try
{
unsafe
{
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
for (int x = 0; x < width; x++)
{
double sx = x + offsetX;
double sy = y + offsetY;
// Rumore di valore su tre ottave: tessitura ovunque e a banda limitata,
// condizioni ideali per uno schema differenziale di optical flow.
// L'ottava più fine serve a rendere misurabile l'effetto del motion blur.
double texture = 0.50 * ValueNoise(sx / 24.0, sy / 24.0, 11)
+ 0.30 * ValueNoise(sx / 7.0, sy / 7.0, 37)
+ 0.20 * ValueNoise(sx / 3.0, sy / 3.0, 53);
double value = Math.Clamp((0.18 + 0.62 * texture) * exposureScale, 0.0, 1.0);
byte gray = (byte)Math.Clamp((int)(Math.Pow(value, 1.0 / 2.2) * 255.0 + 0.5), 0, 255);
byte* pixel = row + x * 3;
pixel[0] = (byte)(gray * 0.94); // B
pixel[1] = gray; // G
pixel[2] = (byte)Math.Min(255, gray * 1.04); // R
}
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
using var stream = new MemoryStream();
var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
using var parameters = new EncoderParameters(1);
using var quality = new EncoderParameter(Encoder.Quality, 92L);
parameters.Param[0] = quality;
bitmap.Save(stream, codec, parameters);
return stream.ToArray();
}
/// <summary>Rumore di valore bilineare su reticolo intero, deterministico.</summary>
private static double ValueNoise(double x, double y, int seed)
{
int x0 = (int)Math.Floor(x);
int y0 = (int)Math.Floor(y);
double fx = x - x0;
double fy = y - y0;
// Interpolazione con curva di Hermite: derivata continua, niente discontinuità di gradiente.
double sx = fx * fx * (3 - 2 * fx);
double sy = fy * fy * (3 - 2 * fy);
double a = Hash(x0, y0, seed);
double b = Hash(x0 + 1, y0, seed);
double c = Hash(x0, y0 + 1, seed);
double d = Hash(x0 + 1, y0 + 1, seed);
double top = a + (b - a) * sx;
double bottom = c + (d - c) * sx;
return top + (bottom - top) * sy;
}
private static double Hash(int x, int y, int seed)
{
unchecked
{
uint h = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(seed * 83492791);
h ^= h >> 13;
h *= 0x85EBCA6B;
h ^= h >> 16;
return (h & 0xFFFFFF) / (double)0xFFFFFF;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Runtime.CompilerServices;
namespace Titano.Imaging;
/// <summary>
/// Conversioni colore implementate in-house: curva sRGB ↔ luce lineare e pesi di luminanza.
/// Tutta l'elaborazione (deflicker, blur, interpolazione) avviene in luce lineare, l'unico
/// spazio in cui somme e medie pesate corrispondono al comportamento fisico della luce.
/// </summary>
public static class ColorSpace
{
/// <summary>LUT sRGB→lineare a 8 bit.</summary>
private static readonly float[] SrgbToLinear8 = BuildLut(256);
/// <summary>LUT sRGB→lineare a 16 bit, campionata a 4096 punti con interpolazione lineare.</summary>
private static readonly float[] SrgbToLinear16 = BuildLut(4096);
/// <summary>LUT inversa lineare→sRGB a 12 bit, per la scrittura verso l'encoder.</summary>
private const int InverseLutSize = 4096;
private static readonly float[] LinearToSrgbLut = BuildInverseLut();
// Coefficienti di luminanza Rec.709 (spazio di lavoro sRGB/Rec.709 lineare).
public const float LumaR = 0.2126f;
public const float LumaG = 0.7152f;
public const float LumaB = 0.0722f;
private static float[] BuildLut(int size)
{
var lut = new float[size];
for (int i = 0; i < size; i++) lut[i] = SrgbToLinearExact(i / (float)(size - 1));
return lut;
}
private static float[] BuildInverseLut()
{
var lut = new float[InverseLutSize + 1];
for (int i = 0; i <= InverseLutSize; i++) lut[i] = LinearToSrgbExact(i / (float)InverseLutSize);
return lut;
}
public static float SrgbToLinearExact(float v)
{
if (v <= 0f) return 0f;
if (v >= 1f) return 1f;
return v <= 0.04045f ? v / 12.92f : MathF.Pow((v + 0.055f) / 1.055f, 2.4f);
}
public static float LinearToSrgbExact(float v)
{
if (v <= 0f) return 0f;
if (v >= 1f) return 1f;
return v <= 0.0031308f ? v * 12.92f : 1.055f * MathF.Pow(v, 1f / 2.4f) - 0.055f;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float FromByte(byte v) => SrgbToLinear8[v];
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float FromUInt16(ushort v)
{
// Interpolazione fra i due campioni adiacenti della LUT: errore < 1e-5.
int scaled = v * (SrgbToLinear16.Length - 1);
int index = scaled / 65535;
int rem = scaled - index * 65535;
if (index >= SrgbToLinear16.Length - 1) return SrgbToLinear16[^1];
float a = SrgbToLinear16[index], b = SrgbToLinear16[index + 1];
return a + (b - a) * (rem / 65535f);
}
/// <summary>Lineare → sRGB con LUT interpolata; valori fuori range vengono saturati.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float ToSrgb(float linear)
{
if (linear <= 0f) return 0f;
if (linear >= 1f) return 1f;
float pos = linear * InverseLutSize;
int i = (int)pos;
float t = pos - i;
return LinearToSrgbLut[i] + (LinearToSrgbLut[i + 1] - LinearToSrgbLut[i]) * t;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte ToSrgbByte(float linear)
=> (byte)Math.Clamp((int)(ToSrgb(linear) * 255f + 0.5f), 0, 255);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float Luminance(float r, float g, float b) => LumaR * r + LumaG * g + LumaB * b;
}
+116
View File
@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
namespace Titano.Imaging;
/// <summary>
/// Fotogramma in memoria: RGB impacchettato, float32, luce lineare (gamma rimossa).
/// L'array proviene sempre da un <see cref="FrameBufferPool"/>: nessuna allocazione
/// per fotogramma dopo il riscaldamento, quindi impronta di memoria stazionaria.
/// </summary>
public sealed class ImageBuffer : IDisposable
{
public const int Channels = 3;
public int Width { get; private set; }
public int Height { get; private set; }
public float[] Data { get; private set; }
internal FrameBufferPool? Owner;
private int _disposed;
public int PixelCount => Width * Height;
public int SampleCount => Width * Height * Channels;
internal ImageBuffer(int width, int height, float[] data, FrameBufferPool? owner)
{
Width = width;
Height = height;
Data = data;
Owner = owner;
}
/// <summary>Indice del primo campione (canale R) del pixel indicato.</summary>
public int Offset(int x, int y) => (y * Width + x) * Channels;
public void CopyFrom(ImageBuffer other)
{
if (other.Width != Width || other.Height != Height)
throw new ArgumentException("Dimensioni non compatibili.", nameof(other));
Array.Copy(other.Data, Data, SampleCount);
}
public ImageBuffer CloneFromPool(FrameBufferPool pool)
{
var copy = pool.Rent(Width, Height);
Array.Copy(Data, copy.Data, SampleCount);
return copy;
}
/// <summary>Restituisce il buffer al pool. Idempotente.</summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
var owner = Owner;
Owner = null;
owner?.Return(Data, Width, Height);
Data = [];
Width = Height = 0;
}
}
/// <summary>
/// Pool di array float per fotogrammi di dimensione omogenea. Gli array sono trattenuti
/// per lunghezza esatta e in numero limitato: l'occupazione totale è funzione della
/// profondità della pipeline, non del numero di fotogrammi della sequenza.
/// </summary>
public sealed class FrameBufferPool(int maxRetained = 8)
{
private readonly ConcurrentDictionary<int, ConcurrentBag<float[]>> _bins = new();
private readonly int _maxRetained = Math.Max(2, maxRetained);
private int _retained;
private long _allocatedBytes;
/// <summary>Numero di array attualmente trattenuti dal pool.</summary>
public int Retained => Volatile.Read(ref _retained);
/// <summary>
/// Byte di memoria pixel effettivamente allocati dall'avvio. Poiché gli array vengono
/// riutilizzati, questo valore si stabilizza dopo i primi fotogrammi e rappresenta
/// l'impronta stazionaria della pipeline.
/// </summary>
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
public ImageBuffer Rent(int width, int height)
{
if (width <= 0 || height <= 0) throw new ArgumentOutOfRangeException(nameof(width));
int length = checked(width * height * ImageBuffer.Channels);
if (_bins.TryGetValue(length, out var bag) && bag.TryTake(out var array))
{
Interlocked.Decrement(ref _retained);
return new ImageBuffer(width, height, array, this);
}
var fresh = GC.AllocateUninitializedArray<float>(length);
Interlocked.Add(ref _allocatedBytes, (long)length * sizeof(float));
return new ImageBuffer(width, height, fresh, this);
}
internal void Return(float[] array, int width, int height)
{
if (array.Length == 0) return;
if (Volatile.Read(ref _retained) >= _maxRetained) return; // eccedenza lasciata al GC
var bag = _bins.GetOrAdd(array.Length, static _ => []);
bag.Add(array);
Interlocked.Increment(ref _retained);
}
/// <summary>Svuota il pool: usato al cambio di risoluzione di lavoro.</summary>
public void Clear()
{
_bins.Clear();
Interlocked.Exchange(ref _retained, 0);
Interlocked.Exchange(ref _allocatedBytes, 0);
}
}
+234
View File
@@ -0,0 +1,234 @@
using System.Buffers;
using System.Runtime.InteropServices;
namespace Titano.Imaging;
/// <summary>
/// Decodifica un file immagine direttamente in un <see cref="ImageBuffer"/> in luce lineare,
/// scalandolo alla risoluzione di lavoro e applicando l'orientamento Exif.
///
/// La catena WIC è pull-based: il ridimensionamento avviene a bande mentre i pixel vengono
/// prelevati, quindi il fotogramma a piena risoluzione non viene mai materializzato per intero
/// e non tocca mai il disco.
/// </summary>
public static class ImageDecoder
{
/// <summary>Dimensioni dell'immagine così come verrà mostrata (orientamento già applicato).</summary>
public static (int Width, int Height) ProbeDisplaySize(string path, int orientation)
{
IWICBitmapDecoder? decoder = null;
IWICBitmapFrameDecode? frame = null;
try
{
Wic.Factory.CreateDecoderFromFilename(path, IntPtr.Zero, Wic.GenericRead,
Wic.MetadataCacheOnDemand, out decoder);
decoder.GetFrame(0, out frame);
frame.GetSize(out uint w, out uint h);
return SwapsAxes(orientation) ? ((int)h, (int)w) : ((int)w, (int)h);
}
finally
{
Wic.Release(frame);
Wic.Release(decoder);
}
}
/// <summary>
/// Decodifica il file producendo un buffer di esattamente <paramref name="targetWidth"/> ×
/// <paramref name="targetHeight"/> pixel (misure già nell'orientamento finale).
/// </summary>
public static ImageBuffer Decode(string path, int targetWidth, int targetHeight,
int orientation, FrameBufferPool pool)
{
if (targetWidth <= 0 || targetHeight <= 0) throw new ArgumentOutOfRangeException(nameof(targetWidth));
bool swap = SwapsAxes(orientation);
uint decodeWidth = (uint)(swap ? targetHeight : targetWidth);
uint decodeHeight = (uint)(swap ? targetWidth : targetHeight);
IWICBitmapDecoder? decoder = null;
IWICBitmapFrameDecode? frame = null;
IWICFormatConverter? converter = null;
IWICBitmapScaler? scaler = null;
try
{
Wic.Factory.CreateDecoderFromFilename(path, IntPtr.Zero, Wic.GenericRead,
Wic.MetadataCacheOnDemand, out decoder);
decoder.GetFrame(0, out frame);
frame.GetSize(out uint sourceWidth, out uint sourceHeight);
if (sourceWidth == 0 || sourceHeight == 0)
throw new InvalidDataException("Il decoder di sistema ha riportato dimensioni nulle.");
bool wantDeep = PrefersHighBitDepth(path);
Guid destinationFormat = wantDeep ? Wic.PixelFormat48bppRGB : Wic.PixelFormat32bppBGRA;
IWICBitmapSource source = CreateChain(frame, ref destinationFormat, decodeWidth, decodeHeight,
sourceWidth, sourceHeight, ref converter, ref scaler);
int bytesPerPixel = destinationFormat == Wic.PixelFormat48bppRGB ? 6 : 4;
int stride = checked((int)decodeWidth * bytesPerPixel);
long total = (long)stride * decodeHeight;
if (total > int.MaxValue) throw new InvalidDataException("Fotogramma troppo grande per il buffer di trasferimento.");
byte[] scratch = ArrayPool<byte>.Shared.Rent((int)total);
try
{
var handle = GCHandle.Alloc(scratch, GCHandleType.Pinned);
try
{
source.CopyPixels(IntPtr.Zero, (uint)stride, (uint)total, handle.AddrOfPinnedObject());
}
finally
{
handle.Free();
}
var buffer = pool.Rent(targetWidth, targetHeight);
try
{
if (bytesPerPixel == 6)
Convert48(scratch, stride, (int)decodeWidth, (int)decodeHeight, buffer, orientation);
else
Convert32(scratch, stride, (int)decodeWidth, (int)decodeHeight, buffer, orientation);
}
catch
{
buffer.Dispose();
throw;
}
return buffer;
}
finally
{
ArrayPool<byte>.Shared.Return(scratch);
}
}
finally
{
Wic.Release(scaler);
Wic.Release(converter);
Wic.Release(frame);
Wic.Release(decoder);
}
}
/// <summary>
/// Costruisce la catena WIC: conversione al formato di lavoro e, se necessario, scalatura.
/// In caso di sorgenti che il codec non sa convertire a 16 bit si ripiega su 8 bit.
/// </summary>
private static IWICBitmapSource CreateChain(IWICBitmapFrameDecode frame, ref Guid destinationFormat,
uint decodeWidth, uint decodeHeight,
uint sourceWidth, uint sourceHeight,
ref IWICFormatConverter? converter, ref IWICBitmapScaler? scaler)
{
IWICBitmapSource current = (IWICBitmapSource)frame;
try
{
Wic.Factory.CreateFormatConverter(out converter);
converter.Initialize(current, ref destinationFormat, Wic.DitherTypeNone,
IntPtr.Zero, 0.0, Wic.PaletteTypeCustom);
}
catch (COMException) when (destinationFormat == Wic.PixelFormat48bppRGB)
{
Wic.Release(converter);
converter = null;
destinationFormat = Wic.PixelFormat32bppBGRA;
Wic.Factory.CreateFormatConverter(out converter);
converter.Initialize(current, ref destinationFormat, Wic.DitherTypeNone,
IntPtr.Zero, 0.0, Wic.PaletteTypeCustom);
}
current = (IWICBitmapSource)converter!;
if (decodeWidth != sourceWidth || decodeHeight != sourceHeight)
{
Wic.Factory.CreateBitmapScaler(out scaler);
scaler.Initialize(current, decodeWidth, decodeHeight, Wic.InterpolationFant);
current = (IWICBitmapSource)scaler;
}
return current;
}
private static bool PrefersHighBitDepth(string path)
{
string ext = Path.GetExtension(path);
return !(ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".jpe", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".jfif", StringComparison.OrdinalIgnoreCase));
}
public static bool SwapsAxes(int orientation) => orientation is 5 or 6 or 7 or 8;
// ------------------------------------------------------------------ conversione + orientamento
private static unsafe void Convert32(byte[] scratch, int stride, int width, int height,
ImageBuffer destination, int orientation)
{
fixed (byte* basePtr = scratch)
fixed (float* destBase = destination.Data)
{
byte* src = basePtr;
float* dst = destBase;
int destWidth = destination.Width;
for (int y = 0; y < height; y++)
{
byte* row = src + (long)y * stride;
for (int x = 0; x < width; x++)
{
byte* px = row + x * 4; // BGRA
int index = MapIndex(x, y, width, height, destWidth, orientation);
dst[index] = ColorSpace.FromByte(px[2]);
dst[index + 1] = ColorSpace.FromByte(px[1]);
dst[index + 2] = ColorSpace.FromByte(px[0]);
}
}
}
}
private static unsafe void Convert48(byte[] scratch, int stride, int width, int height,
ImageBuffer destination, int orientation)
{
fixed (byte* basePtr = scratch)
fixed (float* destBase = destination.Data)
{
float* dst = destBase;
int destWidth = destination.Width;
for (int y = 0; y < height; y++)
{
ushort* row = (ushort*)(basePtr + (long)y * stride);
for (int x = 0; x < width; x++)
{
ushort* px = row + x * 3; // RGB 16 bit
int index = MapIndex(x, y, width, height, destWidth, orientation);
dst[index] = ColorSpace.FromUInt16(px[0]);
dst[index + 1] = ColorSpace.FromUInt16(px[1]);
dst[index + 2] = ColorSpace.FromUInt16(px[2]);
}
}
}
}
/// <summary>Applica la trasformazione Exif (valori 1..8) restituendo l'offset di destinazione.</summary>
private static int MapIndex(int x, int y, int width, int height, int destWidth, int orientation)
{
int dx, dy;
switch (orientation)
{
case 2: dx = width - 1 - x; dy = y; break;
case 3: dx = width - 1 - x; dy = height - 1 - y; break;
case 4: dx = x; dy = height - 1 - y; break;
case 5: dx = y; dy = x; break;
case 6: dx = height - 1 - y; dy = x; break;
case 7: dx = height - 1 - y; dy = width - 1 - x; break;
case 8: dx = y; dy = width - 1 - x; break;
default: dx = x; dy = y; break;
}
return (dy * destWidth + dx) * ImageBuffer.Channels;
}
}
+164
View File
@@ -0,0 +1,164 @@
using System.Runtime.InteropServices;
namespace Titano.Imaging;
// ---------------------------------------------------------------------------------------
// Binding manuale verso Windows Imaging Component (windowscodecs.dll), componente di
// sistema. Nessun wrapper di terze parti: le interfacce COM sono dichiarate qui con
// l'ordine di vtable esatto e vengono usate solo le voci necessarie alla pipeline.
// ---------------------------------------------------------------------------------------
[ComImport, Guid("00000120-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapSource
{
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
}
[ComImport, Guid("3B16811B-6A43-4ec9-A813-3D930C13B940"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapFrameDecode
{
// --- IWICBitmapSource
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
// --- IWICBitmapFrameDecode
void GetMetadataQueryReader(out IntPtr reader);
void GetColorContexts(uint count, IntPtr contexts, out uint actual);
void GetThumbnail(out IWICBitmapSource thumbnail);
}
[ComImport, Guid("9EDDE9E7-8DEE-47ea-99DF-E6FAF2ED44BF"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapDecoder
{
void QueryCapability(IntPtr stream, out uint capability);
void Initialize(IntPtr stream, int cacheOptions);
void GetContainerFormat(out Guid format);
void GetDecoderInfo(out IntPtr info);
void CopyPalette(IntPtr palette);
void GetMetadataQueryReader(out IntPtr reader);
void GetPreview(out IWICBitmapSource preview);
void GetColorContexts(uint count, IntPtr contexts, out uint actual);
void GetThumbnail(out IWICBitmapSource thumbnail);
void GetFrameCount(out uint count);
void GetFrame(uint index, out IWICBitmapFrameDecode frame);
}
[ComImport, Guid("00000301-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICFormatConverter
{
// --- IWICBitmapSource
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
// --- IWICFormatConverter
void Initialize(IWICBitmapSource source, ref Guid destinationFormat, int dither,
IntPtr palette, double alphaThreshold, int paletteTranslate);
void CanConvert(ref Guid source, ref Guid destination, [MarshalAs(UnmanagedType.Bool)] out bool canConvert);
}
[ComImport, Guid("00000302-a8f2-4877-ba0a-fd2b6645fb94"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICBitmapScaler
{
// --- IWICBitmapSource
void GetSize(out uint width, out uint height);
void GetPixelFormat(out Guid format);
void GetResolution(out double dpiX, out double dpiY);
void CopyPalette(IntPtr palette);
void CopyPixels(IntPtr rect, uint stride, uint bufferSize, IntPtr buffer);
// --- IWICBitmapScaler
void Initialize(IWICBitmapSource source, uint width, uint height, int interpolationMode);
}
[ComImport, Guid("EC5EC8A9-C395-4314-9C77-54D7A935FF70"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IWICImagingFactory
{
void CreateDecoderFromFilename([MarshalAs(UnmanagedType.LPWStr)] string filename, IntPtr vendor,
uint desiredAccess, int metadataOptions, out IWICBitmapDecoder decoder);
void CreateDecoderFromStream(IntPtr stream, IntPtr vendor, int metadataOptions, out IntPtr decoder);
void CreateDecoderFromFileHandle(UIntPtr file, IntPtr vendor, int metadataOptions, out IntPtr decoder);
void CreateComponentInfo(ref Guid component, out IntPtr info);
void CreateDecoder(ref Guid containerFormat, IntPtr vendor, out IntPtr decoder);
void CreateEncoder(ref Guid containerFormat, IntPtr vendor, out IntPtr encoder);
void CreatePalette(out IntPtr palette);
void CreateFormatConverter(out IWICFormatConverter converter);
void CreateBitmapScaler(out IWICBitmapScaler scaler);
// Le voci successive della vtable non sono usate e volutamente non dichiarate.
}
internal static class Wic
{
public static readonly Guid ClsidImagingFactory = new("cacaf262-9370-4615-a13b-9f5539da4c0a");
public static readonly Guid IidImagingFactory = new("ec5ec8a9-c395-4314-9c77-54d7a935ff70");
public static readonly Guid PixelFormat32bppBGRA = new("6fddc324-4e03-4bfe-b185-3d77768dc90f");
public static readonly Guid PixelFormat48bppRGB = new("6fddc324-4e03-4bfe-b185-3d77768dc915");
public const uint GenericRead = 0x80000000;
public const int MetadataCacheOnDemand = 0;
public const int DitherTypeNone = 0;
public const int PaletteTypeCustom = 0;
public const int InterpolationFant = 3;
private const uint ClsCtxInprocServer = 1;
private const uint CoInitMultithreaded = 0;
[ThreadStatic] private static IWICImagingFactory? _threadFactory;
[ThreadStatic] private static bool _comReady;
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(ref Guid clsid, IntPtr outer, uint context, ref Guid iid, out IntPtr instance);
[DllImport("ole32.dll")]
private static extern int CoInitializeEx(IntPtr reserved, uint flags);
/// <summary>
/// Factory WIC per thread: gli oggetti COM restano confinati al thread che li crea,
/// così i worker di decodifica lavorano davvero in parallelo senza marshalling.
/// </summary>
public static IWICImagingFactory Factory
{
get
{
if (_threadFactory is not null) return _threadFactory;
if (!_comReady)
{
// S_OK, S_FALSE e RPC_E_CHANGED_MODE sono tutti esiti accettabili.
CoInitializeEx(IntPtr.Zero, CoInitMultithreaded);
_comReady = true;
}
var clsid = ClsidImagingFactory;
var iid = IidImagingFactory;
int hr = CoCreateInstance(ref clsid, IntPtr.Zero, ClsCtxInprocServer, ref iid, out IntPtr raw);
if (hr < 0 || raw == IntPtr.Zero)
throw new InvalidOperationException($"Impossibile creare la factory WIC (HRESULT 0x{hr:X8}).");
try
{
_threadFactory = (IWICImagingFactory)Marshal.GetObjectForIUnknown(raw);
}
finally
{
Marshal.Release(raw);
}
return _threadFactory!;
}
}
public static void Release(object? comObject)
{
if (comObject is not null && Marshal.IsComObject(comObject))
{
try { Marshal.ReleaseComObject(comObject); } catch (ArgumentException) { /* già rilasciato */ }
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.Globalization;
namespace Titano.Metadata;
/// <summary>Origine del timestamp di scatto, usata per segnalare in UI la qualità del dato.</summary>
public enum TimestampSource
{
None = 0,
Exif,
ExifSubSecond,
Xmp,
FileSystem,
}
/// <summary>
/// Metadati di un singolo fotogramma della sequenza, estratti dal parser binario in-house.
/// Immutabile per costruzione: la pipeline la tratta come dato condivisibile fra thread.
/// </summary>
public sealed class FrameMetadata
{
public required string FilePath { get; init; }
public required string FileName { get; init; }
public long FileSize { get; init; }
/// <summary>Istante di scatto, comprensivo di frazione di secondo quando disponibile.</summary>
public DateTime? CaptureTime { get; init; }
public TimestampSource CaptureSource { get; init; }
public TimeSpan? UtcOffset { get; init; }
public double? ExposureSeconds { get; init; }
public double? FNumber { get; init; }
public int? Iso { get; init; }
public double? FocalLength { get; init; }
public double? ExposureBias { get; init; }
public int PixelWidth { get; init; }
public int PixelHeight { get; init; }
public int Orientation { get; init; } = 1;
public string? Camera { get; init; }
public string? Lens { get; init; }
/// <summary>Errori non fatali incontrati durante il parsing (file troncato, IFD corrotta...).</summary>
public string? Warning { get; init; }
public string ExposureText => ExposureSeconds is not { } e
? "—"
: e >= 1
? string.Format(CultureInfo.CurrentCulture, "{0:0.#}s", e)
: string.Format(CultureInfo.CurrentCulture, "1/{0:0.#}", 1.0 / Math.Max(e, 1e-9));
public string ApertureText => FNumber is { } f ? "f/" + f.ToString("0.#", CultureInfo.CurrentCulture) : "—";
public string IsoText => Iso is { } i ? i.ToString(CultureInfo.CurrentCulture) : "—";
/// <summary>Costruisce l'istante completo a partire dai campi Exif grezzi.</summary>
internal static DateTime? CombineDateTime(string? exifDateTime, string? subSec, out TimestampSource source)
{
source = TimestampSource.None;
if (string.IsNullOrWhiteSpace(exifDateTime)) return null;
// Formato canonico Exif: "YYYY:MM:DD HH:MM:SS" (alcuni firmware usano '-' o '/').
Span<int> parts = stackalloc int[6];
int found = 0;
int value = 0;
bool inNumber = false;
foreach (char c in exifDateTime)
{
if (c is >= '0' and <= '9')
{
value = value * 10 + (c - '0');
inNumber = true;
if (value > 999999) return null;
}
else if (inNumber)
{
if (found < 6) parts[found++] = value;
value = 0;
inNumber = false;
if (found == 6) break;
}
}
if (inNumber && found < 6) parts[found++] = value;
if (found < 6) return null;
int year = parts[0], month = parts[1], day = parts[2], hour = parts[3], minute = parts[4], second = parts[5];
if (year < 1900 || year > 3000 || month is < 1 or > 12 || day is < 1 or > 31) return null;
if (hour > 23 || minute > 59 || second > 60) return null;
if (second == 60) second = 59; // leap second difensivo
if (day > DateTime.DaysInMonth(year, month)) return null;
var stamp = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
source = TimestampSource.Exif;
// SubSecTime è una stringa di cifre decimali: "37" -> 0.37 s, "004" -> 0.004 s.
if (!string.IsNullOrWhiteSpace(subSec))
{
double fraction = 0, scale = 0.1;
int digits = 0;
foreach (char c in subSec.Trim())
{
if (c is < '0' or > '9') break;
fraction += (c - '0') * scale;
scale *= 0.1;
if (++digits >= 7) break;
}
if (digits > 0 && fraction > 0)
{
stamp = stamp.AddTicks((long)Math.Round(fraction * TimeSpan.TicksPerSecond));
source = TimestampSource.ExifSubSecond;
}
else if (digits > 0)
{
source = TimestampSource.ExifSubSecond; // frazione presente ma nulla: precisione comunque nota
}
}
return stamp;
}
/// <summary>Interpreta un offset fuso orario Exif nella forma "+02:00".</summary>
internal static TimeSpan? ParseUtcOffset(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
text = text.Trim();
if (text.Length < 3) return null;
int sign = text[0] == '-' ? -1 : text[0] == '+' ? 1 : 0;
if (sign == 0) return null;
int colon = text.IndexOf(':');
string hh = colon > 0 ? text[1..colon] : text[1..Math.Min(3, text.Length)];
string mm = colon > 0 && colon + 1 < text.Length ? text[(colon + 1)..] : "0";
if (!int.TryParse(hh, NumberStyles.Integer, CultureInfo.InvariantCulture, out int h)) return null;
if (!int.TryParse(mm, NumberStyles.Integer, CultureInfo.InvariantCulture, out int m)) m = 0;
if (h > 14 || m > 59) return null;
return new TimeSpan(sign * h, sign * m, 0);
}
}
+388
View File
@@ -0,0 +1,388 @@
using System.Text;
namespace Titano.Metadata;
/// <summary>
/// Ingestion: apre il file, riconosce il contenitore (JPEG, TIFF/RAW, PNG, HEIF/AVIF, WebP),
/// individua i blocchi Exif e XMP e li decodifica con i parser binari in-house.
/// Nessuna libreria esterna, nessun processo figlio.
/// </summary>
public static class MetadataReader
{
/// <summary>Quantità massima di header letta per i contenitori TIFF/RAW (le IFD stanno all'inizio).</summary>
private const int TiffHeaderBudget = 4 * 1024 * 1024;
private static readonly byte[] ExifSignature = "Exif\0\0"u8.ToArray();
private static readonly byte[] XmpSignature = "http://ns.adobe.com/xap/1.0/\0"u8.ToArray();
public static readonly string[] SupportedExtensions =
[
".jpg", ".jpeg", ".jpe", ".jfif",
".tif", ".tiff",
".png",
".heic", ".heif", ".avif",
".webp",
".dng", ".cr2", ".cr3", ".nef", ".nrw", ".arw", ".srf", ".sr2",
".orf", ".rw2", ".raf", ".pef", ".raw", ".3fr", ".iiq",
];
public static bool IsSupported(string path)
=> SupportedExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
public static FrameMetadata Read(string path)
{
var info = new FileInfo(path);
string? warning = null;
DateTime? capture = null;
var source = TimestampSource.None;
TimeSpan? utcOffset = null;
double? exposure = null, fnumber = null, focal = null, bias = null;
int? iso = null;
int width = 0, height = 0, orientation = 1;
string? camera = null, lens = null;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
64 * 1024, FileOptions.SequentialScan);
var container = DetectContainer(stream, out byte[] head);
TiffBlock? tiff = null;
string? xmpPacket = null;
switch (container)
{
case ContainerKind.Jpeg:
ScanJpeg(stream, ref tiff, ref xmpPacket, ref width, ref height, ref warning);
break;
case ContainerKind.Tiff:
{
int budget = (int)Math.Min(info.Length, TiffHeaderBudget);
byte[] buffer = ReadAt(stream, 0, budget);
tiff = TiffBlock.Parse(buffer, 0, buffer.Length);
xmpPacket = FindXmpInBuffer(buffer);
if (tiff is null) warning = "Header TIFF non interpretabile.";
break;
}
case ContainerKind.Png:
ScanPng(stream, ref tiff, ref xmpPacket, ref width, ref height);
break;
default:
{
// Contenitori ISO-BMFF (HEIC/AVIF), WebP e formati proprietari: individuiamo
// il blocco Exif/XMP per scansione diretta dell'header.
int budget = (int)Math.Min(info.Length, TiffHeaderBudget);
byte[] buffer = ReadAt(stream, 0, budget);
tiff = FindTiffInBuffer(buffer);
xmpPacket = FindXmpInBuffer(buffer);
if (tiff is null && xmpPacket is null)
warning = "Nessun blocco Exif/XMP riconosciuto nell'header.";
break;
}
}
if (tiff is not null)
{
string? dt = tiff.GetString(TiffTags.DateTimeOriginal)
?? tiff.GetString(TiffTags.DateTimeDigitized)
?? tiff.GetString(TiffTags.DateTime);
string? sub = tiff.GetString(TiffTags.SubSecTimeOriginal)
?? tiff.GetString(TiffTags.SubSecTimeDigitized)
?? tiff.GetString(TiffTags.SubSecTime);
capture = FrameMetadata.CombineDateTime(dt, sub, out source);
utcOffset = FrameMetadata.ParseUtcOffset(
tiff.GetString(TiffTags.OffsetTimeOriginal) ?? tiff.GetString(TiffTags.OffsetTime));
if (tiff.TryGetDouble(TiffTags.ExposureTime, out double e) && e > 0) exposure = e;
else if (tiff.TryGetDouble(TiffTags.ShutterSpeedValue, out double apexTv))
exposure = Math.Pow(2.0, -apexTv); // APEX: Tv = -log2(t)
if (tiff.TryGetDouble(TiffTags.FNumber, out double f) && f > 0) fnumber = f;
else if (tiff.TryGetDouble(TiffTags.ApertureValue, out double apexAv))
fnumber = Math.Pow(2.0, apexAv / 2.0); // APEX: Av = 2*log2(N)
if (tiff.TryGetFirstUInt32(out uint isoValue, TiffTags.IsoSpeedRatings,
TiffTags.RecommendedExposureIndex, TiffTags.IsoSpeed))
iso = (int)Math.Min(isoValue, int.MaxValue);
if (tiff.TryGetDouble(TiffTags.FocalLength, out double fl) && fl > 0) focal = fl;
if (tiff.TryGetDouble(TiffTags.ExposureBiasValue, out double eb)) bias = eb;
if (tiff.TryGetUInt32(TiffTags.Orientation, out uint o) && o is >= 1 and <= 8) orientation = (int)o;
if (width == 0 && tiff.TryGetUInt32(TiffTags.PixelXDimension, out uint pw)) width = (int)pw;
if (height == 0 && tiff.TryGetUInt32(TiffTags.PixelYDimension, out uint ph)) height = (int)ph;
if (width == 0 && tiff.TryGetUInt32(TiffTags.ImageWidth, out uint iw)) width = (int)iw;
if (height == 0 && tiff.TryGetUInt32(TiffTags.ImageLength, out uint ih)) height = (int)ih;
string? make = tiff.GetString(TiffTags.Make);
string? model = tiff.GetString(TiffTags.Model);
camera = ComposeCamera(make, model);
lens = tiff.GetString(TiffTags.LensModel);
// Alcuni contenitori riportano l'XMP dentro il tag TIFF 0x02BC.
if (xmpPacket is null && tiff.TryFind(TiffTags.XmpPacket, out var xmpEntry))
{
byte[] raw = tiff.View.ToArray(xmpEntry.ValuePosition, Math.Min(xmpEntry.ByteLength, 1 << 20));
if (raw.Length > 0) xmpPacket = XmpScanner.ExtractPacket(Encoding.UTF8.GetString(raw));
}
}
// XMP come sorgente complementare: riempie solo i campi ancora ignoti.
if (xmpPacket is not null)
{
var x = XmpScanner.Scan(xmpPacket);
if (capture is null && x.CaptureTime is { } xc)
{
capture = xc;
source = TimestampSource.Xmp;
}
utcOffset ??= x.UtcOffset;
exposure ??= x.ExposureSeconds;
fnumber ??= x.FNumber;
iso ??= x.Iso;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
warning = "Lettura non riuscita: " + ex.Message;
}
catch (Exception ex)
{
warning = "Metadati non interpretabili: " + ex.Message;
}
if (capture is null)
{
// Ultima risorsa: la data di modifica del file, marcata come tale in UI.
capture = info.Exists ? info.LastWriteTime : null;
if (capture is not null) source = TimestampSource.FileSystem;
}
return new FrameMetadata
{
FilePath = path,
FileName = Path.GetFileName(path),
FileSize = info.Exists ? info.Length : 0,
CaptureTime = capture,
CaptureSource = source,
UtcOffset = utcOffset,
ExposureSeconds = exposure,
FNumber = fnumber,
Iso = iso,
FocalLength = focal,
ExposureBias = bias,
PixelWidth = width,
PixelHeight = height,
Orientation = orientation,
Camera = camera,
Lens = lens,
Warning = warning,
};
}
private static string? ComposeCamera(string? make, string? model)
{
make = make?.Trim();
model = model?.Trim();
if (string.IsNullOrEmpty(model)) return string.IsNullOrEmpty(make) ? null : make;
if (string.IsNullOrEmpty(make)) return model;
// Molti modelli ripetono già il produttore ("NIKON D850" con make "NIKON CORPORATION").
string firstWord = make.Split(' ')[0];
return model.StartsWith(firstWord, StringComparison.OrdinalIgnoreCase) ? model : make + " " + model;
}
// ------------------------------------------------------------------ contenitori
private enum ContainerKind { Unknown, Jpeg, Tiff, Png }
private static ContainerKind DetectContainer(FileStream stream, out byte[] head)
{
head = ReadAt(stream, 0, 16);
if (head.Length >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF) return ContainerKind.Jpeg;
if (head.Length >= 4 && ((head[0] == 0x49 && head[1] == 0x49) || (head[0] == 0x4D && head[1] == 0x4D)))
return ContainerKind.Tiff;
if (head.Length >= 8 && head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47)
return ContainerKind.Png;
return ContainerKind.Unknown;
}
/// <summary>
/// Percorre i marker JPEG senza decodificare l'immagine: legge solo gli header di segmento
/// e i payload APP1 (Exif/XMP), estraendo le dimensioni dal segmento SOF.
/// </summary>
private static void ScanJpeg(FileStream stream, ref TiffBlock? tiff, ref string? xmp,
ref int width, ref int height, ref string? warning)
{
stream.Position = 2;
Span<byte> header = stackalloc byte[4];
long length = stream.Length;
int guard = 0;
while (stream.Position < length && guard++ < 4096)
{
int b = stream.ReadByte();
if (b < 0) break;
if (b != 0xFF) continue; // risincronizzazione sul prossimo marker
int marker;
do { marker = stream.ReadByte(); } while (marker == 0xFF);
if (marker < 0) break;
// Marker senza payload.
if (marker is 0x01 or 0xD8 or 0xD9 || (marker >= 0xD0 && marker <= 0xD7)) continue;
if (marker == 0xDA) break; // inizio dati compressi: oltre non serve
if (stream.Read(header[..2]) != 2) break;
int segLength = (header[0] << 8) | header[1];
if (segLength < 2) break;
int payload = segLength - 2;
long payloadStart = stream.Position;
if (marker == 0xE1 && payload > 6)
{
byte[] data = ReadExactly(stream, Math.Min(payload, 16 * 1024 * 1024));
if (data.Length >= ExifSignature.Length && Matches(data, ExifSignature))
{
tiff ??= TiffBlock.Parse(data, ExifSignature.Length, data.Length - ExifSignature.Length);
if (tiff is null) warning = "Blocco Exif presente ma non interpretabile.";
}
else if (data.Length >= XmpSignature.Length && Matches(data, XmpSignature))
{
xmp ??= XmpScanner.ExtractPacket(Encoding.UTF8.GetString(data, XmpSignature.Length,
data.Length - XmpSignature.Length));
}
}
else if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC)
{
// SOFn: precision(1) height(2) width(2)
byte[] sof = ReadExactly(stream, Math.Min(payload, 9));
if (sof.Length >= 5)
{
height = (sof[1] << 8) | sof[2];
width = (sof[3] << 8) | sof[4];
}
}
stream.Position = payloadStart + payload;
}
}
private static void ScanPng(FileStream stream, ref TiffBlock? tiff, ref string? xmp, ref int width, ref int height)
{
stream.Position = 8;
Span<byte> header = stackalloc byte[8];
int guard = 0;
while (stream.Position + 8 <= stream.Length && guard++ < 1024)
{
if (stream.Read(header) != 8) break;
uint len = ((uint)header[0] << 24) | ((uint)header[1] << 16) | ((uint)header[2] << 8) | header[3];
string type = Encoding.ASCII.GetString(header[4..].ToArray());
long dataStart = stream.Position;
if (len > 64 * 1024 * 1024) break;
switch (type)
{
case "IHDR" when len >= 8:
{
byte[] ihdr = ReadExactly(stream, 8);
if (ihdr.Length == 8)
{
width = (ihdr[0] << 24) | (ihdr[1] << 16) | (ihdr[2] << 8) | ihdr[3];
height = (ihdr[4] << 24) | (ihdr[5] << 16) | (ihdr[6] << 8) | ihdr[7];
}
break;
}
case "eXIf":
{
byte[] data = ReadExactly(stream, (int)len);
tiff ??= TiffBlock.Parse(data, 0, data.Length);
break;
}
case "iTXt" or "tEXt" or "zTXt":
{
byte[] data = ReadExactly(stream, (int)Math.Min(len, 4 * 1024 * 1024));
xmp ??= XmpScanner.ExtractPacket(Encoding.UTF8.GetString(data));
break;
}
case "IDAT" or "IEND":
return; // i metadati utili precedono i dati pixel
}
stream.Position = dataStart + len + 4; // + CRC
}
}
// ------------------------------------------------------------------ fallback per scansione
private static TiffBlock? FindTiffInBuffer(byte[] buffer)
{
var view = new ByteView(buffer, 0, buffer.Length, false);
int idx = view.IndexOf(ExifSignature);
if (idx >= 0)
{
var block = TiffBlock.Parse(buffer, idx + ExifSignature.Length, buffer.Length - idx - ExifSignature.Length);
if (block is not null) return block;
}
// Alcuni contenitori scrivono l'header TIFF senza prefisso "Exif\0\0".
foreach (var marker in (ReadOnlySpan<byte[]>)[[0x49, 0x49, 0x2A, 0x00], [0x4D, 0x4D, 0x00, 0x2A]])
{
int at = view.IndexOf(marker);
if (at < 0) continue;
var block = TiffBlock.Parse(buffer, at, buffer.Length - at);
if (block is not null && (block.Exif.Count > 0 || block.Ifd0.Count > 3)) return block;
}
return null;
}
private static string? FindXmpInBuffer(byte[] buffer)
{
var view = new ByteView(buffer, 0, buffer.Length, false);
int idx = view.IndexOf("<x:xmpmeta"u8);
if (idx < 0) idx = view.IndexOf("<?xpacket"u8);
if (idx < 0) return null;
int take = Math.Min(buffer.Length - idx, 512 * 1024);
return XmpScanner.ExtractPacket(Encoding.UTF8.GetString(buffer, idx, take));
}
// ------------------------------------------------------------------ utilità di I/O
private static bool Matches(byte[] data, byte[] signature)
{
if (data.Length < signature.Length) return false;
for (int i = 0; i < signature.Length; i++)
{
if (data[i] != signature[i]) return false;
}
return true;
}
private static byte[] ReadAt(FileStream stream, long offset, int count)
{
if (count <= 0) return [];
stream.Position = offset;
return ReadExactly(stream, count);
}
private static byte[] ReadExactly(FileStream stream, int count)
{
if (count <= 0) return [];
var buffer = new byte[count];
int read = 0;
while (read < count)
{
int n = stream.Read(buffer, read, count - read);
if (n <= 0) break;
read += n;
}
if (read == count) return buffer;
Array.Resize(ref buffer, read);
return buffer;
}
}
+304
View File
@@ -0,0 +1,304 @@
namespace Titano.Metadata;
/// <summary>
/// Parser in-house di un blocco TIFF (header + catena di IFD), condiviso da JPEG/APP1,
/// file TIFF nativi, DNG e dai principali formati RAW derivati da TIFF (CR2, NEF, ARW, ORF...).
/// </summary>
internal sealed class TiffBlock
{
public ByteView View { get; }
public Dictionary<ushort, TiffEntry> Ifd0 { get; } = [];
public Dictionary<ushort, TiffEntry> Exif { get; } = [];
public Dictionary<ushort, TiffEntry> Gps { get; } = [];
public List<Dictionary<ushort, TiffEntry>> SubIfds { get; } = [];
private TiffBlock(ByteView view) => View = view;
/// <summary>Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory.</summary>
public static TiffBlock? Parse(byte[] data, int origin, int length)
{
var probe = new ByteView(data, origin, length, false);
if (!probe.TryGetUInt16(0, out ushort order)) return null;
bool bigEndian;
if (order == 0x4949) bigEndian = false; // "II" - Intel
else if (order == 0x4D4D) bigEndian = true; // "MM" - Motorola
else return null;
var view = probe.WithEndianness(bigEndian);
if (!view.TryGetUInt16(2, out ushort magic)) return null;
// 42 = TIFF classico. 0x4F52/0x5352 compaiono in alcuni RAW Olympus, li accettiamo.
if (magic != 42 && magic != 0x4F52 && magic != 0x5352) return null;
if (!view.TryGetUInt32(4, out uint firstIfd)) return null;
var block = new TiffBlock(view);
block.ReadChain(firstIfd);
return block;
}
private void ReadChain(uint offset)
{
var visited = new HashSet<uint>();
int guard = 0;
uint next = offset;
while (next != 0 && visited.Add(next) && guard++ < 32)
{
var dir = ReadDirectory(next, out uint following);
if (dir is null) break;
if (Ifd0.Count == 0) MergeInto(Ifd0, dir);
else SubIfds.Add(dir);
// Puntatori alle sub-directory standard.
if (dir.TryGetValue(TiffTags.ExifIfdPointer, out var exifPtr) && TryReadPointer(exifPtr, out uint eo))
{
var d = ReadDirectory(eo, out _);
if (d is not null) MergeInto(Exif, d);
}
if (dir.TryGetValue(TiffTags.GpsIfdPointer, out var gpsPtr) && TryReadPointer(gpsPtr, out uint go))
{
var d = ReadDirectory(go, out _);
if (d is not null) MergeInto(Gps, d);
}
// SubIFDs (DNG / RAW): possono contenere le dimensioni dell'immagine full-res.
if (dir.TryGetValue(TiffTags.SubIfds, out var subs))
{
int count = (int)Math.Min(subs.Count, 8);
for (int i = 0; i < count; i++)
{
if (!View.TryGetUInt32(subs.ValuePosition + i * 4, out uint so)) break;
var d = ReadDirectory(so, out _);
if (d is not null) SubIfds.Add(d);
}
}
next = following;
}
}
private static void MergeInto(Dictionary<ushort, TiffEntry> target, Dictionary<ushort, TiffEntry> source)
{
foreach (var kv in source) target.TryAdd(kv.Key, kv.Value);
}
private bool TryReadPointer(in TiffEntry entry, out uint offset)
{
offset = 0;
if (entry.Count < 1) return false;
return View.TryGetUInt32(entry.ValuePosition, out offset);
}
private Dictionary<ushort, TiffEntry>? ReadDirectory(uint offset, out uint nextIfd)
{
nextIfd = 0;
if (!View.TryGetUInt16(offset, out ushort count) || count == 0 || count > 4096) return null;
var dir = new Dictionary<ushort, TiffEntry>(count);
long p = offset + 2L;
for (int i = 0; i < count; i++, p += 12)
{
if (!View.TryGetUInt16(p, out ushort tag)) break;
if (!View.TryGetUInt16(p + 2, out ushort rawType)) break;
if (!View.TryGetUInt32(p + 4, out uint n)) break;
var type = (TiffType)rawType;
int unit = TiffEntry.SizeOf(type);
if (unit == 0) continue; // tipo sconosciuto: voce ignorata
if (n > int.MaxValue / Math.Max(unit, 1)) continue;
long bytes = (long)unit * n;
long valuePos;
if (bytes <= 4)
{
valuePos = p + 8; // valore inline nei 4 byte della voce
}
else
{
if (!View.TryGetUInt32(p + 8, out uint far)) continue;
valuePos = far;
}
if (!View.InRange(valuePos, bytes)) continue;
dir[tag] = new TiffEntry(tag, type, n, valuePos);
}
View.TryGetUInt32(p, out nextIfd);
return dir;
}
// ---------------------------------------------------------------- accesso ai valori
public bool TryFind(ushort tag, out TiffEntry entry)
{
if (Exif.TryGetValue(tag, out entry)) return true;
if (Ifd0.TryGetValue(tag, out entry)) return true;
foreach (var sub in SubIfds)
{
if (sub.TryGetValue(tag, out entry)) return true;
}
entry = default;
return false;
}
public string? GetString(ushort tag)
{
if (!TryFind(tag, out var e)) return null;
if (e.Type is TiffType.Ascii or TiffType.Byte or TiffType.Undefined)
return View.GetAscii(e.ValuePosition, e.ByteLength);
// Alcuni firmware scrivono valori numerici dove ci si aspetta testo.
return TryGetDouble(tag, out double d) ? d.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
}
public bool TryGetUInt32(ushort tag, out uint value)
{
value = 0;
if (!TryFind(tag, out var e) || e.Count < 1) return false;
return TryReadScalarUInt(e, 0, out value);
}
private bool TryReadScalarUInt(in TiffEntry e, int index, out uint value)
{
value = 0;
long pos = e.ValuePosition + (long)index * TiffEntry.SizeOf(e.Type);
switch (e.Type)
{
case TiffType.Byte or TiffType.SByte or TiffType.Undefined:
if (!View.TryGetByte(pos, out byte b)) return false;
value = b; return true;
case TiffType.Short or TiffType.SShort:
if (!View.TryGetUInt16(pos, out ushort s)) return false;
value = s; return true;
case TiffType.Long or TiffType.SLong or TiffType.Ifd:
return View.TryGetUInt32(pos, out value);
default:
if (!TryReadIndexedDouble(e, index, out double d)) return false;
value = d is >= 0 and <= uint.MaxValue ? (uint)d : 0;
return true;
}
}
public bool TryGetDouble(ushort tag, out double value)
{
value = 0;
if (!TryFind(tag, out var e) || e.Count < 1) return false;
return TryReadIndexedDouble(e, 0, out value);
}
private bool TryReadIndexedDouble(in TiffEntry e, int index, out double value)
{
value = 0;
long pos = e.ValuePosition + (long)index * TiffEntry.SizeOf(e.Type);
switch (e.Type)
{
case TiffType.Rational:
{
if (!View.TryGetUInt32(pos, out uint num) || !View.TryGetUInt32(pos + 4, out uint den)) return false;
if (den == 0) { value = num == 0 ? 0 : double.PositiveInfinity; return num == 0; }
value = (double)num / den;
return true;
}
case TiffType.SRational:
{
if (!View.TryGetInt32(pos, out int num) || !View.TryGetInt32(pos + 4, out int den)) return false;
if (den == 0) return false;
value = (double)num / den;
return true;
}
case TiffType.Float:
{
if (!View.TryGetSingle(pos, out float f)) return false;
value = f; return true;
}
case TiffType.Double:
return View.TryGetDouble(pos, out value);
case TiffType.SShort:
{
if (!View.TryGetUInt16(pos, out ushort us)) return false;
value = (short)us; return true;
}
case TiffType.SLong:
{
if (!View.TryGetInt32(pos, out int i)) return false;
value = i; return true;
}
default:
{
if (!TryReadScalarUIntRaw(e.Type, pos, out uint u)) return false;
value = u; return true;
}
}
}
private bool TryReadScalarUIntRaw(TiffType type, long pos, out uint value)
{
value = 0;
switch (type)
{
case TiffType.Byte or TiffType.SByte or TiffType.Undefined:
if (!View.TryGetByte(pos, out byte b)) return false;
value = b; return true;
case TiffType.Short:
if (!View.TryGetUInt16(pos, out ushort s)) return false;
value = s; return true;
case TiffType.Long or TiffType.Ifd:
return View.TryGetUInt32(pos, out value);
default:
return false;
}
}
/// <summary>Primo valore non nullo di una lista di tag (utile per ISO, presente in più varianti).</summary>
public bool TryGetFirstUInt32(out uint value, params ushort[] tags)
{
foreach (ushort t in tags)
{
if (TryGetUInt32(t, out value) && value != 0) return true;
}
value = 0;
return false;
}
}
/// <summary>Numerazione dei tag TIFF/Exif effettivamente utilizzati dal motore.</summary>
internal static class TiffTags
{
public const ushort ImageWidth = 0x0100;
public const ushort ImageLength = 0x0101;
public const ushort Make = 0x010F;
public const ushort Model = 0x0110;
public const ushort Orientation = 0x0112;
public const ushort SubIfds = 0x014A;
public const ushort DateTime = 0x0132;
public const ushort XmpPacket = 0x02BC;
public const ushort ExposureTime = 0x829A;
public const ushort FNumber = 0x829D;
public const ushort ExifIfdPointer = 0x8769;
public const ushort IsoSpeedRatings = 0x8827;
public const ushort SensitivityType = 0x8830;
public const ushort RecommendedExposureIndex = 0x8832;
public const ushort IsoSpeed = 0x8833;
public const ushort GpsIfdPointer = 0x8825;
public const ushort DateTimeOriginal = 0x9003;
public const ushort DateTimeDigitized = 0x9004;
public const ushort OffsetTime = 0x9010;
public const ushort OffsetTimeOriginal = 0x9011;
public const ushort OffsetTimeDigitized = 0x9012;
public const ushort ShutterSpeedValue = 0x9201;
public const ushort ApertureValue = 0x9202;
public const ushort ExposureBiasValue = 0x9204;
public const ushort MeteringMode = 0x9207;
public const ushort FocalLength = 0x920A;
public const ushort SubSecTime = 0x9290;
public const ushort SubSecTimeOriginal = 0x9291;
public const ushort SubSecTimeDigitized = 0x9292;
public const ushort PixelXDimension = 0xA002;
public const ushort PixelYDimension = 0xA003;
public const ushort ExposureMode = 0xA402;
public const ushort WhiteBalance = 0xA403;
public const ushort LensModel = 0xA434;
}
+165
View File
@@ -0,0 +1,165 @@
namespace Titano.Metadata;
/// <summary>Tipi di dato definiti dalla specifica TIFF 6.0 / Exif 2.32.</summary>
internal enum TiffType : ushort
{
Unknown = 0,
Byte = 1,
Ascii = 2,
Short = 3,
Long = 4,
Rational = 5,
SByte = 6,
Undefined = 7,
SShort = 8,
SLong = 9,
SRational = 10,
Float = 11,
Double = 12,
Ifd = 13,
}
/// <summary>Voce di una IFD: 12 byte nel file, con il valore inline se occupa ≤ 4 byte.</summary>
internal readonly struct TiffEntry
{
public readonly ushort Tag;
public readonly TiffType Type;
public readonly uint Count;
/// <summary>Offset assoluto (rispetto all'inizio del blocco TIFF) dove risiede il valore.</summary>
public readonly long ValuePosition;
public TiffEntry(ushort tag, TiffType type, uint count, long valuePosition)
{
Tag = tag;
Type = type;
Count = count;
ValuePosition = valuePosition;
}
public static int SizeOf(TiffType type) => type switch
{
TiffType.Byte or TiffType.Ascii or TiffType.SByte or TiffType.Undefined => 1,
TiffType.Short or TiffType.SShort => 2,
TiffType.Long or TiffType.SLong or TiffType.Float or TiffType.Ifd => 4,
TiffType.Rational or TiffType.SRational or TiffType.Double => 8,
_ => 0,
};
public long ByteLength => (long)SizeOf(Type) * Count;
}
/// <summary>
/// Lettore binario endian-aware su uno <see cref="ReadOnlySpan{T}"/> logico (buffer immutabile).
/// Tutti gli accessi sono limitati: un file malformato produce valori assenti, mai eccezioni.
/// </summary>
internal readonly struct ByteView
{
private readonly byte[] _data;
private readonly int _origin;
private readonly int _length;
public readonly bool BigEndian;
public ByteView(byte[] data, int origin, int length, bool bigEndian)
{
_data = data;
_origin = Math.Clamp(origin, 0, data.Length);
_length = Math.Clamp(length, 0, data.Length - _origin);
BigEndian = bigEndian;
}
public ByteView WithEndianness(bool bigEndian) => new(_data, _origin, _length, bigEndian);
public int Length => _length;
public bool InRange(long offset, long count)
=> offset >= 0 && count >= 0 && offset + count <= _length;
public bool TryGetByte(long offset, out byte value)
{
if (!InRange(offset, 1)) { value = 0; return false; }
value = _data[_origin + (int)offset];
return true;
}
public bool TryGetUInt16(long offset, out ushort value)
{
value = 0;
if (!InRange(offset, 2)) return false;
int p = _origin + (int)offset;
value = BigEndian
? (ushort)((_data[p] << 8) | _data[p + 1])
: (ushort)((_data[p + 1] << 8) | _data[p]);
return true;
}
public bool TryGetUInt32(long offset, out uint value)
{
value = 0;
if (!InRange(offset, 4)) return false;
int p = _origin + (int)offset;
value = BigEndian
? ((uint)_data[p] << 24) | ((uint)_data[p + 1] << 16) | ((uint)_data[p + 2] << 8) | _data[p + 3]
: ((uint)_data[p + 3] << 24) | ((uint)_data[p + 2] << 16) | ((uint)_data[p + 1] << 8) | _data[p];
return true;
}
public bool TryGetInt32(long offset, out int value)
{
bool ok = TryGetUInt32(offset, out uint raw);
value = unchecked((int)raw);
return ok;
}
public bool TryGetSingle(long offset, out float value)
{
value = 0f;
if (!TryGetUInt32(offset, out uint raw)) return false;
value = BitConverter.UInt32BitsToSingle(raw);
return true;
}
public bool TryGetDouble(long offset, out double value)
{
value = 0d;
if (!InRange(offset, 8)) return false;
TryGetUInt32(offset, out uint a);
TryGetUInt32(offset + 4, out uint b);
ulong raw = BigEndian ? ((ulong)a << 32) | b : ((ulong)b << 32) | a;
value = BitConverter.UInt64BitsToDouble(raw);
return true;
}
public string? GetAscii(long offset, long count)
{
if (!InRange(offset, count) || count <= 0) return null;
int p = _origin + (int)offset;
int n = (int)count;
// Le stringhe Exif sono NUL-terminate; alcuni firmware riempiono di spazi.
int end = n;
for (int i = 0; i < n; i++)
{
if (_data[p + i] == 0) { end = i; break; }
}
while (end > 0 && (_data[p + end - 1] == ' ' || _data[p + end - 1] == '\t')) end--;
if (end <= 0) return null;
return System.Text.Encoding.Latin1.GetString(_data, p, end);
}
/// <summary>Ricerca di un pattern di byte; -1 se assente. Usata per i fallback di scansione.</summary>
public int IndexOf(ReadOnlySpan<byte> pattern, int startAt = 0)
{
if (pattern.Length == 0 || pattern.Length > _length) return -1;
var haystack = new ReadOnlySpan<byte>(_data, _origin, _length);
int idx = haystack[Math.Clamp(startAt, 0, _length)..].IndexOf(pattern);
return idx < 0 ? -1 : idx + Math.Clamp(startAt, 0, _length);
}
public byte[] ToArray(long offset, long count)
{
if (!InRange(offset, count) || count <= 0) return [];
var result = new byte[count];
Array.Copy(_data, _origin + (int)offset, result, 0, (int)count);
return result;
}
}
+149
View File
@@ -0,0 +1,149 @@
using System.Globalization;
namespace Titano.Metadata;
/// <summary>
/// Estrattore XMP minimale scritto in-house: non costruisce un albero XML, esegue una
/// scansione lineare del packet cercando le proprietà utili sia in forma attributo
/// (<c>xmp:CreateDate="..."</c>) sia in forma elemento (<c>&lt;xmp:CreateDate&gt;...&lt;/&gt;</c>).
/// </summary>
internal static class XmpScanner
{
public readonly record struct XmpFields(
DateTime? CaptureTime,
TimeSpan? UtcOffset,
double? ExposureSeconds,
double? FNumber,
int? Iso);
private static readonly string[] DateProps = ["exif:DateTimeOriginal", "photoshop:DateCreated", "xmp:CreateDate"];
public static XmpFields Scan(string packet)
{
DateTime? time = null;
TimeSpan? offset = null;
foreach (string prop in DateProps)
{
string? raw = ReadProperty(packet, prop);
if (raw is null) continue;
if (TryParseIso8601(raw, out var local, out var off))
{
time = local;
offset = off;
break;
}
}
double? exposure = ReadRational(packet, "exif:ExposureTime");
double? fnumber = ReadRational(packet, "exif:FNumber");
if (fnumber is null && ReadRational(packet, "exif:ApertureValue") is { } apex)
fnumber = Math.Round(Math.Pow(2.0, apex / 2.0), 2);
int? iso = null;
// ISOSpeedRatings è tipicamente una rdf:Seq: prendiamo il primo <rdf:li>.
string? isoBlock = ReadProperty(packet, "exif:ISOSpeedRatings") ?? ReadProperty(packet, "exif:PhotographicSensitivity");
if (isoBlock is not null)
{
string digits = ExtractFirstNumber(isoBlock);
if (int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) && v > 0) iso = v;
}
return new XmpFields(time, offset, exposure, fnumber, iso);
}
/// <summary>Individua un packet XMP dentro un buffer generico di testo/binario.</summary>
public static string? ExtractPacket(string text)
{
int start = text.IndexOf("<x:xmpmeta", StringComparison.Ordinal);
if (start < 0) start = text.IndexOf("<rdf:RDF", StringComparison.Ordinal);
if (start < 0) return null;
int end = text.IndexOf("</x:xmpmeta>", start, StringComparison.Ordinal);
if (end < 0) end = text.IndexOf("</rdf:RDF>", start, StringComparison.Ordinal);
if (end < 0) return text[start..];
return text[start..Math.Min(text.Length, end + 12)];
}
private static string? ReadProperty(string packet, string name)
{
// Forma attributo: name="value"
int idx = 0;
while ((idx = packet.IndexOf(name, idx, StringComparison.Ordinal)) >= 0)
{
int after = idx + name.Length;
if (after >= packet.Length) break;
char c = packet[after];
if (c == '=')
{
int q = packet.IndexOfAny(['"', '\''], after);
if (q < 0) break;
char quote = packet[q];
int close = packet.IndexOf(quote, q + 1);
if (close < 0) break;
return packet[(q + 1)..close];
}
if (c is '>' or ' ' or '\r' or '\n' or '\t' or '/')
{
int gt = packet.IndexOf('>', after);
if (gt < 0) break;
if (packet[gt - 1] == '/') { idx = after; continue; } // elemento vuoto
int closeTag = packet.IndexOf("</" + name, gt, StringComparison.Ordinal);
if (closeTag < 0) break;
return packet[(gt + 1)..closeTag].Trim();
}
idx = after;
}
return null;
}
private static double? ReadRational(string packet, string name)
{
string? raw = ReadProperty(packet, name);
if (string.IsNullOrWhiteSpace(raw)) return null;
raw = raw.Trim();
int slash = raw.IndexOf('/');
if (slash > 0)
{
if (double.TryParse(raw[..slash], NumberStyles.Float, CultureInfo.InvariantCulture, out double n) &&
double.TryParse(raw[(slash + 1)..], NumberStyles.Float, CultureInfo.InvariantCulture, out double d) &&
d != 0)
return n / d;
return null;
}
return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double v) ? v : null;
}
private static string ExtractFirstNumber(string text)
{
int i = 0;
while (i < text.Length && (text[i] < '0' || text[i] > '9')) i++;
int start = i;
while (i < text.Length && text[i] is >= '0' and <= '9') i++;
return start < i ? text[start..i] : string.Empty;
}
/// <summary>Parsing ISO-8601 ("2024-06-01T18:32:11.250+02:00") senza dipendere dal formattatore di sistema.</summary>
internal static bool TryParseIso8601(string text, out DateTime local, out TimeSpan? offset)
{
local = default;
offset = null;
text = text.Trim();
if (text.Length < 10) return false;
if (!DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces, out var dto))
{
// Fallback per date parziali "YYYY-MM-DD".
if (!DateTime.TryParseExact(text[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out local)) return false;
return true;
}
bool hasOffset = text.EndsWith('Z') || text.LastIndexOfAny(['+', '-']) > 10;
if (hasOffset) offset = dto.Offset;
local = DateTime.SpecifyKind(dto.DateTime, DateTimeKind.Unspecified);
return true;
}
}
+72
View File
@@ -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.
+48
View File
@@ -0,0 +1,48 @@
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>
/// Interpolazione temporale fra due fotogrammi guidata dal campo vettoriale.
///
/// Si usa il warping all'indietro bidirezionale: l'istante t viene ricostruito prelevando da
/// A lungo t·v e da B lungo +(1t)·v, poi i due contributi vengono miscelati con pesi
/// complementari. Il warping all'indietro non lascia buchi (a differenza di quello in avanti)
/// e la miscelazione incrociata attenua gli errori del campo nelle zone di occlusione.
/// </summary>
public static class FrameInterpolator
{
/// <summary>Genera il fotogramma all'istante <paramref name="t"/> ∈ [0,1] fra A e B.</summary>
public static void Interpolate(ImageBuffer a, ImageBuffer b, MotionField flowAtoB,
float t, ImageBuffer destination)
{
if (t <= 0f) { destination.CopyFrom(a); return; }
if (t >= 1f) { destination.CopyFrom(b); return; }
int width = a.Width;
int height = a.Height;
var srcA = a.Data;
var srcB = b.Data;
var dst = destination.Data;
float wa = 1f - t;
Parallel.For(0, height, y =>
{
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int index = rowBase + x * ImageBuffer.Channels;
flowAtoB.Sample(x, y, out float vx, out float vy);
MotionBlurRenderer.SampleBilinear(srcA, width, height, x - vx * t, y - vy * t,
out float ar, out float ag, out float ab);
MotionBlurRenderer.SampleBilinear(srcB, width, height, x + vx * wa, y + vy * wa,
out float br, out float bg, out float bb);
dst[index] = ar * wa + br * t;
dst[index + 1] = ag * wa + bg * t;
dst[index + 2] = ab * wa + bb * t;
}
});
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Runtime.CompilerServices;
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Piano di luminanza percettiva a precisione singola, con campionamento bilineare.</summary>
public sealed class GrayImage(int width, int height)
{
public int Width { get; } = width;
public int Height { get; } = height;
public float[] Data { get; } = new float[width * height];
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float At(int x, int y)
{
x = x < 0 ? 0 : x >= Width ? Width - 1 : x;
y = y < 0 ? 0 : y >= Height ? Height - 1 : y;
return Data[y * Width + x];
}
/// <summary>Campionamento bilineare con estensione dei bordi.</summary>
public float Sample(float x, float y)
{
int x0 = (int)MathF.Floor(x);
int y0 = (int)MathF.Floor(y);
float fx = x - x0;
float fy = y - y0;
float a = At(x0, y0);
float b = At(x0 + 1, y0);
float c = At(x0, y0 + 1);
float d = At(x0 + 1, y0 + 1);
float top = a + (b - a) * fx;
float bottom = c + (d - c) * fx;
return top + (bottom - top) * fy;
}
}
/// <summary>
/// Piramide gaussiana costruita in-house.
///
/// Il livello 0 è la luminanza dell'immagine ridotta alla risoluzione di analisi: il campo di
/// movimento non richiede la piena risoluzione e lavorare in scala ridotta rende il costo
/// dell'optical flow indipendente dalla dimensione dei file sorgente.
/// La luminanza viene ricodificata in gamma percettiva, perché in luce lineare i gradienti
/// delle zone scure sarebbero numericamente trascurabili rispetto alle alte luci.
/// </summary>
public sealed class GrayPyramid
{
public GrayImage[] Levels { get; }
/// <summary>Rapporto fra la risoluzione di analisi (livello 0) e quella dell'immagine originale.</summary>
public float AnalysisScale { get; }
private GrayPyramid(GrayImage[] levels, float analysisScale)
{
Levels = levels;
AnalysisScale = analysisScale;
}
public int LevelCount => Levels.Length;
public static GrayPyramid Build(ImageBuffer frame, int maxAnalysisWidth, int requestedLevels)
{
float scale = Math.Min(1f, maxAnalysisWidth / (float)frame.Width);
int w = Math.Max(16, (int)MathF.Round(frame.Width * scale));
int h = Math.Max(16, (int)MathF.Round(frame.Height * scale));
scale = w / (float)frame.Width;
var level0 = new GrayImage(w, h);
Downsample(frame, level0);
int levels = Math.Clamp(requestedLevels, 1, 6);
while (levels > 1 && (w >> (levels - 1)) < 24) levels--;
var all = new GrayImage[levels];
all[0] = level0;
for (int i = 1; i < levels; i++) all[i] = HalveWithBlur(all[i - 1]);
return new GrayPyramid(all, scale);
}
/// <summary>Riduzione da RGB lineare a luminanza percettiva con media d'area (box filter).</summary>
private static void Downsample(ImageBuffer source, GrayImage destination)
{
int sw = source.Width, sh = source.Height;
int dw = destination.Width, dh = destination.Height;
var src = source.Data;
var dst = destination.Data;
float xRatio = sw / (float)dw;
float yRatio = sh / (float)dh;
Parallel.For(0, dh, dy =>
{
int y0 = (int)(dy * yRatio);
int y1 = Math.Max(y0 + 1, Math.Min(sh, (int)((dy + 1) * yRatio)));
for (int dx = 0; dx < dw; dx++)
{
int x0 = (int)(dx * xRatio);
int x1 = Math.Max(x0 + 1, Math.Min(sw, (int)((dx + 1) * xRatio)));
float sum = 0;
int count = 0;
for (int y = y0; y < y1; y++)
{
int rowBase = y * sw * ImageBuffer.Channels;
for (int x = x0; x < x1; x++)
{
int i = rowBase + x * ImageBuffer.Channels;
sum += ColorSpace.Luminance(src[i], src[i + 1], src[i + 2]);
count++;
}
}
dst[dy * dw + dx] = ColorSpace.ToSrgb(count > 0 ? sum / count : 0f);
}
});
}
/// <summary>Dimezzamento con kernel binomiale 1-2-1 separabile.</summary>
private static GrayImage HalveWithBlur(GrayImage source)
{
int w = Math.Max(1, source.Width / 2);
int h = Math.Max(1, source.Height / 2);
var result = new GrayImage(w, h);
var dst = result.Data;
for (int y = 0; y < h; y++)
{
int sy = y * 2;
for (int x = 0; x < w; x++)
{
int sx = x * 2;
float sum =
source.At(sx - 1, sy - 1) + 2 * source.At(sx, sy - 1) + source.At(sx + 1, sy - 1)
+ 2 * source.At(sx - 1, sy) + 4 * source.At(sx, sy) + 2 * source.At(sx + 1, sy)
+ source.At(sx - 1, sy + 1) + 2 * source.At(sx, sy + 1) + source.At(sx + 1, sy + 1);
dst[y * w + x] = sum / 16f;
}
}
return result;
}
}
+194
View File
@@ -0,0 +1,194 @@
using System.Runtime.CompilerServices;
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Parametri del motion blur sintetico, esposti nel pannello "Elaborazione immagini".</summary>
public sealed class MotionBlurSettings
{
public bool Enabled { get; set; } = true;
/// <summary>Shutter angle desiderato: 180° è la convenzione cinematografica.</summary>
public double TargetShutterAngle { get; set; } = 180.0;
/// <summary>Quota della sfocatura mancante effettivamente sintetizzata.</summary>
public double Strength { get; set; } = 1.0;
/// <summary>Limite superiore della scia, in pixel: protegge dalle stime di movimento errate.</summary>
public double MaxBlurPixels { get; set; } = 48.0;
/// <summary>Numero massimo di campioni per pixel lungo la scia.</summary>
public int MaxSamples { get; set; } = 25;
public MotionBlurSettings Clone() => (MotionBlurSettings)MemberwiseClone();
}
/// <summary>
/// Motion blur direzionale sintetico lungo il campo vettoriale.
///
/// Il fattore di sfocatura mancante deriva dalla composizione delle varianze: la scia
/// realmente incisa nel fotogramma (proporzionale allo shutter angle di scatto) e quella
/// sintetica si sommano in quadratura, quindi per raggiungere l'apertura obiettivo serve
/// una scia lunga √(target² reale²) volte lo spostamento. Sommare linearmente
/// produrrebbe un'immagine sistematicamente troppo morbida.
///
/// Il filtro di ricostruzione è di tipo "gather": ogni campione contribuisce al pixel
/// centrale solo se la propria scia lo raggiunge davvero, così lo sfondo fermo non viene
/// trascinato dentro i soggetti in movimento.
/// </summary>
public static class MotionBlurRenderer
{
/// <summary>
/// Frazione di spostamento da sintetizzare per portare <paramref name="actualAngle"/>
/// al valore <paramref name="targetAngle"/>. Zero se il fotogramma è già abbastanza mosso.
/// </summary>
public static double MissingBlurFactor(double actualAngle, double targetAngle, double strength)
{
double target = Math.Clamp(targetAngle, 0, 360) / 360.0;
double actual = Math.Clamp(actualAngle, 0, 360) / 360.0;
if (target <= actual) return 0;
return Math.Sqrt(target * target - actual * actual) * Math.Clamp(strength, 0, 1);
}
/// <summary>
/// Applica la sfocatura da <paramref name="source"/> a <paramref name="destination"/>.
/// Restituisce la lunghezza media della scia effettivamente resa, in pixel.
/// </summary>
public static double Render(ImageBuffer source, ImageBuffer destination, MotionField field,
double missingFactor, MotionBlurSettings settings)
{
if (missingFactor <= 1e-4)
{
destination.CopyFrom(source);
return 0;
}
int width = source.Width;
int height = source.Height;
float factor = (float)missingFactor;
float maxBlur = (float)Math.Max(1.0, settings.MaxBlurPixels);
int maxSamples = Math.Clamp(settings.MaxSamples, 3, 129);
var src = source.Data;
var dst = destination.Data;
double lengthSum = 0;
object sumLock = new();
Parallel.For(0, height, () => 0.0, (y, _, localSum) =>
{
int rowBase = y * width * ImageBuffer.Channels;
for (int x = 0; x < width; x++)
{
int index = rowBase + x * ImageBuffer.Channels;
field.Sample(x, y, out float vx, out float vy);
float magnitude = MathF.Sqrt(vx * vx + vy * vy);
float length = Math.Min(magnitude * factor, maxBlur);
localSum += length;
if (length < 0.5f || magnitude < 1e-4f)
{
dst[index] = src[index];
dst[index + 1] = src[index + 1];
dst[index + 2] = src[index + 2];
continue;
}
float dirX = vx / magnitude;
float dirY = vy / magnitude;
float half = length * 0.5f;
int taps = Math.Min(maxSamples, Math.Max(3, (int)MathF.Ceiling(length) | 1));
int side = taps / 2;
float step = half / side;
// Il pixel centrale è sempre presente: garantisce continuità con le zone ferme.
float accR = src[index], accG = src[index + 1], accB = src[index + 2];
float weightSum = 1f;
for (int k = 1; k <= side; k++)
{
float offset = k * step;
weightSum += Accumulate(src, field, width, height, x, y,
dirX, dirY, offset, factor, maxBlur, ref accR, ref accG, ref accB);
weightSum += Accumulate(src, field, width, height, x, y,
dirX, dirY, -offset, factor, maxBlur, ref accR, ref accG, ref accB);
}
float inv = 1f / weightSum;
dst[index] = accR * inv;
dst[index + 1] = accG * inv;
dst[index + 2] = accB * inv;
}
return localSum;
},
localSum =>
{
lock (sumLock) lengthSum += localSum;
});
return lengthSum / Math.Max(1, (long)width * height);
}
/// <summary>
/// Aggiunge il contributo del campione spostato di <paramref name="offset"/> lungo la scia.
/// Il peso vale 1 solo se il movimento proprio del campione arriva a coprire il pixel centrale.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float Accumulate(float[] src, MotionField field, int width, int height,
int x, int y, float dirX, float dirY, float offset,
float factor, float maxBlur,
ref float accR, ref float accG, ref float accB)
{
float sx = x + dirX * offset;
float sy = y + dirY * offset;
if (sx < 0 || sy < 0 || sx > width - 1 || sy > height - 1) return 0f;
field.Sample(sx, sy, out float tvx, out float tvy);
float projection = MathF.Abs(tvx * dirX + tvy * dirY) * factor * 0.5f;
projection = MathF.Min(projection, maxBlur * 0.5f);
// Transizione morbida su un pixel: evita i gradini sui bordi della scia.
float weight = projection - MathF.Abs(offset) + 1f;
if (weight <= 0f) return 0f;
if (weight > 1f) weight = 1f;
SampleBilinear(src, width, height, sx, sy, out float r, out float g, out float b);
accR += r * weight;
accG += g * weight;
accB += b * weight;
return weight;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void SampleBilinear(float[] data, int width, int height, float x, float y,
out float r, out float g, out float b)
{
if (x < 0) x = 0; else if (x > width - 1) x = width - 1;
if (y < 0) y = 0; else if (y > height - 1) y = height - 1;
int x0 = (int)x;
int y0 = (int)y;
int x1 = x0 + 1 < width ? x0 + 1 : x0;
int y1 = y0 + 1 < height ? y0 + 1 : y0;
float fx = x - x0;
float fy = y - y0;
int i00 = (y0 * width + x0) * ImageBuffer.Channels;
int i10 = (y0 * width + x1) * ImageBuffer.Channels;
int i01 = (y1 * width + x0) * ImageBuffer.Channels;
int i11 = (y1 * width + x1) * ImageBuffer.Channels;
float w00 = (1 - fx) * (1 - fy);
float w10 = fx * (1 - fy);
float w01 = (1 - fx) * fy;
float w11 = fx * fy;
r = data[i00] * w00 + data[i10] * w10 + data[i01] * w01 + data[i11] * w11;
g = data[i00 + 1] * w00 + data[i10 + 1] * w10 + data[i01 + 1] * w01 + data[i11 + 1] * w11;
b = data[i00 + 2] * w00 + data[i10 + 2] * w10 + data[i01 + 2] * w01 + data[i11 + 2] * w11;
}
}
+103
View File
@@ -0,0 +1,103 @@
using System.Runtime.CompilerServices;
namespace Titano.Motion;
/// <summary>
/// Campo vettoriale di movimento memorizzato su griglia rada: un nodo ogni
/// <see cref="CellSize"/> pixel dell'immagine a piena risoluzione. I vettori sono espressi
/// in pixel di spostamento fra il fotogramma corrente e il successivo.
///
/// La griglia rada mantiene l'occupazione trascurabile (per un 4K con celle da 16 px sono
/// ~260 KB) e la lettura bilineare restituisce comunque un campo continuo per-pixel.
/// </summary>
public sealed class MotionField
{
public int GridWidth { get; }
public int GridHeight { get; }
/// <summary>Passo della griglia in pixel dell'immagine a piena risoluzione (può non essere intero).</summary>
public float CellSize { get; }
public int ImageWidth { get; }
public int ImageHeight { get; }
public float[] Vx { get; }
public float[] Vy { get; }
public MotionField(int imageWidth, int imageHeight, float cellSize)
: this(imageWidth, imageHeight, cellSize,
Math.Max(2, (int)Math.Ceiling(imageWidth / Math.Max(1f, cellSize)) + 1),
Math.Max(2, (int)Math.Ceiling(imageHeight / Math.Max(1f, cellSize)) + 1))
{
}
internal MotionField(int imageWidth, int imageHeight, float cellSize, int gridWidth, int gridHeight)
{
ImageWidth = imageWidth;
ImageHeight = imageHeight;
CellSize = Math.Max(1f, cellSize);
GridWidth = Math.Max(2, gridWidth);
GridHeight = Math.Max(2, gridHeight);
Vx = new float[GridWidth * GridHeight];
Vy = new float[GridWidth * GridHeight];
}
public int NodeCount => GridWidth * GridHeight;
/// <summary>Campionamento bilineare del campo in coordinate immagine.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Sample(float x, float y, out float vx, out float vy)
{
float gx = x / CellSize;
float gy = y / CellSize;
int x0 = (int)gx;
int y0 = (int)gy;
float fx = gx - x0;
float fy = gy - y0;
if (x0 < 0) { x0 = 0; fx = 0; }
if (y0 < 0) { y0 = 0; fy = 0; }
if (x0 >= GridWidth - 1) { x0 = GridWidth - 2; fx = 1; }
if (y0 >= GridHeight - 1) { y0 = GridHeight - 2; fy = 1; }
int i00 = y0 * GridWidth + x0;
int i10 = i00 + 1;
int i01 = i00 + GridWidth;
int i11 = i01 + 1;
float w00 = (1 - fx) * (1 - fy);
float w10 = fx * (1 - fy);
float w01 = (1 - fx) * fy;
float w11 = fx * fy;
vx = Vx[i00] * w00 + Vx[i10] * w10 + Vx[i01] * w01 + Vx[i11] * w11;
vy = Vy[i00] * w00 + Vy[i10] * w10 + Vy[i01] * w01 + Vy[i11] * w11;
}
/// <summary>Modulo mediano dei vettori: stima robusta dell'entità del movimento.</summary>
public double MedianMagnitude()
{
int n = NodeCount;
if (n == 0) return 0;
var magnitudes = new float[n];
for (int i = 0; i < n; i++) magnitudes[i] = MathF.Sqrt(Vx[i] * Vx[i] + Vy[i] * Vy[i]);
Array.Sort(magnitudes);
return magnitudes[n / 2];
}
/// <summary>Direzione dominante del campo, in gradi, pesata sul modulo dei vettori.</summary>
public double DominantDirection()
{
double sx = 0, sy = 0;
for (int i = 0; i < Vx.Length; i++)
{
float m = MathF.Sqrt(Vx[i] * Vx[i] + Vy[i] * Vy[i]);
sx += Vx[i] * m;
sy += Vy[i] * m;
}
if (Math.Abs(sx) < 1e-6 && Math.Abs(sy) < 1e-6) return 0;
double deg = Math.Atan2(sy, sx) * 180.0 / Math.PI;
return deg < 0 ? deg + 360 : deg;
}
}
+194
View File
@@ -0,0 +1,194 @@
using Titano.Imaging;
namespace Titano.Motion;
/// <summary>Parametri del calcolo del campo vettoriale.</summary>
public sealed class OpticalFlowSettings
{
/// <summary>Larghezza massima a cui viene condotta l'analisi del movimento.</summary>
public int AnalysisWidth { get; set; } = 960;
/// <summary>Passo della griglia di nodi, in pixel della risoluzione di analisi.</summary>
public int CellSize { get; set; } = 8;
/// <summary>Livelli della piramide: determina lo spostamento massimo inseguibile.</summary>
public int PyramidLevels { get; set; } = 4;
/// <summary>Semi-lato della finestra di correlazione.</summary>
public int WindowRadius { get; set; } = 6;
/// <summary>Iterazioni di raffinamento per livello.</summary>
public int Iterations { get; set; } = 5;
/// <summary>Regolarizzazione del sistema normale: stabilizza le zone senza tessitura.</summary>
public double Regularization { get; set; } = 1e-4;
public OpticalFlowSettings Clone() => (OpticalFlowSettings)MemberwiseClone();
}
/// <summary>
/// Calcolo del campo di movimento fra due fotogrammi adiacenti, implementato interamente
/// in-house con uno schema differenziale piramidale (famiglia LucasKanade).
///
/// Su ogni nodo della griglia si risolve iterativamente il sistema normale 2×2
/// costruito dai gradienti spaziali del primo fotogramma e dalla differenza temporale
/// rispetto al secondo, ricampionato secondo la stima corrente. La piramide permette di
/// inseguire spostamenti ampi (nuvole, stelle, folla) partendo dai livelli grossolani;
/// un filtro mediano fra un livello e l'altro elimina i vettori spuri delle zone piatte.
/// </summary>
public sealed class OpticalFlowEngine(OpticalFlowSettings settings)
{
private readonly OpticalFlowSettings _settings = settings;
/// <summary>
/// Calcola il campo dal fotogramma <paramref name="current"/> verso <paramref name="next"/>.
/// I vettori restituiti sono espressi in pixel dell'immagine a piena risoluzione.
/// </summary>
public MotionField Compute(ImageBuffer current, ImageBuffer next)
{
var a = GrayPyramid.Build(current, _settings.AnalysisWidth, _settings.PyramidLevels);
var b = GrayPyramid.Build(next, _settings.AnalysisWidth, _settings.PyramidLevels);
return Compute(a, b, current.Width, current.Height);
}
public MotionField Compute(GrayPyramid a, GrayPyramid b, int fullWidth, int fullHeight)
{
int levels = Math.Min(a.LevelCount, b.LevelCount);
int cell = Math.Max(2, _settings.CellSize);
var baseLevel = a.Levels[0];
int gw = Math.Max(2, baseLevel.Width / cell + 1);
int gh = Math.Max(2, baseLevel.Height / cell + 1);
var vx = new float[gw * gh];
var vy = new float[gw * gh];
for (int level = levels - 1; level >= 0; level--)
{
float levelScale = 1f / (1 << level);
RefineLevel(a.Levels[level], b.Levels[level], vx, vy, gw, gh, cell * levelScale);
MedianFilter(vx, vy, gw, gh);
if (level > 0)
{
// Passando a un livello di risoluzione doppia raddoppia anche lo spostamento.
for (int i = 0; i < vx.Length; i++) { vx[i] *= 2f; vy[i] *= 2f; }
}
}
// Conversione dalle unità della risoluzione di analisi a quelle dell'immagine piena.
float toFull = a.AnalysisScale > 0 ? 1f / a.AnalysisScale : 1f;
var field = new MotionField(fullWidth, fullHeight, cell * toFull, gw, gh);
for (int i = 0; i < vx.Length; i++)
{
field.Vx[i] = vx[i] * toFull;
field.Vy[i] = vy[i] * toFull;
}
return field;
}
/// <summary>Raffina la stima corrente su un livello della piramide.</summary>
private void RefineLevel(GrayImage a, GrayImage b, float[] vx, float[] vy,
int gridWidth, int gridHeight, float nodeSpacing)
{
int radius = Math.Max(2, _settings.WindowRadius);
int iterations = Math.Max(1, _settings.Iterations);
double lambda = Math.Max(0, _settings.Regularization);
float maxStep = Math.Max(4f, radius * 1.5f);
Parallel.For(0, gridHeight, gy =>
{
float py = gy * nodeSpacing;
for (int gx = 0; gx < gridWidth; gx++)
{
float px = gx * nodeSpacing;
int node = gy * gridWidth + gx;
float u = vx[node];
float v = vy[node];
for (int iter = 0; iter < iterations; iter++)
{
double sxx = lambda, sxy = 0, syy = lambda, sxt = 0, syt = 0;
for (int wy = -radius; wy <= radius; wy++)
{
float sy = py + wy;
for (int wx = -radius; wx <= radius; wx++)
{
float sx = px + wx;
// Gradiente spaziale del primo fotogramma (differenze centrali).
float ix = (a.Sample(sx + 1, sy) - a.Sample(sx - 1, sy)) * 0.5f;
float iy = (a.Sample(sx, sy + 1) - a.Sample(sx, sy - 1)) * 0.5f;
// Differenza temporale con il secondo fotogramma ricampionato.
float it = b.Sample(sx + u, sy + v) - a.Sample(sx, sy);
sxx += ix * ix;
sxy += ix * iy;
syy += iy * iy;
sxt += ix * it;
syt += iy * it;
}
}
double det = sxx * syy - sxy * sxy;
if (Math.Abs(det) < 1e-12) break;
// Soluzione del sistema normale: d = -A⁻¹·b
double du = -(syy * sxt - sxy * syt) / det;
double dv = -(sxx * syt - sxy * sxt) / det;
if (double.IsNaN(du) || double.IsNaN(dv)) break;
du = Math.Clamp(du, -maxStep, maxStep);
dv = Math.Clamp(dv, -maxStep, maxStep);
u += (float)du;
v += (float)dv;
if (Math.Abs(du) < 0.01 && Math.Abs(dv) < 0.01) break;
}
// Vincolo fisico: nessun punto può uscire di più dell'intera immagine.
vx[node] = Math.Clamp(u, -a.Width, a.Width);
vy[node] = Math.Clamp(v, -a.Height, a.Height);
}
});
}
private static void MedianFilter(float[] vx, float[] vy, int gridWidth, int gridHeight)
{
var ox = (float[])vx.Clone();
var oy = (float[])vy.Clone();
Span<float> wx = stackalloc float[9];
Span<float> wy = stackalloc float[9];
for (int gy = 0; gy < gridHeight; gy++)
{
for (int gx = 0; gx < gridWidth; gx++)
{
int n = 0;
for (int dy = -1; dy <= 1; dy++)
{
int yy = gy + dy;
if (yy < 0 || yy >= gridHeight) continue;
for (int dx = -1; dx <= 1; dx++)
{
int xx = gx + dx;
if (xx < 0 || xx >= gridWidth) continue;
int idx = yy * gridWidth + xx;
wx[n] = ox[idx];
wy[n] = oy[idx];
n++;
}
}
wx[..n].Sort();
wy[..n].Sort();
int center = gy * gridWidth + gx;
vx[center] = wx[n / 2];
vy[center] = wy[n / 2];
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace Titano.Pipeline;
public enum PipelinePhase
{
Ingestion,
Analysis,
Rendering,
Finalizing,
Completed,
Failed,
}
/// <summary>Stato d'avanzamento pubblicato verso l'interfaccia; immutabile e sicuro da marshalare.</summary>
public readonly record struct PipelineProgress(
PipelinePhase Phase,
int Completed,
int Total,
string Message,
double FramesPerSecond = 0,
TimeSpan Remaining = default)
{
public double Fraction => Total <= 0 ? 0 : Math.Clamp(Completed / (double)Total, 0, 1);
}
/// <summary>Esito di una esportazione completata.</summary>
public sealed record RenderResult(
string OutputPath,
int EncodedFrames,
long OutputBytes,
TimeSpan Elapsed,
string EncoderName,
bool HardwareAccelerated,
long PeakPixelMemoryBytes);
+441
View File
@@ -0,0 +1,441 @@
using System.Diagnostics;
using System.Threading.Channels;
using Titano.Analysis;
using Titano.Core;
using Titano.Imaging;
using Titano.Metadata;
using Titano.Motion;
using Titano.Video;
namespace Titano.Pipeline;
/// <summary>
/// Orchestrazione delle fasi di lavoro.
///
/// Architettura a flusso: i fotogrammi vengono decodificati da più thread e consegnati in
/// ordine attraverso un canale a capacità limitata. Il limite del canale coincide con il
/// numero di decodifiche simultanee, quindi il numero di buffer vivi — e con esso
/// l'occupazione di memoria — resta costante qualunque sia la lunghezza della sequenza.
/// Nessuna fase scrive su disco: l'unico file prodotto è il video finale.
/// </summary>
public sealed class RenderPipeline(TitanoProject project)
{
private readonly TitanoProject _project = project;
// ------------------------------------------------------------------ ingestion
/// <summary>Legge i metadati dei file indicati e costruisce la sequenza ordinata.</summary>
public static async Task<TimelapseSequence> IngestAsync(IReadOnlyList<string> paths,
double cadenceTolerance,
IProgress<PipelineProgress>? progress,
CancellationToken cancellation)
{
var metadata = new FrameMetadata[paths.Count];
int done = 0;
await Task.Run(() =>
{
var options = new ParallelOptions
{
CancellationToken = cancellation,
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 1, 16),
};
Parallel.For(0, paths.Count, options, i =>
{
metadata[i] = MetadataReader.Read(paths[i]);
int completed = Interlocked.Increment(ref done);
if (completed % 16 == 0 || completed == paths.Count)
{
progress?.Report(new PipelineProgress(PipelinePhase.Ingestion, completed, paths.Count,
"Lettura metadati…"));
}
});
}, cancellation).ConfigureAwait(false);
var sequence = TimelapseSequence.Build(metadata);
sequence.RecomputeTiming(cadenceTolerance);
return sequence;
}
// ------------------------------------------------------------------ analisi fotometrica
/// <summary>
/// Passata di analisi: misura la luminanza di ogni fotogramma e calcola la curva di
/// deflicker. La decodifica avviene a risoluzione ridotta perché la media logaritmica
/// troncata è invariante alla scala, e questo rende la fase praticamente istantanea.
/// </summary>
public async Task AnalyzeAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
int count = sequence.Count;
if (count == 0) return;
var (workingWidth, workingHeight) = _project.ResolveWorkingSize();
if (workingWidth <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
int analysisWidth = Math.Clamp(_project.General.AnalysisWidth, 128, workingWidth);
int analysisHeight = Math.Max(2, (int)Math.Round(analysisWidth * workingHeight / (double)workingWidth));
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
var pool = new FrameBufferPool(parallelism + 2);
var stats = new LuminanceStats[count];
var failures = new bool[count];
int done = 0;
await Task.Run(() =>
{
var options = new ParallelOptions
{
CancellationToken = cancellation,
MaxDegreeOfParallelism = parallelism,
};
Parallel.For(0, count, options, i =>
{
var record = sequence.Frames[i];
try
{
using var buffer = ImageDecoder.Decode(record.FilePath, analysisWidth, analysisHeight,
record.Metadata.Orientation, pool);
stats[i] = LuminanceAnalyzer.Analyze(buffer);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failures[i] = true;
}
int completed = Interlocked.Increment(ref done);
progress?.Report(new PipelineProgress(PipelinePhase.Analysis, completed, count,
"Analisi della luminanza…"));
});
}, cancellation).ConfigureAwait(false);
// I fotogrammi illeggibili ereditano la misura del vicino: la curva resta continua.
for (int i = 0; i < count; i++)
{
if (!failures[i]) continue;
stats[i] = i > 0 ? stats[i - 1] : LuminanceStats.Empty;
}
var curve = DeflickerEngine.Compute(stats, _project.Deflicker);
for (int i = 0; i < count; i++)
{
var record = sequence.Frames[i];
record.MeasuredLuminance = Math.Pow(2, curve.Measured[i]);
record.TargetLuminance = Math.Pow(2, curve.Target[i]);
record.Gain = Math.Pow(2, curve.GainStops[i]);
record.ClippedFraction = stats[i].ClippedFraction;
record.LuminanceAnalyzed = !failures[i];
}
_project.Stats = stats;
_project.Curve = curve;
}
/// <summary>
/// Ricalcola solo la curva a partire dalle statistiche già misurate: permette di muovere
/// i cursori del deflicker con riscontro immediato, senza rileggere i file.
/// </summary>
public void RecomputeCurve()
{
if (_project.Stats is not { } stats || _project.Sequence is not { } sequence) return;
var curve = DeflickerEngine.Compute(stats, _project.Deflicker);
for (int i = 0; i < sequence.Count && i < curve.Count; i++)
{
var record = sequence.Frames[i];
record.MeasuredLuminance = Math.Pow(2, curve.Measured[i]);
record.TargetLuminance = Math.Pow(2, curve.Target[i]);
record.Gain = Math.Pow(2, curve.GainStops[i]);
}
_project.Curve = curve;
}
// ------------------------------------------------------------------ render ed esportazione
public async Task<RenderResult> RenderAsync(IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
var sequence = _project.Sequence ?? throw new InvalidOperationException("Nessuna sequenza caricata.");
if (sequence.Count == 0) throw new InvalidOperationException("La sequenza è vuota.");
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
throw new InvalidOperationException("Percorso di destinazione non impostato.");
if (!_project.IsAnalyzed) await AnalyzeAsync(progress, cancellation).ConfigureAwait(false);
var (width, height) = _project.ResolveWorkingSize();
if (width <= 0) throw new InvalidOperationException("Impossibile determinare la risoluzione dei fotogrammi.");
var export = _project.Export.Clone();
export.Width = width;
export.Height = height;
return await Task.Run(() => RenderCore(sequence, export, progress, cancellation), cancellation)
.ConfigureAwait(false);
}
private RenderResult RenderCore(TimelapseSequence sequence, ExportSettings export,
IProgress<PipelineProgress>? progress, CancellationToken cancellation)
{
int width = export.Width;
int height = export.Height;
int count = sequence.Count;
var curve = _project.Curve;
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
var pool = new FrameBufferPool(parallelism + 8);
var flowEngine = new OpticalFlowEngine(_project.Flow);
var blurSettings = _project.MotionBlur;
var deflickerSettings = _project.Deflicker;
uint baseUnits = (uint)Math.Max(1, Math.Round(export.Timescale / Math.Max(1.0, export.FrameRate)));
double nominal = sequence.NominalInterval > 0 ? sequence.NominalInterval : 1.0;
var stopwatch = Stopwatch.StartNew();
using var session = new VideoEncoderSession(export, width, height);
// Canale a capacità limitata: al più "parallelism" decodifiche in volo.
var channel = Channel.CreateBounded<Task<ImageBuffer?>>(new BoundedChannelOptions(parallelism)
{
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.Wait,
});
var producer = Task.Run(async () =>
{
try
{
for (int i = 0; i < count; i++)
{
cancellation.ThrowIfCancellationRequested();
int index = i;
var task = Task.Run(() => DecodeAndCorrect(sequence, index, width, height, pool,
curve, deflickerSettings), cancellation);
await channel.Writer.WriteAsync(task, cancellation).ConfigureAwait(false);
}
channel.Writer.Complete();
}
catch (Exception ex)
{
channel.Writer.TryComplete(ex);
}
}, cancellation);
ImageBuffer? current = null;
ImageBuffer? next = null;
var blurScratch = pool.Rent(width, height);
var interpolated = pool.Rent(width, height);
var interpolatedBlur = pool.Rent(width, height);
int encoded = 0;
bool cancelled = false;
try
{
current = ReadNextOrSubstitute(channel, pool, width, height, null);
next = ReadNextOrSubstitute(channel, pool, width, height, current);
for (int i = 0; i < count && current is not null; i++)
{
if (cancellation.IsCancellationRequested) { cancelled = true; break; }
var record = sequence.Frames[i];
int subdivisions = ComputeSubdivisions(export, record, nominal);
uint duration = ComputeDuration(export, record, nominal, baseUnits, subdivisions);
MotionField? field = null;
bool needsFlow = blurSettings.Enabled || (export.Timing == FrameTimingMode.Interpolated && subdivisions > 1);
if (needsFlow && next is not null)
{
field = flowEngine.Compute(current, next);
record.MotionMagnitude = field.MedianMagnitude();
record.MotionDirection = field.DominantDirection();
}
// In presenza di suddivisioni l'intervallo di ciascun fotogramma d'uscita si
// accorcia: lo shutter angle effettivo cresce e lo spostamento si riduce.
double effectiveAngle = Math.Min(360.0, record.ShutterAngle * subdivisions);
double missing = blurSettings.Enabled
? MotionBlurRenderer.MissingBlurFactor(effectiveAngle, blurSettings.TargetShutterAngle,
blurSettings.Strength) / subdivisions
: 0;
record.BlurLength = EncodeFrame(session, current, field, missing, blurSettings, blurScratch, duration);
encoded++;
record.OutputDurationUnits = (int)duration;
for (int k = 1; k < subdivisions && next is not null && field is not null; k++)
{
if (cancellation.IsCancellationRequested) { cancelled = true; break; }
float t = k / (float)subdivisions;
FrameInterpolator.Interpolate(current, next, field, t, interpolated);
EncodeFrame(session, interpolated, field, missing, blurSettings, interpolatedBlur, duration);
encoded++;
}
ReportRenderProgress(progress, i + 1, count, encoded, stopwatch);
current.Dispose();
current = next;
next = ReadNextOrSubstitute(channel, pool, width, height, current);
}
}
finally
{
current?.Dispose();
next?.Dispose();
blurScratch.Dispose();
interpolated.Dispose();
interpolatedBlur.Dispose();
progress?.Report(new PipelineProgress(PipelinePhase.Finalizing, count, count,
"Chiusura del contenitore…"));
session.Finish();
try { producer.Wait(TimeSpan.FromSeconds(5)); }
catch (AggregateException) { /* già segnalato dal canale */ }
}
stopwatch.Stop();
if (cancelled) cancellation.ThrowIfCancellationRequested();
return new RenderResult(
export.OutputPath,
session.EncodedFrames,
session.OutputBytes,
stopwatch.Elapsed,
session.EncoderName,
session.IsHardware,
pool.AllocatedBytes);
}
/// <summary>Applica la sfocatura, se prevista, e consegna il fotogramma all'encoder.</summary>
private static double EncodeFrame(VideoEncoderSession session, ImageBuffer frame, MotionField? field,
double missing, MotionBlurSettings settings, ImageBuffer scratch,
uint duration)
{
double blurLength = 0;
var toEncode = frame;
if (settings.Enabled && field is not null && missing > 1e-4)
{
blurLength = MotionBlurRenderer.Render(frame, scratch, field, missing, settings);
toEncode = scratch;
}
session.EncodeFrame(toEncode, duration);
return blurLength;
}
/// <summary>Decodifica un fotogramma e vi applica il guadagno di esposizione calcolato.</summary>
private static ImageBuffer? DecodeAndCorrect(TimelapseSequence sequence, int index, int width, int height,
FrameBufferPool pool, DeflickerCurve? curve,
DeflickerSettings settings)
{
var record = sequence.Frames[index];
ImageBuffer buffer;
try
{
buffer = ImageDecoder.Decode(record.FilePath, width, height, record.Metadata.Orientation, pool);
}
catch (Exception)
{
return null; // il consumatore sostituirà con l'ultimo fotogramma valido
}
if (curve is not null && index < curve.Count && settings.Enabled)
{
record.ClippedFraction = ExposureProcessor.Apply(buffer, curve.ChannelGain[index],
settings.ProtectHighlights, settings.HighlightKnee);
}
return buffer;
}
/// <summary>
/// Preleva il prossimo fotogramma in ordine di sequenza.
/// Restituisce false quando il flusso è terminato; un buffer nullo con esito true segnala
/// un fotogramma illeggibile, che il chiamante sostituisce senza perdere il sincronismo.
/// </summary>
private static bool TryReadNext(Channel<Task<ImageBuffer?>> channel, out ImageBuffer? buffer)
{
buffer = null;
while (true)
{
Task<ImageBuffer?> task;
try
{
if (!channel.Reader.WaitToReadAsync().AsTask().GetAwaiter().GetResult()) return false;
if (!channel.Reader.TryRead(out task!)) continue;
}
catch (Exception)
{
return false;
}
try
{
buffer = task.GetAwaiter().GetResult();
}
catch (Exception)
{
buffer = null;
}
return true;
}
}
/// <summary>Legge il fotogramma successivo sostituendo gli illeggibili con una copia del precedente.</summary>
private static ImageBuffer? ReadNextOrSubstitute(Channel<Task<ImageBuffer?>> channel, FrameBufferPool pool,
int width, int height, ImageBuffer? previous)
{
if (!TryReadNext(channel, out var buffer)) return null;
if (buffer is not null) return buffer;
return previous is not null
? previous.CloneFromPool(pool)
: CreateBlack(pool, width, height);
}
private static ImageBuffer CreateBlack(FrameBufferPool pool, int width, int height)
{
var buffer = pool.Rent(width, height);
Array.Clear(buffer.Data, 0, buffer.SampleCount);
return buffer;
}
private static int ComputeSubdivisions(ExportSettings export, FrameRecord record, double nominal)
{
if (export.Timing != FrameTimingMode.Interpolated) return 1;
double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal);
int max = (int)Math.Max(1, Math.Round(export.MaxAdaptiveStretch));
return Math.Clamp((int)Math.Round(ratio), 1, max);
}
private static uint ComputeDuration(ExportSettings export, FrameRecord record, double nominal,
uint baseUnits, int subdivisions)
{
if (export.Timing != FrameTimingMode.Adaptive) return baseUnits;
double ratio = record.IntervalSeconds / Math.Max(1e-6, nominal);
double limited = Math.Clamp(ratio, 1.0 / export.MaxAdaptiveStretch, export.MaxAdaptiveStretch);
return (uint)Math.Max(1, Math.Round(baseUnits * limited));
}
private static void ReportRenderProgress(IProgress<PipelineProgress>? progress, int completed, int total,
int encoded, Stopwatch stopwatch)
{
if (progress is null) return;
double seconds = stopwatch.Elapsed.TotalSeconds;
double fps = seconds > 0.001 ? encoded / seconds : 0;
var remaining = fps > 0.01
? TimeSpan.FromSeconds((total - completed) / fps)
: TimeSpan.Zero;
progress.Report(new PipelineProgress(PipelinePhase.Rendering, completed, total,
"Elaborazione e codifica…", fps, remaining));
}
}
+83
View File
@@ -0,0 +1,83 @@
using Titano.Analysis;
using Titano.Core;
using Titano.Motion;
using Titano.Video;
namespace Titano.Pipeline;
/// <summary>Impostazioni della sezione "Generale".</summary>
public sealed class GeneralSettings
{
/// <summary>Larghezza massima di lavoro; 0 = risoluzione nativa del primo fotogramma.</summary>
public int WorkingWidth { get; set; }
/// <summary>Larghezza usata nella passata di analisi fotometrica: incide solo sulla velocità.</summary>
public int AnalysisWidth { get; set; } = 1024;
/// <summary>Decodifiche simultanee. Limita anche i buffer in volo, quindi la memoria occupata.</summary>
public int DecodeParallelism { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 2, 8);
/// <summary>Tolleranza sulla cadenza oltre la quale un intervallo è segnalato come anomalo.</summary>
public double CadenceTolerance { get; set; } = 0.35;
public GeneralSettings Clone() => (GeneralSettings)MemberwiseClone();
}
/// <summary>
/// Stato completo di un progetto: sequenza caricata e tutti i parametri dei tre pannelli
/// di configurazione. È l'unico oggetto che l'interfaccia scambia con il motore.
/// </summary>
public sealed class TitanoProject
{
public GeneralSettings General { get; set; } = new();
public DeflickerSettings Deflicker { get; set; } = new();
public MotionBlurSettings MotionBlur { get; set; } = new();
public OpticalFlowSettings Flow { get; set; } = new();
public ExportSettings Export { get; set; } = new();
public TimelapseSequence? Sequence { get; set; }
/// <summary>Curva di deflicker dell'ultima analisi, usata dal grafico e dall'esportazione.</summary>
public DeflickerCurve? Curve { get; set; }
/// <summary>Statistiche fotometriche per fotogramma dell'ultima analisi.</summary>
public IReadOnlyList<LuminanceStats>? Stats { get; set; }
public bool HasSequence => Sequence is { Count: > 0 };
public bool IsAnalyzed => Curve is not null && Stats is not null;
/// <summary>
/// Risoluzione di lavoro effettiva: parte dal primo fotogramma, applica l'eventuale limite
/// dell'utente e arrotonda a valori pari, richiesti dal sottocampionamento cromatico 4:2:0.
/// </summary>
public (int Width, int Height) ResolveWorkingSize()
{
if (Sequence is not { Count: > 0 }) return (0, 0);
var first = Sequence.Frames[0].Metadata;
int sourceWidth = first.PixelWidth;
int sourceHeight = first.PixelHeight;
if (sourceWidth <= 0 || sourceHeight <= 0)
{
(sourceWidth, sourceHeight) = Imaging.ImageDecoder.ProbeDisplaySize(first.FilePath, first.Orientation);
}
else if (Imaging.ImageDecoder.SwapsAxes(first.Orientation))
{
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
}
if (sourceWidth <= 0 || sourceHeight <= 0) return (0, 0);
int targetWidth = Export.Width > 0 ? Export.Width
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
: sourceWidth;
double aspect = sourceHeight / (double)sourceWidth;
int targetHeight = Export.Height > 0 ? Export.Height : (int)Math.Round(targetWidth * aspect);
targetWidth = Math.Max(2, targetWidth & ~1);
targetHeight = Math.Max(2, targetHeight & ~1);
return (targetWidth, targetHeight);
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Runtime.InteropServices;
using Titano.Diagnostics;
namespace Titano;
internal static class Program
{
private const int AttachParentProcess = -1;
[DllImport("kernel32.dll")]
private static extern bool AttachConsole(int processId);
[STAThread]
private static int Main(string[] args)
{
if (args.Length > 0 && args[0] is "--selftest" or "-t")
{
AttachConsole(AttachParentProcess);
string directory = args.Length > 1
? args[1]
: Path.Combine(Path.GetTempPath(), "Titano.SelfTest");
Directory.CreateDirectory(directory);
Console.WriteLine();
Console.Write(SelfTest.DescribeEnvironment());
return SelfTest.Run(directory, Console.Out);
}
if (args.Length > 1 && args[0] == "--capture")
{
AttachConsole(AttachParentProcess);
ApplicationConfiguration.Initialize();
return Capture(args[1], args.Length > 2 ? args[2] : null,
args.Length > 3 && int.TryParse(args[3], out int tab) ? tab : 0);
}
ApplicationConfiguration.Initialize();
Application.Run(new UI.MainForm());
return 0;
}
/// <summary>
/// Apre l'interfaccia, opzionalmente vi carica una sequenza, e ne salva un'immagine.
/// Serve a verificare la resa dei controlli disegnati a mano senza intervento manuale.
/// </summary>
private static int Capture(string outputPath, string? sequenceDirectory, int settingsTab)
{
using var form = new UI.MainForm();
form.Show();
Pump(200);
if (sequenceDirectory is not null && Directory.Exists(sequenceDirectory))
{
string[] files = [.. Directory.EnumerateFiles(sequenceDirectory)
.Where(Metadata.MetadataReader.IsSupported)];
var work = form.PrepareForCaptureAsync(files);
while (!work.IsCompleted) Pump(30);
Pump(2500); // attesa del rendering asincrono dell'anteprima
}
if (settingsTab > 0)
{
form.SelectSettingsTab(settingsTab);
Pump(200);
}
using var bitmap = new Bitmap(form.Width, form.Height);
form.DrawToBitmap(bitmap, new Rectangle(0, 0, form.Width, form.Height));
bitmap.Save(outputPath, System.Drawing.Imaging.ImageFormat.Png);
Console.WriteLine($"Interfaccia catturata in {outputPath} ({bitmap.Width}×{bitmap.Height}).");
return 0;
}
private static void Pump(int milliseconds)
{
var deadline = Environment.TickCount64 + milliseconds;
do
{
Application.DoEvents();
Thread.Sleep(10);
}
while (Environment.TickCount64 < deadline);
}
}
+103
View File
@@ -0,0 +1,103 @@
# Titano
Applicazione desktop per la creazione e l'ottimizzazione di time-lapse di livello
professionale, sviluppata interamente in-house.
## Il vincolo che definisce il progetto
**Zero librerie di terze parti.** Il file di progetto non contiene alcun
`<PackageReference>`: tutto ciò che non è la libreria standard di .NET è scritto qui dentro
oppure ottenuto tramite P/Invoke diretto verso componenti del sistema operativo.
| Ambito | Come è risolto |
|---|---|
| Parsing EXIF / XMP | Parser binario proprietario (`Metadata/`) |
| Analisi fotometrica e deflicker | Algoritmi proprietari (`Analysis/`) |
| Campo vettoriale e motion blur | Schema differenziale piramidale proprietario (`Motion/`) |
| Contenitore MP4 | Multiplexer ISO-BMFF proprietario (`Video/Mp4Muxer.cs`) |
| Decodifica immagini | WIC, componente di Windows, via COM interop scritto a mano |
| Codifica video | Media Foundation Transform, encoder hardware di sistema |
| Interfaccia grafica | WinForms + GDI+, ogni controllo disegnato a mano (`UI/`) |
Non viene invocato nessun processo esterno: niente FFmpeg, niente ExifTool.
## Principi architetturali
**Nessun file temporaneo.** I dati passano fra le fasi solo attraverso buffer in RAM. L'unica
scrittura su disco è il flusso compresso finale, prodotto in streaming: `ftyp` e l'header
`mdat` vengono scritti all'apertura, i pacchetti dell'encoder scorrono direttamente nel file
e `moov` viene aggiunto in chiusura.
**Impronta di memoria costante.** I fotogrammi vivono in array float presi da un pool
(`Imaging/ImageBuffer.cs`) e la decodifica è governata da un canale a capacità limitata: al
più *N* fotogrammi sono vivi contemporaneamente, dove *N* è il numero di decodifiche
simultanee scelto dall'utente. L'occupazione non dipende dalla lunghezza della sequenza —
la verifica automatica lo misura esplicitamente.
**Interfaccia reattiva.** Ingestion, analisi e render girano su thread di lavoro; il thread
dell'interfaccia riceve solo aggiornamenti di stato immutabili tramite `IProgress<T>`.
## Struttura
```
Metadata/ parser binario TIFF/Exif, scanner XMP, riconoscimento contenitori
Core/ sequenza, intervalli reali, cadenza nominale, shutter angle
Imaging/ buffer poolati, spazio colore lineare, decodifica via WIC
Analysis/ misura di luminanza, curva di deflicker, applicazione dei guadagni
Motion/ piramide gaussiana, optical flow, motion blur, interpolazione
Video/ conversione NV12, encoder Media Foundation, multiplexer MP4
Pipeline/ orchestrazione delle fasi, progetto e impostazioni
UI/ tema scuro, grafico vettoriale, tabella virtuale, pannelli
Diagnostics/ sequenza sintetica, verifica end-to-end, ispettore MP4
```
## Note sugli algoritmi
**Deflicker.** Per ogni fotogramma si esegue una regressione lineare locale pesata sulla
finestra mobile. I pesi combinano una gaussiana sulla distanza temporale e, in seconda
passata, un peso di robustezza di Tukey che neutralizza i fotogrammi anomali. La componente
lineare segue senza ritardo le rampe reali di luce (alba, tramonto) e rimuove solo la
componente ad alta frequenza dovuta alle micro-variazioni del diaframma. La misura è la media
logaritmica troncata: invariante alla scala, insensibile a cieli bruciati e ombre chiuse.
**Motion blur.** Lo shutter angle reale è `360 × posa / intervallo`. La scia già incisa nel
fotogramma e quella sintetica si compongono in quadratura, quindi per raggiungere l'apertura
obiettivo serve una scia di `√(obiettivo² reale²)` volte lo spostamento: sommare
linearmente produrrebbe un'immagine sistematicamente troppo morbida. Il filtro di
ricostruzione è di tipo *gather*: un campione contribuisce al pixel centrale solo se la
propria scia lo raggiunge davvero, così lo sfondo fermo non viene trascinato nei soggetti in
movimento.
**Optical flow.** Schema differenziale piramidale: su ogni nodo di una griglia rada si risolve
iterativamente il sistema normale 2×2 costruito dai gradienti spaziali e dalla differenza
temporale. Un filtro mediano fra un livello e l'altro elimina i vettori spuri delle zone
piatte. L'analisi avviene a risoluzione ridotta e i vettori vengono riportati in scala piena:
il costo non dipende dalla dimensione dei file sorgente.
## Compilazione ed esecuzione
```
dotnet build -c Release
dotnet run -c Release
```
Richiede .NET 10 SDK su Windows x64.
## Verifica automatica
```
Titano.exe --selftest [cartella]
```
Genera una sequenza sintetica dalle proprietà note — traslazione, sfarfallio e rampa di luce
imposti, pausa dell'intervallometro inclusa — e la fa attraversare l'intera pipeline,
confrontando 23 grandezze misurate con i valori attesi: campi Exif, cadenza, shutter angle,
riduzione dello sfarfallio, conservazione della rampa, modulo e direzione del campo
vettoriale, attenuazione del dettaglio dovuta alla sfocatura, struttura del contenitore
prodotto e — prova conclusiva — la ri-decodifica del file con il lettore di sistema.
```
Titano.exe --capture <file.png> [cartella-sequenza] [scheda]
```
Cattura l'interfaccia in un'immagine, per verificarne la resa in modo riproducibile.
+43
View File
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Titano - motore di time-lapse professionale.
VINCOLO ARCHITETTURALE: questo progetto non contiene NESSUN <PackageReference>.
Tutto ciò che non è BCL è implementato in-house oppure ottenuto tramite
P/Invoke diretto verso API native del sistema operativo (WIC, Media Foundation,
GDI+, DWM, Kernel32), che il brief consente esplicitamente.
-->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<AssemblyName>Titano</AssemblyName>
<RootNamespace>Titano</RootNamespace>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<ApplicationDefaultFont>Segoe UI, 9pt</ApplicationDefaultFont>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<InvariantGlobalization>false</InvariantGlobalization>
<Product>Titano</Product>
<Company>Titano</Company>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Version>1.0.0</Version>
<!--
CA1416: le API Windows-only sono intenzionali, il progetto è vincolato a win-x64.
WFO1000: riguarda la serializzazione dei controlli nel designer visuale. Qui l'intera
interfaccia è costruita in codice e nessun controllo viene mai serializzato.
-->
<NoWarn>$(NoWarn);CA1416;WFO1000</NoWarn>
</PropertyGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="Titano.csproj" />
</Solution>
+488
View File
@@ -0,0 +1,488 @@
using System.Globalization;
namespace Titano.UI;
/// <summary>Pulsante disegnato interamente a mano, con varianti primaria e secondaria.</summary>
internal sealed class DarkButton : Control
{
private bool _hover;
private bool _pressed;
public bool Primary { get; set; }
public bool Danger { get; set; }
public DarkButton()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 32;
Font = Theme.Body;
Cursor = Cursors.Hand;
}
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; _pressed = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseDown(MouseEventArgs e) { _pressed = true; Invalidate(); base.OnMouseDown(e); }
protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; Invalidate(); base.OnMouseUp(e); }
protected override void OnEnabledChanged(EventArgs e) { Invalidate(); base.OnEnabledChanged(e); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var bounds = new RectangleF(0.5f, 0.5f, Width - 1, Height - 1);
Color fill, stroke, text;
if (!Enabled)
{
fill = Theme.SurfaceAlt;
stroke = Theme.Border;
text = Theme.TextFaint;
}
else if (Primary)
{
fill = _pressed ? Theme.AccentDim : _hover ? Theme.Mix(Theme.Accent, Color.White, 0.12) : Theme.Accent;
stroke = fill;
text = Color.FromArgb(0x0B, 0x12, 0x1C);
}
else if (Danger)
{
fill = _pressed ? Theme.SurfaceAlt : _hover ? Theme.Mix(Theme.Danger, Theme.Surface, 0.75) : Theme.Surface;
stroke = Theme.Danger;
text = Theme.Danger;
}
else
{
fill = _pressed ? Theme.Surface : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
stroke = _hover ? Theme.BorderStrong : Theme.Border;
text = Theme.Text;
}
Theme.FillAndStroke(g, bounds, 6f, fill, stroke);
TextRenderer.DrawText(g, Text, Font, new Rectangle(0, 0, Width, Height), text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis);
}
}
/// <summary>Interruttore a due stati con etichetta, disegnato a mano.</summary>
internal sealed class DarkCheckBox : Control
{
private bool _checked;
private bool _hover;
public event EventHandler? CheckedChanged;
public bool Checked
{
get => _checked;
set
{
if (_checked == value) return;
_checked = value;
Invalidate();
CheckedChanged?.Invoke(this, EventArgs.Empty);
}
}
public DarkCheckBox()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 26;
Font = Theme.Body;
Cursor = Cursors.Hand;
}
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnClick(EventArgs e) { Checked = !Checked; base.OnClick(e); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
const int size = 17;
int top = (Height - size) / 2;
var box = new RectangleF(0.5f, top + 0.5f, size, size);
Color fill = _checked ? Theme.Accent : _hover ? Theme.SurfaceHover : Theme.SurfaceAlt;
Color stroke = _checked ? Theme.Accent : _hover ? Theme.BorderStrong : Theme.Border;
Theme.FillAndStroke(g, box, 4f, fill, stroke);
if (_checked)
{
using var pen = new Pen(Color.FromArgb(0x0B, 0x12, 0x1C), 2f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
};
g.DrawLines(pen,
[
new PointF(box.Left + 4f, box.Top + 8.5f),
new PointF(box.Left + 7f, box.Top + 11.5f),
new PointF(box.Left + 13f, box.Top + 5f),
]);
}
var textRect = new Rectangle(size + 9, 0, Width - size - 9, Height);
TextRenderer.DrawText(g, Text, Font, textRect, Enabled ? Theme.Text : Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
}
}
/// <summary>
/// Cursore continuo con didascalia e valore, unità di misura opzionale e tacche.
/// Sostituisce il TrackBar di sistema, che non è tematizzabile.
/// </summary>
internal sealed class ParameterSlider : Control
{
private double _value;
private bool _dragging;
private bool _hover;
public string Caption { get; set; } = string.Empty;
public string Unit { get; set; } = string.Empty;
public string ValueFormat { get; set; } = "0.##";
public double Minimum { get; set; }
public double Maximum { get; set; } = 1;
public double Step { get; set; }
public event EventHandler? ValueChanged;
public double Value
{
get => _value;
set
{
double clamped = Math.Clamp(value, Minimum, Maximum);
if (Step > 0) clamped = Math.Round(clamped / Step) * Step;
if (Math.Abs(clamped - _value) < 1e-9) return;
_value = clamped;
Invalidate();
ValueChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>Imposta il valore senza sollevare l'evento: usata al caricamento delle impostazioni.</summary>
public void SetValueSilently(double value)
{
_value = Math.Clamp(value, Minimum, Maximum);
Invalidate();
}
public ParameterSlider()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 46;
Font = Theme.Body;
}
private Rectangle TrackBounds => new(2, Height - 20, Width - 4, 12);
protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseDown(MouseEventArgs e)
{
if (e.Button != MouseButtons.Left || !Enabled) return;
_dragging = true;
UpdateFromMouse(e.X);
base.OnMouseDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_dragging) UpdateFromMouse(e.X);
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_dragging = false;
base.OnMouseUp(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (!Enabled) return;
double increment = Step > 0 ? Step : (Maximum - Minimum) / 50.0;
Value += Math.Sign(e.Delta) * increment;
}
private void UpdateFromMouse(int x)
{
var track = TrackBounds;
double fraction = Math.Clamp((x - track.Left) / (double)Math.Max(1, track.Width), 0, 1);
Value = Minimum + fraction * (Maximum - Minimum);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
Color captionColor = Enabled ? Theme.TextMuted : Theme.TextFaint;
Color valueColor = Enabled ? Theme.Text : Theme.TextFaint;
TextRenderer.DrawText(g, Caption, Theme.Small, new Rectangle(0, 2, Width - 90, 16),
captionColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
string display = _value.ToString(ValueFormat, CultureInfo.CurrentCulture) +
(string.IsNullOrEmpty(Unit) ? string.Empty : " " + Unit);
TextRenderer.DrawText(g, display, Theme.SmallBold, new Rectangle(Width - 92, 2, 92, 16),
valueColor, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
var track = TrackBounds;
float centerY = track.Top + track.Height / 2f;
var groove = new RectangleF(track.Left, centerY - 2f, track.Width, 4f);
Theme.FillRounded(g, groove, 2f, Enabled ? Theme.SurfaceAlt : Theme.Surface);
double span = Maximum - Minimum;
float fraction = span <= 0 ? 0 : (float)((_value - Minimum) / span);
var filled = new RectangleF(track.Left, centerY - 2f, track.Width * fraction, 4f);
if (filled.Width > 0.5f)
Theme.FillRounded(g, filled, 2f, Enabled ? Theme.Accent : Theme.Border);
float knobX = track.Left + track.Width * fraction;
float radius = _dragging ? 7.5f : _hover ? 7f : 6f;
var knob = new RectangleF(knobX - radius, centerY - radius, radius * 2, radius * 2);
using (var brush = new SolidBrush(Enabled ? Theme.Text : Theme.TextFaint)) g.FillEllipse(brush, knob);
using (var pen = new Pen(Enabled ? Theme.Accent : Theme.Border, 2f))
g.DrawEllipse(pen, RectangleF.Inflate(knob, -1f, -1f));
}
}
/// <summary>Etichetta e menu a discesa affiancati, con lo stile del tema.</summary>
internal sealed class LabeledCombo : Panel
{
public ComboBox Combo { get; }
public LabeledCombo(string caption)
{
Height = 46;
BackColor = Theme.Surface;
var label = new Label
{
Text = caption,
Font = Theme.Small,
ForeColor = Theme.TextMuted,
Dock = DockStyle.Top,
Height = 16,
TextAlign = ContentAlignment.MiddleLeft,
};
Combo = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList,
FlatStyle = FlatStyle.Flat,
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
Font = Theme.Body,
Dock = DockStyle.Top,
DrawMode = DrawMode.OwnerDrawFixed,
ItemHeight = 20,
};
Combo.DrawItem += DrawItem;
Controls.Add(Combo);
Controls.Add(label);
}
private void DrawItem(object? sender, DrawItemEventArgs e)
{
if (e.Index < 0) return;
bool selected = (e.State & DrawItemState.Selected) != 0;
e.Graphics.FillRectangle(new SolidBrush(selected ? Theme.AccentDim : Theme.SurfaceAlt), e.Bounds);
TextRenderer.DrawText(e.Graphics, Combo.Items[e.Index]?.ToString() ?? string.Empty, Theme.Body,
Rectangle.Inflate(e.Bounds, -4, 0), Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
/// <summary>Intestazione di sezione con filetto di separazione.</summary>
internal sealed class SectionHeader : Control
{
public SectionHeader(string text)
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Text = text;
Height = 30;
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var size = TextRenderer.MeasureText(g, Text, Theme.SmallBold);
TextRenderer.DrawText(g, Text.ToUpperInvariant(), Theme.SmallBold,
new Rectangle(0, 0, Width, Height), Theme.TextFaint,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
int lineStart = size.Width + 14;
if (lineStart < Width - 4)
{
using var pen = new Pen(Theme.Border);
int y = Height / 2;
g.DrawLine(pen, lineStart, y, Width - 2, y);
}
}
}
/// <summary>Selettore a schede orizzontali usato dal pannello di configurazione.</summary>
internal sealed class TabStrip : Control
{
private readonly List<string> _tabs = [];
private int _selected;
private int _hovered = -1;
public event EventHandler? SelectedChanged;
public int SelectedIndex
{
get => _selected;
set
{
int clamped = Math.Clamp(value, 0, Math.Max(0, _tabs.Count - 1));
if (clamped == _selected) return;
_selected = clamped;
Invalidate();
SelectedChanged?.Invoke(this, EventArgs.Empty);
}
}
public TabStrip(params string[] tabs)
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
_tabs.AddRange(tabs);
Height = 36;
Cursor = Cursors.Hand;
}
/// <summary>
/// Larghezze proporzionali al testo: le etichette lunghe non vengono troncate solo
/// perché condividono la barra con etichette corte.
/// </summary>
private float[] TabWidths()
{
var widths = new float[_tabs.Count];
float total = 0;
using (var graphics = CreateGraphics())
{
for (int i = 0; i < _tabs.Count; i++)
{
widths[i] = TextRenderer.MeasureText(graphics, _tabs[i], Theme.SmallBold).Width + 22;
total += widths[i];
}
}
if (total <= 0) return widths;
float scale = Width / total;
for (int i = 0; i < widths.Length; i++) widths[i] *= scale;
return widths;
}
private int IndexAt(int x)
{
if (_tabs.Count == 0) return -1;
var widths = TabWidths();
float cursor = 0;
for (int i = 0; i < widths.Length; i++)
{
cursor += widths[i];
if (x < cursor) return i;
}
return _tabs.Count - 1;
}
protected override void OnMouseMove(MouseEventArgs e)
{
int index = IndexAt(e.X);
if (index != _hovered) { _hovered = index; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e) { _hovered = -1; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseDown(MouseEventArgs e) { SelectedIndex = IndexAt(e.X); base.OnMouseDown(e); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
if (_tabs.Count == 0) return;
var widths = TabWidths();
float offset = 0;
for (int i = 0; i < _tabs.Count; i++)
{
var bounds = new RectangleF(offset, 0, widths[i], Height);
offset += widths[i];
bool active = i == _selected;
if (active) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.SurfaceAlt);
else if (i == _hovered) Theme.FillRounded(g, new RectangleF(bounds.X + 2, 3, bounds.Width - 4, Height - 6), 6f, Theme.Surface);
TextRenderer.DrawText(g, _tabs[i], active ? Theme.SmallBold : Theme.Small,
Rectangle.Round(bounds), active ? Theme.Text : Theme.TextMuted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.EndEllipsis);
if (active)
{
using var brush = new SolidBrush(Theme.Accent);
g.FillRectangle(brush, bounds.X + bounds.Width / 2 - 12, Height - 3, 24, 2);
}
}
}
}
/// <summary>Barra di avanzamento sottile con etichetta interna.</summary>
internal sealed class DarkProgressBar : Control
{
private double _fraction;
public double Fraction
{
get => _fraction;
set { _fraction = Math.Clamp(value, 0, 1); Invalidate(); }
}
public DarkProgressBar()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
Height = 22;
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Parent?.BackColor ?? Theme.Surface);
var bounds = new RectangleF(0, (Height - 8) / 2f, Width, 8);
Theme.FillRounded(g, bounds, 4f, Theme.SurfaceAlt);
if (_fraction > 0.0005)
{
var filled = new RectangleF(bounds.X, bounds.Y, (float)(bounds.Width * _fraction), bounds.Height);
Theme.FillRounded(g, filled, 4f, Theme.Accent);
}
}
}
+288
View File
@@ -0,0 +1,288 @@
using Titano.Core;
using Titano.Metadata;
namespace Titano.UI;
/// <summary>
/// Tabella dei fotogrammi a rendering virtuale: disegna soltanto le righe visibili, quindi
/// regge sequenze da decine di migliaia di scatti senza creare un controllo per riga.
/// Barra di scorrimento, intestazioni e selezione sono disegnate a mano nel tema scuro.
/// </summary>
internal sealed class FrameTable : Control
{
private sealed record Column(string Title, int Width, bool RightAligned, Func<FrameRecord, string> Value);
private const int RowHeight = 24;
private const int HeaderHeight = 30;
private const int ScrollWidth = 12;
private readonly Column[] _columns;
private TimelapseSequence? _sequence;
private int _scroll;
private int _hoverRow = -1;
private int _selectedIndex = -1;
private bool _draggingScroll;
private int _dragOffset;
public event EventHandler? SelectionChanged;
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectedIndex == value) return;
_selectedIndex = value;
EnsureVisible(value);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
public FrameTable()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
TabStop = true;
_columns =
[
new Column("#", 52, true, r => (r.Index + 1).ToString()),
new Column("File", 178, false, r => r.FileName),
new Column("Ora di scatto", 104, false, r => r.Metadata.CaptureTime?.ToString("HH:mm:ss.ff") ?? "—"),
new Column("Δt", 68, true, r => r.CadenceText),
new Column("Posa", 62, true, r => r.Metadata.ExposureText),
new Column("Apertura", 68, true, r => r.Metadata.ApertureText),
new Column("ISO", 52, true, r => r.Metadata.IsoText),
new Column("Otturatore", 74, true, r => r.ShutterAngle > 0 ? $"{r.ShutterAngle:0.#}°" : "—"),
new Column("Luminanza", 80, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.MeasuredLuminance, 1e-9)):0.00}" : "—"),
new Column("Target", 72, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.TargetLuminance, 1e-9)):0.00}" : "—"),
new Column("Guadagno", 76, true, r => r.LuminanceAnalyzed ? $"{r.GainStops:+0.00;-0.00;0.00}" : "—"),
new Column("Blur", 62, true, r => r.BlurLength > 0.01 ? $"{r.BlurLength:0.0} px" : "—"),
new Column("Movimento", 82, true, r => r.MotionMagnitude > 0.01 ? $"{r.MotionMagnitude:0.0} px" : "—"),
];
}
public void SetSequence(TimelapseSequence? sequence)
{
_sequence = sequence;
_scroll = 0;
_hoverRow = -1;
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
Invalidate();
}
public void Refresh(TimelapseSequence? sequence)
{
_sequence = sequence;
Invalidate();
}
private int VisibleRows => Math.Max(1, (Height - HeaderHeight) / RowHeight);
private int RowCount => _sequence?.Count ?? 0;
private int MaxScroll => Math.Max(0, RowCount - VisibleRows);
private void EnsureVisible(int index)
{
if (index < 0) return;
if (index < _scroll) _scroll = index;
else if (index >= _scroll + VisibleRows) _scroll = index - VisibleRows + 1;
_scroll = Math.Clamp(_scroll, 0, MaxScroll);
}
// ------------------------------------------------------------------ interazione
protected override bool IsInputKey(Keys keyData) => keyData is Keys.Up or Keys.Down or Keys.PageUp or Keys.PageDown;
protected override void OnKeyDown(KeyEventArgs e)
{
if (RowCount == 0) return;
switch (e.KeyCode)
{
case Keys.Up: SelectedIndex = Math.Max(0, _selectedIndex - 1); break;
case Keys.Down: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + 1); break;
case Keys.PageUp: SelectedIndex = Math.Max(0, _selectedIndex - VisibleRows); break;
case Keys.PageDown: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + VisibleRows); break;
case Keys.Home: SelectedIndex = 0; break;
case Keys.End: SelectedIndex = RowCount - 1; break;
default: base.OnKeyDown(e); return;
}
e.Handled = true;
}
protected override void OnMouseWheel(MouseEventArgs e)
{
_scroll = Math.Clamp(_scroll - Math.Sign(e.Delta) * 3, 0, MaxScroll);
Invalidate();
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.X >= Width - ScrollWidth && RowCount > VisibleRows)
{
var thumb = ThumbBounds();
if (thumb.Contains(e.Location)) { _draggingScroll = true; _dragOffset = e.Y - thumb.Top; }
else ScrollToThumb(e.Y - thumb.Height / 2);
return;
}
int row = (e.Y - HeaderHeight) / RowHeight;
int index = _scroll + row;
if (e.Y >= HeaderHeight && index >= 0 && index < RowCount) SelectedIndex = index;
base.OnMouseDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_draggingScroll) { ScrollToThumb(e.Y - _dragOffset); return; }
int row = e.Y >= HeaderHeight ? (e.Y - HeaderHeight) / RowHeight : -1;
if (row != _hoverRow) { _hoverRow = row; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e) { _draggingScroll = false; base.OnMouseUp(e); }
protected override void OnMouseLeave(EventArgs e) { _hoverRow = -1; Invalidate(); base.OnMouseLeave(e); }
private Rectangle ThumbBounds()
{
int trackHeight = Height - HeaderHeight;
if (RowCount <= VisibleRows) return new Rectangle(Width - ScrollWidth, HeaderHeight, ScrollWidth, trackHeight);
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / RowCount);
int available = trackHeight - thumbHeight;
int offset = MaxScroll == 0 ? 0 : available * _scroll / MaxScroll;
return new Rectangle(Width - ScrollWidth + 2, HeaderHeight + offset, ScrollWidth - 4, thumbHeight);
}
private void ScrollToThumb(int top)
{
int trackHeight = Height - HeaderHeight;
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / Math.Max(1, RowCount));
int available = Math.Max(1, trackHeight - thumbHeight);
double fraction = Math.Clamp((top - HeaderHeight) / (double)available, 0, 1);
_scroll = (int)Math.Round(fraction * MaxScroll);
Invalidate();
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
int contentWidth = Width - (RowCount > VisibleRows ? ScrollWidth : 0);
DrawHeader(g, contentWidth);
if (_sequence is not { Count: > 0 })
{
TextRenderer.DrawText(g, "Trascina qui le immagini della sequenza, oppure usa «Aggiungi cartella»",
Theme.Body, new Rectangle(0, HeaderHeight, Width, Height - HeaderHeight),
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
int last = Math.Min(RowCount, _scroll + VisibleRows + 1);
for (int index = _scroll; index < last; index++)
{
int y = HeaderHeight + (index - _scroll) * RowHeight;
if (y > Height) break;
DrawRow(g, _sequence.Frames[index], index, y, contentWidth);
}
DrawScrollBar(g);
}
private void DrawHeader(Graphics g, int contentWidth)
{
using (var brush = new SolidBrush(Theme.Background))
g.FillRectangle(brush, 0, 0, Width, HeaderHeight);
using (var pen = new Pen(Theme.Border))
g.DrawLine(pen, 0, HeaderHeight - 1, Width, HeaderHeight - 1);
int x = 8;
foreach (var column in _columns)
{
if (x > contentWidth) break;
var bounds = new Rectangle(x, 0, Math.Min(column.Width - 8, contentWidth - x), HeaderHeight);
TextRenderer.DrawText(g, column.Title, Theme.SmallBold, bounds, Theme.TextMuted,
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
x += column.Width;
}
}
private void DrawRow(Graphics g, FrameRecord record, int index, int y, int contentWidth)
{
bool selected = index == _selectedIndex;
bool hovered = _hoverRow == index - _scroll;
if (selected)
{
using var brush = new SolidBrush(Color.FromArgb(58, Theme.Accent));
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
using var edge = new SolidBrush(Theme.Accent);
g.FillRectangle(edge, 0, y, 2, RowHeight);
}
else if (hovered)
{
using var brush = new SolidBrush(Theme.SurfaceAlt);
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
}
else if ((index & 1) == 1)
{
using var brush = new SolidBrush(Color.FromArgb(0x1F, 0x22, 0x29));
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
}
int x = 8;
for (int c = 0; c < _columns.Length; c++)
{
var column = _columns[c];
if (x > contentWidth) break;
Color color = c switch
{
0 => Theme.TextFaint,
1 => selected ? Theme.Text : Theme.Text,
10 => GainColor(record),
_ => Theme.TextMuted,
};
// Un intervallo anomalo va segnalato dove si legge: sulla colonna Δt.
if (c == 3 && record.IsCadenceAnomaly) color = Theme.Warning;
if (c == 2 && record.Metadata.CaptureSource == TimestampSource.FileSystem) color = Theme.Warning;
var bounds = new Rectangle(x, y, Math.Min(column.Width - 8, Math.Max(0, contentWidth - x)), RowHeight);
TextRenderer.DrawText(g, column.Value(record), c == 1 ? Theme.Body : Theme.Small, bounds, color,
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
x += column.Width;
}
}
private static Color GainColor(FrameRecord record)
{
if (!record.LuminanceAnalyzed) return Theme.TextFaint;
double stops = Math.Abs(record.GainStops);
if (stops < 0.02) return Theme.TextMuted;
return record.GainStops > 0 ? Theme.Success : Theme.Danger;
}
private void DrawScrollBar(Graphics g)
{
if (RowCount <= VisibleRows) return;
using (var track = new SolidBrush(Theme.Background))
g.FillRectangle(track, Width - ScrollWidth, HeaderHeight, ScrollWidth, Height - HeaderHeight);
var thumb = ThumbBounds();
Theme.FillRounded(g, thumb, 3f, _draggingScroll ? Theme.Accent : Theme.BorderStrong);
}
}
+549
View File
@@ -0,0 +1,549 @@
using System.Drawing.Drawing2D;
using Titano.Analysis;
using Titano.Core;
namespace Titano.UI;
/// <summary>
/// Sistema di rendering vettoriale per la curva di esposizione.
///
/// Sovrappone la luminanza misurata (lo sfarfallio, in ambra) alla curva target calcolata
/// dal deflicker (in blu), riempiendo lo scarto fra le due — che è esattamente la correzione
/// applicata. Una corsia inferiore mostra il guadagno in stop per fotogramma e le anomalie
/// di cadenza dell'intervallometro. Tutto è disegnato con primitive vettoriali: zoom e
/// spostamento non degradano la resa.
/// </summary>
internal sealed class LuminanceChart : Control
{
private TimelapseSequence? _sequence;
private DeflickerCurve? _curve;
private double _viewStart;
private double _viewEnd = 1;
private int _hoverIndex = -1;
private int _selectedIndex = -1;
private Point _mousePosition;
private bool _panning;
private double _panAnchor;
private int _panOriginX;
private const int GutterLeft = 62;
private const int GutterBottom = 22;
private const int GutterTop = 26;
private const int GainLaneHeight = 62;
public event EventHandler? SelectionChanged;
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectedIndex == value) return;
_selectedIndex = value;
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
public LuminanceChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
}
public void SetData(TimelapseSequence? sequence, DeflickerCurve? curve)
{
_sequence = sequence;
_curve = curve;
_viewStart = 0;
_viewEnd = Math.Max(1, (sequence?.Count ?? 1) - 1);
_hoverIndex = -1;
Invalidate();
}
/// <summary>Aggiorna la sola curva conservando zoom e selezione (cursori del deflicker).</summary>
public void UpdateCurve(DeflickerCurve? curve)
{
_curve = curve;
Invalidate();
}
private Rectangle PlotArea
{
get
{
int height = Math.Max(40, Height - GutterTop - GutterBottom - GainLaneHeight);
return new Rectangle(GutterLeft, GutterTop, Math.Max(10, Width - GutterLeft - 12), height);
}
}
private Rectangle GainArea
{
get
{
var plot = PlotArea;
return new Rectangle(plot.Left, plot.Bottom + 6, plot.Width, GainLaneHeight - 12);
}
}
// ------------------------------------------------------------------ interazione
protected override void OnMouseMove(MouseEventArgs e)
{
_mousePosition = e.Location;
if (_panning)
{
var plot = PlotArea;
double span = _viewEnd - _viewStart;
double delta = (e.X - _panOriginX) / (double)Math.Max(1, plot.Width) * span;
double start = _panAnchor - delta;
int max = Math.Max(0, (_sequence?.Count ?? 1) - 1);
start = Math.Clamp(start, 0, Math.Max(0, max - span));
_viewStart = start;
_viewEnd = start + span;
Invalidate();
return;
}
int index = IndexAt(e.X);
if (index != _hoverIndex) { _hoverIndex = index; Invalidate(); }
else if (index >= 0) Invalidate();
base.OnMouseMove(e);
}
protected override void OnMouseLeave(EventArgs e)
{
_hoverIndex = -1;
Invalidate();
base.OnMouseLeave(e);
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.Button == MouseButtons.Left)
{
int index = IndexAt(e.X);
if (index >= 0) SelectedIndex = index;
}
else if (e.Button == MouseButtons.Right)
{
_panning = true;
_panAnchor = _viewStart;
_panOriginX = e.X;
Cursor = Cursors.SizeWE;
}
base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseEventArgs e)
{
_panning = false;
Cursor = Cursors.Default;
base.OnMouseUp(e);
}
protected override void OnMouseWheel(MouseEventArgs e)
{
if (_sequence is not { Count: > 1 }) return;
int last = _sequence.Count - 1;
var plot = PlotArea;
double fraction = Math.Clamp((e.X - plot.Left) / (double)Math.Max(1, plot.Width), 0, 1);
double focus = _viewStart + fraction * (_viewEnd - _viewStart);
double factor = e.Delta > 0 ? 0.8 : 1.25;
double span = Math.Clamp((_viewEnd - _viewStart) * factor, 4, last);
_viewStart = Math.Clamp(focus - fraction * span, 0, Math.Max(0, last - span));
_viewEnd = _viewStart + span;
Invalidate();
}
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
_viewStart = 0;
_viewEnd = Math.Max(1, (_sequence?.Count ?? 1) - 1);
Invalidate();
base.OnMouseDoubleClick(e);
}
private int IndexAt(int x)
{
if (_sequence is not { Count: > 0 }) return -1;
var plot = PlotArea;
if (x < plot.Left - 4 || x > plot.Right + 4) return -1;
double fraction = (x - plot.Left) / (double)Math.Max(1, plot.Width);
double position = _viewStart + fraction * (_viewEnd - _viewStart);
return Math.Clamp((int)Math.Round(position), 0, _sequence.Count - 1);
}
private float XFor(double index, Rectangle plot)
{
double span = Math.Max(1e-6, _viewEnd - _viewStart);
return plot.Left + (float)((index - _viewStart) / span * plot.Width);
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
var plot = PlotArea;
if (_sequence is not { Count: > 1 } || _curve is null || _curve.Count < 2)
{
DrawEmptyState(g);
return;
}
// Estensione verticale: unione delle due curve con un margine costante.
double minValue = double.MaxValue, maxValue = double.MinValue;
int from = Math.Max(0, (int)Math.Floor(_viewStart));
int to = Math.Min(_curve.Count - 1, (int)Math.Ceiling(_viewEnd));
for (int i = from; i <= to; i++)
{
minValue = Math.Min(minValue, Math.Min(_curve.Measured[i], _curve.Target[i]));
maxValue = Math.Max(maxValue, Math.Max(_curve.Measured[i], _curve.Target[i]));
}
if (minValue > maxValue) { minValue = -4; maxValue = -1; }
double padding = Math.Max(0.12, (maxValue - minValue) * 0.15);
minValue -= padding;
maxValue += padding;
DrawGrid(g, plot, minValue, maxValue);
DrawCadenceMarkers(g, plot);
DrawCorrectionBand(g, plot, from, to, minValue, maxValue);
DrawCurve(g, plot, _curve.Measured, from, to, minValue, maxValue, Theme.Measured, 1.4f);
DrawCurve(g, plot, _curve.Target, from, to, minValue, maxValue, Theme.Accent, 2.1f);
DrawGainLane(g, from, to);
DrawSelection(g, plot);
DrawLegend(g);
DrawHover(g, plot, minValue, maxValue);
}
private void DrawEmptyState(Graphics g)
{
string message = _sequence is null
? "Nessuna sequenza caricata"
: "Esegui l'analisi per visualizzare la curva di esposizione";
TextRenderer.DrawText(g, message, Theme.Body, new Rectangle(0, 0, Width, Height),
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
private float YFor(double value, Rectangle plot, double min, double max)
{
double span = Math.Max(1e-6, max - min);
return plot.Bottom - (float)((value - min) / span * plot.Height);
}
private void DrawGrid(Graphics g, Rectangle plot, double min, double max)
{
using var gridPen = new Pen(Theme.Border) { DashStyle = DashStyle.Dot };
using var axisPen = new Pen(Theme.BorderStrong);
// Linee orizzontali a passo di stop intero (o mezzo stop se l'intervallo è stretto).
double step = (max - min) > 4 ? 1.0 : (max - min) > 1.6 ? 0.5 : 0.25;
double first = Math.Ceiling(min / step) * step;
for (double value = first; value <= max; value += step)
{
float y = YFor(value, plot, min, max);
g.DrawLine(gridPen, plot.Left, y, plot.Right, y);
TextRenderer.DrawText(g, value.ToString("0.##") + " EV", Theme.Small,
new Rectangle(0, (int)y - 8, GutterLeft - 6, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
}
g.DrawLine(axisPen, plot.Left, plot.Top, plot.Left, plot.Bottom);
g.DrawLine(axisPen, plot.Left, plot.Bottom, plot.Right, plot.Bottom);
// Etichette dei fotogrammi lungo l'asse orizzontale.
int count = _sequence!.Count;
double span = _viewEnd - _viewStart;
double labelStep = NiceStep(span / 8.0);
double firstLabel = Math.Ceiling(_viewStart / labelStep) * labelStep;
for (double index = firstLabel; index <= _viewEnd; index += labelStep)
{
float x = XFor(index, plot);
if (x < plot.Left - 1 || x > plot.Right + 1) continue;
g.DrawLine(gridPen, x, plot.Top, x, plot.Bottom);
int frame = Math.Clamp((int)Math.Round(index), 0, count - 1);
TextRenderer.DrawText(g, (frame + 1).ToString(), Theme.Small,
new Rectangle((int)x - 30, Height - GutterBottom + 2, 60, 16),
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
}
private static double NiceStep(double raw)
{
if (raw <= 1) return 1;
double magnitude = Math.Pow(10, Math.Floor(Math.Log10(raw)));
double normalized = raw / magnitude;
double nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
/// <summary>Tacche verticali sugli intervalli che si discostano dalla cadenza nominale.</summary>
private void DrawCadenceMarkers(Graphics g, Rectangle plot)
{
if (_sequence is null) return;
using var brush = new SolidBrush(Color.FromArgb(60, Theme.Warning));
for (int i = Math.Max(0, (int)_viewStart); i <= Math.Min(_sequence.Count - 1, (int)Math.Ceiling(_viewEnd)); i++)
{
if (!_sequence.Frames[i].IsCadenceAnomaly) continue;
float x = XFor(i, plot);
float next = XFor(i + 1, plot);
g.FillRectangle(brush, x, plot.Top, Math.Max(1.5f, next - x), plot.Height);
}
}
/// <summary>Area fra misurato e target: è la correzione che verrà applicata.</summary>
private void DrawCorrectionBand(Graphics g, Rectangle plot, int from, int to, double min, double max)
{
if (to - from < 1) return;
var points = new List<PointF>((to - from + 1) * 2);
for (int i = from; i <= to; i++) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Measured[i], plot, min, max)));
for (int i = to; i >= from; i--) points.Add(new PointF(XFor(i, plot), YFor(_curve!.Target[i], plot, min, max)));
using var path = new GraphicsPath();
path.AddPolygon(points.ToArray());
using var brush = new SolidBrush(Color.FromArgb(38, Theme.Accent));
var clip = g.Clip;
g.SetClip(plot);
g.FillPath(brush, path);
g.Clip = clip;
}
private void DrawCurve(Graphics g, Rectangle plot, double[] values, int from, int to,
double min, double max, Color color, float width)
{
if (to - from < 1) return;
var clip = g.Clip;
g.SetClip(Rectangle.Inflate(plot, 2, 2));
using var pen = new Pen(color, width)
{
LineJoin = LineJoin.Round,
StartCap = LineCap.Round,
EndCap = LineCap.Round,
};
int visible = to - from + 1;
if (visible > plot.Width * 2)
{
// Più campioni che pixel: si traccia l'inviluppo min/max per colonna,
// preservando l'ampiezza reale dello sfarfallio invece di alias arbitrari.
DrawEnvelope(g, plot, values, from, to, min, max, color);
}
else
{
var points = new PointF[visible];
for (int i = 0; i < visible; i++)
{
points[i] = new PointF(XFor(from + i, plot), YFor(values[from + i], plot, min, max));
}
if (points.Length >= 2) g.DrawLines(pen, points);
}
// Punti singoli quando lo zoom è sufficiente a distinguerli.
if (visible <= 90)
{
using var dot = new SolidBrush(color);
for (int i = from; i <= to; i++)
{
float x = XFor(i, plot);
float y = YFor(values[i], plot, min, max);
g.FillEllipse(dot, x - 2f, y - 2f, 4f, 4f);
}
}
g.Clip = clip;
}
private void DrawEnvelope(Graphics g, Rectangle plot, double[] values, int from, int to,
double min, double max, Color color)
{
using var pen = new Pen(Color.FromArgb(200, color), 1f);
double perPixel = (to - from + 1) / (double)plot.Width;
for (int px = 0; px < plot.Width; px++)
{
int start = from + (int)(px * perPixel);
int end = Math.Min(to, from + (int)((px + 1) * perPixel));
if (start > end) continue;
double lo = double.MaxValue, hi = double.MinValue;
for (int i = start; i <= end; i++)
{
lo = Math.Min(lo, values[i]);
hi = Math.Max(hi, values[i]);
}
float x = plot.Left + px;
g.DrawLine(pen, x, YFor(hi, plot, min, max), x, YFor(lo, plot, min, max) + 0.5f);
}
}
/// <summary>Corsia inferiore: guadagno per fotogramma in stop, con lo zero al centro.</summary>
private void DrawGainLane(Graphics g, int from, int to)
{
var lane = GainArea;
if (lane.Height < 12) return;
double peak = 0.05;
for (int i = from; i <= to; i++) peak = Math.Max(peak, Math.Abs(_curve!.GainStops[i]));
peak = Math.Max(peak, 0.05);
float zero = lane.Top + lane.Height / 2f;
using (var basePen = new Pen(Theme.Border)) g.DrawLine(basePen, lane.Left, zero, lane.Right, zero);
TextRenderer.DrawText(g, "guadagno", Theme.Small, new Rectangle(0, (int)zero - 8, GutterLeft - 6, 16),
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
TextRenderer.DrawText(g, $"fondoscala ±{peak:0.##} EV", Theme.Small,
new Rectangle(Width - 172, 4, 160, 16), Theme.TextFaint,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
double perColumn = (to - from + 1) / (double)Math.Max(1, lane.Width);
float barWidth = Math.Max(1f, (float)(lane.Width / (double)Math.Max(1, to - from + 1)) - 1f);
using var positive = new SolidBrush(Color.FromArgb(190, Theme.Success));
using var negative = new SolidBrush(Color.FromArgb(190, Theme.Danger));
if (perColumn <= 1.0)
{
for (int i = from; i <= to; i++)
{
double gain = _curve!.GainStops[i];
float x = XFor(i, lane) - barWidth / 2f;
float height = (float)(Math.Abs(gain) / peak * (lane.Height / 2f - 2));
if (height < 0.6f) continue;
var rect = gain >= 0
? new RectangleF(x, zero - height, barWidth, height)
: new RectangleF(x, zero, barWidth, height);
g.FillRectangle(gain >= 0 ? positive : negative, rect);
}
}
else
{
for (int px = 0; px < lane.Width; px++)
{
int start = from + (int)(px * perColumn);
int end = Math.Min(to, from + (int)((px + 1) * perColumn));
if (start > end) continue;
double lo = 0, hi = 0;
for (int i = start; i <= end; i++)
{
lo = Math.Min(lo, _curve!.GainStops[i]);
hi = Math.Max(hi, _curve!.GainStops[i]);
}
float x = lane.Left + px;
float top = zero - (float)(hi / peak * (lane.Height / 2f - 2));
float bottom = zero - (float)(lo / peak * (lane.Height / 2f - 2));
g.FillRectangle(hi >= -lo ? positive : negative, x, top, 1f, Math.Max(1f, bottom - top));
}
}
}
private void DrawSelection(Graphics g, Rectangle plot)
{
if (_selectedIndex < 0 || _sequence is null || _selectedIndex >= _sequence.Count) return;
float x = XFor(_selectedIndex, plot);
if (x < plot.Left || x > plot.Right) return;
using var pen = new Pen(Color.FromArgb(150, Theme.Text), 1f) { DashStyle = DashStyle.Dash };
g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
}
private void DrawLegend(Graphics g)
{
var entries = new (Color Color, string Label)[]
{
(Theme.Measured, "luminanza misurata"),
(Theme.Accent, "curva target"),
(Theme.Warning, "cadenza anomala"),
};
int x = GutterLeft;
for (int i = 0; i < entries.Length; i++)
{
var (color, label) = entries[i];
// L'ultima voce indica una fascia di sfondo, non una curva: si disegna come tale.
bool band = i == entries.Length - 1;
using (var brush = new SolidBrush(band ? Color.FromArgb(90, color) : color))
g.FillRectangle(brush, x, band ? 5 : 10, 14, band ? 12 : 3);
var size = TextRenderer.MeasureText(g, label, Theme.Small);
TextRenderer.DrawText(g, label, Theme.Small, new Rectangle(x + 19, 4, size.Width + 4, 16),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
x += 19 + size.Width + 18;
}
}
private void DrawHover(Graphics g, Rectangle plot, double min, double max)
{
if (_hoverIndex < 0 || _sequence is null || _hoverIndex >= _sequence.Count || _curve is null) return;
float x = XFor(_hoverIndex, plot);
if (x < plot.Left - 2 || x > plot.Right + 2) return;
using (var pen = new Pen(Color.FromArgb(90, Theme.Text), 1f)) g.DrawLine(pen, x, plot.Top, x, GainArea.Bottom);
float measuredY = YFor(_curve.Measured[_hoverIndex], plot, min, max);
float targetY = YFor(_curve.Target[_hoverIndex], plot, min, max);
using (var brush = new SolidBrush(Theme.Measured)) g.FillEllipse(brush, x - 3.5f, measuredY - 3.5f, 7, 7);
using (var brush = new SolidBrush(Theme.Accent)) g.FillEllipse(brush, x - 3.5f, targetY - 3.5f, 7, 7);
var record = _sequence.Frames[_hoverIndex];
string[] lines =
[
$"#{_hoverIndex + 1} {record.FileName}",
$"misurata {_curve.Measured[_hoverIndex]:0.00} EV",
$"target {_curve.Target[_hoverIndex]:0.00} EV",
$"guadagno {_curve.GainStops[_hoverIndex]:+0.00;-0.00;0.00} EV",
$"intervallo {record.CadenceText} otturatore {record.ShutterAngle:0.#}°",
];
int widthNeeded = 0;
foreach (string line in lines)
widthNeeded = Math.Max(widthNeeded, TextRenderer.MeasureText(g, line, Theme.Small).Width);
int boxWidth = widthNeeded + 18;
int boxHeight = lines.Length * 15 + 12;
int boxX = (int)(x + 14);
if (boxX + boxWidth > Width - 6) boxX = (int)(x - 14 - boxWidth);
int boxY = Math.Clamp(_mousePosition.Y - boxHeight / 2, plot.Top, Math.Max(plot.Top, Height - boxHeight - 4));
var box = new RectangleF(boxX, boxY, boxWidth, boxHeight);
Theme.FillAndStroke(g, box, 6f, Color.FromArgb(242, Theme.Background), Theme.BorderStrong);
for (int i = 0; i < lines.Length; i++)
{
TextRenderer.DrawText(g, lines[i], i == 0 ? Theme.SmallBold : Theme.Small,
new Rectangle(boxX + 9, boxY + 6 + i * 15, boxWidth - 18, 15),
i == 0 ? Theme.Text : Theme.TextMuted,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
}
}
}
+588
View File
@@ -0,0 +1,588 @@
using Titano.Core;
using Titano.Metadata;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>Finestra principale: collega i controlli disegnati a mano al motore di elaborazione.</summary>
internal sealed class MainForm : Form
{
private readonly TitanoProject _project = new();
private readonly LuminanceChart _chart;
private readonly FrameTable _table;
private readonly PreviewPanel _preview;
private readonly SettingsPanel _settings;
private readonly DarkProgressBar _progress;
private readonly Label _status;
private readonly Label _summary;
private readonly DarkButton _addFilesButton;
private readonly DarkButton _addFolderButton;
private readonly DarkButton _clearButton;
private readonly DarkButton _analyzeButton;
private readonly DarkButton _exportButton;
private readonly DarkButton _cancelButton;
private CancellationTokenSource? _operation;
private bool _busy;
public MainForm()
{
Text = "Titano — time-lapse";
MinimumSize = new Size(1180, 720);
Size = new Size(1560, 950);
StartPosition = FormStartPosition.CenterScreen;
BackColor = Theme.Background;
ForeColor = Theme.Text;
Font = Theme.Body;
AllowDrop = true;
DoubleBuffered = true;
_chart = new LuminanceChart { Dock = DockStyle.Fill };
_table = new FrameTable { Dock = DockStyle.Fill };
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
_settings = new SettingsPanel(_project) { Dock = DockStyle.Fill };
_progress = new DarkProgressBar { Dock = DockStyle.Fill };
_status = new Label
{
Dock = DockStyle.Fill,
ForeColor = Theme.TextMuted,
Font = Theme.Small,
TextAlign = ContentAlignment.MiddleLeft,
Text = "Pronto. Trascina qui una sequenza di immagini per iniziare.",
};
_summary = new Label
{
Dock = DockStyle.Fill,
ForeColor = Theme.TextFaint,
Font = Theme.Small,
TextAlign = ContentAlignment.MiddleRight,
Text = string.Empty,
};
_addFilesButton = new DarkButton { Text = "Aggiungi file…", Width = 130 };
_addFolderButton = new DarkButton { Text = "Aggiungi cartella…", Width = 150 };
_clearButton = new DarkButton { Text = "Svuota", Width = 84 };
_analyzeButton = new DarkButton { Text = "Analizza sequenza", Width = 160 };
_exportButton = new DarkButton { Text = "Esporta video", Width = 140, Primary = true };
_cancelButton = new DarkButton { Text = "Annulla", Width = 96, Danger = true, Visible = false };
BuildLayout();
WireEvents();
UpdateCommandState();
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
Theme.ApplyDarkTitleBar(this);
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
// La cornice viene ridisegnata dal sistema alla comparsa: l'attributo va riconfermato.
Theme.ApplyDarkTitleBar(this);
}
// ------------------------------------------------------------------ struttura
private void BuildLayout()
{
var toolbar = BuildToolbar();
var statusBar = BuildStatusBar();
var settingsHost = new Panel
{
Dock = DockStyle.Right,
Width = 372,
BackColor = Theme.Surface,
Padding = new Padding(1, 0, 0, 0),
};
settingsHost.Controls.Add(_settings);
settingsHost.Paint += (_, e) =>
{
using var pen = new Pen(Theme.Border);
e.Graphics.DrawLine(pen, 0, 0, 0, settingsHost.Height);
};
var center = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
var tableHost = Card(_table, "Fotogrammi", DockStyle.Fill);
var chartHost = Card(_chart, "Curva di esposizione", DockStyle.Top, 268);
var previewHost = Card(_preview, "Anteprima", DockStyle.Top, 300);
var chartSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
var previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
center.Controls.Add(tableHost);
center.Controls.Add(chartSplitter);
center.Controls.Add(chartHost);
center.Controls.Add(previewSplitter);
center.Controls.Add(previewHost);
Controls.Add(center);
Controls.Add(settingsHost);
Controls.Add(statusBar);
Controls.Add(toolbar);
}
/// <summary>Riquadro con intestazione: unità visiva ricorrente dell'interfaccia.</summary>
private static Panel Card(Control content, string title, DockStyle dock, int height = 0)
{
var host = new Panel
{
Dock = dock,
BackColor = Theme.Surface,
Padding = new Padding(1, 30, 1, 1),
Margin = new Padding(0),
};
if (height > 0) host.Height = height;
host.Controls.Add(content);
host.Paint += (_, e) =>
{
var g = e.Graphics;
Theme.HighQuality(g);
using (var brush = new SolidBrush(Theme.Background))
g.FillRectangle(brush, 0, 0, host.Width, 30);
TextRenderer.DrawText(g, title, Theme.SmallBold, new Rectangle(12, 0, host.Width - 24, 30),
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
using var pen = new Pen(Theme.Border);
g.DrawRectangle(pen, 0, 0, host.Width - 1, host.Height - 1);
g.DrawLine(pen, 0, 29, host.Width, 29);
};
return host;
}
private Panel BuildToolbar()
{
var bar = new Panel { Dock = DockStyle.Top, Height = 58, BackColor = Theme.Background };
var title = new Label
{
Text = "TITANO",
Font = Theme.Title,
ForeColor = Theme.Text,
AutoSize = false,
Bounds = new Rectangle(16, 12, 110, 32),
TextAlign = ContentAlignment.MiddleLeft,
};
var subtitle = new Label
{
Text = "elaborazione time-lapse",
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Bounds = new Rectangle(112, 20, 160, 18),
TextAlign = ContentAlignment.MiddleLeft,
};
int x = 288;
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
{
button.Bounds = new Rectangle(x, 13, button.Width, 32);
bar.Controls.Add(button);
x += button.Width + 8;
}
_analyzeButton.Bounds = new Rectangle(x + 16, 13, _analyzeButton.Width, 32);
bar.Controls.Add(_analyzeButton);
x += _analyzeButton.Width + 24;
_exportButton.Bounds = new Rectangle(x, 13, _exportButton.Width, 32);
bar.Controls.Add(_exportButton);
bar.Controls.Add(title);
bar.Controls.Add(subtitle);
bar.Paint += (_, e) =>
{
using var pen = new Pen(Theme.Border);
e.Graphics.DrawLine(pen, 0, bar.Height - 1, bar.Width, bar.Height - 1);
};
return bar;
}
private Panel BuildStatusBar()
{
var bar = new Panel { Dock = DockStyle.Bottom, Height = 52, BackColor = Theme.Background };
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 3,
RowCount = 2,
BackColor = Theme.Background,
Padding = new Padding(16, 6, 16, 6),
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 60));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 104));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
layout.Controls.Add(_status, 0, 0);
layout.Controls.Add(_summary, 1, 0);
layout.Controls.Add(_progress, 0, 1);
layout.SetColumnSpan(_progress, 2);
_cancelButton.Dock = DockStyle.Fill;
_cancelButton.Margin = new Padding(8, 4, 0, 4);
layout.Controls.Add(_cancelButton, 2, 0);
layout.SetRowSpan(_cancelButton, 2);
bar.Controls.Add(layout);
bar.Paint += (_, e) =>
{
using var pen = new Pen(Theme.Border);
e.Graphics.DrawLine(pen, 0, 0, bar.Width, 0);
};
return bar;
}
// ------------------------------------------------------------------ eventi
private void WireEvents()
{
_addFilesButton.Click += (_, _) => AddFiles();
_addFolderButton.Click += (_, _) => AddFolder();
_clearButton.Click += (_, _) => ClearSequence();
_analyzeButton.Click += async (_, _) => await AnalyzeAsync();
_exportButton.Click += async (_, _) => await ExportAsync();
_cancelButton.Click += (_, _) => _operation?.Cancel();
_chart.SelectionChanged += (_, _) =>
{
if (_chart.SelectedIndex >= 0) _table.SelectedIndex = _chart.SelectedIndex;
ShowPreview(_chart.SelectedIndex);
};
_table.SelectionChanged += (_, _) =>
{
if (_table.SelectedIndex >= 0) _chart.SelectedIndex = _table.SelectedIndex;
ShowPreview(_table.SelectedIndex);
};
_settings.DeflickerChanged += (_, _) => RecomputeCurve();
_settings.AnalysisInvalidated += (_, _) => InvalidateAnalysis();
_settings.PreviewInvalidated += (_, _) => ShowPreview(_table.SelectedIndex);
_settings.BrowseOutputRequested += (_, _) => BrowseOutput();
DragEnter += (_, e) =>
{
if (e.Data?.GetDataPresent(DataFormats.FileDrop) == true) e.Effect = DragDropEffects.Copy;
};
DragDrop += (_, e) =>
{
if (e.Data?.GetData(DataFormats.FileDrop) is string[] items) _ = LoadAsync(ExpandPaths(items));
};
}
// ------------------------------------------------------------------ comandi
private void AddFiles()
{
using var dialog = new OpenFileDialog
{
Multiselect = true,
Title = "Seleziona i fotogrammi della sequenza",
Filter = "Immagini (" + string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + ")|" +
string.Join(";", MetadataReader.SupportedExtensions.Select(e => "*" + e)) + "|Tutti i file|*.*",
};
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(dialog.FileNames);
}
private void AddFolder()
{
using var dialog = new FolderBrowserDialog { Description = "Seleziona la cartella della sequenza" };
if (dialog.ShowDialog(this) == DialogResult.OK) _ = LoadAsync(ExpandPaths([dialog.SelectedPath]));
}
private static string[] ExpandPaths(IEnumerable<string> items)
{
var files = new List<string>();
foreach (string item in items)
{
if (Directory.Exists(item))
{
files.AddRange(Directory.EnumerateFiles(item).Where(MetadataReader.IsSupported));
}
else if (File.Exists(item) && MetadataReader.IsSupported(item))
{
files.Add(item);
}
}
return [.. files];
}
private async Task LoadAsync(IReadOnlyList<string> paths)
{
if (_busy || paths.Count == 0)
{
if (paths.Count == 0) SetStatus("Nessun file d'immagine riconosciuto fra quelli indicati.");
return;
}
BeginOperation("Lettura dei metadati…");
try
{
var progress = new Progress<PipelineProgress>(ReportProgress);
var sequence = await RenderPipeline.IngestAsync(paths, _project.General.CadenceTolerance,
progress, _operation!.Token);
_project.Sequence = sequence;
_project.Curve = null;
_project.Stats = null;
_table.SetSequence(sequence);
_chart.SetData(sequence, null);
SuggestOutputPath(sequence);
UpdateSummary();
ShowPreview(0);
SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " +
$"{sequence.CadenceAnomalies} intervalli anomali.");
}
catch (OperationCanceledException)
{
SetStatus("Caricamento annullato.");
}
catch (Exception ex)
{
SetStatus("Caricamento non riuscito: " + ex.Message);
}
finally
{
EndOperation();
}
}
private void ClearSequence()
{
if (_busy) return;
_project.Sequence = null;
_project.Curve = null;
_project.Stats = null;
_table.SetSequence(null);
_chart.SetData(null, null);
_preview.Clear();
_summary.Text = string.Empty;
SetStatus("Sequenza svuotata.");
UpdateCommandState();
}
private async Task AnalyzeAsync()
{
if (_busy || !_project.HasSequence) return;
BeginOperation("Analisi della luminanza…");
try
{
var pipeline = new RenderPipeline(_project);
var progress = new Progress<PipelineProgress>(ReportProgress);
await pipeline.AnalyzeAsync(progress, _operation!.Token);
_chart.SetData(_project.Sequence, _project.Curve);
_table.Refresh(_project.Sequence);
UpdateSummary();
ShowPreview(_table.SelectedIndex);
var curve = _project.Curve!;
double before = Analysis.DeflickerCurve.FlickerIndex(curve.Measured);
var corrected = new double[curve.Count];
for (int i = 0; i < curve.Count; i++) corrected[i] = curve.Measured[i] + curve.GainStops[i];
double after = Analysis.DeflickerCurve.FlickerIndex(corrected);
SetStatus($"Analisi completata. Sfarfallio {before:0.000} EV → {after:0.000} EV " +
$"({100 * (1 - after / Math.Max(before, 1e-9)):0.#}% di riduzione).");
}
catch (OperationCanceledException)
{
SetStatus("Analisi annullata.");
}
catch (Exception ex)
{
SetStatus("Analisi non riuscita: " + ex.Message);
}
finally
{
EndOperation();
}
}
private async Task ExportAsync()
{
if (_busy || !_project.HasSequence) return;
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath))
{
BrowseOutput();
if (string.IsNullOrWhiteSpace(_project.Export.OutputPath)) return;
}
BeginOperation("Elaborazione e codifica…");
try
{
var pipeline = new RenderPipeline(_project);
var progress = new Progress<PipelineProgress>(ReportProgress);
var result = await pipeline.RenderAsync(progress, _operation!.Token);
_table.Refresh(_project.Sequence);
SetStatus($"Esportazione completata: {result.EncodedFrames} fotogrammi, " +
$"{result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.Elapsed.TotalSeconds:0.0} s " +
$"con {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}.");
}
catch (OperationCanceledException)
{
SetStatus("Esportazione interrotta. Il file contiene i fotogrammi già codificati.");
}
catch (Exception ex)
{
SetStatus("Esportazione non riuscita: " + ex.Message);
}
finally
{
EndOperation();
}
}
private void BrowseOutput()
{
using var dialog = new SaveFileDialog
{
Title = "Destinazione del video",
Filter = "Video MP4|*.mp4",
DefaultExt = "mp4",
FileName = Path.GetFileName(_project.Export.OutputPath) is { Length: > 0 } name ? name : "timelapse.mp4",
};
if (dialog.ShowDialog(this) != DialogResult.OK) return;
_project.Export.OutputPath = dialog.FileName;
_settings.OutputPathBox.Text = dialog.FileName;
}
private void SuggestOutputPath(TimelapseSequence sequence)
{
if (!string.IsNullOrWhiteSpace(_project.Export.OutputPath) || sequence.Count == 0) return;
string? directory = Path.GetDirectoryName(sequence.Frames[0].FilePath);
if (directory is null) return;
string suggestion = Path.Combine(directory, "timelapse.mp4");
_project.Export.OutputPath = suggestion;
_settings.OutputPathBox.Text = suggestion;
}
// ------------------------------------------------------------------ aggiornamenti
private void RecomputeCurve()
{
if (!_project.IsAnalyzed) return;
new RenderPipeline(_project).RecomputeCurve();
_chart.UpdateCurve(_project.Curve);
_table.Refresh(_project.Sequence);
UpdateSummary();
ShowPreview(_table.SelectedIndex);
}
private void InvalidateAnalysis()
{
if (_project.Sequence is { } sequence) sequence.RecomputeTiming(_project.General.CadenceTolerance);
_project.Curve = null;
_project.Stats = null;
_chart.SetData(_project.Sequence, null);
_table.Refresh(_project.Sequence);
UpdateSummary();
UpdateCommandState();
}
private void ShowPreview(int index)
{
if (_project.Sequence is not { Count: > 0 } sequence || index < 0) { _preview.Clear(); return; }
_preview.Show(sequence, Math.Clamp(index, 0, sequence.Count - 1));
}
private void UpdateSummary()
{
if (_project.Sequence is not { Count: > 0 } sequence)
{
_summary.Text = string.Empty;
return;
}
var (width, height) = _project.ResolveWorkingSize();
double outputSeconds = sequence.Count / Math.Max(1.0, _project.Export.FrameRate);
_summary.Text = $"{sequence.Count} scatti · {sequence.TotalDuration:hh\\:mm\\:ss} di ripresa · " +
$"{width}×{height} · {outputSeconds:0.0} s di video" +
(_project.IsAnalyzed ? " · analizzata" : string.Empty);
}
private void ReportProgress(PipelineProgress progress)
{
_progress.Fraction = progress.Fraction;
string detail = progress.Total > 0 ? $" {progress.Completed}/{progress.Total}" : string.Empty;
string speed = progress.FramesPerSecond > 0.01
? $" · {progress.FramesPerSecond:0.0} fps · {progress.Remaining:hh\\:mm\\:ss} rimanenti"
: string.Empty;
_status.Text = progress.Message + detail + speed;
}
private void SetStatus(string message)
{
_status.Text = message;
_progress.Fraction = 0;
}
private void BeginOperation(string message)
{
_busy = true;
_operation?.Dispose();
_operation = new CancellationTokenSource();
_status.Text = message;
_progress.Fraction = 0;
_cancelButton.Visible = true;
UpdateCommandState();
}
private void EndOperation()
{
_busy = false;
_cancelButton.Visible = false;
_progress.Fraction = 0;
UpdateCommandState();
}
private void UpdateCommandState()
{
bool hasSequence = _project.HasSequence;
_addFilesButton.Enabled = !_busy;
_addFolderButton.Enabled = !_busy;
_clearButton.Enabled = !_busy && hasSequence;
_analyzeButton.Enabled = !_busy && hasSequence;
_exportButton.Enabled = !_busy && hasSequence;
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
_operation?.Cancel();
base.OnFormClosing(e);
}
/// <summary>
/// Carica e analizza una sequenza senza interazione: usata dalla modalità di cattura
/// dell'interfaccia, che serve a verificare la resa grafica in modo riproducibile.
/// </summary>
internal void SelectSettingsTab(int index) => _settings.SelectPage(index);
internal async Task PrepareForCaptureAsync(IReadOnlyList<string> paths)
{
await LoadAsync(paths);
await AnalyzeAsync();
_table.SelectedIndex = Math.Min(12, Math.Max(0, (_project.Sequence?.Count ?? 1) - 1));
}
}
+272
View File
@@ -0,0 +1,272 @@
using System.Drawing.Imaging;
using Titano.Core;
using Titano.Imaging;
using Titano.Motion;
using Titano.Pipeline;
namespace Titano.UI;
/// <summary>
/// Anteprima del fotogramma selezionato, resa dallo stesso motore usato in esportazione:
/// decodifica, correzione di esposizione e — se attivo — motion blur sintetico calcolato
/// sul campo vettoriale verso il fotogramma successivo.
/// </summary>
internal sealed class PreviewPanel : Control
{
private readonly TitanoProject _project;
private Bitmap? _bitmap;
private string _caption = string.Empty;
private string _status = "Nessun fotogramma selezionato";
private CancellationTokenSource? _pending;
private int _requestId;
private bool _busy;
public PreviewPanel(TitanoProject project)
{
_project = project;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Background;
}
public void Clear()
{
Interlocked.Increment(ref _requestId);
_pending?.Cancel();
SwapBitmap(null);
_caption = string.Empty;
_status = "Nessun fotogramma selezionato";
Invalidate();
}
/// <summary>Richiede il rendering del fotogramma indicato; le richieste precedenti vengono annullate.</summary>
public void Show(TimelapseSequence sequence, int index)
{
if (index < 0 || index >= sequence.Count) { Clear(); return; }
int requestId = Interlocked.Increment(ref _requestId);
_pending?.Cancel();
var source = new CancellationTokenSource();
_pending = source;
var record = sequence.Frames[index];
_caption = $"#{index + 1} {record.FileName}";
_busy = true;
Invalidate();
var project = _project;
var token = source.Token;
_ = Task.Run(() =>
{
try
{
var (bitmap, status) = Render(project, sequence, index, PreviewSize(), token);
if (token.IsCancellationRequested || requestId != Volatile.Read(ref _requestId))
{
bitmap?.Dispose();
return;
}
BeginInvoke(() =>
{
if (requestId != Volatile.Read(ref _requestId)) { bitmap?.Dispose(); return; }
SwapBitmap(bitmap);
_status = status;
_busy = false;
Invalidate();
});
}
catch (Exception ex)
{
if (token.IsCancellationRequested) return;
try
{
BeginInvoke(() =>
{
SwapBitmap(null);
_status = "Anteprima non disponibile: " + ex.Message;
_busy = false;
Invalidate();
});
}
catch (InvalidOperationException) { /* finestra già chiusa */ }
}
}, token);
}
private Size PreviewSize()
{
int width = Math.Clamp(Width - 24, 160, 1600);
int height = Math.Clamp(Height - 46, 120, 1200);
return new Size(width, height);
}
private void SwapBitmap(Bitmap? bitmap)
{
var previous = _bitmap;
_bitmap = bitmap;
previous?.Dispose();
}
// ------------------------------------------------------------------ rendering
private static (Bitmap? Bitmap, string Status) Render(TitanoProject project, TimelapseSequence sequence,
int index, Size available, CancellationToken token)
{
var record = sequence.Frames[index];
var metadata = record.Metadata;
int sourceWidth = metadata.PixelWidth;
int sourceHeight = metadata.PixelHeight;
if (sourceWidth <= 0 || sourceHeight <= 0)
(sourceWidth, sourceHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation);
else if (ImageDecoder.SwapsAxes(metadata.Orientation))
(sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
if (sourceWidth <= 0 || sourceHeight <= 0) return (null, "Immagine non leggibile");
double scale = Math.Min(available.Width / (double)sourceWidth, available.Height / (double)sourceHeight);
scale = Math.Min(scale, 1.0);
int width = Math.Max(16, (int)(sourceWidth * scale) & ~1);
int height = Math.Max(16, (int)(sourceHeight * scale) & ~1);
var pool = new FrameBufferPool(4);
using var frame = ImageDecoder.Decode(metadata.FilePath, width, height, metadata.Orientation, pool);
token.ThrowIfCancellationRequested();
var status = new System.Text.StringBuilder();
status.Append($"{sourceWidth}×{sourceHeight}");
var curve = project.Curve;
if (project.Deflicker.Enabled && curve is not null && index < curve.Count)
{
Analysis.ExposureProcessor.Apply(frame, curve.ChannelGain[index],
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
status.Append($" guadagno {curve.GainStops[index]:+0.00;-0.00;0.00} EV");
}
ImageBuffer result = frame;
ImageBuffer? blurred = null;
if (project.MotionBlur.Enabled && index + 1 < sequence.Count)
{
var nextMetadata = sequence.Frames[index + 1].Metadata;
using var next = ImageDecoder.Decode(nextMetadata.FilePath, width, height, nextMetadata.Orientation, pool);
token.ThrowIfCancellationRequested();
if (project.Deflicker.Enabled && curve is not null && index + 1 < curve.Count)
{
Analysis.ExposureProcessor.Apply(next, curve.ChannelGain[index + 1],
project.Deflicker.ProtectHighlights, project.Deflicker.HighlightKnee);
}
var flow = new OpticalFlowEngine(project.Flow).Compute(frame, next);
token.ThrowIfCancellationRequested();
double missing = MotionBlurRenderer.MissingBlurFactor(record.ShutterAngle,
project.MotionBlur.TargetShutterAngle,
project.MotionBlur.Strength);
// La scia è proporzionale alla risoluzione: in anteprima va riscalata.
var scaledSettings = project.MotionBlur.Clone();
scaledSettings.MaxBlurPixels = project.MotionBlur.MaxBlurPixels * scale;
blurred = pool.Rent(width, height);
double length = MotionBlurRenderer.Render(frame, blurred, flow, missing, scaledSettings);
result = blurred;
status.Append($" otturatore {record.ShutterAngle:0.#}° → {project.MotionBlur.TargetShutterAngle:0}°");
status.Append($" scia {length:0.0} px");
}
var bitmap = ToBitmap(result);
blurred?.Dispose();
return (bitmap, status.ToString());
}
/// <summary>Conversione dal buffer in luce lineare a una bitmap GDI+ a 32 bit.</summary>
private static unsafe Bitmap ToBitmap(ImageBuffer buffer)
{
var bitmap = new Bitmap(buffer.Width, buffer.Height, PixelFormat.Format32bppRgb);
var locked = bitmap.LockBits(new Rectangle(0, 0, buffer.Width, buffer.Height),
ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb);
try
{
var data = buffer.Data;
byte* basePtr = (byte*)locked.Scan0;
for (int y = 0; y < buffer.Height; y++)
{
byte* row = basePtr + (long)y * locked.Stride;
int sourceIndex = y * buffer.Width * ImageBuffer.Channels;
for (int x = 0; x < buffer.Width; x++)
{
int i = sourceIndex + x * ImageBuffer.Channels;
byte* pixel = row + x * 4;
pixel[0] = ColorSpace.ToSrgbByte(data[i + 2]);
pixel[1] = ColorSpace.ToSrgbByte(data[i + 1]);
pixel[2] = ColorSpace.ToSrgbByte(data[i]);
pixel[3] = 255;
}
}
}
finally
{
bitmap.UnlockBits(locked);
}
return bitmap;
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Background);
var frame = new Rectangle(0, 0, Width, Height - 22);
if (_bitmap is null)
{
TextRenderer.DrawText(g, _busy ? "Elaborazione dell'anteprima…" : _status, Theme.Body, frame,
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
double scale = Math.Min(frame.Width / (double)_bitmap.Width, frame.Height / (double)_bitmap.Height);
int width = Math.Max(1, (int)(_bitmap.Width * scale));
int height = Math.Max(1, (int)(_bitmap.Height * scale));
var target = new Rectangle(frame.Left + (frame.Width - width) / 2,
frame.Top + (frame.Height - height) / 2, width, height);
g.DrawImage(_bitmap, target);
using (var pen = new Pen(Theme.Border)) g.DrawRectangle(pen, target);
if (_busy)
{
using var overlay = new SolidBrush(Color.FromArgb(120, Theme.Background));
g.FillRectangle(overlay, target);
TextRenderer.DrawText(g, "Aggiornamento…", Theme.Small, target, Theme.Text,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
var footer = new Rectangle(8, Height - 20, Width - 16, 18);
TextRenderer.DrawText(g, _caption, Theme.SmallBold, footer, Theme.Text,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
TextRenderer.DrawText(g, _status, Theme.Small, footer, Theme.TextMuted,
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_pending?.Cancel();
_pending?.Dispose();
_bitmap?.Dispose();
}
base.Dispose(disposing);
}
}
+354
View File
@@ -0,0 +1,354 @@
using Titano.Pipeline;
using Titano.Video;
namespace Titano.UI;
/// <summary>
/// Pannello di configurazione avanzata, suddiviso nelle tre sezioni richieste:
/// Generale, Elaborazione immagini (deflicker, motion blur, campo vettoriale) ed
/// Esportazione video. Ogni controllo scrive direttamente nel progetto e segnala
/// quale parte della pipeline va ricalcolata.
/// </summary>
internal sealed class SettingsPanel : Panel
{
private readonly TitanoProject _project;
private readonly TabStrip _tabs;
private readonly Panel[] _pages;
/// <summary>Un parametro del deflicker è cambiato: basta ricalcolare la curva.</summary>
public event EventHandler? DeflickerChanged;
/// <summary>È cambiato un parametro che invalida l'analisi già svolta.</summary>
public event EventHandler? AnalysisInvalidated;
/// <summary>È cambiato un parametro che modifica solo l'anteprima o l'esportazione.</summary>
public event EventHandler? PreviewInvalidated;
public event EventHandler? BrowseOutputRequested;
public TextBox OutputPathBox { get; }
public SettingsPanel(TitanoProject project)
{
_project = project;
BackColor = Theme.Surface;
Padding = new Padding(0);
_tabs = new TabStrip("Generale", "Elaborazione immagini", "Esportazione") { Dock = DockStyle.Top };
_tabs.SelectedChanged += (_, _) => ShowPage(_tabs.SelectedIndex);
OutputPathBox = new TextBox
{
BackColor = Theme.SurfaceAlt,
ForeColor = Theme.Text,
BorderStyle = BorderStyle.FixedSingle,
Font = Theme.Body,
Dock = DockStyle.Top,
};
OutputPathBox.TextChanged += (_, _) =>
{
_project.Export.OutputPath = OutputPathBox.Text;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
};
// L'ordine di inserimento determina l'ordine di ancoraggio: i controlli in coda alla
// collezione vengono disposti per primi, quindi la barra delle schede va aggiunta
// dopo le pagine per riservarsi la propria fascia in alto.
_pages = [BuildGeneralPage(), BuildImagePage(), BuildExportPage()];
foreach (var page in _pages)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
Controls.Add(page);
}
Controls.Add(_tabs);
ShowPage(0);
}
/// <summary>Seleziona una delle tre sezioni; usata anche dalla modalità di cattura.</summary>
internal void SelectPage(int index) => _tabs.SelectedIndex = index;
private void ShowPage(int index)
{
for (int i = 0; i < _pages.Length; i++) _pages[i].Visible = i == index;
}
// ------------------------------------------------------------------ pagine
private Panel BuildGeneralPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Sequenza"));
stack.Add(Combo("Risoluzione di lavoro",
["Nativa (piena risoluzione)", "3840 px (4K UHD)", "2560 px", "1920 px (Full HD)", "1280 px"],
WorkingWidthToIndex(_project.General.WorkingWidth),
index =>
{
_project.General.WorkingWidth = index switch { 1 => 3840, 2 => 2560, 3 => 1920, 4 => 1280, _ => 0 };
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
value =>
{
_project.General.CadenceTolerance = value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(new SectionHeader("Prestazioni"));
stack.Add(Slider("Larghezza della passata di analisi", 256, 2048, _project.General.AnalysisWidth, 64, "0", "px",
value =>
{
_project.General.AnalysisWidth = (int)value;
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Decodifiche simultanee", 1, 16, _project.General.DecodeParallelism, 1, "0", "thread",
value =>
{
_project.General.DecodeParallelism = (int)value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Note("Le decodifiche simultanee determinano anche quanti fotogrammi restano " +
"contemporaneamente in memoria: l'occupazione non dipende dalla lunghezza della sequenza."));
return stack.Panel;
}
private Panel BuildImagePage()
{
var stack = NewStack();
// ---- Deflicker
stack.Add(new SectionHeader("Deflicker"));
var deflickerEnabled = Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
{
_project.Deflicker.Enabled = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
});
stack.Add(deflickerEnabled);
stack.Add(Slider("Finestra temporale", 3, 121, _project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
value => { _project.Deflicker.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Correzione massima", 0.1, 3.0, _project.Deflicker.MaxCorrectionStops, 0.1, "0.0", "EV",
value => { _project.Deflicker.MaxCorrectionStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers, value =>
{
_project.Deflicker.RejectOutliers = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Stabilizza il bilanciamento colore", _project.Deflicker.StabilizeColor, value =>
{
_project.Deflicker.StabilizeColor = value;
DeflickerChanged?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
{
_project.Deflicker.ProtectHighlights = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00", string.Empty,
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
// ---- Motion blur
stack.Add(new SectionHeader("Motion blur sintetico"));
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
{
_project.MotionBlur.Enabled = value;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Shutter angle obiettivo", 0, 360, _project.MotionBlur.TargetShutterAngle, 5, "0", "°",
value => { _project.MotionBlur.TargetShutterAngle = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Intensità", 0, 1, _project.MotionBlur.Strength, 0.05, "0.00", string.Empty,
value => { _project.MotionBlur.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Lunghezza massima della scia", 4, 160, _project.MotionBlur.MaxBlurPixels, 2, "0", "px",
value => { _project.MotionBlur.MaxBlurPixels = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Campioni per pixel", 3, 65, _project.MotionBlur.MaxSamples, 2, "0", string.Empty,
value => { _project.MotionBlur.MaxSamples = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Note("La scia sintetizzata compensa in quadratura la sfocatura mancante: " +
"√(obiettivo² reale²). A 180° si ottiene la resa cinematografica."));
// ---- Optical flow
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
value => { _project.Flow.AnalysisWidth = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Passo della griglia", 4, 32, _project.Flow.CellSize, 1, "0", "px",
value => { _project.Flow.CellSize = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Livelli della piramide", 1, 6, _project.Flow.PyramidLevels, 1, "0", string.Empty,
value => { _project.Flow.PyramidLevels = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Raggio della finestra", 2, 12, _project.Flow.WindowRadius, 1, "0", "px",
value => { _project.Flow.WindowRadius = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
return stack.Panel;
}
private Panel BuildExportPage()
{
var stack = NewStack();
stack.Add(new SectionHeader("Formato"));
stack.Add(Combo("Codec", ["H.264 / AVC", "H.265 / HEVC"], _project.Export.Codec == VideoCodec.H264 ? 0 : 1,
index =>
{
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
index => _project.Export.Profile = index switch
{
0 => H264Profile.Baseline,
1 => H264Profile.Main,
_ => H264Profile.High,
}));
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
value => _project.Export.BitrateMbps = value));
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
value => _project.Export.KeyframeIntervalSeconds = (int)value));
stack.Add(new SectionHeader("Andamento temporale"));
stack.Add(Combo("Durata dei fotogrammi",
["Costante — un fotogramma per scatto",
"Adattiva — durata proporzionale all'intervallo",
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
(int)_project.Export.Timing,
index =>
{
_project.Export.Timing = (FrameTimingMode)index;
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
}));
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
value => _project.Export.MaxAdaptiveStretch = value));
stack.Add(new SectionHeader("Codifica"));
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
value => _project.Export.PreferHardware = value));
stack.Add(new SectionHeader("Destinazione"));
stack.Add(OutputPathBox);
var browse = new DarkButton { Text = "Scegli il file di destinazione…", Height = 32, Dock = DockStyle.Top };
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
stack.Add(browse);
stack.Add(Note("Il video viene scritto in un unico flusso continuo: l'elaborazione non " +
"genera alcun file temporaneo su disco."));
return stack.Panel;
}
// ------------------------------------------------------------------ costruttori di controlli
private sealed class Stack(Panel panel)
{
public Panel Panel { get; } = panel;
private int _y;
public void Add(Control control)
{
control.Dock = DockStyle.None;
control.Left = 14;
control.Top = _y;
control.Width = Panel.ClientSize.Width - 34;
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right;
Panel.Controls.Add(control);
_y += control.Height + 6;
}
}
private static Stack NewStack()
{
var panel = new Panel
{
BackColor = Theme.Surface,
Padding = new Padding(0, 8, 0, 12),
Width = 360,
};
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 360 };
panel.Controls.Add(host);
return new Stack(host);
}
private static ParameterSlider Slider(string caption, double min, double max, double value, double step,
string format, string unit, Action<double> onChange)
{
var slider = new ParameterSlider
{
Caption = caption,
Minimum = min,
Maximum = max,
Step = step,
ValueFormat = format,
Unit = unit,
};
slider.SetValueSilently(value);
slider.ValueChanged += (_, _) => onChange(slider.Value);
return slider;
}
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
{
var box = new DarkCheckBox { Text = caption, Checked = value };
box.CheckedChanged += (_, _) => onChange(box.Checked);
return box;
}
private static LabeledCombo Combo(string caption, string[] items, int selected, Action<int> onChange)
{
var row = new LabeledCombo(caption);
row.Combo.Items.AddRange(items);
row.Combo.SelectedIndex = Math.Clamp(selected, 0, items.Length - 1);
row.Combo.SelectedIndexChanged += (_, _) => onChange(row.Combo.SelectedIndex);
return row;
}
private static Label Note(string text) => new()
{
Text = text,
Font = Theme.Small,
ForeColor = Theme.TextFaint,
AutoSize = false,
Height = 52,
BackColor = Theme.Surface,
};
private static int WorkingWidthToIndex(int width) => width switch
{
3840 => 1,
2560 => 2,
1920 => 3,
1280 => 4,
_ => 0,
};
}
+110
View File
@@ -0,0 +1,110 @@
using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
namespace Titano.UI;
/// <summary>
/// Tavolozza e primitive di disegno del tema scuro. Tutti i controlli dell'applicazione
/// sono resi con GDI+ a partire da questi valori: nessun tema di terze parti.
/// </summary>
internal static class Theme
{
public static readonly Color Background = Color.FromArgb(0x14, 0x16, 0x1A);
public static readonly Color Surface = Color.FromArgb(0x1B, 0x1E, 0x24);
public static readonly Color SurfaceAlt = Color.FromArgb(0x22, 0x26, 0x2E);
public static readonly Color SurfaceHover = Color.FromArgb(0x2A, 0x2F, 0x39);
public static readonly Color Border = Color.FromArgb(0x2E, 0x33, 0x3D);
public static readonly Color BorderStrong = Color.FromArgb(0x3C, 0x43, 0x50);
public static readonly Color Text = Color.FromArgb(0xE6, 0xE9, 0xEF);
public static readonly Color TextMuted = Color.FromArgb(0x98, 0xA0, 0xAE);
public static readonly Color TextFaint = Color.FromArgb(0x6B, 0x73, 0x82);
public static readonly Color Accent = Color.FromArgb(0x4C, 0x9A, 0xFF);
public static readonly Color AccentDim = Color.FromArgb(0x2F, 0x6C, 0xC2);
public static readonly Color Measured = Color.FromArgb(0xF5, 0xA5, 0x24);
public static readonly Color Success = Color.FromArgb(0x35, 0xC4, 0x8F);
public static readonly Color Warning = Color.FromArgb(0xE8, 0xB3, 0x39);
public static readonly Color Danger = Color.FromArgb(0xF0, 0x57, 0x5A);
public static readonly Font Body = new("Segoe UI", 9f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font BodyBold = new("Segoe UI", 9f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Small = new("Segoe UI", 8f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font SmallBold = new("Segoe UI", 8f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Title = new("Segoe UI Semibold", 14f, FontStyle.Regular, GraphicsUnit.Point);
private const int DwmUseImmersiveDarkMode = 20;
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
/// <summary>Estende il tema scuro alla barra del titolo, disegnata dal sistema.</summary>
public static void ApplyDarkTitleBar(Form form)
{
if (!form.IsHandleCreated) return;
int enabled = 1;
DwmSetWindowAttribute(form.Handle, DwmUseImmersiveDarkMode, ref enabled, sizeof(int));
}
/// <summary>Rettangolo con angoli arrotondati, primitiva di base dell'interfaccia.</summary>
public static GraphicsPath RoundedRect(RectangleF bounds, float radius)
{
var path = new GraphicsPath();
if (radius <= 0.5f)
{
path.AddRectangle(bounds);
return path;
}
float diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height));
var arc = new RectangleF(bounds.X, bounds.Y, diameter, diameter);
path.AddArc(arc, 180, 90);
arc.X = bounds.Right - diameter;
path.AddArc(arc, 270, 90);
arc.Y = bounds.Bottom - diameter;
path.AddArc(arc, 0, 90);
arc.X = bounds.X;
path.AddArc(arc, 90, 90);
path.CloseFigure();
return path;
}
public static void FillRounded(Graphics g, RectangleF bounds, float radius, Color fill)
{
using var path = RoundedRect(bounds, radius);
using var brush = new SolidBrush(fill);
g.FillPath(brush, path);
}
public static void DrawRounded(Graphics g, RectangleF bounds, float radius, Color stroke, float width = 1f)
{
using var path = RoundedRect(bounds, radius);
using var pen = new Pen(stroke, width);
g.DrawPath(pen, path);
}
public static void FillAndStroke(Graphics g, RectangleF bounds, float radius, Color fill, Color stroke)
{
FillRounded(g, bounds, radius, fill);
DrawRounded(g, RectangleF.Inflate(bounds, -0.5f, -0.5f), radius, stroke);
}
public static void HighQuality(Graphics g)
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
g.InterpolationMode = InterpolationMode.HighQualityBilinear;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
}
public static Color Mix(Color a, Color b, double t)
{
t = Math.Clamp(t, 0, 1);
return Color.FromArgb(
(int)(a.A + (b.A - a.A) * t),
(int)(a.R + (b.R - a.R) * t),
(int)(a.G + (b.G - a.G) * t),
(int)(a.B + (b.B - a.B) * t));
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace Titano.Video;
/// <summary>
/// Scansione di un bitstream Annex-B (quello prodotto dagli encoder di sistema): individua
/// le NAL unit delimitate dai codici di avvio 00 00 01 / 00 00 00 01, senza copie di memoria.
/// </summary>
internal static class AnnexBParser
{
internal readonly record struct NalRange(int Start, int Length);
public static NalEnumerator EnumerateNals(ReadOnlySpan<byte> data) => new(data);
internal ref struct NalEnumerator(ReadOnlySpan<byte> data)
{
private readonly ReadOnlySpan<byte> _data = data;
private int _position = 0;
private NalRange _current = default;
public readonly NalEnumerator GetEnumerator() => this;
public readonly NalRange Current => _current;
public bool MoveNext()
{
int start = FindStartCode(_data, _position, out int codeLength);
if (start < 0) return false;
int payloadStart = start + codeLength;
int next = FindStartCode(_data, payloadStart, out _);
int end = next < 0 ? _data.Length : next;
// Gli zeri finali non fanno parte della NAL (trailing_zero_8bits).
while (end > payloadStart && _data[end - 1] == 0) end--;
_position = next < 0 ? _data.Length : next;
_current = new NalRange(payloadStart, end - payloadStart);
return _current.Length > 0 || next >= 0;
}
private static int FindStartCode(ReadOnlySpan<byte> data, int from, out int codeLength)
{
for (int i = Math.Max(0, from); i + 2 < data.Length; i++)
{
if (data[i] != 0 || data[i + 1] != 0) continue;
if (data[i + 2] == 1)
{
codeLength = 3;
return i;
}
if (data[i + 2] == 0 && i + 3 < data.Length && data[i + 3] == 1)
{
codeLength = 4;
return i;
}
}
codeLength = 0;
return -1;
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using System.Buffers.Binary;
using System.Text;
namespace Titano.Video;
/// <summary>
/// Scrittore di box ISO-BMFF. Ogni box riserva quattro byte per la propria dimensione e li
/// corregge alla chiusura: i box possono così essere annidati scrivendo direttamente sul
/// flusso di uscita, senza costruire alberi in memoria.
/// </summary>
internal sealed class BoxWriter : IDisposable
{
private readonly Stream _stream;
private readonly long _start;
private bool _closed;
public BoxWriter(Stream stream, string type)
{
_stream = stream;
_start = stream.Position;
Span<byte> header = stackalloc byte[8];
BinaryPrimitives.WriteUInt32BigEndian(header, 0); // segnaposto
Encoding.ASCII.GetBytes(type, header[4..]);
_stream.Write(header);
}
public BoxWriter Child(string type) => new(_stream, type);
public void WriteFullBoxHeader(byte version, uint flags)
{
Span<byte> buffer = stackalloc byte[4];
buffer[0] = version;
buffer[1] = (byte)((flags >> 16) & 0xFF);
buffer[2] = (byte)((flags >> 8) & 0xFF);
buffer[3] = (byte)(flags & 0xFF);
_stream.Write(buffer);
}
public void WriteByte(byte value) => _stream.WriteByte(value);
public void WriteBytes(ReadOnlySpan<byte> value) => _stream.Write(value);
public void WriteUInt16(ushort value)
{
Span<byte> buffer = stackalloc byte[2];
BinaryPrimitives.WriteUInt16BigEndian(buffer, value);
_stream.Write(buffer);
}
public void WriteUInt32(uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32BigEndian(buffer, value);
_stream.Write(buffer);
}
public void WriteUInt64(ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(buffer, value);
_stream.Write(buffer);
}
public void WriteFourCc(string value)
{
Span<byte> buffer = stackalloc byte[4];
buffer.Fill((byte)' ');
Encoding.ASCII.GetBytes(value.AsSpan(0, Math.Min(4, value.Length)), buffer);
_stream.Write(buffer);
}
/// <summary>Stringa terminata da NUL, come richiesto dal box hdlr.</summary>
public void WriteCString(string value)
{
_stream.Write(Encoding.UTF8.GetBytes(value));
_stream.WriteByte(0);
}
/// <summary>Stringa Pascal in campo fisso da 32 byte (compressorname del sample entry).</summary>
public void WritePascalString32(string value)
{
Span<byte> buffer = stackalloc byte[32];
buffer.Clear();
int length = Math.Min(31, Encoding.ASCII.GetByteCount(value));
buffer[0] = (byte)length;
Encoding.ASCII.GetBytes(value.AsSpan(0, length), buffer[1..]);
_stream.Write(buffer);
}
/// <summary>Matrice di trasformazione identità in virgola fissa 16.16 / 2.30.</summary>
public void WriteMatrix()
{
WriteUInt32(0x00010000);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0x00010000);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0);
WriteUInt32(0x40000000);
}
public void Dispose()
{
if (_closed) return;
_closed = true;
long end = _stream.Position;
long size = end - _start;
_stream.Position = _start;
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)size);
_stream.Write(buffer);
_stream.Position = end;
}
}
+55
View File
@@ -0,0 +1,55 @@
namespace Titano.Video;
/// <summary>Come viene tradotta in durate di fotogramma la cadenza reale della sequenza.</summary>
public enum FrameTimingMode
{
/// <summary>Ogni scatto dura esattamente un fotogramma: il time-lapse classico.</summary>
Constant,
/// <summary>La durata segue l'intervallo reale: le pause dell'intervallometro restano visibili.</summary>
Adaptive,
/// <summary>La sequenza viene riportata su una griglia temporale uniforme sintetizzando i fotogrammi mancanti.</summary>
Interpolated,
}
public enum H264Profile
{
Baseline = 66,
Main = 77,
High = 100,
}
/// <summary>Parametri del pannello "Esportazione video".</summary>
public sealed class ExportSettings
{
public string OutputPath { get; set; } = string.Empty;
public VideoCodec Codec { get; set; } = VideoCodec.H264;
public H264Profile Profile { get; set; } = H264Profile.High;
/// <summary>Larghezza del video; 0 = dedotta dal primo fotogramma.</summary>
public int Width { get; set; }
public int Height { get; set; }
public double FrameRate { get; set; } = 30.0;
/// <summary>Bitrate medio in megabit al secondo.</summary>
public double BitrateMbps { get; set; } = 60.0;
public FrameTimingMode Timing { get; set; } = FrameTimingMode.Constant;
/// <summary>Fattore di dilatazione applicato in modalità adattiva, limitato per non congelare la scena.</summary>
public double MaxAdaptiveStretch { get; set; } = 4.0;
public bool PreferHardware { get; set; } = true;
public int KeyframeIntervalSeconds { get; set; } = 2;
/// <summary>Unità temporali della traccia video: 90 kHz consente durate variabili precise.</summary>
public uint Timescale { get; set; } = 90000;
public uint AverageBitrate => (uint)Math.Clamp(BitrateMbps * 1_000_000.0, 1_000_000.0, 800_000_000.0);
public ExportSettings Clone() => (ExportSettings)MemberwiseClone();
}
+433
View File
@@ -0,0 +1,433 @@
using System.Runtime.InteropServices;
namespace Titano.Video;
// ---------------------------------------------------------------------------------------
// Binding manuale verso Media Foundation (mfplat.dll / mfreadwrite.dll), lo stack di
// codifica nativo di Windows che espone gli encoder hardware di Intel, AMD e NVIDIA.
// Le interfacce sono dichiarate con l'ordine di vtable esatto; le voci non utilizzate
// sono comunque presenti per non alterare gli offset.
// ---------------------------------------------------------------------------------------
[ComImport, Guid("2cd2d921-c447-44a7-a13c-4adabfc247e3"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFAttributes
{
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
}
[ComImport, Guid("44ae0fa8-ea31-4109-8d2e-4cae4997c555"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaType
{
// --- IMFAttributes
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFMediaType
void GetMajorType(out Guid majorType);
void IsCompressedFormat([MarshalAs(UnmanagedType.Bool)] out bool compressed);
[PreserveSig] int IsEqual(IMFMediaType type, out uint flags);
void GetRepresentation(Guid representation, out IntPtr data);
void FreeRepresentation(Guid representation, IntPtr data);
}
[ComImport, Guid("045fa593-8799-42b8-bc8d-8968c6453507"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaBuffer
{
void Lock(out IntPtr buffer, out uint maxLength, out uint currentLength);
void Unlock();
void GetCurrentLength(out uint length);
void SetCurrentLength(uint length);
void GetMaxLength(out uint length);
}
[ComImport, Guid("c40a00f2-b93a-4d80-ae8c-5a1c634f58e4"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFSample
{
// --- IMFAttributes
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFSample
void GetSampleFlags(out uint flags);
void SetSampleFlags(uint flags);
[PreserveSig] int GetSampleTime(out long time);
void SetSampleTime(long time);
[PreserveSig] int GetSampleDuration(out long duration);
void SetSampleDuration(long duration);
void GetBufferCount(out uint count);
void GetBufferByIndex(uint index, out IMFMediaBuffer buffer);
void ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
void AddBuffer(IMFMediaBuffer buffer);
void RemoveBufferByIndex(uint index);
void RemoveAllBuffers();
void GetTotalLength(out uint length);
void CopyToBuffer(IMFMediaBuffer buffer);
}
[ComImport, Guid("bf94c121-5b05-4e6f-8000-ba598961414d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFTransform
{
void GetStreamLimits(out uint inputMin, out uint inputMax, out uint outputMin, out uint outputMax);
void GetStreamCount(out uint inputs, out uint outputs);
[PreserveSig] int GetStreamIDs(uint inputSize, [Out, MarshalAs(UnmanagedType.LPArray)] uint[] inputIds,
uint outputSize, [Out, MarshalAs(UnmanagedType.LPArray)] uint[] outputIds);
void GetInputStreamInfo(uint streamId, out MftInputStreamInfo info);
void GetOutputStreamInfo(uint streamId, out MftOutputStreamInfo info);
[PreserveSig] int GetAttributes(out IMFAttributes attributes);
[PreserveSig] int GetInputStreamAttributes(uint streamId, out IMFAttributes attributes);
[PreserveSig] int GetOutputStreamAttributes(uint streamId, out IMFAttributes attributes);
[PreserveSig] int DeleteInputStream(uint streamId);
[PreserveSig] int AddInputStreams(uint count, [MarshalAs(UnmanagedType.LPArray)] uint[] ids);
[PreserveSig] int GetInputAvailableType(uint streamId, uint index, out IMFMediaType type);
[PreserveSig] int GetOutputAvailableType(uint streamId, uint index, out IMFMediaType type);
[PreserveSig] int SetInputType(uint streamId, IMFMediaType? type, uint flags);
[PreserveSig] int SetOutputType(uint streamId, IMFMediaType? type, uint flags);
[PreserveSig] int GetInputCurrentType(uint streamId, out IMFMediaType type);
[PreserveSig] int GetOutputCurrentType(uint streamId, out IMFMediaType type);
[PreserveSig] int GetInputStatus(uint streamId, out uint flags);
[PreserveSig] int GetOutputStatus(out uint flags);
[PreserveSig] int SetOutputBounds(long lower, long upper);
[PreserveSig] int ProcessEvent(uint streamId, IntPtr mediaEvent);
[PreserveSig] int ProcessMessage(int message, IntPtr param);
[PreserveSig] int ProcessInput(uint streamId, IMFSample sample, uint flags);
[PreserveSig] int ProcessOutput(uint flags, uint count, ref MftOutputDataBuffer buffers, out uint status);
}
[ComImport, Guid("7fee9e9a-4a89-47a6-899c-b6a53a70fb67"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFActivate
{
// --- IMFAttributes
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFActivate
[PreserveSig] int ActivateObject(ref Guid riid, out IntPtr instance);
[PreserveSig] int ShutdownObject();
[PreserveSig] int DetachObject();
}
[ComImport, Guid("2cd0bd52-bcd5-4b89-b62c-eadc0c031e7d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaEventGenerator
{
[PreserveSig] int GetEvent(uint flags, out IMFMediaEvent mediaEvent);
[PreserveSig] int BeginGetEvent(IntPtr callback, IntPtr state);
[PreserveSig] int EndGetEvent(IntPtr result, out IMFMediaEvent mediaEvent);
[PreserveSig] int QueueEvent(uint met, ref Guid extendedType, int status, IntPtr value);
}
[ComImport, Guid("df598932-f10c-4e39-bba2-c308f101daa3"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMFMediaEvent
{
// --- IMFAttributes (le prime 30 voci)
void GetItem(ref Guid key, IntPtr value);
void GetItemType(ref Guid key, out int type);
void CompareItem(ref Guid key, IntPtr value, [MarshalAs(UnmanagedType.Bool)] out bool result);
void Compare(IMFAttributes attributes, int matchType, [MarshalAs(UnmanagedType.Bool)] out bool result);
[PreserveSig] int GetUINT32(ref Guid key, out uint value);
[PreserveSig] int GetUINT64(ref Guid key, out ulong value);
[PreserveSig] int GetDouble(ref Guid key, out double value);
[PreserveSig] int GetGUID(ref Guid key, out Guid value);
[PreserveSig] int GetStringLength(ref Guid key, out uint length);
[PreserveSig] int GetString(ref Guid key, [Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder value, uint size, ref uint length);
void GetAllocatedString(ref Guid key, out IntPtr value, out uint length);
[PreserveSig] int GetBlobSize(ref Guid key, out uint size);
[PreserveSig] int GetBlob(ref Guid key, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint bufferSize, ref uint blobSize);
void GetAllocatedBlob(ref Guid key, out IntPtr buffer, out uint size);
void GetUnknown(ref Guid key, ref Guid riid, out IntPtr value);
void SetItem(ref Guid key, IntPtr value);
void DeleteItem(ref Guid key);
void DeleteAllItems();
void SetUINT32(ref Guid key, uint value);
void SetUINT64(ref Guid key, ulong value);
void SetDouble(ref Guid key, double value);
void SetGUID(ref Guid key, ref Guid value);
void SetString(ref Guid key, [MarshalAs(UnmanagedType.LPWStr)] string value);
void SetBlob(ref Guid key, [MarshalAs(UnmanagedType.LPArray)] byte[] buffer, uint size);
void SetUnknown(ref Guid key, IntPtr unknown);
void LockStore();
void UnlockStore();
void GetCount(out uint count);
void GetItemByIndex(uint index, out Guid key, IntPtr value);
void CopyAllItems(IMFAttributes destination);
// --- IMFMediaEvent
[PreserveSig] int GetEventType(out uint met);
[PreserveSig] int GetExtendedType(out Guid extendedType);
[PreserveSig] int GetStatus(out int status);
[PreserveSig] int GetValue(IntPtr value);
}
[ComImport, Guid("901db4c7-31ce-41a2-85dc-8fa0bf41b8da"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ICodecAPI
{
[PreserveSig] int IsSupported(ref Guid api);
[PreserveSig] int IsModifiable(ref Guid api);
[PreserveSig] int GetParameterRange(ref Guid api, out PropVariant min, out PropVariant max, out PropVariant delta);
[PreserveSig] int GetParameterValues(ref Guid api, out IntPtr values, out uint count);
[PreserveSig] int GetDefaultValue(ref Guid api, out PropVariant value);
[PreserveSig] int GetValue(ref Guid api, out PropVariant value);
[PreserveSig] int SetValue(ref Guid api, ref PropVariant value);
}
[StructLayout(LayoutKind.Sequential)]
internal struct MftInputStreamInfo
{
public long MaxLatency;
public uint Flags;
public uint Size;
public uint MaxLookahead;
public uint Alignment;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MftOutputStreamInfo
{
public uint Flags;
public uint Size;
public uint Alignment;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MftOutputDataBuffer
{
public uint StreamId;
public IntPtr Sample;
public uint Status;
public IntPtr Events;
}
/// <summary>PROPVARIANT ridotta ai soli tipi scalari usati per la configurazione dell'encoder.</summary>
[StructLayout(LayoutKind.Sequential, Size = 24)]
internal struct PropVariant
{
public ushort Type;
public ushort Reserved1;
public ushort Reserved2;
public ushort Reserved3;
public ulong Value;
public const ushort VtUI4 = 19;
public const ushort VtBool = 11;
public static PropVariant FromUInt32(uint value) => new() { Type = VtUI4, Value = value };
}
internal static class MediaFoundation
{
public const uint Version = 0x00020070;
public const uint StartupLite = 1;
// Categorie e formati
public static Guid CategoryVideoEncoder = new("f79eac7d-e545-4387-bdee-d647d7bde42a");
public static Guid MajorTypeVideo = new("73646976-0000-0010-8000-00aa00389b71");
public static Guid VideoFormatH264 = new("34363248-0000-0010-8000-00aa00389b71");
public static Guid VideoFormatHevc = new("43564548-0000-0010-8000-00aa00389b71");
public static Guid VideoFormatNv12 = new("3231564e-0000-0010-8000-00aa00389b71");
// Attributi dei media type
public static Guid MtMajorType = new("48eba18e-f8c9-4687-bf11-0a74c9f96a8f");
public static Guid MtSubtype = new("f7e34c9a-42e8-4714-b74b-cb29d72c35e5");
public static Guid MtAvgBitrate = new("20332624-fb0d-4d9e-bd0d-cbf6786c102e");
public static Guid MtFrameSize = new("1652c33d-d6b2-4012-b834-72030849a37d");
public static Guid MtFrameRate = new("c459a2e8-3d2c-4e44-b132-fee5156c7bb0");
public static Guid MtPixelAspectRatio = new("c6376a1e-8d0a-4027-be45-6d9a0ad39bb6");
public static Guid MtInterlaceMode = new("e2724bb8-e676-4806-b4b2-a8d6efb44ccd");
public static Guid MtMpeg2Profile = new("ad76a80b-2d5c-4e0b-b375-64e520137036");
public static Guid MtMpegSequenceHeader = new("3c036de7-3ad0-4c9e-9216-ee6d6ac21cb3");
public static Guid MtAllSamplesIndependent = new("c9173739-5e56-461c-b713-46fb995cb95f");
public static Guid MtYuvMatrix = new("3e23d450-2c75-4d25-a00e-b91670d12327");
public static Guid MtVideoPrimaries = new("dbfbe4d7-0740-4ee0-8192-850ab0e21935");
public static Guid MtTransferFunction = new("5fb0fce9-be5c-4935-a811-ec838f8eed93");
public static Guid MtVideoNominalRange = new("c21b8ee5-b956-4071-8daf-325edf5cab11");
// Attributi delle trasformazioni
public static Guid TransformAsync = new("f81a699a-649a-497d-8c73-29f8fed6ad7a");
public static Guid TransformAsyncUnlock = new("e5666d6b-3422-4eb6-a421-da7db1f8e207");
public static Guid LowLatency = new("9c27891a-ed7a-40e1-88e8-b22727a024ee");
public static Guid MftFriendlyName = new("314ffbae-5b41-4c95-9c19-4e7d586face3");
public static Guid MftEnumHardwareUrl = new("2fb866ac-b078-4942-ab6c-003d05cda674");
// Attributi dei campioni
public static Guid SampleCleanPoint = new("9cdf01d8-a0f0-43ba-b077-eaa06cbd728a");
// Parametri ICodecAPI
public static Guid AvEncCommonRateControlMode = new("1c0608e9-370c-4710-8a58-cb6181c42423");
public static Guid AvEncCommonMeanBitRate = new("f7222374-2144-4815-b550-a37f8e12ee52");
public static Guid AvEncMpvDefaultBPictureCount = new("8d390aac-dc5c-4200-b57f-814d04babab2");
public static Guid AvEncMpvgopSize = new("95f31b26-95a4-41aa-9303-246a7fc6eef1");
// Messaggi e stati
public const int MessageCommandFlush = 0x00000000;
public const int MessageCommandDrain = 0x00000001;
public const int MessageNotifyBeginStreaming = 0x10000000;
public const int MessageNotifyEndStreaming = 0x10000001;
public const int MessageNotifyEndOfStream = 0x10000002;
public const int MessageNotifyStartOfStream = 0x10000003;
public const uint EventTransformNeedInput = 601;
public const uint EventTransformHaveOutput = 602;
public const uint EventTransformDrainComplete = 603;
public const uint EnumFlagSyncMft = 0x00000001;
public const uint EnumFlagAsyncMft = 0x00000002;
public const uint EnumFlagHardware = 0x00000004;
public const uint EnumFlagTranscodeOnly = 0x00000010;
public const uint EnumFlagSortAndFilter = 0x00000040;
public const uint OutputStreamProvidesSamples = 0x00000100;
public const uint InterlaceModeProgressive = 2;
public const int ErrorTransformNeedMoreInput = unchecked((int)0xC00D6D72);
public const int ErrorTransformStreamChange = unchecked((int)0xC00D6D61);
public const int ErrorNoMoreTypes = unchecked((int)0xC00D36B9);
public const int ErrorAttributeNotFound = unchecked((int)0xC00D36E6);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFStartup(uint version, uint flags);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFShutdown();
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFCreateMediaType(out IMFMediaType type);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFCreateSample(out IMFSample sample);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFCreateMemoryBuffer(uint maxLength, out IMFMediaBuffer buffer);
[DllImport("mfplat.dll", ExactSpelling = true)]
public static extern int MFTEnumEx(Guid category, uint flags, IntPtr inputType, IntPtr outputType,
out IntPtr activateArray, out uint count);
[DllImport("ole32.dll", ExactSpelling = true)]
public static extern void CoTaskMemFree(IntPtr memory);
/// <summary>Impacchetta due valori a 32 bit in un attributo UINT64 (dimensioni, frame rate).</summary>
public static ulong Pack(uint high, uint low) => ((ulong)high << 32) | low;
public static void Check(int hr, string what)
{
if (hr < 0) throw new VideoEncoderException($"{what} non riuscita (HRESULT 0x{hr:X8}).");
}
}
/// <summary>Errore riconducibile allo stack di codifica di sistema.</summary>
public sealed class VideoEncoderException(string message) : Exception(message);
[StructLayout(LayoutKind.Sequential)]
internal struct MftRegisterTypeInfo
{
public Guid MajorType;
public Guid Subtype;
}
+488
View File
@@ -0,0 +1,488 @@
using System.Buffers.Binary;
using System.Text;
namespace Titano.Video;
public enum VideoCodec
{
H264,
Hevc,
}
/// <summary>
/// Multiplexer ISO Base Media File Format (MP4) scritto interamente in-house.
///
/// Struttura generata: ftyp → mdat (in streaming, con dimensione a 64 bit corretta a
/// posteriori) → moov. I campioni vengono scritti sul file di uscita mentre arrivano
/// dall'encoder: nessun file temporaneo, nessuna copia intermedia dell'intero flusso.
///
/// Il muxer accetta bitstream in formato Annex-B (quello prodotto dagli encoder di sistema),
/// ne estrae i parameter set per la configurazione del codec e converte le NAL nel formato
/// a lunghezza prefissata richiesto dal contenitore.
/// </summary>
public sealed class Mp4Muxer : IDisposable
{
private readonly Stream _output;
private readonly VideoCodec _codec;
private readonly int _width;
private readonly int _height;
private readonly uint _timescale;
private readonly List<uint> _sampleSizes = [];
private readonly List<uint> _sampleDurations = [];
private readonly List<int> _syncSamples = [];
private readonly List<byte[]> _vps = [];
private readonly List<byte[]> _sps = [];
private readonly List<byte[]> _pps = [];
private long _mdatHeaderPosition;
private long _mdatPayloadStart;
private long _mediaDuration;
private bool _finished;
/// <summary>Buffer di lavoro riusato per la conversione Annex-B → lunghezza prefissata.</summary>
private byte[] _scratch = new byte[1 << 20];
public int SampleCount => _sampleSizes.Count;
public long BytesWritten { get; private set; }
public bool HasParameterSets => _sps.Count > 0;
public Mp4Muxer(Stream output, VideoCodec codec, int width, int height, uint timescale)
{
if (!output.CanSeek) throw new ArgumentException("Il flusso di uscita deve essere posizionabile.", nameof(output));
_output = output;
_codec = codec;
_width = width;
_height = height;
_timescale = timescale == 0 ? 90000u : timescale;
WriteFileTypeBox();
BeginMediaData();
}
/// <summary>
/// Registra parameter set forniti fuori banda dall'encoder (blob MF_MT_MPEG_SEQUENCE_HEADER).
/// </summary>
public void AddParameterSets(ReadOnlySpan<byte> annexB)
{
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
{
ClassifyAndStore(annexB, nal);
}
}
/// <summary>
/// Scrive un campione codificato. <paramref name="duration"/> è espressa nella timescale
/// della traccia, così ogni fotogramma può avere durata propria (playback adattivo).
/// </summary>
public void WriteSample(ReadOnlySpan<byte> annexB, uint duration)
{
ObjectDisposedException.ThrowIf(_finished, this);
int written = 0;
bool keyframe = false;
foreach (var nal in AnnexBParser.EnumerateNals(annexB))
{
var payload = annexB.Slice(nal.Start, nal.Length);
if (payload.Length == 0) continue;
if (IsParameterSet(payload[0]))
{
ClassifyAndStore(annexB, nal);
continue; // i parameter set vivono in stsd, non in mdat
}
if (IsKeyframeNal(payload[0])) keyframe = true;
EnsureScratch(written + payload.Length + 4);
BinaryPrimitives.WriteUInt32BigEndian(_scratch.AsSpan(written), (uint)payload.Length);
payload.CopyTo(_scratch.AsSpan(written + 4));
written += payload.Length + 4;
}
if (written == 0) return;
_output.Write(_scratch, 0, written);
BytesWritten += written;
if (keyframe) _syncSamples.Add(_sampleSizes.Count + 1); // gli indici in stss partono da 1
_sampleSizes.Add((uint)written);
_sampleDurations.Add(duration);
_mediaDuration += duration;
}
/// <summary>Chiude mdat, scrive moov e finalizza il file.</summary>
public void Finish()
{
if (_finished) return;
_finished = true;
long mdatEnd = _output.Position;
long mdatSize = mdatEnd - _mdatHeaderPosition;
// Correzione della dimensione a 64 bit riservata all'apertura del box.
_output.Position = _mdatHeaderPosition + 8;
Span<byte> size = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(size, mdatSize);
_output.Write(size);
_output.Position = mdatEnd;
WriteMovieBox();
_output.Flush();
}
public void Dispose() => Finish();
// ------------------------------------------------------------------ box di apertura
private void WriteFileTypeBox()
{
using var box = new BoxWriter(_output, "ftyp");
box.WriteFourCc("isom");
box.WriteUInt32(0x200);
box.WriteFourCc("isom");
box.WriteFourCc("iso2");
box.WriteFourCc(_codec == VideoCodec.H264 ? "avc1" : "hvc1");
box.WriteFourCc("mp41");
}
private void BeginMediaData()
{
_mdatHeaderPosition = _output.Position;
Span<byte> header = stackalloc byte[16];
BinaryPrimitives.WriteUInt32BigEndian(header, 1); // size = 1 → largesize a 64 bit
Encoding.ASCII.GetBytes("mdat", header[4..]);
BinaryPrimitives.WriteInt64BigEndian(header[8..], 16); // valore provvisorio
_output.Write(header);
_mdatPayloadStart = _output.Position;
}
// ------------------------------------------------------------------ moov
private void WriteMovieBox()
{
uint movieTimescale = 1000;
long movieDuration = _timescale == 0 ? 0 : _mediaDuration * movieTimescale / _timescale;
using var moov = new BoxWriter(_output, "moov");
using (var mvhd = moov.Child("mvhd"))
{
mvhd.WriteFullBoxHeader(0, 0);
mvhd.WriteUInt32(0); // creation_time
mvhd.WriteUInt32(0); // modification_time
mvhd.WriteUInt32(movieTimescale);
mvhd.WriteUInt32((uint)movieDuration);
mvhd.WriteUInt32(0x00010000); // rate 1.0
mvhd.WriteUInt16(0x0100); // volume 1.0
mvhd.WriteUInt16(0); // reserved
mvhd.WriteUInt32(0);
mvhd.WriteUInt32(0);
mvhd.WriteMatrix();
for (int i = 0; i < 6; i++) mvhd.WriteUInt32(0); // pre_defined
mvhd.WriteUInt32(2); // next_track_ID
}
using (var trak = moov.Child("trak"))
{
using (var tkhd = trak.Child("tkhd"))
{
tkhd.WriteFullBoxHeader(0, 0x000007); // enabled | in movie | in preview
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(1); // track_ID
tkhd.WriteUInt32(0); // reserved
tkhd.WriteUInt32((uint)movieDuration);
tkhd.WriteUInt32(0);
tkhd.WriteUInt32(0);
tkhd.WriteUInt16(0); // layer
tkhd.WriteUInt16(0); // alternate_group
tkhd.WriteUInt16(0); // volume (0 per il video)
tkhd.WriteUInt16(0);
tkhd.WriteMatrix();
tkhd.WriteUInt32((uint)_width << 16);
tkhd.WriteUInt32((uint)_height << 16);
}
using var mdia = trak.Child("mdia");
using (var mdhd = mdia.Child("mdhd"))
{
mdhd.WriteFullBoxHeader(0, 0);
mdhd.WriteUInt32(0);
mdhd.WriteUInt32(0);
mdhd.WriteUInt32(_timescale);
mdhd.WriteUInt32((uint)_mediaDuration);
mdhd.WriteUInt16(0x55C4); // lingua "und" impacchettata a 5 bit
mdhd.WriteUInt16(0);
}
using (var hdlr = mdia.Child("hdlr"))
{
hdlr.WriteFullBoxHeader(0, 0);
hdlr.WriteUInt32(0); // pre_defined
hdlr.WriteFourCc("vide");
hdlr.WriteUInt32(0);
hdlr.WriteUInt32(0);
hdlr.WriteUInt32(0);
hdlr.WriteCString("Titano Video Handler");
}
using var minf = mdia.Child("minf");
using (var vmhd = minf.Child("vmhd"))
{
vmhd.WriteFullBoxHeader(0, 1);
vmhd.WriteUInt16(0); // graphicsmode
vmhd.WriteUInt16(0); // opcolor
vmhd.WriteUInt16(0);
vmhd.WriteUInt16(0);
}
using (var dinf = minf.Child("dinf"))
using (var dref = dinf.Child("dref"))
{
dref.WriteFullBoxHeader(0, 0);
dref.WriteUInt32(1); // entry_count
using var url = dref.Child("url ");
url.WriteFullBoxHeader(0, 1); // flag 1 = dati nello stesso file
}
using var stbl = minf.Child("stbl");
WriteSampleDescription(stbl);
WriteTimeToSample(stbl);
WriteSyncSamples(stbl);
WriteSampleToChunk(stbl);
WriteSampleSizes(stbl);
WriteChunkOffsets(stbl);
}
}
private void WriteSampleDescription(BoxWriter stbl)
{
using var stsd = stbl.Child("stsd");
stsd.WriteFullBoxHeader(0, 0);
stsd.WriteUInt32(1); // entry_count
string entryName = _codec == VideoCodec.H264 ? "avc1" : "hvc1";
using var entry = stsd.Child(entryName);
for (int i = 0; i < 6; i++) entry.WriteByte(0); // reserved
entry.WriteUInt16(1); // data_reference_index
entry.WriteUInt16(0); // pre_defined
entry.WriteUInt16(0); // reserved
for (int i = 0; i < 3; i++) entry.WriteUInt32(0);
entry.WriteUInt16((ushort)_width);
entry.WriteUInt16((ushort)_height);
entry.WriteUInt32(0x00480000); // 72 dpi orizzontali
entry.WriteUInt32(0x00480000); // 72 dpi verticali
entry.WriteUInt32(0); // reserved
entry.WriteUInt16(1); // frame_count
entry.WritePascalString32("Titano"); // compressorname
entry.WriteUInt16(0x0018); // profondità 24 bit
entry.WriteUInt16(0xFFFF); // pre_defined = -1
if (_codec == VideoCodec.H264) WriteAvcConfiguration(entry);
else WriteHevcConfiguration(entry);
using (var colr = entry.Child("colr"))
{
colr.WriteFourCc("nclx");
colr.WriteUInt16(1); // primarie BT.709
colr.WriteUInt16(1); // funzione di trasferimento BT.709
colr.WriteUInt16(1); // matrice BT.709
colr.WriteByte(0); // range televisivo (16-235)
}
using var pasp = entry.Child("pasp");
pasp.WriteUInt32(1); // pixel quadrati
pasp.WriteUInt32(1);
}
private void WriteAvcConfiguration(BoxWriter entry)
{
using var avcc = entry.Child("avcC");
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
avcc.WriteByte(1); // configurationVersion
avcc.WriteByte(sps.Length > 1 ? sps[1] : (byte)0x64); // AVCProfileIndication
avcc.WriteByte(sps.Length > 2 ? sps[2] : (byte)0x00); // profile_compatibility
avcc.WriteByte(sps.Length > 3 ? sps[3] : (byte)0x28); // AVCLevelIndication
avcc.WriteByte(0xFF); // 6 bit riservati + lengthSizeMinusOne = 3
avcc.WriteByte((byte)(0xE0 | Math.Min(_sps.Count, 31))); // 3 bit riservati + numOfSPS
foreach (var set in _sps)
{
avcc.WriteUInt16((ushort)set.Length);
avcc.WriteBytes(set);
}
avcc.WriteByte((byte)Math.Min(_pps.Count, 255));
foreach (var set in _pps)
{
avcc.WriteUInt16((ushort)set.Length);
avcc.WriteBytes(set);
}
}
private void WriteHevcConfiguration(BoxWriter entry)
{
using var hvcc = entry.Child("hvcC");
byte[] sps = _sps.Count > 0 ? _sps[0] : [];
// profile_tier_level occupa 12 byte subito dopo i due byte di header NAL e il byte
// che contiene sps_video_parameter_set_id / sps_max_sub_layers_minus1.
Span<byte> ptl = stackalloc byte[12];
if (sps.Length >= 15) sps.AsSpan(3, 12).CopyTo(ptl);
else ptl[0] = 0x01; // Main profile come ripiego
hvcc.WriteByte(1); // configurationVersion
hvcc.WriteByte(ptl[0]); // profile_space/tier/profile_idc
hvcc.WriteBytes(ptl[1..5]); // general_profile_compatibility_flags
hvcc.WriteBytes(ptl[5..11]); // general_constraint_indicator_flags
hvcc.WriteByte(ptl[11]); // general_level_idc
hvcc.WriteUInt16(0xF000); // min_spatial_segmentation = 0
hvcc.WriteByte(0xFC); // parallelismType sconosciuto
hvcc.WriteByte(0xFD); // chromaFormat 4:2:0
hvcc.WriteByte(0xF8); // bitDepthLumaMinus8 = 0
hvcc.WriteByte(0xF8); // bitDepthChromaMinus8 = 0
hvcc.WriteUInt16(0); // avgFrameRate (non dichiarato)
hvcc.WriteByte(0x0F); // costantFrameRate/temporalId + lengthSizeMinusOne
hvcc.WriteByte((byte)((_vps.Count > 0 ? 1 : 0) + (_sps.Count > 0 ? 1 : 0) + (_pps.Count > 0 ? 1 : 0)));
WriteHevcArray(hvcc, 32, _vps);
WriteHevcArray(hvcc, 33, _sps);
WriteHevcArray(hvcc, 34, _pps);
}
private static void WriteHevcArray(BoxWriter hvcc, byte nalType, List<byte[]> sets)
{
if (sets.Count == 0) return;
hvcc.WriteByte((byte)(0x80 | nalType)); // array_completeness + NAL_unit_type
hvcc.WriteUInt16((ushort)sets.Count);
foreach (var set in sets)
{
hvcc.WriteUInt16((ushort)set.Length);
hvcc.WriteBytes(set);
}
}
private void WriteTimeToSample(BoxWriter stbl)
{
// Codifica a corse: le durate uguali consecutive occupano una sola voce.
var runs = new List<(uint Count, uint Delta)>();
foreach (uint delta in _sampleDurations)
{
if (runs.Count > 0 && runs[^1].Delta == delta) runs[^1] = (runs[^1].Count + 1, delta);
else runs.Add((1, delta));
}
using var stts = stbl.Child("stts");
stts.WriteFullBoxHeader(0, 0);
stts.WriteUInt32((uint)runs.Count);
foreach (var (count, delta) in runs)
{
stts.WriteUInt32(count);
stts.WriteUInt32(delta);
}
}
private void WriteSyncSamples(BoxWriter stbl)
{
// Se ogni campione è un punto di sincronizzazione la tabella si omette per convenzione.
if (_syncSamples.Count == _sampleSizes.Count || _syncSamples.Count == 0) return;
using var stss = stbl.Child("stss");
stss.WriteFullBoxHeader(0, 0);
stss.WriteUInt32((uint)_syncSamples.Count);
foreach (int index in _syncSamples) stss.WriteUInt32((uint)index);
}
private void WriteSampleToChunk(BoxWriter stbl)
{
using var stsc = stbl.Child("stsc");
stsc.WriteFullBoxHeader(0, 0);
stsc.WriteUInt32(1); // una sola voce: tutti i campioni in un chunk
stsc.WriteUInt32(1); // first_chunk
stsc.WriteUInt32((uint)Math.Max(1, _sampleSizes.Count));
stsc.WriteUInt32(1); // sample_description_index
}
private void WriteSampleSizes(BoxWriter stbl)
{
using var stsz = stbl.Child("stsz");
stsz.WriteFullBoxHeader(0, 0);
stsz.WriteUInt32(0); // dimensione variabile
stsz.WriteUInt32((uint)_sampleSizes.Count);
foreach (uint size in _sampleSizes) stsz.WriteUInt32(size);
}
private void WriteChunkOffsets(BoxWriter stbl)
{
using var co64 = stbl.Child("co64");
co64.WriteFullBoxHeader(0, 0);
co64.WriteUInt32(1);
co64.WriteUInt64((ulong)_mdatPayloadStart);
}
// ------------------------------------------------------------------ NAL
private bool IsParameterSet(byte header)
{
if (_codec == VideoCodec.H264)
{
int type = header & 0x1F;
return type is 7 or 8; // SPS, PPS
}
int hevcType = (header >> 1) & 0x3F;
return hevcType is 32 or 33 or 34; // VPS, SPS, PPS
}
private bool IsKeyframeNal(byte header)
{
if (_codec == VideoCodec.H264) return (header & 0x1F) == 5; // IDR
int type = (header >> 1) & 0x3F;
return type is >= 16 and <= 21; // BLA/IDR/CRA
}
private void ClassifyAndStore(ReadOnlySpan<byte> source, AnnexBParser.NalRange nal)
{
if (nal.Length <= 0) return;
var payload = source.Slice(nal.Start, nal.Length);
byte header = payload[0];
List<byte[]> target;
if (_codec == VideoCodec.H264)
{
int type = header & 0x1F;
if (type == 7) target = _sps;
else if (type == 8) target = _pps;
else return;
}
else
{
int type = (header >> 1) & 0x3F;
if (type == 32) target = _vps;
else if (type == 33) target = _sps;
else if (type == 34) target = _pps;
else return;
}
var copy = payload.ToArray();
foreach (var existing in target)
{
if (existing.AsSpan().SequenceEqual(copy)) return;
}
target.Add(copy);
}
private void EnsureScratch(int required)
{
if (_scratch.Length >= required) return;
int size = _scratch.Length;
while (size < required) size *= 2;
_scratch = new byte[size];
}
}
+81
View File
@@ -0,0 +1,81 @@
using Titano.Imaging;
namespace Titano.Video;
/// <summary>
/// Conversione da RGB lineare a NV12 (Y a piena risoluzione + CbCr interlacciato a metà
/// risoluzione), il formato d'ingresso atteso dagli encoder hardware.
///
/// La matrice è quella BT.709 in range televisivo (Y 16-235, C 16-240): è la convenzione
/// dichiarata nel box "colr" del contenitore, quindi il colore resta coerente in riproduzione.
/// La codifica di gamma viene riapplicata qui, all'ultimo passaggio utile: tutta l'elaborazione
/// a monte è avvenuta in luce lineare.
/// </summary>
public static class Nv12Converter
{
public static int RequiredSize(int width, int height) => width * height * 3 / 2;
public static unsafe void Convert(ImageBuffer source, byte* destination)
{
int width = source.Width;
int height = source.Height;
var data = source.Data;
byte* yPlane = destination;
byte* uvPlane = destination + (long)width * height;
int chromaRows = height / 2;
Parallel.For(0, chromaRows, cy =>
{
int y0 = cy * 2;
int y1 = Math.Min(y0 + 1, height - 1);
for (int cx = 0; cx < width / 2; cx++)
{
int x0 = cx * 2;
int x1 = Math.Min(x0 + 1, width - 1);
double cbSum = 0, crSum = 0;
cbSum += Encode(data, width, x0, y0, yPlane, out double cr00); crSum += cr00;
cbSum += Encode(data, width, x1, y0, yPlane, out double cr10); crSum += cr10;
cbSum += Encode(data, width, x0, y1, yPlane, out double cr01); crSum += cr01;
cbSum += Encode(data, width, x1, y1, yPlane, out double cr11); crSum += cr11;
long uvIndex = (long)cy * width + cx * 2;
uvPlane[uvIndex] = Quantize(128.0 + 224.0 * (cbSum * 0.25));
uvPlane[uvIndex + 1] = Quantize(128.0 + 224.0 * (crSum * 0.25));
}
});
// Riga o colonna dispari residua: la luma va comunque scritta.
if ((height & 1) != 0)
{
int y = height - 1;
for (int x = 0; x < width; x++) Encode(data, width, x, y, yPlane, out _);
}
if ((width & 1) != 0)
{
int x = width - 1;
for (int y = 0; y < height; y++) Encode(data, width, x, y, yPlane, out _);
}
}
/// <summary>Scrive la luma del pixel indicato e restituisce le due crominanze normalizzate.</summary>
private static unsafe double Encode(float[] data, int width, int x, int y, byte* yPlane, out double cr)
{
int index = (y * width + x) * ImageBuffer.Channels;
float r = ColorSpace.ToSrgb(data[index]);
float g = ColorSpace.ToSrgb(data[index + 1]);
float b = ColorSpace.ToSrgb(data[index + 2]);
double luma = ColorSpace.LumaR * r + ColorSpace.LumaG * g + ColorSpace.LumaB * b;
yPlane[(long)y * width + x] = Quantize(16.0 + 219.0 * luma);
cr = (r - luma) / 1.5748;
return (b - luma) / 1.8556;
}
private static byte Quantize(double value)
=> (byte)Math.Clamp((int)(value + 0.5), 0, 255);
}
+638
View File
@@ -0,0 +1,638 @@
using System.Runtime.InteropServices;
using System.Text;
using Titano.Imaging;
namespace Titano.Video;
/// <summary>
/// Sessione di codifica: pilota direttamente la Media Foundation Transform dell'encoder di
/// sistema (hardware se disponibile) e riversa le NAL prodotte nel multiplexer MP4 in-house.
///
/// I fotogrammi entrano come buffer in memoria, escono come pacchetti compressi e finiscono
/// nel file di destinazione: nessun passaggio intermedio su disco.
/// </summary>
public sealed class VideoEncoderSession : IDisposable
{
private readonly ExportSettings _settings;
private readonly int _width;
private readonly int _height;
private readonly Stream _output;
private readonly Mp4Muxer _muxer;
private IMFTransform? _transform;
private IMFMediaEventGenerator? _events;
private uint _inputStreamId;
private uint _outputStreamId;
private bool _async;
private MftOutputStreamInfo _outputInfo;
private readonly Queue<uint> _pendingDurations = new();
private long _presentationTime;
private byte[] _packetBuffer = new byte[1 << 20];
private int _pendingNeedInput;
private bool _finished;
public string EncoderName { get; private set; } = "sconosciuto";
public bool IsHardware { get; private set; }
public int EncodedFrames { get; private set; }
public long OutputBytes => _muxer.BytesWritten;
public VideoEncoderSession(ExportSettings settings, int width, int height)
{
if ((width & 1) != 0 || (height & 1) != 0)
throw new ArgumentException("La codifica 4:2:0 richiede dimensioni pari.");
_settings = settings;
_width = width;
_height = height;
MediaFoundationRuntime.Startup();
_output = new FileStream(settings.OutputPath, FileMode.Create, FileAccess.ReadWrite,
FileShare.Read, 1 << 20, FileOptions.SequentialScan);
try
{
_muxer = new Mp4Muxer(_output, settings.Codec, width, height, settings.Timescale);
CreateTransform();
ConfigureTypes();
StartStreaming();
}
catch
{
_output.Dispose();
TryDeletePartialFile();
throw;
}
}
// ------------------------------------------------------------------ configurazione
private void CreateTransform()
{
Guid subtype = _settings.Codec == VideoCodec.H264
? MediaFoundation.VideoFormatH264
: MediaFoundation.VideoFormatHevc;
var outputInfo = new MftRegisterTypeInfo
{
MajorType = MediaFoundation.MajorTypeVideo,
Subtype = subtype,
};
// Prima gli encoder hardware, poi quelli software: stessa interfaccia, stesso codice.
if (_settings.PreferHardware &&
TryActivate(outputInfo, MediaFoundation.EnumFlagHardware | MediaFoundation.EnumFlagSortAndFilter, true))
return;
if (TryActivate(outputInfo, MediaFoundation.EnumFlagSyncMft | MediaFoundation.EnumFlagAsyncMft |
MediaFoundation.EnumFlagSortAndFilter, false))
return;
throw new VideoEncoderException(
$"Nessun encoder {( _settings.Codec == VideoCodec.H264 ? "H.264" : "HEVC")} disponibile nel sistema. " +
"Su alcune edizioni di Windows occorre installare il Media Feature Pack.");
}
private unsafe bool TryActivate(MftRegisterTypeInfo outputInfo, uint flags, bool hardware)
{
IntPtr array = IntPtr.Zero;
try
{
// I parametri sono già su stack: se ne può prendere l'indirizzo senza "fixed".
MftRegisterTypeInfo* pOutput = &outputInfo;
int hr = MediaFoundation.MFTEnumEx(MediaFoundation.CategoryVideoEncoder, flags,
IntPtr.Zero, (IntPtr)pOutput, out array, out uint count);
if (hr < 0 || count == 0 || array == IntPtr.Zero) return false;
for (uint i = 0; i < count; i++)
{
IntPtr activatePtr = Marshal.ReadIntPtr(array, (int)i * IntPtr.Size);
if (activatePtr == IntPtr.Zero) continue;
var activate = (IMFActivate)Marshal.GetObjectForIUnknown(activatePtr);
try
{
if (_transform is null && TryActivateOne(activate, hardware)) { /* trovato */ }
}
finally
{
Marshal.ReleaseComObject(activate);
Marshal.Release(activatePtr);
}
if (_transform is not null) return true;
}
return false;
}
finally
{
if (array != IntPtr.Zero) MediaFoundation.CoTaskMemFree(array);
}
}
private bool TryActivateOne(IMFActivate activate, bool hardware)
{
var iid = typeof(IMFTransform).GUID;
if (activate.ActivateObject(ref iid, out IntPtr instance) < 0 || instance == IntPtr.Zero) return false;
IMFTransform transform;
try
{
transform = (IMFTransform)Marshal.GetObjectForIUnknown(instance);
}
finally
{
Marshal.Release(instance);
}
try
{
string name = ReadFriendlyName(activate);
// Le trasformazioni hardware sono asincrone e vanno sbloccate esplicitamente.
bool isAsync = false;
if (transform.GetAttributes(out var attributes) >= 0 && attributes is not null)
{
try
{
var asyncKey = MediaFoundation.TransformAsync;
if (attributes.GetUINT32(ref asyncKey, out uint asyncFlag) >= 0 && asyncFlag != 0)
{
isAsync = true;
var unlockKey = MediaFoundation.TransformAsyncUnlock;
attributes.SetUINT32(ref unlockKey, 1);
}
}
finally
{
Marshal.ReleaseComObject(attributes);
}
}
_transform = transform;
_async = isAsync;
IsHardware = hardware;
EncoderName = name;
return true;
}
catch
{
Marshal.ReleaseComObject(transform);
return false;
}
}
private static string ReadFriendlyName(IMFActivate activate)
{
var key = MediaFoundation.MftFriendlyName;
if (activate.GetStringLength(ref key, out uint length) < 0 || length == 0) return "encoder di sistema";
var builder = new StringBuilder((int)length + 1);
uint written = 0;
return activate.GetString(ref key, builder, length + 1, ref written) < 0
? "encoder di sistema"
: builder.ToString();
}
private void ConfigureTypes()
{
var transform = _transform!;
transform.GetStreamCount(out uint inputs, out uint outputs);
var inputIds = new uint[Math.Max(1, inputs)];
var outputIds = new uint[Math.Max(1, outputs)];
if (transform.GetStreamIDs((uint)inputIds.Length, inputIds, (uint)outputIds.Length, outputIds) >= 0)
{
_inputStreamId = inputIds[0];
_outputStreamId = outputIds[0];
}
uint fpsNumerator = (uint)Math.Round(_settings.FrameRate * 1000.0);
const uint fpsDenominator = 1000;
// L'ordine è vincolante: prima il tipo di uscita (compresso), poi quello d'ingresso.
MediaFoundation.Check(MediaFoundation.MFCreateMediaType(out IMFMediaType outputType), "Creazione del tipo di uscita");
try
{
SetGuid(outputType, MediaFoundation.MtMajorType, MediaFoundation.MajorTypeVideo);
SetGuid(outputType, MediaFoundation.MtSubtype,
_settings.Codec == VideoCodec.H264 ? MediaFoundation.VideoFormatH264 : MediaFoundation.VideoFormatHevc);
SetUInt32(outputType, MediaFoundation.MtAvgBitrate, _settings.AverageBitrate);
SetUInt32(outputType, MediaFoundation.MtInterlaceMode, MediaFoundation.InterlaceModeProgressive);
SetUInt64(outputType, MediaFoundation.MtFrameSize, MediaFoundation.Pack((uint)_width, (uint)_height));
SetUInt64(outputType, MediaFoundation.MtFrameRate, MediaFoundation.Pack(fpsNumerator, fpsDenominator));
SetUInt64(outputType, MediaFoundation.MtPixelAspectRatio, MediaFoundation.Pack(1, 1));
if (_settings.Codec == VideoCodec.H264)
SetUInt32(outputType, MediaFoundation.MtMpeg2Profile, (uint)_settings.Profile);
int hr = transform.SetOutputType(_outputStreamId, outputType, 0);
MediaFoundation.Check(hr, "Impostazione del formato compresso");
}
finally
{
Marshal.ReleaseComObject(outputType);
}
MediaFoundation.Check(MediaFoundation.MFCreateMediaType(out IMFMediaType inputType), "Creazione del tipo d'ingresso");
try
{
SetGuid(inputType, MediaFoundation.MtMajorType, MediaFoundation.MajorTypeVideo);
SetGuid(inputType, MediaFoundation.MtSubtype, MediaFoundation.VideoFormatNv12);
SetUInt32(inputType, MediaFoundation.MtInterlaceMode, MediaFoundation.InterlaceModeProgressive);
SetUInt64(inputType, MediaFoundation.MtFrameSize, MediaFoundation.Pack((uint)_width, (uint)_height));
SetUInt64(inputType, MediaFoundation.MtFrameRate, MediaFoundation.Pack(fpsNumerator, fpsDenominator));
SetUInt64(inputType, MediaFoundation.MtPixelAspectRatio, MediaFoundation.Pack(1, 1));
SetUInt32(inputType, MediaFoundation.MtYuvMatrix, 2); // BT.709
SetUInt32(inputType, MediaFoundation.MtVideoPrimaries, 3); // BT.709
SetUInt32(inputType, MediaFoundation.MtTransferFunction, 5); // BT.709
SetUInt32(inputType, MediaFoundation.MtVideoNominalRange, 2); // 16-235
int hr = transform.SetInputType(_inputStreamId, inputType, 0);
MediaFoundation.Check(hr, "Impostazione del formato d'ingresso NV12");
}
finally
{
Marshal.ReleaseComObject(inputType);
}
ConfigureCodecApi();
transform.GetOutputStreamInfo(_outputStreamId, out _outputInfo);
ReadSequenceHeader();
}
/// <summary>
/// Configurazione fine dell'encoder. Le B-frame vengono azzerate: senza riordino,
/// l'ordine di decodifica coincide con quello di presentazione e il muxer può usare
/// direttamente le durate della sequenza, comprese quelle variabili.
/// </summary>
private void ConfigureCodecApi()
{
if (_transform is not ICodecAPI codec) return;
TrySet(codec, MediaFoundation.AvEncMpvDefaultBPictureCount, 0);
TrySet(codec, MediaFoundation.AvEncCommonRateControlMode, 0); // bitrate costante
TrySet(codec, MediaFoundation.AvEncCommonMeanBitRate, _settings.AverageBitrate);
uint gop = (uint)Math.Clamp(_settings.KeyframeIntervalSeconds * _settings.FrameRate, 1, 600);
TrySet(codec, MediaFoundation.AvEncMpvgopSize, gop);
static void TrySet(ICodecAPI codec, Guid parameter, uint value)
{
try
{
var variant = PropVariant.FromUInt32(value);
codec.SetValue(ref parameter, ref variant);
}
catch (COMException)
{
// Parametro non supportato dall'encoder: si prosegue con il valore predefinito.
}
}
}
/// <summary>Preleva i parameter set fuori banda, se l'encoder li espone già.</summary>
private void ReadSequenceHeader()
{
if (_transform!.GetOutputCurrentType(_outputStreamId, out IMFMediaType current) < 0) return;
try
{
var key = MediaFoundation.MtMpegSequenceHeader;
if (current.GetBlobSize(ref key, out uint size) < 0 || size == 0) return;
var blob = new byte[size];
uint actual = 0;
if (current.GetBlob(ref key, blob, size, ref actual) < 0) return;
_muxer.AddParameterSets(blob.AsSpan(0, (int)Math.Min(actual == 0 ? size : actual, size)));
}
finally
{
Marshal.ReleaseComObject(current);
}
}
private void StartStreaming()
{
var transform = _transform!;
if (_async)
{
var generator = transform as IMFMediaEventGenerator
?? throw new VideoEncoderException("L'encoder asincrono non espone la coda eventi.");
_events = generator;
}
MediaFoundation.Check(transform.ProcessMessage(MediaFoundation.MessageNotifyBeginStreaming, IntPtr.Zero),
"Avvio dello streaming");
MediaFoundation.Check(transform.ProcessMessage(MediaFoundation.MessageNotifyStartOfStream, IntPtr.Zero),
"Notifica di inizio flusso");
}
// ------------------------------------------------------------------ codifica
/// <summary>
/// Codifica un fotogramma. <paramref name="durationUnits"/> è la durata nella timescale
/// della traccia, quindi ogni fotogramma può restare a schermo per un tempo diverso.
/// </summary>
public unsafe void EncodeFrame(ImageBuffer frame, uint durationUnits)
{
ObjectDisposedException.ThrowIf(_finished, this);
if (frame.Width != _width || frame.Height != _height)
throw new ArgumentException("Il fotogramma non corrisponde alla risoluzione della sessione.");
long durationHns = durationUnits * 10_000_000L / _settings.Timescale;
if (_async) WaitForInputSlot();
int size = Nv12Converter.RequiredSize(_width, _height);
MediaFoundation.Check(MediaFoundation.MFCreateMemoryBuffer((uint)size, out IMFMediaBuffer buffer),
"Allocazione del buffer d'ingresso");
IMFSample? sample = null;
try
{
buffer.Lock(out IntPtr pointer, out _, out _);
try
{
Nv12Converter.Convert(frame, (byte*)pointer);
}
finally
{
buffer.Unlock();
}
buffer.SetCurrentLength((uint)size);
MediaFoundation.Check(MediaFoundation.MFCreateSample(out sample), "Creazione del campione");
sample.AddBuffer(buffer);
sample.SetSampleTime(_presentationTime);
sample.SetSampleDuration(durationHns);
_pendingDurations.Enqueue(durationUnits);
int hr = _transform!.ProcessInput(_inputStreamId, sample, 0);
if (hr < 0)
{
_pendingDurations.Dequeue();
MediaFoundation.Check(hr, "Invio del fotogramma all'encoder");
}
_presentationTime += durationHns;
}
finally
{
if (sample is not null) Marshal.ReleaseComObject(sample);
Marshal.ReleaseComObject(buffer);
}
if (!_async)
{
while (TryDrainOutput()) { }
}
}
/// <summary>Chiude il flusso, svuota l'encoder e finalizza il contenitore.</summary>
public void Finish()
{
if (_finished) return;
_finished = true;
try
{
var transform = _transform;
if (transform is not null)
{
transform.ProcessMessage(MediaFoundation.MessageNotifyEndOfStream, IntPtr.Zero);
transform.ProcessMessage(MediaFoundation.MessageCommandDrain, IntPtr.Zero);
if (_async) PumpUntilDrained();
else
{
while (TryDrainOutput()) { }
}
transform.ProcessMessage(MediaFoundation.MessageNotifyEndStreaming, IntPtr.Zero);
}
}
finally
{
_muxer.Finish();
_output.Dispose();
ReleaseTransform();
}
}
// ------------------------------------------------------------------ pompa eventi (MFT asincrone)
private void WaitForInputSlot()
{
while (_pendingNeedInput == 0)
{
if (!ProcessNextEvent()) throw new VideoEncoderException("L'encoder ha chiuso la coda eventi.");
}
_pendingNeedInput--;
}
private void PumpUntilDrained()
{
int guard = 0;
while (guard++ < 1_000_000)
{
if (!ProcessNextEvent()) return;
if (_drainComplete) return;
}
}
private bool _drainComplete;
private bool ProcessNextEvent()
{
var generator = _events;
if (generator is null) return false;
int hr = generator.GetEvent(0, out IMFMediaEvent mediaEvent);
if (hr < 0 || mediaEvent is null) return false;
try
{
if (mediaEvent.GetEventType(out uint type) < 0) return true;
switch (type)
{
case MediaFoundation.EventTransformNeedInput:
_pendingNeedInput++;
break;
case MediaFoundation.EventTransformHaveOutput:
TryDrainOutput();
break;
case MediaFoundation.EventTransformDrainComplete:
_drainComplete = true;
break;
}
return true;
}
finally
{
Marshal.ReleaseComObject(mediaEvent);
}
}
/// <summary>Estrae un pacchetto compresso dall'encoder e lo consegna al multiplexer.</summary>
private bool TryDrainOutput()
{
var transform = _transform;
if (transform is null) return false;
bool providesSamples = (_outputInfo.Flags & MediaFoundation.OutputStreamProvidesSamples) != 0;
IMFSample? ownSample = null;
IMFMediaBuffer? ownBuffer = null;
IntPtr providedPointer = IntPtr.Zero;
var descriptor = new MftOutputDataBuffer { StreamId = _outputStreamId };
try
{
if (!providesSamples)
{
uint size = Math.Max(_outputInfo.Size, (uint)(_width * _height));
MediaFoundation.Check(MediaFoundation.MFCreateMemoryBuffer(size, out ownBuffer),
"Allocazione del buffer di uscita");
MediaFoundation.Check(MediaFoundation.MFCreateSample(out ownSample), "Creazione del campione di uscita");
ownSample.AddBuffer(ownBuffer);
providedPointer = Marshal.GetIUnknownForObject(ownSample);
descriptor.Sample = providedPointer;
}
int hr = transform.ProcessOutput(0, 1, ref descriptor, out _);
if (hr == MediaFoundation.ErrorTransformNeedMoreInput) return false;
if (hr == MediaFoundation.ErrorTransformStreamChange)
{
RenegotiateOutputType();
return true;
}
MediaFoundation.Check(hr, "Estrazione del pacchetto compresso");
IMFSample? produced = providesSamples
? (descriptor.Sample != IntPtr.Zero ? (IMFSample)Marshal.GetObjectForIUnknown(descriptor.Sample) : null)
: ownSample;
if (produced is null) return false;
try
{
ConsumeSample(produced);
}
finally
{
if (providesSamples) Marshal.ReleaseComObject(produced);
}
return true;
}
finally
{
if (descriptor.Events != IntPtr.Zero) Marshal.Release(descriptor.Events);
if (providesSamples && descriptor.Sample != IntPtr.Zero) Marshal.Release(descriptor.Sample);
if (providedPointer != IntPtr.Zero) Marshal.Release(providedPointer);
if (ownSample is not null) Marshal.ReleaseComObject(ownSample);
if (ownBuffer is not null) Marshal.ReleaseComObject(ownBuffer);
}
}
private unsafe void ConsumeSample(IMFSample sample)
{
sample.ConvertToContiguousBuffer(out IMFMediaBuffer buffer);
try
{
uint length;
buffer.Lock(out IntPtr pointer, out _, out length);
try
{
if (length == 0) return;
if (_packetBuffer.Length < length) _packetBuffer = new byte[Math.Max(length, (uint)_packetBuffer.Length * 2)];
Marshal.Copy(pointer, _packetBuffer, 0, (int)length);
}
finally
{
buffer.Unlock();
}
uint duration = _pendingDurations.Count > 0
? _pendingDurations.Dequeue()
: (uint)Math.Max(1, _settings.Timescale / Math.Max(1.0, _settings.FrameRate));
_muxer.WriteSample(_packetBuffer.AsSpan(0, (int)length), duration);
EncodedFrames++;
}
finally
{
Marshal.ReleaseComObject(buffer);
}
}
/// <summary>L'encoder può richiedere di riconfermare il tipo di uscita dopo la negoziazione iniziale.</summary>
private void RenegotiateOutputType()
{
var transform = _transform!;
if (transform.GetOutputAvailableType(_outputStreamId, 0, out IMFMediaType type) < 0) return;
try
{
transform.SetOutputType(_outputStreamId, type, 0);
transform.GetOutputStreamInfo(_outputStreamId, out _outputInfo);
ReadSequenceHeader();
}
finally
{
Marshal.ReleaseComObject(type);
}
}
// ------------------------------------------------------------------ utilità
private static void SetGuid(IMFMediaType type, Guid key, Guid value) => type.SetGUID(ref key, ref value);
private static void SetUInt32(IMFMediaType type, Guid key, uint value) => type.SetUINT32(ref key, value);
private static void SetUInt64(IMFMediaType type, Guid key, ulong value) => type.SetUINT64(ref key, value);
private void ReleaseTransform()
{
if (_events is not null && !ReferenceEquals(_events, _transform))
{
Marshal.ReleaseComObject(_events);
}
_events = null;
if (_transform is not null)
{
Marshal.ReleaseComObject(_transform);
_transform = null;
}
}
private void TryDeletePartialFile()
{
try
{
if (File.Exists(_settings.OutputPath)) File.Delete(_settings.OutputPath);
}
catch (IOException) { /* il file resta, verrà sovrascritto al tentativo successivo */ }
catch (UnauthorizedAccessException) { }
}
public void Dispose() => Finish();
}
/// <summary>Inizializzazione una tantum della piattaforma Media Foundation.</summary>
internal static class MediaFoundationRuntime
{
private static int _started;
public static void Startup()
{
if (Interlocked.Exchange(ref _started, 1) != 0) return;
int hr = MediaFoundation.MFStartup(MediaFoundation.Version, MediaFoundation.StartupLite);
if (hr < 0)
{
Interlocked.Exchange(ref _started, 0);
throw new VideoEncoderException($"Inizializzazione di Media Foundation non riuscita (HRESULT 0x{hr:X8}).");
}
AppDomain.CurrentDomain.ProcessExit += (_, _) => MediaFoundation.MFShutdown();
}
}