using System.Text; namespace VFXReviewWorker; /// /// Minimal OpenEXR header reader (RenderPipeline2 §8.1a). /// /// Everything validation needs — timecode, fps, resolution, channel types, /// compression — lives in the header, so a check costs one 64 KB read and no /// pixel decode. That is why the engine needs no oiiotool/OpenImageIO /// dependency and can run on a bare workstation. /// /// Header layout: magic(4) version(4) then attributes as /// `name\0 type\0 int32 size data`, terminated by an empty name. /// public sealed class ExrHeader { public const uint Magic = 20000630; private const int MaxHeaderBytes = 1 << 18; // 256 KB — far beyond any sane header public int DataWidth { get; private init; } public int DataHeight { get; private init; } public int DisplayWidth { get; private init; } public int DisplayHeight { get; private init; } public IReadOnlyList Channels { get; private init; } = Array.Empty(); public byte? CompressionCode { get; private init; } public Timecode? Timecode { get; private init; } public string? TimecodeString { get; private init; } public double? FramesPerSecond { get; private init; } public bool HasChromaticities { get; private init; } public bool IsTiled { get; private init; } public IReadOnlyList AttributeNames { get; private init; } = Array.Empty(); public bool HasAlpha => Channels.Any(c => c.Name.Equals("A", StringComparison.Ordinal)); /// half / float / uint — the deepest channel type present. public string? BitDepth { get { if (Channels.Count == 0) return null; if (Channels.Any(c => c.PixelType == 2)) return "float"; if (Channels.Any(c => c.PixelType == 1)) return "half"; return "uint"; } } public string CompressionName => CompressionCode switch { 0 => "none", 1 => "RLE", 2 => "ZIPS", 3 => "ZIP", 4 => "PIZ", 5 => "PXR24", 6 => "B44", 7 => "B44A", 8 => "DWAA", 9 => "DWAB", null => "unknown", _ => $"code {CompressionCode}", }; /// Reads the header, or throws when the file is not a readable EXR. public static ExrHeader Read(string path) { using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var buf = new byte[Math.Min(MaxHeaderBytes, Math.Max(1024, (int)Math.Min(fs.Length, MaxHeaderBytes)))]; var read = fs.Read(buf, 0, buf.Length); if (read < 16) throw new InvalidDataException("File is too small to be an EXR"); var span = new ReadOnlySpan(buf, 0, read); if (BitConverter.ToUInt32(span[..4]) != Magic) throw new InvalidDataException("Not an EXR (bad magic number)"); var version = BitConverter.ToInt32(span.Slice(4, 4)); int pos = 8; var names = new List(); var channels = new List(); int dataW = 0, dataH = 0, dispW = 0, dispH = 0; byte? compression = null; Timecode? tc = null; string? tcString = null; double? fps = null; bool chroma = false; while (pos < read) { var name = ReadCString(span, ref pos); if (name.Length == 0) break; // end of header var type = ReadCString(span, ref pos); if (pos + 4 > read) throw new InvalidDataException("Truncated EXR header (attribute size)"); var size = BitConverter.ToInt32(span.Slice(pos, 4)); pos += 4; if (size < 0 || pos + size > read) throw new InvalidDataException($"Truncated EXR header (attribute \"{name}\")"); var data = span.Slice(pos, size); names.Add(name); switch (name) { case "dataWindow" when size >= 16: dataW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1; dataH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1; break; case "displayWindow" when size >= 16: dispW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1; dispH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1; break; case "compression" when size >= 1: compression = data[0]; break; case "timeCode" when size >= 8: tc = VFXReviewWorker.Timecode.FromPacked( BitConverter.ToUInt32(data[..4]), BitConverter.ToUInt32(data.Slice(4, 4))); break; case "timeCodeString": tcString = Encoding.ASCII.GetString(data).TrimEnd('\0'); break; case "framesPerSecond" when size >= 8: { var num = BitConverter.ToInt32(data[..4]); var den = BitConverter.ToUInt32(data.Slice(4, 4)); if (den != 0) fps = num / (double)den; break; } case "chromaticities": chroma = true; break; case "channels": channels.AddRange(ReadChannels(data)); break; } pos += size; } return new ExrHeader { DataWidth = dataW, DataHeight = dataH, DisplayWidth = dispW, DisplayHeight = dispH, Channels = channels, CompressionCode = compression, Timecode = tc, TimecodeString = tcString, FramesPerSecond = fps, HasChromaticities = chroma, IsTiled = (version & 0x200) != 0, AttributeNames = names, }; } /// Header read that returns null instead of throwing — for "is this file readable at all" checks. public static ExrHeader? TryRead(string path) { try { return Read(path); } catch { return null; } } private static IEnumerable ReadChannels(ReadOnlySpan data) { var result = new List(); int p = 0; while (p < data.Length) { var name = ReadCString(data, ref p); if (name.Length == 0) break; // null terminator ends the channel list if (p + 16 > data.Length) break; var pixelType = BitConverter.ToInt32(data.Slice(p, 4)); p += 16; // pixelType(4) + pLinear(1) + reserved(3) + xSampling(4) + ySampling(4) result.Add(new ExrChannel(name, pixelType)); } return result; } private static string ReadCString(ReadOnlySpan span, ref int pos) { int start = pos; while (pos < span.Length && span[pos] != 0) pos++; var s = Encoding.ASCII.GetString(span[start..pos]); pos++; // skip the terminator return s; } } public readonly record struct ExrChannel(string Name, int PixelType) { public string TypeName => PixelType switch { 0 => "uint", 1 => "half", 2 => "float", _ => $"type {PixelType}" }; } /// /// SMPTE 12M timecode as stored in the EXR `timeCode` attribute: BCD-packed, /// which is why the raw value reads back as its own hex digits /// (0x18585712 → 18:58:57:12). /// public readonly record struct Timecode(int Hours, int Minutes, int Seconds, int Frames, bool DropFrame) { public static Timecode FromPacked(uint timeAndFlags, uint _userData = 0) { int Bits(int shift, int mask) => (int)((timeAndFlags >> shift) & (uint)mask); return new Timecode( Bits(24, 0xF) + Bits(28, 0x3) * 10, Bits(16, 0xF) + Bits(20, 0x7) * 10, Bits(8, 0xF) + Bits(12, 0x7) * 10, Bits(0, 0xF) + Bits(4, 0x3) * 10, Bits(6, 0x1) == 1); } public static Timecode? Parse(string? text, bool dropFrame = false) { if (string.IsNullOrWhiteSpace(text)) return null; var parts = text.Trim().Split(':', ';'); if (parts.Length < 4) return null; if (!int.TryParse(parts[0], out var hh) || !int.TryParse(parts[1], out var mm) || !int.TryParse(parts[2], out var ss) || !int.TryParse(parts[3], out var ff)) return null; return new Timecode(hh, mm, ss, ff, dropFrame || text.Contains(';')); } /// /// Non-drop frame count. The studio shoots 24 fps, so drop-frame never /// applies; a drop-frame flag mismatch is reported as its own check rather /// than silently changing this arithmetic. /// public long ToFrames(double fps) { var rate = (long)Math.Round(fps <= 0 ? 24 : fps); return ((Hours * 60L + Minutes) * 60L + Seconds) * rate + Frames; } public static Timecode FromFrames(long frames, double fps) { var rate = (long)Math.Round(fps <= 0 ? 24 : fps); if (rate <= 0) rate = 24; var f = Math.Max(0, frames); var ff = (int)(f % rate); var totalSeconds = f / rate; return new Timecode((int)(totalSeconds / 3600), (int)(totalSeconds / 60 % 60), (int)(totalSeconds % 60), ff, false); } public override string ToString() => $"{Hours:D2}:{Minutes:D2}:{Seconds:D2}{(DropFrame ? ';' : ':')}{Frames:D2}"; }