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(); // 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(); } } }