Harden movie playback lifecycle and diagnostics
This commit is contained in:
13
tools/movie-corpus-gate/Age.MovieCorpusGate.csproj
Normal file
13
tools/movie-corpus-gate/Age.MovieCorpusGate.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NuGetAudit>false</NuGetAudit>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\engine\Age.Engine\Age.Engine.csproj" />
|
||||
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
228
tools/movie-corpus-gate/MovieCorpusGate.cs
Normal file
228
tools/movie-corpus-gate/MovieCorpusGate.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System.Diagnostics;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
internal sealed record MovieCorpusInput(long PackedId, string Name, Func<MoviePayload> ReadPayload);
|
||||
|
||||
internal sealed record MovieCorpusItemResult(
|
||||
long PackedId,
|
||||
string PackedIdHex,
|
||||
string Name,
|
||||
long PayloadBytes,
|
||||
int ExpectedWidth,
|
||||
int ExpectedHeight,
|
||||
int Width,
|
||||
int Height,
|
||||
long StopTimeMs,
|
||||
int FrameRateNumerator,
|
||||
int FrameRateDenominator,
|
||||
bool HasAudio,
|
||||
long FrameCount,
|
||||
long FirstPresentationTimeMs,
|
||||
long LastPresentationTimeMs,
|
||||
bool FramesChanged,
|
||||
long ReadMilliseconds,
|
||||
long OpenMilliseconds,
|
||||
long DecodeMilliseconds,
|
||||
long DisposeMilliseconds,
|
||||
bool Passed,
|
||||
string? Error);
|
||||
|
||||
internal sealed record MovieCorpusReport(
|
||||
string StartedUtc,
|
||||
string CompletedUtc,
|
||||
int ExpectedCount,
|
||||
int CandidateCount,
|
||||
int PassedCount,
|
||||
int FailedCount,
|
||||
long ElapsedMilliseconds,
|
||||
bool Passed,
|
||||
IReadOnlyList<string> SelectionErrors,
|
||||
IReadOnlyList<MovieCorpusItemResult> Movies);
|
||||
|
||||
internal static class MovieCorpusDiscovery
|
||||
{
|
||||
private static ReadOnlySpan<byte> MpegPackStart => [0, 0, 1, 0xba];
|
||||
|
||||
public static IReadOnlyList<PackedAssetEntry> DiscoverMpegMovies(
|
||||
Sys4AssetCatalog catalog, IAssetStore store)
|
||||
{
|
||||
var movies = new List<PackedAssetEntry>();
|
||||
Span<byte> signature = stackalloc byte[4];
|
||||
foreach (var packed in catalog.EnumerateAssets())
|
||||
{
|
||||
if (!packed.Asset.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
using Stream stream = store.Open(packed.Asset);
|
||||
int length = 0;
|
||||
while (length < signature.Length)
|
||||
{
|
||||
int read = stream.Read(signature[length..]);
|
||||
if (read == 0) break;
|
||||
length += read;
|
||||
}
|
||||
if (length == signature.Length && signature.SequenceEqual(MpegPackStart)) movies.Add(packed);
|
||||
}
|
||||
return movies;
|
||||
}
|
||||
|
||||
public static bool TryReadSequenceDimensions(ReadOnlySpan<byte> payload, out int width, out int height)
|
||||
{
|
||||
for (int offset = 0; offset <= payload.Length - 7; offset++)
|
||||
{
|
||||
if (payload[offset] != 0 || payload[offset + 1] != 0
|
||||
|| payload[offset + 2] != 1 || payload[offset + 3] != 0xb3) continue;
|
||||
width = (payload[offset + 4] << 4) | (payload[offset + 5] >> 4);
|
||||
height = ((payload[offset + 5] & 0x0f) << 8) | payload[offset + 6];
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
width = 0;
|
||||
height = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MovieCorpusGate
|
||||
{
|
||||
private readonly Func<MoviePayload, IFfmpegFrameSource> _open;
|
||||
|
||||
public MovieCorpusGate(Func<MoviePayload, IFfmpegFrameSource> open)
|
||||
=> _open = open ?? throw new ArgumentNullException(nameof(open));
|
||||
|
||||
public MovieCorpusReport Run(IReadOnlyList<MovieCorpusInput> inputs, int expectedCount,
|
||||
long maximumItemMilliseconds,
|
||||
Action<int, int, MovieCorpusItemResult>? progress = null)
|
||||
{
|
||||
if (expectedCount < 0) throw new ArgumentOutOfRangeException(nameof(expectedCount));
|
||||
if (maximumItemMilliseconds <= 0) throw new ArgumentOutOfRangeException(nameof(maximumItemMilliseconds));
|
||||
DateTimeOffset started = DateTimeOffset.UtcNow;
|
||||
var total = Stopwatch.StartNew();
|
||||
var selectionErrors = new List<string>();
|
||||
if (inputs.Count != expectedCount)
|
||||
selectionErrors.Add($"expected {expectedCount} MPEG movies, discovered {inputs.Count}");
|
||||
|
||||
var results = new List<MovieCorpusItemResult>(inputs.Count);
|
||||
for (int index = 0; index < inputs.Count; index++)
|
||||
{
|
||||
MovieCorpusItemResult result = RunOne(inputs[index], maximumItemMilliseconds);
|
||||
results.Add(result);
|
||||
progress?.Invoke(index + 1, inputs.Count, result);
|
||||
}
|
||||
|
||||
total.Stop();
|
||||
int passed = results.Count(result => result.Passed);
|
||||
int failed = results.Count - passed;
|
||||
return new MovieCorpusReport(
|
||||
started.ToString("O"), DateTimeOffset.UtcNow.ToString("O"), expectedCount, inputs.Count,
|
||||
passed, failed, total.ElapsedMilliseconds, selectionErrors.Count == 0 && failed == 0,
|
||||
selectionErrors, results);
|
||||
}
|
||||
|
||||
private MovieCorpusItemResult RunOne(MovieCorpusInput input, long maximumItemMilliseconds)
|
||||
{
|
||||
long payloadBytes = 0;
|
||||
int expectedWidth = 0, expectedHeight = 0, width = 0, height = 0;
|
||||
long stopTimeMs = 0, frameCount = 0, firstPts = -1, lastPts = -1;
|
||||
int frameRateNumerator = 0, frameRateDenominator = 0;
|
||||
bool hasAudio = false, framesChanged = false;
|
||||
long readMs = 0, openMs = 0, decodeMs = 0, disposeMs = 0;
|
||||
string? error = null;
|
||||
IFfmpegFrameSource? source = null;
|
||||
var itemTime = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var phase = Stopwatch.StartNew();
|
||||
MoviePayload payload = input.ReadPayload();
|
||||
phase.Stop();
|
||||
readMs = phase.ElapsedMilliseconds;
|
||||
payloadBytes = payload.Bytes.LongLength;
|
||||
if (!MovieCorpusDiscovery.TryReadSequenceDimensions(payload.Bytes, out expectedWidth, out expectedHeight))
|
||||
throw new InvalidDataException("MPEG sequence header was not found");
|
||||
|
||||
phase.Restart();
|
||||
source = _open(payload);
|
||||
phase.Stop();
|
||||
openMs = phase.ElapsedMilliseconds;
|
||||
FfmpegMovieInfo info = source.Info;
|
||||
width = info.Width;
|
||||
height = info.Height;
|
||||
stopTimeMs = info.StopTimeMs;
|
||||
frameRateNumerator = info.FrameRateNumerator;
|
||||
frameRateDenominator = info.FrameRateDenominator;
|
||||
hasAudio = info.HasAudio;
|
||||
if (width != expectedWidth || height != expectedHeight)
|
||||
throw new InvalidDataException(
|
||||
$"decoder dimensions {width}x{height} differ from MPEG sequence {expectedWidth}x{expectedHeight}");
|
||||
if (width <= 0 || height <= 0 || stopTimeMs <= 0
|
||||
|| frameRateNumerator <= 0 || frameRateDenominator <= 0)
|
||||
throw new InvalidDataException(
|
||||
$"invalid metadata {width}x{height}, {stopTimeMs} ms, " +
|
||||
$"rate {frameRateNumerator}/{frameRateDenominator}");
|
||||
|
||||
var decodeTime = Stopwatch.StartNew();
|
||||
byte[]? firstPixels = null;
|
||||
try
|
||||
{
|
||||
while (source.TryDecodeNextVideoFrame(out FfmpegVideoFrame frame))
|
||||
{
|
||||
if (frame.Image.Width != width || frame.Image.Height != height)
|
||||
throw new InvalidDataException(
|
||||
$"frame {frameCount} dimensions are {frame.Image.Width}x{frame.Image.Height}, expected {width}x{height}");
|
||||
int expectedBytes = checked(width * height * 4);
|
||||
if (frame.Image.Pixels.Length != expectedBytes)
|
||||
throw new InvalidDataException(
|
||||
$"frame {frameCount} has {frame.Image.Pixels.Length} RGBA bytes, expected {expectedBytes}");
|
||||
if (frame.PresentationTimeMs < 0 || frame.PresentationTimeMs < lastPts)
|
||||
throw new InvalidDataException(
|
||||
$"frame {frameCount} timestamp {frame.PresentationTimeMs} follows {lastPts}");
|
||||
|
||||
if (frameCount == 0)
|
||||
{
|
||||
firstPts = frame.PresentationTimeMs;
|
||||
firstPixels = frame.Image.Pixels;
|
||||
}
|
||||
else if (!framesChanged && !frame.Image.Pixels.AsSpan().SequenceEqual(firstPixels))
|
||||
framesChanged = true;
|
||||
lastPts = frame.PresentationTimeMs;
|
||||
frameCount++;
|
||||
if (itemTime.ElapsedMilliseconds > maximumItemMilliseconds)
|
||||
throw new TimeoutException($"item exceeded {maximumItemMilliseconds} ms before EOF");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
decodeTime.Stop();
|
||||
decodeMs = decodeTime.ElapsedMilliseconds;
|
||||
}
|
||||
if (frameCount == 0) throw new InvalidDataException("decoder reached EOF without a video frame");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (source != null)
|
||||
{
|
||||
var disposeTime = Stopwatch.StartNew();
|
||||
try { source.Dispose(); }
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = error == null ? $"dispose failed: {exception.Message}"
|
||||
: $"{error}; dispose failed: {exception.Message}";
|
||||
}
|
||||
disposeTime.Stop();
|
||||
disposeMs = disposeTime.ElapsedMilliseconds;
|
||||
}
|
||||
}
|
||||
|
||||
itemTime.Stop();
|
||||
if (error == null && itemTime.ElapsedMilliseconds > maximumItemMilliseconds)
|
||||
error = $"item took {itemTime.ElapsedMilliseconds} ms, limit is {maximumItemMilliseconds} ms";
|
||||
return new MovieCorpusItemResult(
|
||||
input.PackedId, $"0x{input.PackedId:x}", input.Name, payloadBytes,
|
||||
expectedWidth, expectedHeight, width, height, stopTimeMs,
|
||||
frameRateNumerator, frameRateDenominator, hasAudio, frameCount, firstPts, lastPts,
|
||||
framesChanged, readMs, openMs, decodeMs, disposeMs, error == null, error);
|
||||
}
|
||||
|
||||
}
|
||||
60
tools/movie-corpus-gate/Program.cs
Normal file
60
tools/movie-corpus-gate/Program.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
static string? Option(string[] arguments, string name)
|
||||
{
|
||||
int index = Array.IndexOf(arguments, name);
|
||||
return index >= 0 && index + 1 < arguments.Length ? arguments[index + 1] : null;
|
||||
}
|
||||
|
||||
if (args.Contains("--help"))
|
||||
{
|
||||
Console.WriteLine("usage: dotnet run --project tools/movie-corpus-gate -- [--output <json>] [--native-dir <dir>] [--expected-count 213] [--max-item-ms 30000]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
string output = Path.GetFullPath(Option(args, "--output")
|
||||
?? Path.Combine(Paths.Build, "movie-corpus-ffmpeg.json"));
|
||||
string nativeDirectory = Path.GetFullPath(Option(args, "--native-dir")
|
||||
?? Path.Combine(Paths.Build, "native", "win-x64"));
|
||||
int expectedCount = int.Parse(Option(args, "--expected-count") ?? "213");
|
||||
long maximumItemMilliseconds = long.Parse(Option(args, "--max-item-ms") ?? "30000");
|
||||
string nativeLibrary = Path.Combine(nativeDirectory, OperatingSystem.IsWindows()
|
||||
? "age_movie_ffmpeg.dll" : OperatingSystem.IsMacOS()
|
||||
? "libage_movie_ffmpeg.dylib" : "libage_movie_ffmpeg.so");
|
||||
if (!File.Exists(nativeLibrary))
|
||||
{
|
||||
Console.Error.WriteLine($"native movie shim not found: {nativeLibrary}");
|
||||
return 2;
|
||||
}
|
||||
Environment.SetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR", nativeDirectory);
|
||||
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var store = new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir);
|
||||
var resources = new ResourceMap(catalog, store);
|
||||
Console.WriteLine("discovering MPEG program streams from .AGF catalog entries...");
|
||||
var discovered = MovieCorpusDiscovery.DiscoverMpegMovies(catalog, store);
|
||||
var inputs = discovered.Select(packed => new MovieCorpusInput(
|
||||
packed.PackedId, packed.Asset.Name, () => resources.ReadMovie(packed.Asset))).ToArray();
|
||||
Console.WriteLine($"discovered {inputs.Length} movies; decoding every frame without presentation waits");
|
||||
|
||||
var gate = new MovieCorpusGate(payload => new FfmpegMovieSession(payload));
|
||||
MovieCorpusReport report = gate.Run(inputs, expectedCount, maximumItemMilliseconds,
|
||||
(index, count, item) => Console.WriteLine(
|
||||
$"[{index,3}/{count}] {(item.Passed ? "PASS" : "FAIL")} {item.PackedIdHex,-10} {item.Name,-16} " +
|
||||
$"{item.Width}x{item.Height} {item.FrameCount}f {item.StopTimeMs}ms decode={item.DecodeMilliseconds}ms" +
|
||||
(item.Error == null ? "" : $" :: {item.Error}")));
|
||||
|
||||
string? parent = Path.GetDirectoryName(output);
|
||||
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent);
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
File.WriteAllText(output, JsonSerializer.Serialize(report, jsonOptions));
|
||||
Console.WriteLine($"summary: {report.PassedCount}/{report.CandidateCount} passed, " +
|
||||
$"{report.FailedCount} failed in {report.ElapsedMilliseconds} ms");
|
||||
foreach (string error in report.SelectionErrors) Console.Error.WriteLine("selection: " + error);
|
||||
Console.WriteLine("report: " + output);
|
||||
return report.Passed ? 0 : 1;
|
||||
Reference in New Issue
Block a user