Harden movie playback lifecycle and diagnostics

This commit is contained in:
gamer147
2026-07-22 09:44:57 -04:00
parent 816673cf6a
commit 4f5137a98f
25 changed files with 1145 additions and 147 deletions

View 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>

View 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);
}
}

View 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;