991abe072c
Deploy / deploy (push) Successful in 6m12s
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>
200 lines
7.3 KiB
C#
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);
|
|
}
|
|
}
|