Files
vfxreview/RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs
T
twotalesanimation 991abe072c
Deploy / deploy (push) Successful in 6m12s
fix(validation): anchor the timecode check on a delivered plate
UNG_110_030_010 v002 failed with a 461614-frame offset on a render whose
timecode was correct. Two causes, both in how the plate reference was
recorded.

The comp held three same-length elements and the timing plate was picked
by duration alone, so a PRERENDER won on layer order. Using a pre-render
as the timecode reference is circular anyway: it was rendered from this
comp, so it can only ever agree with it — including when both are wrong.
Only footage under the Footage Root is now eligible; among those, longest
still wins.

The offset itself came from the plate's frameStart. AE writes renders as
"..._PRERENDER_01001.exr" while delivered plates use "....01001.exr", and
the parser only handled the dot form, so the number fell back to the
comp's frame range — 462615 against a render numbered from 1001. Both
separators now parse, and an unparseable name omits the field rather than
filling it with a number from the wrong coordinate system.

Verified against the real manifest: the BG01 plate is now chosen over
both pre-renders, and all three filenames parse to 1001.

Panel 2.2.1, worker 1.2.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 08:56:18 +02:00

200 lines
7.3 KiB
C#

using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// 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.
/// </summary>
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<InvalidDataException>(() => 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);
}
}
/// <summary>Builds a minimal but structurally real EXR header on disk.</summary>
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);
}
}