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}";
}