522 lines
20 KiB
C#
522 lines
20 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Vav1Player.Container;
|
|
|
|
namespace Vav1Player.Video
|
|
{
|
|
/// <summary>
|
|
/// Streaming video file reader that loads data on-demand
|
|
/// </summary>
|
|
public class VideoFileReader : IDisposable
|
|
{
|
|
private readonly string _filePath;
|
|
private readonly FileStream _fileStream;
|
|
private readonly VideoTrackInfo? _trackInfo;
|
|
private readonly IVideoContainerParser _parser;
|
|
private volatile bool _disposed = false;
|
|
private long _currentSampleIndex = 0;
|
|
|
|
public string FilePath => _filePath;
|
|
public VideoTrackInfo? TrackInfo => _trackInfo;
|
|
public long TotalSamples => _trackInfo?.Samples?.Count ?? 0;
|
|
public long CurrentSampleIndex => _currentSampleIndex;
|
|
public bool HasMoreData => _currentSampleIndex < TotalSamples;
|
|
public double EstimatedFrameRate => _trackInfo?.EstimatedFrameRate ?? 30.0;
|
|
|
|
public VideoFileReader(string filePath)
|
|
{
|
|
_filePath = filePath;
|
|
_fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
|
bufferSize: 64 * 1024, useAsync: true);
|
|
|
|
// Determine parser based on file extension
|
|
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
|
_parser = extension switch
|
|
{
|
|
".mp4" => new StreamingMp4Parser(_fileStream),
|
|
".webm" or ".mkv" => new StreamingMatroskaParser(_fileStream),
|
|
_ => throw new NotSupportedException($"Unsupported video format: {extension}")
|
|
};
|
|
|
|
// Parse header to get track information
|
|
_trackInfo = _parser.ParseHeader();
|
|
if (_trackInfo == null)
|
|
{
|
|
throw new InvalidDataException("No AV1 video track found in file");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read the next chunk of video data
|
|
/// </summary>
|
|
public async Task<VideoDataChunk?> ReadNextChunkAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine("[VideoFileReader] Disposed, returning null");
|
|
return null;
|
|
}
|
|
|
|
if (!HasMoreData)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[VideoFileReader] No more data: {_currentSampleIndex}/{TotalSamples}");
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[VideoFileReader] Reading chunk {_currentSampleIndex}/{TotalSamples}");
|
|
var chunk = await _parser.ReadNextChunkAsync(_currentSampleIndex, cancellationToken);
|
|
if (chunk != null)
|
|
{
|
|
_currentSampleIndex++;
|
|
System.Diagnostics.Debug.WriteLine($"[VideoFileReader] Successfully read chunk: {chunk}");
|
|
}
|
|
else
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[VideoFileReader] Parser returned null chunk for index {_currentSampleIndex}");
|
|
}
|
|
return chunk;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[VideoFileReader] Error reading chunk: {ex.Message}");
|
|
System.Diagnostics.Debug.WriteLine($"[VideoFileReader] Stack trace: {ex.StackTrace}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seek to a specific time position
|
|
/// </summary>
|
|
public async Task<bool> SeekToTimeAsync(TimeSpan time, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_disposed || _trackInfo == null)
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var targetSampleIndex = await _parser.SeekToTimeAsync(time, cancellationToken);
|
|
if (targetSampleIndex >= 0)
|
|
{
|
|
_currentSampleIndex = targetSampleIndex;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[VideoFileReader] Error seeking: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset to beginning of file
|
|
/// </summary>
|
|
public void Reset()
|
|
{
|
|
_currentSampleIndex = 0;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
_disposed = true;
|
|
_parser?.Dispose();
|
|
_fileStream?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a chunk of video data with timing information
|
|
/// </summary>
|
|
public class VideoDataChunk
|
|
{
|
|
public byte[] Data { get; }
|
|
public long PresentationTimeMs { get; }
|
|
public bool IsKeyFrame { get; }
|
|
public long SampleIndex { get; }
|
|
public long FileOffset { get; }
|
|
|
|
public VideoDataChunk(byte[] data, long presentationTimeMs, bool isKeyFrame, long sampleIndex, long fileOffset = 0)
|
|
{
|
|
Data = data;
|
|
PresentationTimeMs = presentationTimeMs;
|
|
IsKeyFrame = isKeyFrame;
|
|
SampleIndex = sampleIndex;
|
|
FileOffset = fileOffset;
|
|
}
|
|
|
|
public override string ToString()
|
|
{
|
|
return $"Chunk #{SampleIndex}: {Data.Length} bytes, PTS: {PresentationTimeMs}ms, Key: {IsKeyFrame}";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Video track information
|
|
/// </summary>
|
|
public class VideoTrackInfo
|
|
{
|
|
public int Width { get; init; }
|
|
public int Height { get; init; }
|
|
public double Duration { get; init; }
|
|
public double EstimatedFrameRate { get; init; }
|
|
public string CodecType { get; init; } = "";
|
|
public List<SampleInfo>? Samples { get; init; }
|
|
public byte[]? Av1ConfigurationRecord { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sample information for navigation
|
|
/// </summary>
|
|
public class SampleInfo
|
|
{
|
|
public long Offset { get; init; }
|
|
public int Size { get; init; }
|
|
public long PresentationTimeMs { get; init; }
|
|
public bool IsKeyFrame { get; init; }
|
|
public byte[]? Data { get; init; } // Pre-extracted pure AV1 data
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for container parsers
|
|
/// </summary>
|
|
public interface IVideoContainerParser : IDisposable
|
|
{
|
|
VideoTrackInfo? ParseHeader();
|
|
Task<VideoDataChunk?> ReadNextChunkAsync(long chunkIndex, CancellationToken cancellationToken = default);
|
|
Task<long> SeekToTimeAsync(TimeSpan time, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streaming MP4 parser
|
|
/// </summary>
|
|
public class StreamingMp4Parser : IVideoContainerParser
|
|
{
|
|
private readonly FileStream _stream;
|
|
private VideoTrackInfo? _trackInfo;
|
|
private List<SampleInfo>? _samples;
|
|
|
|
public StreamingMp4Parser(FileStream stream)
|
|
{
|
|
_stream = stream;
|
|
}
|
|
|
|
public VideoTrackInfo? ParseHeader()
|
|
{
|
|
try
|
|
{
|
|
// Parse with optimized memory usage
|
|
var parser = CreateOptimizedMp4Parser();
|
|
var tracks = parser.Parse();
|
|
var av1Track = tracks.FirstOrDefault(t => t.CodecType == "av01");
|
|
|
|
if (av1Track != null)
|
|
{
|
|
var estimatedFrameRate = av1Track.Duration > 0 ? av1Track.Samples?.Count / av1Track.Duration ?? 30.0 : 30.0;
|
|
|
|
_samples = av1Track.Samples?.Select((s, index) => new SampleInfo
|
|
{
|
|
Offset = s.Offset,
|
|
Size = (int)s.Size,
|
|
PresentationTimeMs = (long)(index * 1000.0 / estimatedFrameRate),
|
|
IsKeyFrame = s.IsKeyFrame
|
|
}).ToList() ?? new List<SampleInfo>();
|
|
|
|
_trackInfo = new VideoTrackInfo
|
|
{
|
|
Width = (int)av1Track.Width,
|
|
Height = (int)av1Track.Height,
|
|
Duration = av1Track.Duration,
|
|
EstimatedFrameRate = estimatedFrameRate,
|
|
CodecType = av1Track.CodecType ?? string.Empty,
|
|
Samples = _samples,
|
|
Av1ConfigurationRecord = av1Track.Av1ConfigurationRecord
|
|
};
|
|
}
|
|
|
|
return _trackInfo;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMp4Parser] Error parsing header: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<VideoDataChunk?> ReadNextChunkAsync(long chunkIndex, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_samples == null || chunkIndex >= _samples.Count)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var sample = _samples[(int)chunkIndex];
|
|
_stream.Position = sample.Offset;
|
|
|
|
var buffer = new byte[sample.Size];
|
|
var bytesRead = await _stream.ReadAsync(buffer, 0, sample.Size, cancellationToken);
|
|
|
|
if (bytesRead != sample.Size)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMp4Parser] Expected {sample.Size} bytes, got {bytesRead}");
|
|
return null;
|
|
}
|
|
|
|
return new VideoDataChunk(buffer, sample.PresentationTimeMs, sample.IsKeyFrame, chunkIndex, sample.Offset);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMp4Parser] Error reading chunk {chunkIndex}: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public Task<long> SeekToTimeAsync(TimeSpan time, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_samples == null)
|
|
return Task.FromResult(-1L);
|
|
|
|
var targetTimeMs = (long)time.TotalMilliseconds;
|
|
|
|
// Find the closest keyframe before or at the target time
|
|
var keyFrames = _samples
|
|
.Select((sample, index) => new { sample, index })
|
|
.Where(x => x.sample.IsKeyFrame && x.sample.PresentationTimeMs <= targetTimeMs)
|
|
.OrderBy(x => x.sample.PresentationTimeMs)
|
|
.ToList();
|
|
|
|
if (keyFrames.Any())
|
|
{
|
|
return Task.FromResult((long)keyFrames.Last().index);
|
|
}
|
|
|
|
return Task.FromResult(0L); // Return to beginning if no suitable keyframe found
|
|
}
|
|
|
|
private Mp4Parser CreateOptimizedMp4Parser()
|
|
{
|
|
// Find and read only the moov box instead of entire file
|
|
var buffer = new byte[8192]; // 8KB buffer for reading boxes
|
|
_stream.Position = 0;
|
|
|
|
while (_stream.Position < _stream.Length)
|
|
{
|
|
// Read box header (8 bytes minimum)
|
|
var headerRead = _stream.Read(buffer, 0, 8);
|
|
if (headerRead < 8) break;
|
|
|
|
var boxSize = Mp4Reader.ReadUInt32BigEndian(buffer, 0);
|
|
var boxType = Mp4Reader.ReadFourCC(buffer, 4);
|
|
|
|
if (boxSize == 0) // Box extends to end of file
|
|
boxSize = (uint)(_stream.Length - _stream.Position + 8);
|
|
|
|
if (boxType == "moov")
|
|
{
|
|
// Found metadata box - read only this box
|
|
var moovSize = boxSize - 8;
|
|
var moovData = new byte[moovSize];
|
|
var totalRead = 0;
|
|
while (totalRead < moovSize)
|
|
{
|
|
var bytesRead = _stream.Read(moovData, totalRead, (int)moovSize - totalRead);
|
|
if (bytesRead == 0) break;
|
|
totalRead += bytesRead;
|
|
}
|
|
|
|
// Create full moov box data for parser
|
|
var fullMoovData = new byte[boxSize];
|
|
Array.Copy(buffer, 0, fullMoovData, 0, 8);
|
|
Array.Copy(moovData, 0, fullMoovData, 8, (int)moovSize);
|
|
|
|
return new Mp4Parser(fullMoovData);
|
|
}
|
|
else
|
|
{
|
|
// Skip this box
|
|
_stream.Position += boxSize - 8;
|
|
}
|
|
}
|
|
|
|
throw new InvalidDataException("No moov box found in MP4 file");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Stream is owned by VideoFileReader, don't dispose here
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streaming Matroska parser
|
|
/// </summary>
|
|
public class StreamingMatroskaParser : IVideoContainerParser
|
|
{
|
|
private readonly FileStream _stream;
|
|
private VideoTrackInfo? _trackInfo;
|
|
private List<SampleInfo>? _blocks;
|
|
|
|
public StreamingMatroskaParser(FileStream stream)
|
|
{
|
|
_stream = stream;
|
|
}
|
|
|
|
public VideoTrackInfo? ParseHeader()
|
|
{
|
|
try
|
|
{
|
|
// Parse with limited memory usage
|
|
_stream.Position = 0;
|
|
var parser = CreateOptimizedMatroskaParser();
|
|
var tracks = parser.Parse();
|
|
var av1Track = tracks.FirstOrDefault(t => t.CodecId == "V_AV1");
|
|
|
|
if (av1Track != null)
|
|
{
|
|
var sortedBlocks = av1Track.Blocks.OrderBy(b => b.Timestamp).ToList();
|
|
|
|
_blocks = sortedBlocks.Select((block, index) => new SampleInfo
|
|
{
|
|
Offset = block.Offset,
|
|
Size = block.Size,
|
|
PresentationTimeMs = (long)block.Timestamp,
|
|
IsKeyFrame = block.IsKeyFrame,
|
|
Data = block.Data // Include pre-extracted pure AV1 data
|
|
}).ToList();
|
|
|
|
// Estimate frame rate from timestamps
|
|
double frameRate = 30.0; // Default
|
|
if (sortedBlocks.Count > 1)
|
|
{
|
|
var avgTimeDiff = sortedBlocks.Skip(1)
|
|
.Select((block, i) => (double)(block.Timestamp - sortedBlocks[i].Timestamp))
|
|
.Where(diff => diff > 0)
|
|
.DefaultIfEmpty(33)
|
|
.Average();
|
|
frameRate = 1000.0 / avgTimeDiff;
|
|
}
|
|
|
|
_trackInfo = new VideoTrackInfo
|
|
{
|
|
Width = (int)av1Track.PixelWidth,
|
|
Height = (int)av1Track.PixelHeight,
|
|
Duration = sortedBlocks.Count / frameRate,
|
|
EstimatedFrameRate = frameRate,
|
|
CodecType = av1Track.CodecId ?? string.Empty,
|
|
Samples = _blocks,
|
|
Av1ConfigurationRecord = av1Track.CodecPrivate
|
|
};
|
|
}
|
|
|
|
return _trackInfo;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMatroskaParser] Error parsing header: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<VideoDataChunk?> ReadNextChunkAsync(long chunkIndex, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_blocks == null || chunkIndex >= _blocks.Count)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var block = _blocks[(int)chunkIndex];
|
|
|
|
// Use pre-extracted pure AV1 data from MatroskaParser instead of re-reading from file
|
|
// This ensures we only pass pure AV1 bitstream to the decoder, not WebM container data
|
|
if (block.Data != null && block.Data.Length > 0)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMatroskaParser] ✅ Using pre-extracted pure AV1 data: {block.Data.Length} bytes (was {block.Size} in container)");
|
|
Console.WriteLine($"[AV1_EXTRACT] Using pure AV1 data: {block.Data.Length} bytes (container size: {block.Size})");
|
|
|
|
var hexData = string.Join(" ", block.Data.Take(16).Select(b => b.ToString("X2")));
|
|
Console.WriteLine($"[AV1_EXTRACT] Pure AV1 data starts with: {hexData}");
|
|
|
|
return new VideoDataChunk(block.Data, block.PresentationTimeMs, block.IsKeyFrame, chunkIndex, block.Offset);
|
|
}
|
|
else
|
|
{
|
|
// Fallback to file reading if Data is not available
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMatroskaParser] Fallback: reading from file at offset {block.Offset}");
|
|
_stream.Position = block.Offset;
|
|
|
|
var buffer = new byte[block.Size];
|
|
var bytesRead = await _stream.ReadAsync(buffer, 0, block.Size, cancellationToken);
|
|
|
|
if (bytesRead != block.Size)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMatroskaParser] Expected {block.Size} bytes, got {bytesRead}");
|
|
return null;
|
|
}
|
|
|
|
return new VideoDataChunk(buffer, block.PresentationTimeMs, block.IsKeyFrame, chunkIndex, block.Offset);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMatroskaParser] Error reading chunk {chunkIndex}: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public Task<long> SeekToTimeAsync(TimeSpan time, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_blocks == null)
|
|
return Task.FromResult(-1L);
|
|
|
|
var targetTimeMs = (long)time.TotalMilliseconds;
|
|
|
|
// Find the closest keyframe before or at the target time
|
|
var keyFrames = _blocks
|
|
.Select((block, index) => new { block, index })
|
|
.Where(x => x.block.IsKeyFrame && x.block.PresentationTimeMs <= targetTimeMs)
|
|
.OrderBy(x => x.block.PresentationTimeMs)
|
|
.ToList();
|
|
|
|
if (keyFrames.Any())
|
|
{
|
|
return Task.FromResult((long)keyFrames.Last().index);
|
|
}
|
|
|
|
return Task.FromResult(0L);
|
|
}
|
|
|
|
private MatroskaParser CreateOptimizedMatroskaParser()
|
|
{
|
|
// For Matroska, we need to read the entire file to get correct offsets
|
|
// The previous approach of reading only 10MB caused offset misalignment
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMatroskaParser] Reading entire WebM file for accurate parsing ({_stream.Length} bytes)");
|
|
|
|
var buffer = new byte[_stream.Length];
|
|
_stream.Position = 0;
|
|
var totalRead = 0;
|
|
while (totalRead < _stream.Length)
|
|
{
|
|
var bytesRead = _stream.Read(buffer, totalRead, (int)_stream.Length - totalRead);
|
|
if (bytesRead == 0) break;
|
|
totalRead += bytesRead;
|
|
}
|
|
|
|
System.Diagnostics.Debug.WriteLine($"[StreamingMatroskaParser] Successfully read {totalRead} bytes for parsing");
|
|
return new MatroskaParser(buffer);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Stream is owned by VideoFileReader, don't dispose here
|
|
}
|
|
}
|
|
} |