diff --git a/Titano/Diagnostics/Mp4Inspector.cs b/Titano/Diagnostics/Mp4Inspector.cs index e922e37..a872832 100644 --- a/Titano/Diagnostics/Mp4Inspector.cs +++ b/Titano/Diagnostics/Mp4Inspector.cs @@ -19,7 +19,8 @@ internal static class Mp4Inspector uint Timescale, long MediaDuration, int CodecConfigBytes, - List TopLevelBoxes); + List 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(); + var sampleSizes = new List(); + 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 buffer = stackalloc byte[8]; + return file.Read(buffer) == 8 ? BinaryPrimitives.ReadUInt64BigEndian(buffer) : 0; + } + static uint ReadUInt32(FileStream file) { Span buffer = stackalloc byte[4]; @@ -130,4 +153,39 @@ internal static class Mp4Inspector return file.Read(buffer) == 2 ? BinaryPrimitives.ReadUInt16BigEndian(buffer) : (ushort)0; } } + + /// + /// 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. + /// + private static string? VerifySampleData(FileStream stream, List sizes, long firstChunkOffset) + { + if (sizes.Count == 0 || firstChunkOffset < 0) return null; + + long position = firstChunkOffset; + Span 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; + } } diff --git a/Titano/Diagnostics/Mp4Playback.cs b/Titano/Diagnostics/Mp4Playback.cs index b3c61bb..72240f1 100644 --- a/Titano/Diagnostics/Mp4Playback.cs +++ b/Titano/Diagnostics/Mp4Playback.cs @@ -100,14 +100,17 @@ internal static class Mp4Playback var luma = new List(); 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) { diff --git a/Titano/Diagnostics/SelfTest.cs b/Titano/Diagnostics/SelfTest.cs index 0da3a8f..0561577 100644 --- a/Titano/Diagnostics/SelfTest.cs +++ b/Titano/Diagnostics/SelfTest.cs @@ -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)[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", diff --git a/Titano/Diagnostics/SequenceDiagnostics.cs b/Titano/Diagnostics/SequenceDiagnostics.cs new file mode 100644 index 0000000..429da53 --- /dev/null +++ b/Titano/Diagnostics/SequenceDiagnostics.cs @@ -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; + +/// +/// 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. +/// +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(); + + 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)[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; + } + + /// + /// Esporta i primi fotogrammi della sequenza reale: è l'unica prova che dice davvero + /// se decodifica, analisi, sfocatura, encoder e contenitore reggono questi file. + /// + private static void RunTrialRender(List 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 PickSamples(List files, int count) + { + if (files.Count <= count) return files; + var picked = new List(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"; +} diff --git a/Titano/Imaging/ImageDecoder.cs b/Titano/Imaging/ImageDecoder.cs index 852ccad..69f7f5d 100644 --- a/Titano/Imaging/ImageDecoder.cs +++ b/Titano/Imaging/ImageDecoder.cs @@ -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) diff --git a/Titano/Metadata/MetadataReader.cs b/Titano/Metadata/MetadataReader.cs index 9108df0..f667bbc 100644 --- a/Titano/Metadata/MetadataReader.cs +++ b/Titano/Metadata/MetadataReader.cs @@ -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); diff --git a/Titano/Metadata/TiffDirectory.cs b/Titano/Metadata/TiffDirectory.cs index cb7e77e..47ae819 100644 --- a/Titano/Metadata/TiffDirectory.cs +++ b/Titano/Metadata/TiffDirectory.cs @@ -12,12 +12,25 @@ internal sealed class TiffBlock public Dictionary Gps { get; } = []; public List> SubIfds { get; } = []; + /// Tutte le directory che descrivono un'immagine: IFD0, la catena e le SubIFD. + private readonly List> _imageDirectories = []; + private TiffBlock(ByteView view) => View = view; - /// Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory. + /// Blocco TIFF già interamente in memoria (segmento APP1 di un JPEG, chunk PNG). public static TiffBlock? Parse(byte[] data, int origin, int length) + => Parse(new ByteView(data, origin, length, false)); + + /// + /// 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. + /// + public static TiffBlock? ParseStream(Stream stream, long total, int initialRead = 128 * 1024) + => Parse(new ByteView(new ByteWindow(stream, total, initialRead), 0, false)); + + /// Riconosce l'header TIFF ("II*\0" oppure "MM\0*") e percorre le directory. + 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 } } + /// + /// 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. + /// + 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 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; + } + /// Primo valore non nullo di una lista di tag (utile per ISO, presente in più varianti). public bool TryGetFirstUInt32(out uint value, params ushort[] tags) { @@ -264,6 +329,7 @@ internal sealed class TiffBlock /// Numerazione dei tag TIFF/Exif effettivamente utilizzati dal motore. internal static class TiffTags { + public const ushort NewSubfileType = 0x00FE; public const ushort ImageWidth = 0x0100; public const ushort ImageLength = 0x0101; public const ushort Make = 0x010F; diff --git a/Titano/Metadata/TiffPrimitives.cs b/Titano/Metadata/TiffPrimitives.cs index 58e5fd8..ee6638a 100644 --- a/Titano/Metadata/TiffPrimitives.cs +++ b/Titano/Metadata/TiffPrimitives.cs @@ -50,35 +50,119 @@ internal readonly struct TiffEntry } /// -/// Lettore binario endian-aware su uno 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. +/// +internal sealed class ByteWindow +{ + /// Granularità delle letture: evita una chiamata di I/O per ogni tag. + private const int ChunkSize = 64 * 1024; + + private readonly Stream? _stream; + private readonly long _total; + private byte[] _buffer; + private int _loaded; + + /// Sorgente già interamente in memoria (segmento APP1 di un JPEG, chunk PNG). + public ByteWindow(byte[] data, int length) + { + _buffer = data; + _loaded = Math.Clamp(length, 0, data.Length); + _total = _loaded; + } + + /// Sorgente su file: si carica subito solo la testa. + 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; + + /// Byte effettivamente presenti in memoria. + public int Loaded => _loaded; + + /// Byte complessivamente disponibili nella sorgente. + public long Total => _total; + + /// Garantisce che i primi byte siano caricati. + 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; + } +} + +/// +/// Lettore binario endian-aware su una , con origine propria. /// Tutti gli accessi sono limitati: un file malformato produce valori assenti, mai eccezioni. /// 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); + + /// Byte teoricamente raggiungibili da questa origine. + public long Length => Math.Max(0, _window.Total - _origin); + + /// Byte già caricati a partire da questa origine: limite delle ricerche per scansione. + 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); } - /// Ricerca di un pattern di byte; -1 se assente. Usata per i fallback di scansione. + /// + /// 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. + /// public int IndexOf(ReadOnlySpan pattern, int startAt = 0) { - if (pattern.Length == 0 || pattern.Length > _length) return -1; - var haystack = new ReadOnlySpan(_data, _origin, _length); - int idx = haystack[Math.Clamp(startAt, 0, _length)..].IndexOf(pattern); - return idx < 0 ? -1 : idx + Math.Clamp(startAt, 0, _length); + int available = Available; + if (pattern.Length == 0 || pattern.Length > available) return -1; + + int from = Math.Clamp(startAt, 0, available); + var haystack = new ReadOnlySpan(_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; } } diff --git a/Titano/Pipeline/RenderPipeline.cs b/Titano/Pipeline/RenderPipeline.cs index 6c36365..f989f1d 100644 --- a/Titano/Pipeline/RenderPipeline.cs +++ b/Titano/Pipeline/RenderPipeline.cs @@ -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; diff --git a/Titano/Pipeline/TitanoProject.cs b/Titano/Pipeline/TitanoProject.cs index c9f972d..bcc3f64 100644 --- a/Titano/Pipeline/TitanoProject.cs +++ b/Titano/Pipeline/TitanoProject.cs @@ -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); + } + + /// + /// Risoluzione che si otterrebbe senza il vincolo di conformità del codec. + /// Serve solo a segnalare all'utente che è stata applicata una riduzione. + /// + 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)); } } diff --git a/Titano/Program.cs b/Titano/Program.cs index 128d88e..2774098 100644 --- a/Titano/Program.cs +++ b/Titano/Program.cs @@ -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(); diff --git a/Titano/README.md b/Titano/README.md index 2e9cd0c..a952f32 100644 --- a/Titano/README.md +++ b/Titano/README.md @@ -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 [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 [cartella-sequenza] [scheda] diff --git a/Titano/UI/MainForm.cs b/Titano/UI/MainForm.cs index 865715d..96440bf 100644 --- a/Titano/UI/MainForm.cs +++ b/Titano/UI/MainForm.cs @@ -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); } + /// Avverte quando la conformità del codec impone una risoluzione inferiore alla sorgente. + 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; diff --git a/Titano/Video/CodecLimits.cs b/Titano/Video/CodecLimits.cs new file mode 100644 index 0000000..b4e591a --- /dev/null +++ b/Titano/Video/CodecLimits.cs @@ -0,0 +1,70 @@ +namespace Titano.Video; + +/// +/// 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. +/// +public static class CodecLimits +{ + /// Macroblocchi 16×16 per fotogramma ammessi dal Livello 5.2 di H.264. + private const int H264MaxMacroblocks = 36_864; + + /// Campioni di luminanza per fotogramma ammessi dal Livello 5.1 di HEVC. + private const int HevcMaxLumaSamples = 8_912_896; + + /// Lato massimo accettato dai decodificatori hardware diffusi. + private const int MaxDimension = 4096; + + /// Numero massimo di pixel per fotogramma con il codec indicato. + 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; + } + + /// + /// Riduce la risoluzione al massimo riproducibile conservando le proporzioni. + /// Restituisce le dimensioni invariate se sono già ammissibili. + /// + 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); +} diff --git a/Titano/Video/Mp4Muxer.cs b/Titano/Video/Mp4Muxer.cs index d69f58c..18e0c8c 100644 --- a/Titano/Video/Mp4Muxer.cs +++ b/Titano/Video/Mp4Muxer.cs @@ -41,8 +41,13 @@ public sealed class Mp4Muxer : IDisposable private long _mediaDuration; private bool _finished; - /// Buffer di lavoro riusato per la conversione Annex-B → lunghezza prefissata. - private byte[] _scratch = new byte[1 << 20]; + /// + /// 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. + /// + 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); } + /// + /// 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. + /// 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)); } }