Correzione dei difetti emersi sui DNG GoPro reali
Provato il programma sulle quattro sequenze in K:\2024 (oltre 3000 scatti DNG convertiti da GPR con le applicazioni Adobe). Ne sono usciti quattro difetti, tutti latenti perché la verifica sintetica usa JPEG piccoli. 1. Risoluzione letta dalla miniatura. Nei DNG la IFD0 descrive un'anteprima 256x192 marcata NewSubfileType=1 e l'immagine vera vive in una SubIFD: leggendo ImageWidth dalla prima directory il programma credeva di lavorare su 256x192 e avrebbe esportato un video di quelle dimensioni. Ora si sceglie la directory dell'immagine principale, preferendo quelle non marcate come versione ridotta. 2. Lettura dei metadati lentissima. Si leggevano 4 MiB per file a prescindere: 179 s per una cartella da 1005 scatti. La finestra sul file ora si estende solo quando un offset punta oltre quanto gia' caricato, e di un DNG da 15 MiB ne bastano 128 KiB. Stessa cartella: 7,4 s. 3. Decodifica corrotta quando interveniva il ridimensionamento. Il convertitore a 48bppRGB precedeva lo scaler; con i codec RAW quell'ordine restituisce righe disallineate, immagini a strisce e luminanza sbagliata di due stop, senza segnalare alcun errore. Lo scaler ora precede il convertitore, come indica Microsoft. I 16 bit per canale restano. 4. Multiplexer che corrompeva il primo fotogramma chiave. Il buffer di conversione Annex-B veniva riallocato senza copiare il contenuto: le NAL gia' scritte per quel campione diventavano zeri e il campione usciva con un prefisso di lunghezza nullo in testa. Il file restava formalmente valido ma il lettore di sistema si fermava dopo sedici fotogrammi su quaranta. Con i JPEG del test il campione non superava mai la capacita' iniziale, per questo non era mai emerso; ora il buffer parte piccolo, cosi' il percorso di crescita viene esercitato da qualunque sequenza. Aggiunto inoltre il vincolo di conformita' dei codec. Una sorgente 4:3 da 4000x3000 supera il Livello 5.2 di H.264: l'encoder hardware la accetta e dichiara il Livello 6.0, ma i decodificatori comuni non aprono il file. La risoluzione viene ora ricondotta al massimo riproducibile conservando le proporzioni, e la riduzione e' dichiarata nella barra di stato invece di avvenire in silenzio. Le sorgenti 16:9 fino al 4K UHD non sono toccate. Il campo di movimento non viene piu' calcolato quando non serve: con pose da 30 s su intervalli da 34 s lo shutter angle e' gia' 317 gradi e non c'e' sfocatura da sintetizzare. Il render della sequenza aurora passa da 1,3 a 8,5 fotogrammi al secondo. La verifica del motore sale a 24 controlli: si aggiungono l'invarianza della luminanza alla scala di decodifica e l'allineamento delle NAL dentro ogni campione, i due invarianti che avrebbero intercettato i difetti 3 e 4. Nuovo comando --diagnose per esaminare una cartella reale. Verificato sui file dell'utente: 40 fotogrammi a risoluzione nativa, H.264 e HEVC, entrambi riletti per intero dal lettore di sistema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,8 @@ internal static class Mp4Inspector
|
||||
uint Timescale,
|
||||
long MediaDuration,
|
||||
int CodecConfigBytes,
|
||||
List<string> TopLevelBoxes);
|
||||
List<string> TopLevelBoxes,
|
||||
string? SampleProblem);
|
||||
|
||||
private static readonly string[] Containers =
|
||||
["moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "avc1", "hvc1"];
|
||||
@@ -27,6 +28,8 @@ internal static class Mp4Inspector
|
||||
public static Report Inspect(string path)
|
||||
{
|
||||
var boxes = new List<string>();
|
||||
var sampleSizes = new List<uint>();
|
||||
long firstChunkOffset = -1;
|
||||
int sampleCount = 0, width = 0, height = 0, configBytes = 0;
|
||||
uint timescale = 0;
|
||||
long mediaDuration = 0;
|
||||
@@ -47,12 +50,15 @@ internal static class Mp4Inspector
|
||||
if (sampleCount == 0) problems.Add("tabella stsz vuota");
|
||||
if (configBytes == 0) problems.Add("configurazione del codec assente");
|
||||
|
||||
string? sampleProblem = VerifySampleData(stream, sampleSizes, firstChunkOffset);
|
||||
if (sampleProblem is not null) problems.Add(sampleProblem);
|
||||
|
||||
string summary = problems.Count == 0
|
||||
? "struttura conforme"
|
||||
: string.Join(", ", problems);
|
||||
|
||||
return new Report(problems.Count == 0, summary, sampleCount, width, height,
|
||||
timescale, mediaDuration, configBytes, boxes);
|
||||
timescale, mediaDuration, configBytes, boxes, sampleProblem);
|
||||
|
||||
void Walk(FileStream file, long start, long end, int depth)
|
||||
{
|
||||
@@ -85,8 +91,19 @@ internal static class Mp4Inspector
|
||||
switch (type)
|
||||
{
|
||||
case "stsz":
|
||||
{
|
||||
file.Position = payload + 8; // versione/flag + sample_size
|
||||
sampleCount = (int)ReadUInt32(file);
|
||||
for (int s = 0; s < sampleCount && s < 100_000; s++) sampleSizes.Add(ReadUInt32(file));
|
||||
break;
|
||||
}
|
||||
case "co64":
|
||||
file.Position = payload + 8; // versione/flag + entry_count
|
||||
firstChunkOffset = (long)ReadUInt64(file);
|
||||
break;
|
||||
case "stco":
|
||||
file.Position = payload + 8;
|
||||
firstChunkOffset = ReadUInt32(file);
|
||||
break;
|
||||
case "mdhd":
|
||||
file.Position = payload + 12; // versione/flag + due timestamp
|
||||
@@ -118,6 +135,12 @@ internal static class Mp4Inspector
|
||||
}
|
||||
}
|
||||
|
||||
static ulong ReadUInt64(FileStream file)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
return file.Read(buffer) == 8 ? BinaryPrimitives.ReadUInt64BigEndian(buffer) : 0;
|
||||
}
|
||||
|
||||
static uint ReadUInt32(FileStream file)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
@@ -130,4 +153,39 @@ internal static class Mp4Inspector
|
||||
return file.Read(buffer) == 2 ? BinaryPrimitives.ReadUInt16BigEndian(buffer) : (ushort)0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica che ogni campione sia una successione esatta di NAL con lunghezza prefissata.
|
||||
/// Un prefisso nullo o che sborda dal campione significa dati corrotti dal multiplexer:
|
||||
/// il contenitore resterebbe formalmente valido ma il flusso sarebbe indecodificabile.
|
||||
/// </summary>
|
||||
private static string? VerifySampleData(FileStream stream, List<uint> sizes, long firstChunkOffset)
|
||||
{
|
||||
if (sizes.Count == 0 || firstChunkOffset < 0) return null;
|
||||
|
||||
long position = firstChunkOffset;
|
||||
Span<byte> prefix = stackalloc byte[4];
|
||||
|
||||
for (int index = 0; index < sizes.Count; index++)
|
||||
{
|
||||
long end = position + sizes[index];
|
||||
if (end > stream.Length) return $"campione {index + 1} oltre la fine del file";
|
||||
|
||||
long cursor = position;
|
||||
while (cursor + 4 <= end)
|
||||
{
|
||||
stream.Position = cursor;
|
||||
if (stream.Read(prefix) != 4) return $"campione {index + 1} troncato";
|
||||
|
||||
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
|
||||
if (length == 0) return $"campione {index + 1}: NAL di lunghezza nulla";
|
||||
if (cursor + 4 + length > end) return $"campione {index + 1}: NAL che sborda dal campione";
|
||||
cursor += 4 + length;
|
||||
}
|
||||
|
||||
if (cursor != end) return $"campione {index + 1}: {end - cursor} byte residui non allineati";
|
||||
position = end;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +100,17 @@ internal static class Mp4Playback
|
||||
|
||||
var luma = new List<double>();
|
||||
int frames = 0;
|
||||
int emptyReads = 0;
|
||||
uint flags = 0;
|
||||
|
||||
while (frames < maxFrames)
|
||||
{
|
||||
hr = reader.ReadSample(FirstVideoStream, 0, out _, out uint flags, out _, out IMFSample? sample);
|
||||
hr = reader.ReadSample(FirstVideoStream, 0, out _, out 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)
|
||||
{
|
||||
emptyReads = 0;
|
||||
try
|
||||
{
|
||||
luma.Add(MeasureMeanLuma(sample, width, height));
|
||||
@@ -118,11 +121,19 @@ internal static class Mp4Playback
|
||||
Marshal.ReleaseComObject(sample);
|
||||
}
|
||||
}
|
||||
else if (++emptyReads > 64)
|
||||
{
|
||||
// Il lettore restituisce campioni nulli senza mai segnalare la fine:
|
||||
// meglio fermarsi che girare a vuoto.
|
||||
return new Playback(frames, width, height, luma,
|
||||
$"{frames} fotogrammi, poi solo campioni nulli (flag 0x{flags:X})");
|
||||
}
|
||||
|
||||
if ((flags & EndOfStream) != 0) break;
|
||||
}
|
||||
|
||||
return new Playback(frames, width, height, luma, null);
|
||||
return new Playback(frames, width, height, luma,
|
||||
frames == 0 ? $"nessun fotogramma decodificato (flag 0x{flags:X})" : null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -98,11 +98,29 @@ public static class SelfTest
|
||||
Add(checks, "Rampa di luce preservata", rampOk,
|
||||
$"{rampBefore:0.00} stop → {rampAfter:0.00} stop");
|
||||
|
||||
// ---------------------------------------------------------------- 4. optical flow
|
||||
// ---------------------------------------------------------------- 4. decodifica
|
||||
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);
|
||||
|
||||
// La media logaritmica della luminanza è invariante alla scala: decodificare la
|
||||
// stessa immagine a risoluzioni diverse deve dare lo stesso valore. È il controllo
|
||||
// che smaschera una catena di decodifica mal composta, dove il ridimensionamento
|
||||
// restituisce righe disallineate senza segnalare alcun errore.
|
||||
double nativeLuma = LuminanceAnalyzer.Analyze(frameA).Log2Average;
|
||||
double worstScaleError = 0;
|
||||
foreach (int scaled in (ReadOnlySpan<int>)[320, 200])
|
||||
{
|
||||
int scaledHeight = Math.Max(2, (int)Math.Round(scaled * definition.Height / (double)definition.Width) & ~1);
|
||||
using var small = ImageDecoder.Decode(paths[10], scaled, scaledHeight, 1, pool);
|
||||
double delta = LuminanceAnalyzer.Analyze(small).Log2Average - nativeLuma;
|
||||
worstScaleError = Math.Max(worstScaleError, Math.Abs(delta));
|
||||
}
|
||||
Add(checks, "Decodifica — luminanza invariante alla scala", worstScaleError < 0.12,
|
||||
$"scarto massimo {worstScaleError:0.000} EV fra piena risoluzione e versioni ridotte");
|
||||
|
||||
// ---------------------------------------------------------------- 5. optical flow
|
||||
|
||||
var flowEngine = new OpticalFlowEngine(project.Flow);
|
||||
var field = flowEngine.Compute(frameA, frameB);
|
||||
|
||||
@@ -121,7 +139,7 @@ public static class SelfTest
|
||||
Add(checks, "Campo vettoriale — direzione dominante", directionError < 12,
|
||||
$"{measuredDirection:0.0}° (atteso {expectedDirection:0.0}°)");
|
||||
|
||||
// ---------------------------------------------------------------- 5. motion blur
|
||||
// ---------------------------------------------------------------- 6. 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);
|
||||
@@ -137,7 +155,7 @@ public static class SelfTest
|
||||
$"{detailBefore:0.0000} → {detailAfter:0.0000} " +
|
||||
$"({100 * (1 - detailAfter / detailBefore):0.#}% di attenuazione)");
|
||||
|
||||
// ---------------------------------------------------------------- 6. encoder + muxer
|
||||
// ---------------------------------------------------------------- 7. encoder + muxer
|
||||
RenderResult? result = null;
|
||||
string encodeDetail;
|
||||
try
|
||||
@@ -191,7 +209,7 @@ public static class SelfTest
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 7. impronta di memoria
|
||||
// ---------------------------------------------------------------- 8. 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",
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Titano.Analysis;
|
||||
using Titano.Core;
|
||||
using Titano.Imaging;
|
||||
using Titano.Metadata;
|
||||
|
||||
namespace Titano.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Analisi di una cartella reale: riporta ciò che il programma riesce davvero a leggere e
|
||||
/// decodificare, file per file. Serve a distinguere un difetto del motore da un formato
|
||||
/// sorgente che il sistema non sa aprire.
|
||||
/// </summary>
|
||||
public static class SequenceDiagnostics
|
||||
{
|
||||
public static int Run(string directory, TextWriter output, int sampleCount = 6,
|
||||
string? renderPath = null, int renderFrames = 24,
|
||||
int renderWidth = 0, Video.VideoCodec renderCodec = Video.VideoCodec.H264)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
output.WriteLine($"Cartella non trovata: {directory}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(directory)
|
||||
.Where(MetadataReader.IsSupported)
|
||||
.OrderBy(path => Path.GetFileName(path), NaturalFileNameComparer.Instance)
|
||||
.ToList();
|
||||
|
||||
output.WriteLine($"Cartella: {directory}");
|
||||
output.WriteLine($"File riconosciuti: {files.Count}");
|
||||
if (files.Count == 0)
|
||||
{
|
||||
var all = Directory.EnumerateFiles(directory).Take(5).Select(Path.GetExtension).Distinct();
|
||||
output.WriteLine("Estensioni presenti ma non supportate: " + string.Join(", ", all));
|
||||
return 1;
|
||||
}
|
||||
output.WriteLine();
|
||||
|
||||
// ------------------------------------------------------------------ metadati
|
||||
output.WriteLine("METADATI");
|
||||
output.WriteLine(new string('-', 74));
|
||||
|
||||
var samples = PickSamples(files, sampleCount);
|
||||
var sampleMetadata = new List<FrameMetadata>();
|
||||
|
||||
foreach (string path in samples)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var metadata = MetadataReader.Read(path);
|
||||
stopwatch.Stop();
|
||||
sampleMetadata.Add(metadata);
|
||||
|
||||
output.WriteLine($" {metadata.FileName} ({metadata.FileSize / 1024 / 1024.0:0.0} MiB, letti in {stopwatch.ElapsedMilliseconds} ms)");
|
||||
output.WriteLine($" scatto {Describe(metadata.CaptureTime)} [{metadata.CaptureSource}]" +
|
||||
(metadata.UtcOffset is { } o ? $" fuso {o}" : string.Empty));
|
||||
output.WriteLine($" posa {metadata.ExposureText} diaframma {metadata.ApertureText} ISO {metadata.IsoText}");
|
||||
output.WriteLine($" dimensioni {metadata.PixelWidth}×{metadata.PixelHeight} orientamento {metadata.Orientation}");
|
||||
output.WriteLine($" fotocamera {metadata.Camera ?? "—"} obiettivo {metadata.Lens ?? "—"}");
|
||||
if (metadata.Warning is { } warning) output.WriteLine($" ATTENZIONE {warning}");
|
||||
}
|
||||
output.WriteLine();
|
||||
|
||||
// ------------------------------------------------------------------ decodifica
|
||||
output.WriteLine("DECODIFICA (WIC, codec di sistema)");
|
||||
output.WriteLine(new string('-', 74));
|
||||
|
||||
var pool = new FrameBufferPool(4);
|
||||
int decoded = 0;
|
||||
|
||||
foreach (var metadata in sampleMetadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var (probeWidth, probeHeight) = ImageDecoder.ProbeDisplaySize(metadata.FilePath, metadata.Orientation);
|
||||
long probeMs = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
int width = Math.Max(2, probeWidth & ~1);
|
||||
int height = Math.Max(2, probeHeight & ~1);
|
||||
|
||||
stopwatch.Restart();
|
||||
using var buffer = ImageDecoder.Decode(metadata.FilePath, width, height, metadata.Orientation, pool);
|
||||
long decodeMs = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
var stats = LuminanceAnalyzer.Analyze(buffer);
|
||||
decoded++;
|
||||
|
||||
output.WriteLine($" {metadata.FileName}");
|
||||
output.WriteLine($" dimensione reale del pixel {probeWidth}×{probeHeight} (sonda {probeMs} ms)");
|
||||
output.WriteLine($" dichiarata nei metadati {metadata.PixelWidth}×{metadata.PixelHeight}" +
|
||||
(probeWidth != metadata.PixelWidth || probeHeight != metadata.PixelHeight
|
||||
? " ← NON COINCIDE"
|
||||
: string.Empty));
|
||||
output.WriteLine($" decodifica {decodeMs} ms");
|
||||
output.WriteLine($" luminanza media {stats.Log2Average:0.00} EV " +
|
||||
$"percentili {stats.Percentile01:0.0000} / {stats.Percentile50:0.0000} / {stats.Percentile99:0.0000}");
|
||||
output.WriteLine($" saturati {stats.ClippedFraction * 100:0.00}% neri {stats.BlackFraction * 100:0.00}%");
|
||||
|
||||
// Stessa immagine a scale diverse: la luminanza media è invariante alla scala,
|
||||
// quindi qualunque scostamento denuncia una decodifica sbagliata.
|
||||
foreach (int scaled in (ReadOnlySpan<int>)[2048, 1024, 512])
|
||||
{
|
||||
if (scaled >= width) continue;
|
||||
int h2 = Math.Max(2, (int)Math.Round(scaled * height / (double)width) & ~1);
|
||||
using var small = ImageDecoder.Decode(metadata.FilePath, scaled, h2, metadata.Orientation, pool);
|
||||
var s2 = LuminanceAnalyzer.Analyze(small);
|
||||
double delta = s2.Log2Average - stats.Log2Average;
|
||||
output.WriteLine($" a {scaled,4}×{h2,-4} {s2.Log2Average,7:0.00} EV " +
|
||||
$"scarto {delta,6:+0.00;-0.00;0.00} EV" +
|
||||
(Math.Abs(delta) > 0.25 ? " ← DECODIFICA INCOERENTE" : string.Empty));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
output.WriteLine($" {metadata.FileName}");
|
||||
output.WriteLine($" DECODIFICA NON RIUSCITA: {ex.GetType().Name} — {ex.Message}");
|
||||
}
|
||||
}
|
||||
output.WriteLine();
|
||||
|
||||
// ------------------------------------------------------------------ cadenza
|
||||
output.WriteLine("CADENZA SULL'INTERA SEQUENZA");
|
||||
output.WriteLine(new string('-', 74));
|
||||
|
||||
var timer = Stopwatch.StartNew();
|
||||
var all2 = files.AsParallel().Select(MetadataReader.Read).ToList();
|
||||
timer.Stop();
|
||||
|
||||
var sequence = TimelapseSequence.Build(all2);
|
||||
sequence.RecomputeTiming();
|
||||
|
||||
int withExif = all2.Count(m => m.CaptureSource is TimestampSource.Exif or TimestampSource.ExifSubSecond);
|
||||
int withSubSecond = all2.Count(m => m.CaptureSource == TimestampSource.ExifSubSecond);
|
||||
int withExposure = all2.Count(m => m.ExposureSeconds is not null);
|
||||
|
||||
output.WriteLine($" lettura di {files.Count} file in {timer.Elapsed.TotalSeconds:0.0} s");
|
||||
output.WriteLine($" timestamp da Exif {withExif}/{all2.Count} di cui al sotto-secondo {withSubSecond}");
|
||||
output.WriteLine($" tempo di posa noto {withExposure}/{all2.Count}");
|
||||
output.WriteLine($" cadenza nominale {sequence.NominalInterval:0.###} s");
|
||||
output.WriteLine($" intervalli anomali {sequence.CadenceAnomalies}");
|
||||
output.WriteLine($" durata della ripresa {sequence.TotalDuration}");
|
||||
output.WriteLine($" shutter angle {sequence.Frames[0].ShutterAngle:0.0}° sul primo fotogramma");
|
||||
|
||||
var distinct = all2.Select(m => (m.PixelWidth, m.PixelHeight)).Distinct().ToList();
|
||||
output.WriteLine($" dimensioni dichiarate distinte: {string.Join(", ", distinct.Select(d => $"{d.PixelWidth}×{d.PixelHeight}"))}");
|
||||
|
||||
if (renderPath is not null)
|
||||
RunTrialRender(all2, renderPath, renderFrames, renderWidth, renderCodec, output);
|
||||
|
||||
return decoded == sampleMetadata.Count ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero
|
||||
/// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file.
|
||||
/// </summary>
|
||||
private static void RunTrialRender(List<FrameMetadata> metadata, string outputPath, int frames,
|
||||
int forcedWidth, Video.VideoCodec codec, TextWriter output)
|
||||
{
|
||||
output.WriteLine();
|
||||
output.WriteLine("RENDER DI PROVA");
|
||||
output.WriteLine(new string('-', 74));
|
||||
|
||||
var subset = metadata.OrderBy(m => m.CaptureTime ?? DateTime.MaxValue)
|
||||
.ThenBy(m => m.FileName, NaturalFileNameComparer.Instance)
|
||||
.Take(Math.Max(2, frames))
|
||||
.ToList();
|
||||
|
||||
var project = new Pipeline.TitanoProject();
|
||||
project.Sequence = TimelapseSequence.Build(subset);
|
||||
project.Sequence.RecomputeTiming();
|
||||
project.Export.OutputPath = outputPath;
|
||||
project.Export.FrameRate = 24;
|
||||
project.Export.BitrateMbps = 80;
|
||||
project.Export.Codec = codec;
|
||||
project.General.WorkingWidth = forcedWidth;
|
||||
|
||||
var (width, height) = project.ResolveWorkingSize();
|
||||
var (requestedWidth, requestedHeight) = project.ResolveRequestedSize();
|
||||
long perFrame = (long)width * height * 3 * sizeof(float);
|
||||
output.WriteLine($" fotogrammi {subset.Count}");
|
||||
output.WriteLine($" risoluzione {width}×{height}" +
|
||||
(width != requestedWidth || height != requestedHeight
|
||||
? $" (ridotta da {requestedWidth}×{requestedHeight} per il limite del codec)"
|
||||
: string.Empty));
|
||||
output.WriteLine($" memoria per buffer {perFrame / (1024.0 * 1024.0):0.0} MiB");
|
||||
output.WriteLine($" codec {codec}");
|
||||
|
||||
var pipeline = new Pipeline.RenderPipeline(project);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
pipeline.AnalyzeAsync(null, CancellationToken.None).GetAwaiter().GetResult();
|
||||
output.WriteLine($" analisi completata in {stopwatch.Elapsed.TotalSeconds:0.0} s");
|
||||
|
||||
var result = pipeline.RenderAsync(null, CancellationToken.None).GetAwaiter().GetResult();
|
||||
output.WriteLine($" codifica {result.EncodedFrames} fotogrammi in {result.Elapsed.TotalSeconds:0.0} s " +
|
||||
$"({result.EncodedFrames / Math.Max(0.001, result.Elapsed.TotalSeconds):0.00} fps)");
|
||||
output.WriteLine($" encoder {result.EncoderName}{(result.HardwareAccelerated ? " (hardware)" : " (software)")}");
|
||||
output.WriteLine($" file prodotto {result.OutputBytes / (1024.0 * 1024.0):0.0} MiB in {result.OutputPath}");
|
||||
output.WriteLine($" memoria pixel {result.PeakPixelMemoryBytes / (1024.0 * 1024.0):0.0} MiB " +
|
||||
$"({result.PeakPixelMemoryBytes / (double)perFrame:0.0} fotogrammi)");
|
||||
|
||||
var report = Mp4Inspector.Inspect(result.OutputPath);
|
||||
output.WriteLine($" contenitore {report.Summary}, {report.SampleCount} campioni, {report.Width}×{report.Height}");
|
||||
|
||||
var playback = Mp4Playback.Read(result.OutputPath);
|
||||
output.WriteLine($" rilettura {playback.Error ?? $"{playback.FrameCount} fotogrammi decodificati"}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
output.WriteLine($" RENDER NON RIUSCITO: {ex.GetType().Name} — {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> PickSamples(List<string> files, int count)
|
||||
{
|
||||
if (files.Count <= count) return files;
|
||||
var picked = new List<string>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
picked.Add(files[(int)((long)i * (files.Count - 1) / Math.Max(1, count - 1))]);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
private static string Describe(DateTime? value)
|
||||
=> value?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "assente";
|
||||
}
|
||||
@@ -124,6 +124,17 @@ public static class ImageDecoder
|
||||
{
|
||||
IWICBitmapSource current = (IWICBitmapSource)frame;
|
||||
|
||||
// Lo scaler precede il convertitore. È l'ordine indicato da Microsoft e non è un
|
||||
// dettaglio: interponendo prima la conversione a 48bppRGB, lo scaler di alcuni codec
|
||||
// RAW restituisce righe disallineate — l'immagine esce a strisce e la luminanza media
|
||||
// sbaglia di due stop. A piena risoluzione, senza scaler, lo stesso percorso è esatto.
|
||||
if (decodeWidth != sourceWidth || decodeHeight != sourceHeight)
|
||||
{
|
||||
Wic.Factory.CreateBitmapScaler(out scaler);
|
||||
scaler.Initialize(current, decodeWidth, decodeHeight, Wic.InterpolationFant);
|
||||
current = (IWICBitmapSource)scaler;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Wic.Factory.CreateFormatConverter(out converter);
|
||||
@@ -140,16 +151,7 @@ public static class ImageDecoder
|
||||
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;
|
||||
return (IWICBitmapSource)converter!;
|
||||
}
|
||||
|
||||
private static bool PrefersHighBitDepth(string path)
|
||||
|
||||
@@ -59,10 +59,9 @@ public static class MetadataReader
|
||||
|
||||
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);
|
||||
// Nessuna lettura a blocco fisso: la finestra si estende solo se serve.
|
||||
// Nei TIFF e nei RAW l'XMP vive nel tag 0x02BC, letto più sotto.
|
||||
tiff = TiffBlock.ParseStream(stream, info.Length);
|
||||
if (tiff is null) warning = "Header TIFF non interpretabile.";
|
||||
break;
|
||||
}
|
||||
@@ -115,10 +114,22 @@ public static class MetadataReader
|
||||
|
||||
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;
|
||||
// PixelXDimension descrive per definizione l'immagine finale: quando c'è, vince.
|
||||
if (width == 0 &&
|
||||
tiff.TryGetUInt32(TiffTags.PixelXDimension, out uint pw) &&
|
||||
tiff.TryGetUInt32(TiffTags.PixelYDimension, out uint ph))
|
||||
{
|
||||
width = (int)pw;
|
||||
height = (int)ph;
|
||||
}
|
||||
|
||||
// Altrimenti si individua la directory dell'immagine principale. Leggere
|
||||
// ImageWidth dalla IFD0 sarebbe sbagliato: nei DNG quella è la miniatura.
|
||||
if (width == 0 && tiff.TryGetMainImageSize(out int mainWidth, out int mainHeight))
|
||||
{
|
||||
width = mainWidth;
|
||||
height = mainHeight;
|
||||
}
|
||||
|
||||
string? make = tiff.GetString(TiffTags.Make);
|
||||
string? model = tiff.GetString(TiffTags.Model);
|
||||
|
||||
@@ -12,12 +12,25 @@ internal sealed class TiffBlock
|
||||
public Dictionary<ushort, TiffEntry> Gps { get; } = [];
|
||||
public List<Dictionary<ushort, TiffEntry>> SubIfds { get; } = [];
|
||||
|
||||
/// <summary>Tutte le directory che descrivono un'immagine: IFD0, la catena e le SubIFD.</summary>
|
||||
private readonly List<Dictionary<ushort, TiffEntry>> _imageDirectories = [];
|
||||
|
||||
private TiffBlock(ByteView view) => View = view;
|
||||
|
||||
/// <summary>Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory.</summary>
|
||||
/// <summary>Blocco TIFF già interamente in memoria (segmento APP1 di un JPEG, chunk PNG).</summary>
|
||||
public static TiffBlock? Parse(byte[] data, int origin, int length)
|
||||
=> Parse(new ByteView(data, origin, length, false));
|
||||
|
||||
/// <summary>
|
||||
/// Blocco TIFF su file (TIFF, DNG, RAW): la finestra si estende da sola se una directory
|
||||
/// risiede oltre la testa già letta, così un RAW da 15 MiB costa una lettura di 128 KiB.
|
||||
/// </summary>
|
||||
public static TiffBlock? ParseStream(Stream stream, long total, int initialRead = 128 * 1024)
|
||||
=> Parse(new ByteView(new ByteWindow(stream, total, initialRead), 0, false));
|
||||
|
||||
/// <summary>Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory.</summary>
|
||||
private static TiffBlock? Parse(ByteView probe)
|
||||
{
|
||||
var probe = new ByteView(data, origin, length, false);
|
||||
if (!probe.TryGetUInt16(0, out ushort order)) return null;
|
||||
|
||||
bool bigEndian;
|
||||
@@ -47,6 +60,7 @@ internal sealed class TiffBlock
|
||||
var dir = ReadDirectory(next, out uint following);
|
||||
if (dir is null) break;
|
||||
|
||||
_imageDirectories.Add(dir);
|
||||
if (Ifd0.Count == 0) MergeInto(Ifd0, dir);
|
||||
else SubIfds.Add(dir);
|
||||
|
||||
@@ -69,7 +83,9 @@ internal sealed class TiffBlock
|
||||
{
|
||||
if (!View.TryGetUInt32(subs.ValuePosition + i * 4, out uint so)) break;
|
||||
var d = ReadDirectory(so, out _);
|
||||
if (d is not null) SubIfds.Add(d);
|
||||
if (d is null) continue;
|
||||
_imageDirectories.Add(d);
|
||||
SubIfds.Add(d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +265,55 @@ internal sealed class TiffBlock
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dimensioni dell'immagine principale.
|
||||
///
|
||||
/// Non basta leggere ImageWidth dalla prima directory: nei DNG — e in quasi tutti i RAW —
|
||||
/// la IFD0 descrive una miniatura (NewSubfileType = 1) e l'immagine vera vive in una
|
||||
/// SubIFD. Si scorrono quindi tutte le directory preferendo quelle non marcate come
|
||||
/// versione ridotta e, a parità di rango, la più grande.
|
||||
/// </summary>
|
||||
public bool TryGetMainImageSize(out int width, out int height)
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
long bestArea = 0;
|
||||
bool bestIsFull = false;
|
||||
|
||||
foreach (var directory in _imageDirectories)
|
||||
{
|
||||
if (!TryReadDimension(directory, TiffTags.ImageWidth, out int w)) continue;
|
||||
if (!TryReadDimension(directory, TiffTags.ImageLength, out int h)) continue;
|
||||
|
||||
bool full = true;
|
||||
if (directory.TryGetValue(TiffTags.NewSubfileType, out var marker) &&
|
||||
TryReadScalarUInt(marker, 0, out uint kind))
|
||||
{
|
||||
full = (kind & 1) == 0; // bit 0 acceso = versione a risoluzione ridotta
|
||||
}
|
||||
|
||||
long area = (long)w * h;
|
||||
bool better = (full && !bestIsFull) || (full == bestIsFull && area > bestArea);
|
||||
if (!better) continue;
|
||||
|
||||
width = w;
|
||||
height = h;
|
||||
bestArea = area;
|
||||
bestIsFull = full;
|
||||
}
|
||||
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
|
||||
private bool TryReadDimension(Dictionary<ushort, TiffEntry> directory, ushort tag, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!directory.TryGetValue(tag, out var entry) || entry.Count < 1) return false;
|
||||
if (!TryReadScalarUInt(entry, 0, out uint raw) || raw == 0 || raw > int.MaxValue) return false;
|
||||
value = (int)raw;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
@@ -264,6 +329,7 @@ internal sealed class TiffBlock
|
||||
/// <summary>Numerazione dei tag TIFF/Exif effettivamente utilizzati dal motore.</summary>
|
||||
internal static class TiffTags
|
||||
{
|
||||
public const ushort NewSubfileType = 0x00FE;
|
||||
public const ushort ImageWidth = 0x0100;
|
||||
public const ushort ImageLength = 0x0101;
|
||||
public const ushort Make = 0x010F;
|
||||
|
||||
@@ -50,35 +50,119 @@ internal readonly struct TiffEntry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lettore binario endian-aware su uno <see cref="ReadOnlySpan{T}"/> logico (buffer immutabile).
|
||||
/// Finestra su una sorgente di byte, caricata progressivamente.
|
||||
///
|
||||
/// I file RAW pesano decine di megabyte ma le directory dei metadati stanno nei primi
|
||||
/// chilobyte: leggerli per intero, o anche solo a blocchi fissi di qualche megabyte,
|
||||
/// costa centinaia di millisecondi per file e rende impraticabile l'ingestion di una
|
||||
/// sequenza da mille scatti. Qui si legge il minimo indispensabile e si estende la
|
||||
/// finestra solo se un offset punta oltre quanto già caricato.
|
||||
/// </summary>
|
||||
internal sealed class ByteWindow
|
||||
{
|
||||
/// <summary>Granularità delle letture: evita una chiamata di I/O per ogni tag.</summary>
|
||||
private const int ChunkSize = 64 * 1024;
|
||||
|
||||
private readonly Stream? _stream;
|
||||
private readonly long _total;
|
||||
private byte[] _buffer;
|
||||
private int _loaded;
|
||||
|
||||
/// <summary>Sorgente già interamente in memoria (segmento APP1 di un JPEG, chunk PNG).</summary>
|
||||
public ByteWindow(byte[] data, int length)
|
||||
{
|
||||
_buffer = data;
|
||||
_loaded = Math.Clamp(length, 0, data.Length);
|
||||
_total = _loaded;
|
||||
}
|
||||
|
||||
/// <summary>Sorgente su file: si carica subito solo la testa.</summary>
|
||||
public ByteWindow(Stream stream, long total, int initial)
|
||||
{
|
||||
_stream = stream;
|
||||
_total = Math.Max(0, total);
|
||||
_buffer = new byte[(int)Math.Min(_total, Math.Max(ChunkSize, initial))];
|
||||
Ensure(Math.Min(_total, initial));
|
||||
}
|
||||
|
||||
public byte[] Buffer => _buffer;
|
||||
|
||||
/// <summary>Byte effettivamente presenti in memoria.</summary>
|
||||
public int Loaded => _loaded;
|
||||
|
||||
/// <summary>Byte complessivamente disponibili nella sorgente.</summary>
|
||||
public long Total => _total;
|
||||
|
||||
/// <summary>Garantisce che i primi <paramref name="end"/> byte siano caricati.</summary>
|
||||
public bool Ensure(long end)
|
||||
{
|
||||
if (end <= _loaded) return true;
|
||||
if (end > _total || _stream is null || end > int.MaxValue) return false;
|
||||
|
||||
long target = Math.Min(_total, Math.Max(end, (long)_loaded + ChunkSize));
|
||||
|
||||
if (_buffer.Length < target)
|
||||
{
|
||||
long grown = Math.Min(_total, Math.Max(target, (long)_buffer.Length * 2));
|
||||
Array.Resize(ref _buffer, (int)grown);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_stream.Position = _loaded;
|
||||
while (_loaded < target)
|
||||
{
|
||||
int read = _stream.Read(_buffer, _loaded, (int)(target - _loaded));
|
||||
if (read <= 0) break;
|
||||
_loaded += read;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return end <= _loaded;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lettore binario endian-aware su una <see cref="ByteWindow"/>, con origine propria.
|
||||
/// Tutti gli accessi sono limitati: un file malformato produce valori assenti, mai eccezioni.
|
||||
/// </summary>
|
||||
internal readonly struct ByteView
|
||||
{
|
||||
private readonly byte[] _data;
|
||||
private readonly ByteWindow _window;
|
||||
private readonly int _origin;
|
||||
private readonly int _length;
|
||||
public readonly bool BigEndian;
|
||||
|
||||
public ByteView(byte[] data, int origin, int length, bool bigEndian)
|
||||
public ByteView(ByteWindow window, int origin, bool bigEndian)
|
||||
{
|
||||
_data = data;
|
||||
_origin = Math.Clamp(origin, 0, data.Length);
|
||||
_length = Math.Clamp(length, 0, data.Length - _origin);
|
||||
_window = window;
|
||||
_origin = Math.Max(0, origin);
|
||||
BigEndian = bigEndian;
|
||||
}
|
||||
|
||||
public ByteView WithEndianness(bool bigEndian) => new(_data, _origin, _length, bigEndian);
|
||||
public ByteView(byte[] data, int origin, int length, bool bigEndian)
|
||||
: this(new ByteWindow(data, Math.Clamp(origin + length, 0, data.Length)), origin, bigEndian)
|
||||
{
|
||||
}
|
||||
|
||||
public int Length => _length;
|
||||
public ByteView WithEndianness(bool bigEndian) => new(_window, _origin, bigEndian);
|
||||
|
||||
/// <summary>Byte teoricamente raggiungibili da questa origine.</summary>
|
||||
public long Length => Math.Max(0, _window.Total - _origin);
|
||||
|
||||
/// <summary>Byte già caricati a partire da questa origine: limite delle ricerche per scansione.</summary>
|
||||
private int Available => Math.Max(0, _window.Loaded - _origin);
|
||||
|
||||
public bool InRange(long offset, long count)
|
||||
=> offset >= 0 && count >= 0 && offset + count <= _length;
|
||||
=> offset >= 0 && count >= 0 && _window.Ensure(_origin + offset + count);
|
||||
|
||||
public bool TryGetByte(long offset, out byte value)
|
||||
{
|
||||
if (!InRange(offset, 1)) { value = 0; return false; }
|
||||
value = _data[_origin + (int)offset];
|
||||
value = _window.Buffer[_origin + (int)offset];
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -86,10 +170,11 @@ internal readonly struct ByteView
|
||||
{
|
||||
value = 0;
|
||||
if (!InRange(offset, 2)) return false;
|
||||
var data = _window.Buffer;
|
||||
int p = _origin + (int)offset;
|
||||
value = BigEndian
|
||||
? (ushort)((_data[p] << 8) | _data[p + 1])
|
||||
: (ushort)((_data[p + 1] << 8) | _data[p]);
|
||||
? (ushort)((data[p] << 8) | data[p + 1])
|
||||
: (ushort)((data[p + 1] << 8) | data[p]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -97,10 +182,11 @@ internal readonly struct ByteView
|
||||
{
|
||||
value = 0;
|
||||
if (!InRange(offset, 4)) return false;
|
||||
var data = _window.Buffer;
|
||||
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];
|
||||
? ((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;
|
||||
}
|
||||
|
||||
@@ -132,34 +218,42 @@ internal readonly struct ByteView
|
||||
|
||||
public string? GetAscii(long offset, long count)
|
||||
{
|
||||
if (!InRange(offset, count) || count <= 0) return null;
|
||||
if (count <= 0 || !InRange(offset, count)) return null;
|
||||
var data = _window.Buffer;
|
||||
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; }
|
||||
if (data[p + i] == 0) { end = i; break; }
|
||||
}
|
||||
while (end > 0 && (_data[p + end - 1] == ' ' || _data[p + end - 1] == '\t')) end--;
|
||||
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);
|
||||
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>
|
||||
/// <summary>
|
||||
/// Ricerca di un pattern di byte nella porzione già caricata; -1 se assente.
|
||||
/// Usata dai ripieghi per scansione, che lavorano su sorgenti interamente in memoria.
|
||||
/// </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);
|
||||
int available = Available;
|
||||
if (pattern.Length == 0 || pattern.Length > available) return -1;
|
||||
|
||||
int from = Math.Clamp(startAt, 0, available);
|
||||
var haystack = new ReadOnlySpan<byte>(_window.Buffer, _origin + from, available - from);
|
||||
int index = haystack.IndexOf(pattern);
|
||||
return index < 0 ? -1 : index + from;
|
||||
}
|
||||
|
||||
public byte[] ToArray(long offset, long count)
|
||||
{
|
||||
if (!InRange(offset, count) || count <= 0) return [];
|
||||
if (count <= 0 || !InRange(offset, count)) return [];
|
||||
var result = new byte[count];
|
||||
Array.Copy(_data, _origin + (int)offset, result, 0, (int)count);
|
||||
Array.Copy(_window.Buffer, _origin + (int)offset, result, 0, (int)count);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,12 @@ public sealed class RenderPipeline(TitanoProject project)
|
||||
var curve = _project.Curve;
|
||||
|
||||
int parallelism = Math.Clamp(_project.General.DecodeParallelism, 1, 16);
|
||||
var pool = new FrameBufferPool(parallelism + 8);
|
||||
|
||||
// Buffer contemporaneamente vivi: i cinque fissi del ciclo (corrente, successivo,
|
||||
// sfocatura, interpolato, interpolato sfocato), le decodifiche in volo e la copia
|
||||
// di ripiego per un fotogramma illeggibile. Trattenerne di più è memoria ferma:
|
||||
// con sorgenti da 12 megapixel ogni buffer pesa oltre cento megabyte.
|
||||
var pool = new FrameBufferPool(parallelism + 6);
|
||||
var flowEngine = new OpticalFlowEngine(_project.Flow);
|
||||
var blurSettings = _project.MotionBlur;
|
||||
var deflickerSettings = _project.Deflicker;
|
||||
@@ -245,15 +250,6 @@ public sealed class RenderPipeline(TitanoProject project)
|
||||
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);
|
||||
@@ -262,6 +258,20 @@ public sealed class RenderPipeline(TitanoProject project)
|
||||
blurSettings.Strength) / subdivisions
|
||||
: 0;
|
||||
|
||||
// Il campo vettoriale si calcola solo se serve davvero. Nelle riprese notturne
|
||||
// la posa copre quasi tutto l'intervallo, lo shutter angle supera già i 180°
|
||||
// e non c'è sfocatura da sintetizzare: calcolarlo lo stesso costerebbe la voce
|
||||
// di spesa più pesante della pipeline per nulla.
|
||||
MotionField? field = null;
|
||||
bool needsFlow = missing > 1e-4 ||
|
||||
(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();
|
||||
}
|
||||
|
||||
record.BlurLength = EncodeFrame(session, current, field, missing, blurSettings, blurScratch, duration);
|
||||
encoded++;
|
||||
record.OutputDurationUnits = (int)duration;
|
||||
|
||||
@@ -78,6 +78,35 @@ public sealed class TitanoProject
|
||||
|
||||
targetWidth = Math.Max(2, targetWidth & ~1);
|
||||
targetHeight = Math.Max(2, targetHeight & ~1);
|
||||
return (targetWidth, targetHeight);
|
||||
|
||||
// Ultimo vincolo, non negoziabile: un fotogramma oltre i limiti del codec darebbe un
|
||||
// file che nessun lettore comune apre. Meglio un video leggermente più piccolo che uno
|
||||
// inutilizzabile; la riduzione viene riportata in interfaccia.
|
||||
return Video.CodecLimits.Clamp(Export.Codec, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risoluzione che si otterrebbe senza il vincolo di conformità del codec.
|
||||
/// Serve solo a segnalare all'utente che è stata applicata una riduzione.
|
||||
/// </summary>
|
||||
public (int Width, int Height) ResolveRequestedSize()
|
||||
{
|
||||
var (width, height) = ResolveWorkingSize();
|
||||
if (Sequence is not { Count: > 0 }) return (width, height);
|
||||
|
||||
var first = Sequence.Frames[0].Metadata;
|
||||
int sourceWidth = first.PixelWidth;
|
||||
int sourceHeight = first.PixelHeight;
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) return (width, height);
|
||||
if (Imaging.ImageDecoder.SwapsAxes(first.Orientation)) (sourceWidth, sourceHeight) = (sourceHeight, sourceWidth);
|
||||
|
||||
int requestedWidth = Export.Width > 0 ? Export.Width
|
||||
: General.WorkingWidth > 0 ? Math.Min(General.WorkingWidth, sourceWidth)
|
||||
: sourceWidth;
|
||||
int requestedHeight = Export.Height > 0
|
||||
? Export.Height
|
||||
: (int)Math.Round(requestedWidth * (sourceHeight / (double)sourceWidth));
|
||||
|
||||
return (Math.Max(2, requestedWidth & ~1), Math.Max(2, requestedHeight & ~1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@ internal static class Program
|
||||
return SelfTest.Run(directory, Console.Out);
|
||||
}
|
||||
|
||||
if (args.Length > 1 && args[0] == "--diagnose")
|
||||
{
|
||||
EnsureConsole();
|
||||
Console.WriteLine();
|
||||
return SequenceDiagnostics.Run(args[1], Console.Out,
|
||||
renderPath: args.Length > 2 ? args[2] : null,
|
||||
renderFrames: args.Length > 3 && int.TryParse(args[3], out int n) ? n : 24,
|
||||
renderWidth: args.Length > 4 && int.TryParse(args[4], out int w) ? w : 0,
|
||||
renderCodec: args.Length > 5 && args[5].Equals("hevc", StringComparison.OrdinalIgnoreCase)
|
||||
? Video.VideoCodec.Hevc : Video.VideoCodec.H264);
|
||||
}
|
||||
|
||||
if (args.Length > 1 && args[0] == "--capture")
|
||||
{
|
||||
EnsureConsole();
|
||||
|
||||
+33
-5
@@ -72,7 +72,24 @@ movimento.
|
||||
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.
|
||||
il costo non dipende dalla dimensione dei file sorgente. Il campo si calcola solo quando serve
|
||||
davvero: se la posa copre già più dell'apertura obiettivo — il caso delle riprese notturne, con
|
||||
pose da trenta secondi su intervalli da trentaquattro — non c'è sfocatura da sintetizzare e la
|
||||
voce di spesa più pesante della pipeline viene saltata.
|
||||
|
||||
## Sorgenti RAW e limiti dei codec
|
||||
|
||||
I file RAW e DNG non espongono l'immagine principale nella prima directory: quella è una
|
||||
miniatura, e le dimensioni vere vivono in una SubIFD marcata come non ridotta. Titano sceglie
|
||||
la directory dell'immagine principale invece di fidarsi della prima, e legge i metadati con una
|
||||
finestra che si estende su richiesta: di un DNG da 15 MiB ne tocca 128 KiB.
|
||||
|
||||
La risoluzione di lavoro viene infine ricondotta ai limiti del codec. Un encoder hardware
|
||||
accetta volentieri un fotogramma 4000×3000 e dichiara un livello alto: il flusso è formalmente
|
||||
valido ma supera il Livello 5.2 di H.264, e i decodificatori comuni si rifiutano di aprirlo. Il
|
||||
video uscirebbe, peserebbe, e non si riprodurrebbe. Titano riduce quindi il fotogramma al
|
||||
massimo riproducibile conservando le proporzioni, e lo dichiara in interfaccia. Le sorgenti
|
||||
16:9 fino al 4K UHD non vengono toccate.
|
||||
|
||||
## Compilazione ed esecuzione
|
||||
|
||||
@@ -91,10 +108,21 @@ 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.
|
||||
confrontando 24 grandezze misurate con i valori attesi: campi Exif, cadenza, shutter angle,
|
||||
riduzione dello sfarfallio, conservazione della rampa, invarianza della luminanza alla scala di
|
||||
decodifica, modulo e direzione del campo vettoriale, attenuazione del dettaglio dovuta alla
|
||||
sfocatura, allineamento delle NAL dentro ogni campione del contenitore e — prova conclusiva —
|
||||
la ri-decodifica del file con il lettore di sistema.
|
||||
|
||||
```
|
||||
Titano.exe --diagnose <cartella> [uscita.mp4] [fotogrammi] [larghezza] [h264|hevc]
|
||||
```
|
||||
|
||||
Riporta cosa il programma riesce davvero a leggere da una cartella reale: metadati file per
|
||||
file, dimensione dichiarata contro dimensione decodificata, coerenza della luminanza fra
|
||||
risoluzioni diverse, cadenza sull'intera sequenza. Indicando un file di uscita esegue anche un
|
||||
render di prova e ne riverifica il contenitore. È lo strumento con cui si distingue un difetto
|
||||
del motore da un formato che il sistema non sa aprire.
|
||||
|
||||
```
|
||||
Titano.exe --capture <file.png> [cartella-sequenza] [scheda]
|
||||
|
||||
+23
-2
@@ -345,7 +345,7 @@ internal sealed class MainForm : Form
|
||||
ShowPreview(0);
|
||||
|
||||
SetStatus($"{sequence.Count} fotogrammi caricati. Cadenza nominale {sequence.NominalInterval:0.###} s, " +
|
||||
$"{sequence.CadenceAnomalies} intervalli anomali.");
|
||||
$"{sequence.CadenceAnomalies} intervalli anomali." + DescribeResolutionClamp());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -515,13 +515,34 @@ internal sealed class MainForm : Form
|
||||
}
|
||||
|
||||
var (width, height) = _project.ResolveWorkingSize();
|
||||
var (requestedWidth, requestedHeight) = _project.ResolveRequestedSize();
|
||||
double outputSeconds = sequence.Count / Math.Max(1.0, _project.Export.FrameRate);
|
||||
|
||||
// Una riduzione imposta dal codec va detta: chi esporta deve sapere che il video
|
||||
// non esce alla risoluzione della sorgente.
|
||||
string resolution = width == requestedWidth && height == requestedHeight
|
||||
? $"{width}×{height}"
|
||||
: $"{width}×{height} (da {requestedWidth}×{requestedHeight})";
|
||||
|
||||
_summary.Text = $"{sequence.Count} scatti · {sequence.TotalDuration:hh\\:mm\\:ss} di ripresa · " +
|
||||
$"{width}×{height} · {outputSeconds:0.0} s di video" +
|
||||
$"{resolution} · {outputSeconds:0.0} s di video" +
|
||||
(_project.IsAnalyzed ? " · analizzata" : string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Avverte quando la conformità del codec impone una risoluzione inferiore alla sorgente.</summary>
|
||||
private string DescribeResolutionClamp()
|
||||
{
|
||||
if (!_project.HasSequence) return string.Empty;
|
||||
|
||||
var (width, height) = _project.ResolveWorkingSize();
|
||||
var (requestedWidth, requestedHeight) = _project.ResolveRequestedSize();
|
||||
if (width == requestedWidth && height == requestedHeight) return string.Empty;
|
||||
|
||||
string codec = _project.Export.Codec == Video.VideoCodec.H264 ? "H.264" : "HEVC";
|
||||
return $" Risoluzione ridotta a {width}×{height}: {requestedWidth}×{requestedHeight} eccede " +
|
||||
$"il livello {codec} che i lettori supportano.";
|
||||
}
|
||||
|
||||
private void ReportProgress(PipelineProgress progress)
|
||||
{
|
||||
_progress.Fraction = progress.Fraction;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace Titano.Video;
|
||||
|
||||
/// <summary>
|
||||
/// Limiti di conformità dei codec, applicati alla risoluzione di lavoro.
|
||||
///
|
||||
/// Un encoder hardware accetta volentieri fotogrammi enormi e dichiara un livello alto,
|
||||
/// producendo un flusso formalmente valido che però nessun decodificatore comune sa aprire:
|
||||
/// il file esce, pesa, e non si riproduce. Una sorgente 4:3 da 4000×3000 — tipica delle
|
||||
/// GoPro convertite in DNG — cade esattamente in questo caso, perché supera il Livello 5.2.
|
||||
///
|
||||
/// Qui la risoluzione viene ricondotta al livello che i decodificatori supportano
|
||||
/// universalmente, conservando le proporzioni. Le sorgenti 16:9 fino al 4K UHD non
|
||||
/// vengono toccate: 3840×2160 sta comodamente dentro i limiti.
|
||||
/// </summary>
|
||||
public static class CodecLimits
|
||||
{
|
||||
/// <summary>Macroblocchi 16×16 per fotogramma ammessi dal Livello 5.2 di H.264.</summary>
|
||||
private const int H264MaxMacroblocks = 36_864;
|
||||
|
||||
/// <summary>Campioni di luminanza per fotogramma ammessi dal Livello 5.1 di HEVC.</summary>
|
||||
private const int HevcMaxLumaSamples = 8_912_896;
|
||||
|
||||
/// <summary>Lato massimo accettato dai decodificatori hardware diffusi.</summary>
|
||||
private const int MaxDimension = 4096;
|
||||
|
||||
/// <summary>Numero massimo di pixel per fotogramma con il codec indicato.</summary>
|
||||
public static long MaxPixels(VideoCodec codec)
|
||||
=> codec == VideoCodec.H264 ? (long)H264MaxMacroblocks * 256 : HevcMaxLumaSamples;
|
||||
|
||||
public static bool IsWithinLimits(VideoCodec codec, int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) return false;
|
||||
if (width > MaxDimension || height > MaxDimension) return false;
|
||||
|
||||
return codec == VideoCodec.H264
|
||||
? (long)CeilBlocks(width) * CeilBlocks(height) <= H264MaxMacroblocks
|
||||
: (long)width * height <= HevcMaxLumaSamples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Riduce la risoluzione al massimo riproducibile conservando le proporzioni.
|
||||
/// Restituisce le dimensioni invariate se sono già ammissibili.
|
||||
/// </summary>
|
||||
public static (int Width, int Height) Clamp(VideoCodec codec, int width, int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0) return (width, height);
|
||||
if (IsWithinLimits(codec, width, height)) return (Even(width), Even(height));
|
||||
|
||||
double aspect = height / (double)width;
|
||||
double scale = Math.Sqrt(MaxPixels(codec) / ((double)width * height));
|
||||
|
||||
int candidate = (int)(width * Math.Min(1.0, scale));
|
||||
candidate = Math.Min(candidate, MaxDimension);
|
||||
if ((int)(candidate * aspect) > MaxDimension) candidate = (int)(MaxDimension / aspect);
|
||||
|
||||
// Si scende a passi di un macroblocco: il primo valore ammissibile è anche il migliore.
|
||||
for (int w = candidate - candidate % 16; w >= 16; w -= 16)
|
||||
{
|
||||
int h = Even((int)Math.Round(w * aspect));
|
||||
if (h <= 0) continue;
|
||||
if (IsWithinLimits(codec, w, h)) return (w, h);
|
||||
}
|
||||
|
||||
return (Even(Math.Min(width, MaxDimension)), Even(Math.Min(height, MaxDimension)));
|
||||
}
|
||||
|
||||
private static int CeilBlocks(int size) => (size + 15) / 16;
|
||||
|
||||
private static int Even(int value) => Math.Max(2, value & ~1);
|
||||
}
|
||||
@@ -41,8 +41,13 @@ public sealed class Mp4Muxer : IDisposable
|
||||
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];
|
||||
/// <summary>
|
||||
/// Buffer di lavoro riusato per la conversione Annex-B → lunghezza prefissata.
|
||||
/// Parte piccolo di proposito: così il percorso di crescita viene esercitato da
|
||||
/// qualunque sequenza, verifica sintetica compresa, invece di restare latente
|
||||
/// fino al primo fotogramma chiave di una ripresa ad alta risoluzione.
|
||||
/// </summary>
|
||||
private byte[] _scratch = new byte[64 * 1024];
|
||||
|
||||
public int SampleCount => _sampleSizes.Count;
|
||||
public long BytesWritten { get; private set; }
|
||||
@@ -478,11 +483,21 @@ public sealed class Mp4Muxer : IDisposable
|
||||
target.Add(copy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allarga il buffer di conversione conservandone il contenuto.
|
||||
///
|
||||
/// La copia non è un dettaglio: il buffer accumula le NAL di un campione una dopo l'altra,
|
||||
/// quindi rimpiazzarlo con un array nuovo azzererebbe quelle già scritte. Il campione
|
||||
/// uscirebbe con un prefisso di lunghezza nullo in testa e nessun decodificatore lo
|
||||
/// supererebbe. Il caso si presenta al primo fotogramma chiave, l'unico abbastanza
|
||||
/// grande da superare la capacità iniziale.
|
||||
/// </summary>
|
||||
private void EnsureScratch(int required)
|
||||
{
|
||||
if (_scratch.Length >= required) return;
|
||||
int size = _scratch.Length;
|
||||
|
||||
long size = Math.Max(_scratch.Length, 1);
|
||||
while (size < required) size *= 2;
|
||||
_scratch = new byte[size];
|
||||
Array.Resize(ref _scratch, (int)Math.Min(size, Array.MaxLength));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user