using VFXReviewWorker; using Xunit; namespace VFXReviewWorker.Tests; /// /// The timecode check is the reason the validation engine exists (§8.1a), so /// the header parse and the timecode arithmetic are pinned here against a /// synthetic EXR built byte by byte. /// public class ExrHeaderTests { [Fact] public void ReadsTimecodeResolutionChannelsAndFps() { var path = ExrFixture.Write(timecode: "18:58:57:12", width: 1920, height: 1080, channels: new[] { ("A", 1), ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 4); try { var header = ExrHeader.Read(path); Assert.Equal(new Timecode(18, 58, 57, 12, false), header.Timecode); Assert.Equal(1920, header.DataWidth); Assert.Equal(1080, header.DataHeight); Assert.Equal(24, header.FramesPerSecond); Assert.Equal("half", header.BitDepth); Assert.True(header.HasAlpha); Assert.Equal("PIZ", header.CompressionName); } finally { File.Delete(path); } } [Fact] public void MissingTimecodeIsReportedAsAbsentNotAsZero() { var path = ExrFixture.Write(timecode: null, width: 1920, height: 1080, channels: new[] { ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 3); try { var header = ExrHeader.Read(path); Assert.Null(header.Timecode); Assert.False(header.HasAlpha); } finally { File.Delete(path); } } [Fact] public void NonExrFileThrows() { var path = Path.GetTempFileName(); try { File.WriteAllText(path, "this is not an exr at all, not even close"); Assert.Throws(() => ExrHeader.Read(path)); Assert.Null(ExrHeader.TryRead(path)); } finally { File.Delete(path); } } [Theory] // The BCD packing means the raw value reads back as its own hex digits. [InlineData(0x18585712u, 18, 58, 57, 12)] [InlineData(0x00004117u, 0, 0, 41, 17)] [InlineData(0x00000000u, 0, 0, 0, 0)] public void DecodesPackedSmpteTimecode(uint packed, int hh, int mm, int ss, int ff) { Assert.Equal(new Timecode(hh, mm, ss, ff, false), Timecode.FromPacked(packed)); } [Fact] public void FrameArithmeticRoundTrips() { var tc = new Timecode(18, 58, 57, 12, false); Assert.Equal(tc, Timecode.FromFrames(tc.ToFrames(24), 24)); // 133 frames on from 18:58:57:12 at 24 fps is 18:59:03:01 — the real // first/last frame pair measured on UNG_102_019_010 v005. Assert.Equal(new Timecode(18, 59, 3, 1, false), Timecode.FromFrames(tc.ToFrames(24) + 133, 24)); } [Fact] public void ApiTimecodeIsOneHandleAheadOfThePlate() { // The defect class from §8.1a: the API stores the editorial in-point and // the plate starts 8 frames earlier, so a comp anchored to the API value // renders 8 frames late. var plate = Timecode.Parse("13:44:06:02")!.Value; var api = Timecode.Parse("13:44:06:10")!.Value; Assert.Equal(8, api.ToFrames(24) - plate.ToFrames(24)); } [Fact] public void FrameDerivedTimecodeIsFrameNumberOverFps() { // 00:00:41:17 is frame 1001 at 24 fps — the signature of a comp built // with no source timecode at all. Assert.Equal(1001, Timecode.Parse("00:00:41:17")!.Value.ToFrames(24)); } [Theory] [InlineData("UNG_102_019_010_cmp_TT_v005.01001.exr", 1001)] [InlineData("UNG_108_030_010_cmp_TT_v003.01204.exr", 1204)] // AE-written renders separate the frame with an underscore, and turn up as // layers in a shot comp alongside dot-separated delivered plates. [InlineData("UNG_110_030_010_PRERENDER_01001.exr", 1001)] [InlineData("UNG_110_030_010_render_1001.exr", 1001)] [InlineData("no_frame_number.exr", null)] // A version suffix is not a frame number. [InlineData("UNG_110_030_010_BG01_TT_v001.exr", null)] public void ParsesFrameNumbersFromFilenames(string name, int? expected) { Assert.Equal(expected, SequencePattern.FrameFromName(name)); } [Fact] public void ParsesOutputPattern() { var p = SequencePattern.FromOutputPattern("UNG_102_019_010_cmp_TT_v005.[#####].exr"); Assert.Equal("UNG_102_019_010_cmp_TT_v005", p.Prefix); Assert.Equal(".exr", p.Extension); Assert.Equal(5, p.Padding); } } /// Builds a minimal but structurally real EXR header on disk. internal static class ExrFixture { public static string Write(string? timecode, int width, int height, (string Name, int PixelType)[] channels, int fpsNum, byte compression) { var path = Path.Combine(Path.GetTempPath(), $"vfxr_test_{Guid.NewGuid():N}.exr"); using var fs = new FileStream(path, FileMode.Create, FileAccess.Write); using var w = new BinaryWriter(fs); w.Write(ExrHeader.Magic); w.Write(2); // version 2, no flags // channels (chlist) using (var chData = new MemoryStream()) using (var cw = new BinaryWriter(chData)) { foreach (var (name, pixelType) in channels) { WriteCString(cw, name); cw.Write(pixelType); cw.Write((byte)0); // pLinear cw.Write(new byte[3]); // reserved cw.Write(1); // xSampling cw.Write(1); // ySampling } cw.Write((byte)0); // list terminator WriteAttr(w, "channels", "chlist", chData.ToArray()); } WriteAttr(w, "compression", "compression", new[] { compression }); var window = new byte[16]; BitConverter.GetBytes(0).CopyTo(window, 0); BitConverter.GetBytes(0).CopyTo(window, 4); BitConverter.GetBytes(width - 1).CopyTo(window, 8); BitConverter.GetBytes(height - 1).CopyTo(window, 12); WriteAttr(w, "dataWindow", "box2i", window); WriteAttr(w, "displayWindow", "box2i", window); var rational = new byte[8]; BitConverter.GetBytes(fpsNum).CopyTo(rational, 0); BitConverter.GetBytes(1u).CopyTo(rational, 4); WriteAttr(w, "framesPerSecond", "rational", rational); if (timecode is not null) { var parts = timecode.Split(':'); uint Bcd(int v) => (uint)((v / 10) * 16 + v % 10); var packed = (Bcd(int.Parse(parts[0])) << 24) | (Bcd(int.Parse(parts[1])) << 16) | (Bcd(int.Parse(parts[2])) << 8) | Bcd(int.Parse(parts[3])); var tcData = new byte[8]; BitConverter.GetBytes(packed).CopyTo(tcData, 0); WriteAttr(w, "timeCode", "timecode", tcData); WriteAttr(w, "timeCodeString", "string", System.Text.Encoding.ASCII.GetBytes(timecode)); } w.Write((byte)0); // end of header return path; } private static void WriteAttr(BinaryWriter w, string name, string type, byte[] data) { WriteCString(w, name); WriteCString(w, type); w.Write(data.Length); w.Write(data); } private static void WriteCString(BinaryWriter w, string s) { w.Write(System.Text.Encoding.ASCII.GetBytes(s)); w.Write((byte)0); } }