456 lines
20 KiB
C#
456 lines
20 KiB
C#
using FluentAssertions;
|
|
using System;
|
|
using System.IO;
|
|
using System.Threading.Tasks;
|
|
using Vav1Player.Container;
|
|
using Vav1Player.Decoder;
|
|
using Vav1Player.Video;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace Vav1Player.Tests.WebM
|
|
{
|
|
public class WebMFrameDecodingTests : IDisposable
|
|
{
|
|
private readonly ITestOutputHelper _output;
|
|
private readonly string _webmFilePath;
|
|
private Dav1dDecoder? _decoder;
|
|
|
|
public WebMFrameDecodingTests(ITestOutputHelper output)
|
|
{
|
|
_output = output;
|
|
// Navigate from test output directory to project root and then to sample
|
|
var baseDir = AppContext.BaseDirectory;
|
|
var projectRoot = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", ".."));
|
|
_webmFilePath = Path.Combine(projectRoot, "sample", "output.webm");
|
|
|
|
_output.WriteLine($"Base directory: {baseDir}");
|
|
_output.WriteLine($"Project root: {projectRoot}");
|
|
_output.WriteLine($"WebM file path: {_webmFilePath}");
|
|
}
|
|
|
|
[Fact]
|
|
public void WebMFile_ShouldExist()
|
|
{
|
|
// Arrange & Act & Assert
|
|
File.Exists(_webmFilePath).Should().BeTrue($"WebM test file should exist at: {_webmFilePath}");
|
|
_output.WriteLine($"WebM file path: {_webmFilePath}");
|
|
_output.WriteLine($"File size: {new FileInfo(_webmFilePath).Length} bytes");
|
|
}
|
|
|
|
[Fact]
|
|
public void WebMParser_ShouldParseHeader_Successfully()
|
|
{
|
|
// Arrange
|
|
File.Exists(_webmFilePath).Should().BeTrue();
|
|
|
|
// Act
|
|
VideoFileReader? reader = null;
|
|
try
|
|
{
|
|
reader = new VideoFileReader(_webmFilePath);
|
|
var trackInfo = reader.TrackInfo;
|
|
|
|
// Assert
|
|
trackInfo.Should().NotBeNull("WebM file should contain a valid AV1 track");
|
|
trackInfo!.CodecType.Should().Be("V_AV1", "Track should be identified as AV1 codec");
|
|
trackInfo.Width.Should().BeGreaterThan(0, "Video width should be positive");
|
|
trackInfo.Height.Should().BeGreaterThan(0, "Video height should be positive");
|
|
trackInfo.EstimatedFrameRate.Should().BeGreaterThan(0, "Frame rate should be positive");
|
|
|
|
_output.WriteLine($"Track Info:");
|
|
_output.WriteLine($" Codec: {trackInfo.CodecType}");
|
|
_output.WriteLine($" Resolution: {trackInfo.Width}x{trackInfo.Height}");
|
|
_output.WriteLine($" Duration: {trackInfo.Duration:F2}s");
|
|
_output.WriteLine($" Frame Rate: {trackInfo.EstimatedFrameRate:F2} FPS");
|
|
_output.WriteLine($" Total Samples: {reader.TotalSamples}");
|
|
}
|
|
finally
|
|
{
|
|
reader?.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WebMDecoder_ShouldDecodeAllFrames_Successfully()
|
|
{
|
|
// Arrange
|
|
File.Exists(_webmFilePath).Should().BeTrue();
|
|
|
|
_decoder = new Dav1dDecoder();
|
|
_decoder.Initialize().Should().BeTrue("Decoder should initialize successfully");
|
|
|
|
VideoFileReader? reader = null;
|
|
int totalFrames = 0;
|
|
int decodedFrames = 0;
|
|
int failedFrames = 0;
|
|
|
|
try
|
|
{
|
|
reader = new VideoFileReader(_webmFilePath);
|
|
var trackInfo = reader.TrackInfo;
|
|
trackInfo.Should().NotBeNull();
|
|
|
|
_output.WriteLine($"Starting frame-by-frame decoding test...");
|
|
_output.WriteLine($"Expected total samples: {reader.TotalSamples}");
|
|
|
|
// Initialize decoder with sequence header from CodecPrivate if available
|
|
byte[]? sequenceOBU = null;
|
|
|
|
if (trackInfo!.Av1ConfigurationRecord != null)
|
|
{
|
|
_output.WriteLine($"Found CodecPrivate data: {trackInfo.Av1ConfigurationRecord.Length} bytes");
|
|
var hexData = string.Join(" ", trackInfo.Av1ConfigurationRecord.Take(Math.Min(32, trackInfo.Av1ConfigurationRecord.Length)).Select(b => b.ToString("X2")));
|
|
_output.WriteLine($"CodecPrivate data: {hexData}");
|
|
|
|
// Look for sequence header OBU (type 1) in CodecPrivate
|
|
// Analysis shows CodecPrivate: `81 0C 0C 00 0A 0F 00 00 00 62 EF BF E1 BD DA F8`
|
|
// Frame 1 starts with: `0A 0F 00 00 00 62 EF BF E1 BD DA F8`
|
|
// So the actual sequence header is at offset 4!
|
|
for (int i = 0; i < Math.Min(10, trackInfo.Av1ConfigurationRecord.Length); i++)
|
|
{
|
|
byte currentByte = trackInfo.Av1ConfigurationRecord[i];
|
|
int obuType = (currentByte >> 3) & 0xF;
|
|
_output.WriteLine($"Offset {i}: byte=0x{currentByte:X2}, OBU type={obuType}");
|
|
}
|
|
|
|
// Based on analysis, try extracting from offset 4
|
|
if (trackInfo.Av1ConfigurationRecord.Length > 4)
|
|
{
|
|
byte offset4Byte = trackInfo.Av1ConfigurationRecord[4];
|
|
int offset4OBUType = (offset4Byte >> 3) & 0xF;
|
|
_output.WriteLine($"Offset 4: byte=0x{offset4Byte:X2}, OBU type={offset4OBUType}");
|
|
|
|
if (offset4OBUType == 1) // Sequence Header OBU at offset 4
|
|
{
|
|
_output.WriteLine($"Found sequence header OBU at offset 4, reconstructing with correct size field");
|
|
|
|
// Original OBU: 0A 0F 00 00 00 62 EF BF E1 BD DA F8 10 10 10 10 40
|
|
// Header: 0A (type 1, has_size_field=1)
|
|
// Size: 0F (15) - INCORRECT, should be 14
|
|
// Payload: 00 00 00 62 EF BF E1 BD DA F8 10 10 10 10 (14 bytes)
|
|
|
|
byte headerByte = trackInfo.Av1ConfigurationRecord[4]; // 0x0A
|
|
int payloadSize = trackInfo.Av1ConfigurationRecord.Length - 4 - 1 - 1; // 21 - 4 - 1 - 1 = 15 bytes, but we need 14
|
|
|
|
// The original has an off-by-one error in the payload
|
|
payloadSize = 14; // Correct payload size
|
|
|
|
_output.WriteLine($"Reconstructing OBU: header=0x{headerByte:X2}, payload_size={payloadSize}");
|
|
|
|
// Reconstruct the OBU with correct size field
|
|
sequenceOBU = new byte[1 + 1 + payloadSize]; // header + size + payload
|
|
sequenceOBU[0] = headerByte; // 0x0A
|
|
sequenceOBU[1] = (byte)payloadSize; // 0x0E (14)
|
|
|
|
// Copy payload (skip original header and size field)
|
|
Array.Copy(trackInfo.Av1ConfigurationRecord, 6, sequenceOBU, 2, payloadSize);
|
|
|
|
var sequenceHex = string.Join(" ", sequenceOBU.Take(Math.Min(16, sequenceOBU.Length)).Select(b => b.ToString("X2")));
|
|
_output.WriteLine($"Reconstructed sequence OBU: {sequenceHex} (length: {sequenceOBU.Length})");
|
|
_output.WriteLine("Will also try using frame 1's sequence header for comparison");
|
|
}
|
|
}
|
|
|
|
if (sequenceOBU != null)
|
|
{
|
|
var sequenceHex = string.Join(" ", sequenceOBU.Take(Math.Min(16, sequenceOBU.Length)).Select(b => b.ToString("X2")));
|
|
_output.WriteLine($"Using sequence OBU for init: {sequenceHex} (length: {sequenceOBU.Length})");
|
|
|
|
_output.WriteLine("About to initialize decoder with sequence OBU...");
|
|
var initResult = _decoder.DecodeFrame(sequenceOBU, out var _);
|
|
_output.WriteLine($"Decoder initialization with extracted sequence OBU: {(initResult ? "SUCCESS" : "FAILED")}");
|
|
|
|
if (!initResult)
|
|
{
|
|
_output.WriteLine("Attempting to get more detailed error information...");
|
|
// Try to decode a small part to get error details
|
|
var testResult = _decoder.DecodeFrame(new byte[] { 0x0A, 0x01, 0x00 }, out var _);
|
|
_output.WriteLine($"Test decode result: {testResult}");
|
|
}
|
|
|
|
if (!initResult)
|
|
{
|
|
_output.WriteLine("CodecPrivate sequence header is invalid, will extract from first frame instead");
|
|
sequenceOBU = null; // Clear invalid sequence header
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_output.WriteLine("No sequence header OBU found in CodecPrivate");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_output.WriteLine("No CodecPrivate data found");
|
|
}
|
|
|
|
// If CodecPrivate sequence header failed, extract from first frame
|
|
if (sequenceOBU == null)
|
|
{
|
|
_output.WriteLine("Will extract sequence header from first keyframe");
|
|
}
|
|
|
|
// Act - Decode each frame one by one
|
|
bool firstFrameUsedForInit = false;
|
|
while (reader.HasMoreData)
|
|
{
|
|
var chunk = await reader.ReadNextChunkAsync();
|
|
if (chunk == null)
|
|
{
|
|
_output.WriteLine($"Received null chunk at sample {totalFrames}");
|
|
break;
|
|
}
|
|
|
|
totalFrames++;
|
|
|
|
_output.WriteLine($"Frame {totalFrames}: Size={chunk.Data.Length} bytes, PTS={chunk.PresentationTimeMs}ms, KeyFrame={chunk.IsKeyFrame}");
|
|
|
|
// Validate chunk data
|
|
chunk.Data.Should().NotBeNull("Frame data should not be null");
|
|
chunk.Data.Length.Should().BeGreaterThan(0, "Frame data should not be empty");
|
|
|
|
// Log first few bytes for debugging
|
|
var hexData = string.Join(" ", chunk.Data.Take(Math.Min(16, chunk.Data.Length)).Select(b => b.ToString("X2")));
|
|
_output.WriteLine($" First bytes: [{hexData}]");
|
|
|
|
// Special handling for first frame - try direct decoding without separate initialization
|
|
if (totalFrames == 1 && chunk.IsKeyFrame && !firstFrameUsedForInit)
|
|
{
|
|
_output.WriteLine(" First keyframe - attempting direct decode (letting dav1d handle initialization internally)");
|
|
|
|
// Try decoding the entire first keyframe directly
|
|
// dav1d should handle sequence header initialization internally
|
|
var frameResult = _decoder.DecodeFrame(chunk.Data, out var decodedFrame);
|
|
if (frameResult && decodedFrame.HasValue)
|
|
{
|
|
decodedFrames++;
|
|
var frame = decodedFrame.Value;
|
|
_output.WriteLine($" ✓ Successfully decoded first keyframe: {frame.Width}x{frame.Height}, Layout={frame.PixelLayout}");
|
|
frame.Release();
|
|
firstFrameUsedForInit = true;
|
|
|
|
// Limit test to prevent excessive output
|
|
if (totalFrames >= 50)
|
|
{
|
|
_output.WriteLine($"Stopping at 50 frames for test performance...");
|
|
break;
|
|
}
|
|
continue; // Skip normal decoding logic for this frame
|
|
}
|
|
else
|
|
{
|
|
_output.WriteLine(" ✗ Failed to decode first keyframe directly");
|
|
_output.WriteLine(" This suggests the WebM data format may not be compatible with dav1d");
|
|
|
|
// Log frame structure for debugging
|
|
LogFrameAnalysis(chunk.Data);
|
|
}
|
|
}
|
|
|
|
// Attempt to decode the frame
|
|
try
|
|
{
|
|
var decodeSuccess = _decoder.DecodeFrame(chunk.Data, out var decodedFrame);
|
|
if (decodeSuccess && decodedFrame.HasValue)
|
|
{
|
|
decodedFrames++;
|
|
var frame = decodedFrame.Value;
|
|
_output.WriteLine($" ✓ Decoded successfully: {frame.Width}x{frame.Height}, Layout={frame.PixelLayout}");
|
|
frame.Release();
|
|
}
|
|
else
|
|
{
|
|
failedFrames++;
|
|
_output.WriteLine($" ✗ Failed to decode frame {totalFrames}");
|
|
|
|
// Try to get more detailed error information
|
|
LogFrameAnalysis(chunk.Data);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failedFrames++;
|
|
_output.WriteLine($" ✗ Exception decoding frame {totalFrames}: {ex.Message}");
|
|
LogFrameAnalysis(chunk.Data);
|
|
}
|
|
|
|
// Limit test to prevent excessive output
|
|
if (totalFrames >= 50)
|
|
{
|
|
_output.WriteLine($"Stopping at 50 frames for test performance...");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Assert
|
|
totalFrames.Should().BeGreaterThan(0, "Should have read at least one frame");
|
|
decodedFrames.Should().BeGreaterThan(0, "Should have successfully decoded at least one frame");
|
|
|
|
double successRate = (double)decodedFrames / totalFrames;
|
|
_output.WriteLine($"\nDecoding Summary:");
|
|
_output.WriteLine($" Total frames processed: {totalFrames}");
|
|
_output.WriteLine($" Successfully decoded: {decodedFrames}");
|
|
_output.WriteLine($" Failed to decode: {failedFrames}");
|
|
_output.WriteLine($" Success rate: {successRate:P1}");
|
|
|
|
// We expect at least 80% success rate for a valid WebM file
|
|
successRate.Should().BeGreaterOrEqualTo(0.8, "Should decode at least 80% of frames successfully");
|
|
}
|
|
finally
|
|
{
|
|
reader?.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WebMParser_ShouldProvideValidTimestamps()
|
|
{
|
|
// Arrange
|
|
File.Exists(_webmFilePath).Should().BeTrue();
|
|
|
|
VideoFileReader? reader = null;
|
|
try
|
|
{
|
|
reader = new VideoFileReader(_webmFilePath);
|
|
var trackInfo = reader.TrackInfo;
|
|
trackInfo.Should().NotBeNull();
|
|
|
|
long previousTimestamp = -1;
|
|
int frameCount = 0;
|
|
var timestamps = new List<long>();
|
|
|
|
// Act
|
|
while (reader.HasMoreData && frameCount < 20) // Test first 20 frames
|
|
{
|
|
var chunk = await reader.ReadNextChunkAsync();
|
|
if (chunk == null) break;
|
|
|
|
frameCount++;
|
|
timestamps.Add(chunk.PresentationTimeMs);
|
|
|
|
_output.WriteLine($"Frame {frameCount}: PTS={chunk.PresentationTimeMs}ms, KeyFrame={chunk.IsKeyFrame}");
|
|
|
|
// Assert timestamps are generally increasing (allowing for some B-frames)
|
|
if (previousTimestamp >= 0)
|
|
{
|
|
// Allow small backwards jumps (B-frames), but not too large
|
|
var timeDiff = chunk.PresentationTimeMs - previousTimestamp;
|
|
timeDiff.Should().BeGreaterThan(-1000, "Timestamp jumps should not be too large backwards");
|
|
}
|
|
|
|
previousTimestamp = chunk.PresentationTimeMs;
|
|
}
|
|
|
|
// Assert
|
|
frameCount.Should().BeGreaterThan(0, "Should read at least some frames");
|
|
timestamps.Should().NotBeEmpty("Should have collected timestamps");
|
|
|
|
// Check that we have reasonable timestamp progression
|
|
if (timestamps.Count > 1)
|
|
{
|
|
var sortedTimestamps = timestamps.OrderBy(t => t).ToList();
|
|
var avgFrameTime = (sortedTimestamps.Last() - sortedTimestamps.First()) / (double)(sortedTimestamps.Count - 1);
|
|
_output.WriteLine($"Average frame time: {avgFrameTime:F2}ms");
|
|
|
|
avgFrameTime.Should().BeInRange(10, 200, "Average frame time should be reasonable (5-100 FPS)");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
reader?.Dispose();
|
|
}
|
|
}
|
|
|
|
private void LogFrameAnalysis(byte[] frameData)
|
|
{
|
|
if (frameData == null || frameData.Length == 0)
|
|
{
|
|
_output.WriteLine(" Frame data is null or empty");
|
|
return;
|
|
}
|
|
|
|
_output.WriteLine($" Frame size: {frameData.Length} bytes");
|
|
|
|
// Check if this looks like raw AV1 data or if it needs processing
|
|
var hexData = string.Join(" ", frameData.Take(Math.Min(32, frameData.Length)).Select(b => b.ToString("X2")));
|
|
_output.WriteLine($" Full header: [{hexData}]");
|
|
|
|
// Check for common AV1 OBU patterns
|
|
if (frameData.Length > 0)
|
|
{
|
|
byte firstByte = frameData[0];
|
|
int obuType = (firstByte >> 3) & 0xF;
|
|
bool extensionFlag = (firstByte & 0x4) != 0;
|
|
bool hasSizeField = (firstByte & 0x2) != 0;
|
|
|
|
_output.WriteLine($" First byte analysis: 0x{firstByte:X2}");
|
|
_output.WriteLine($" OBU Type: {obuType} ({GetObuTypeName(obuType)})");
|
|
_output.WriteLine($" Extension Flag: {extensionFlag}");
|
|
_output.WriteLine($" Has Size Field: {hasSizeField}");
|
|
}
|
|
}
|
|
|
|
private byte[]? ExtractSequenceHeaderFromFrame(byte[] frameData)
|
|
{
|
|
if (frameData == null || frameData.Length < 3) return null;
|
|
|
|
// Parse the first OBU to extract sequence header
|
|
int position = 0;
|
|
byte obuHeader = frameData[position++];
|
|
|
|
int obuType = (obuHeader >> 3) & 0xF;
|
|
bool hasSizeField = (obuHeader & 0x02) != 0;
|
|
|
|
if (obuType != 1) return null; // Not a sequence header
|
|
|
|
if (hasSizeField && position < frameData.Length)
|
|
{
|
|
// Read LEB128 size field
|
|
uint size = 0;
|
|
int shift = 0;
|
|
while (position < frameData.Length && shift < 35)
|
|
{
|
|
byte b = frameData[position++];
|
|
size |= (uint)(b & 0x7F) << shift;
|
|
if ((b & 0x80) == 0) break;
|
|
shift += 7;
|
|
}
|
|
|
|
// Extract the complete sequence header OBU
|
|
int totalOBUSize = position + (int)size;
|
|
if (totalOBUSize <= frameData.Length)
|
|
{
|
|
byte[] sequenceOBU = new byte[totalOBUSize];
|
|
Array.Copy(frameData, 0, sequenceOBU, 0, totalOBUSize);
|
|
return sequenceOBU;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string GetObuTypeName(int obuType)
|
|
{
|
|
return obuType switch
|
|
{
|
|
0 => "Reserved",
|
|
1 => "Sequence Header",
|
|
2 => "Temporal Delimiter",
|
|
3 => "Frame Header",
|
|
4 => "Tile Group",
|
|
5 => "Metadata",
|
|
6 => "Frame",
|
|
7 => "Redundant Frame Header",
|
|
8 => "Tile List",
|
|
15 => "Padding",
|
|
_ => $"Unknown({obuType})"
|
|
};
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_decoder?.Dispose();
|
|
}
|
|
}
|
|
} |