From 5864328e35faf30ecee12cf904c61baa93a9f95c Mon Sep 17 00:00:00 2001 From: Berk <12572227+par274@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:31:14 +0300 Subject: [PATCH] Merge media decoding into one FFmpeg bridge (#706) * [media] merge bink into shared ffmpeg bridge * [avplayer] decode in process and fix stream info size * [font] add glyph and teardown exports * [build] bump ffmpeg runtime to 3b502d4 * [font] add glyph and teardown exports * [build] bump ffmpeg runtime to 3b502d4 * [avplayer] decode in process * [font] add glyph and teardown exports * [build] bump ffmpeg runtime to 3b502d4 --- src/SharpEmu.CLI/SharpEmu.CLI.csproj | 7 +- src/SharpEmu.Libs/Agc/AgcExports.cs | 2 +- src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs | 492 ++++++------------ .../Bink/FfmpegBinkFrameSource.cs | 166 ------ src/SharpEmu.Libs/Font/FontExports.cs | 151 ++++++ .../Kernel/KernelMemoryCompatExports.cs | 16 +- src/SharpEmu.Libs/Media/FfmpegMediaStream.cs | 489 +++++++++++++++++ src/SharpEmu.Libs/Media/FfmpegRuntime.cs | 32 ++ .../FfmpegVideoDecoder.cs} | 43 +- .../HostMovieBridge.cs} | 87 ++-- .../MediaFramePlayback.cs} | 10 +- .../VideoOut/VulkanVideoPresenter.cs | 6 +- .../AvPlayer/AvPlayerPathTests.cs | 43 -- .../HostMovieBridgeTests.cs} | 14 +- .../MediaFramePlaybackTests.cs} | 16 +- 15 files changed, 920 insertions(+), 654 deletions(-) delete mode 100644 src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs create mode 100644 src/SharpEmu.Libs/Media/FfmpegMediaStream.cs create mode 100644 src/SharpEmu.Libs/Media/FfmpegRuntime.cs rename src/SharpEmu.Libs/{Bink/FfmpegNativeBinkFrameSource.cs => Media/FfmpegVideoDecoder.cs} (92%) rename src/SharpEmu.Libs/{Bink/Bink2MovieBridge.cs => Media/HostMovieBridge.cs} (89%) rename src/SharpEmu.Libs/{Bink/BinkFramePlayback.cs => Media/MediaFramePlayback.cs} (97%) rename tests/SharpEmu.Libs.Tests/{Bink/Bink2MovieBridgeTests.cs => Media/HostMovieBridgeTests.cs} (84%) rename tests/SharpEmu.Libs.Tests/{Bink/BinkFramePlaybackTests.cs => Media/MediaFramePlaybackTests.cs} (86%) diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj index 2714e437..a2a896c5 100644 --- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj +++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj @@ -111,15 +111,14 @@ SPDX-License-Identifier: GPL-2.0-or-later name is a fixed constant, not derived from the RID/architecture: each publish output only ever holds one architecture's binaries anyway, so varying the name added a class of bugs (RID resolution timing, host-OS - vs. target-RID mixups) for no benefit. Runtime code (Program.cs's - FfmpegNativeBinkFrameSource uses the same literal "plugins" folder - name. --> + vs. target-RID mixups) for no benefit. Runtime code (FfmpegRuntime) + uses the same literal "plugins" folder name. --> plugins - 2c92585 + 3b502d4 $(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier) ffmpeg-windows-x64.zip diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 0af15bce..ba27ac17 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using SharpEmu.HLE; -using SharpEmu.Libs.Bink; +using SharpEmu.Libs.Media; using SharpEmu.Libs.Gpu; using SharpEmu.ShaderCompiler; using SharpEmu.Libs.Kernel; diff --git a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs index a92983df..8260bb13 100644 --- a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs +++ b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs @@ -3,6 +3,7 @@ using SharpEmu.HLE; using SharpEmu.Libs.Kernel; +using SharpEmu.Libs.Media; using System.Buffers.Binary; using System.Diagnostics; using System.Globalization; @@ -15,6 +16,10 @@ public static class AvPlayerExports private const int InvalidParameters = unchecked((int)0x806A0001); private const int OperationFailed = unchecked((int)0x806A0002); private const int FrameBufferCount = 3; + private const int MaxCatchUpFrames = 2; + private const ulong TextureAllocationAlignment = 0x100; + private const int FramePitchAlignment = 64; + private const int FrameHeightAlignment = 16; private const int FrameInfoSize = 40; private const int FrameInfoExSize = 104; // This structure is 32 bytes. A larger write can damage the guest stack. @@ -22,6 +27,7 @@ public static class AvPlayerExports private const int StreamInfoExSize = 32; private const int MaxGuestPathLength = 4096; private static readonly object StateGate = new(); + private static readonly HashSet TracedOnce = new(); private static readonly Dictionary Players = new(); private static int _traceCount; @@ -31,6 +37,7 @@ public static class AvPlayerExports public bool AutoStart { get; init; } public ulong AllocatorObject { get; init; } public ulong AllocateTextureCallback { get; init; } + public ulong AllocateCallback { get; init; } public ulong EventObject { get; init; } public ulong EventCallback { get; init; } public string? SourcePath { get; set; } @@ -42,11 +49,10 @@ public static class AvPlayerExports public bool Paused { get; set; } public bool Looping { get; set; } public bool EndOfStream { get; set; } - public Process? Decoder { get; set; } public Stream? DecoderOutput { get; set; } - public Process? AudioDecoder { get; set; } public Stream? AudioDecoderOutput { get; set; } public Stopwatch PlaybackClock { get; } = new(); + public long SkippedFrameDebt { get; set; } public byte[]? RawFrame { get; set; } public byte[]? RawAudioFrame { get; set; } public byte[]? PaddedFrame { get; set; } @@ -66,42 +72,6 @@ public static class AvPlayerExports DecoderOutput = null; AudioDecoderOutput?.Dispose(); AudioDecoderOutput = null; - if (Decoder is not null) - { - try - { - if (!Decoder.HasExited) - { - Decoder.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) - { - } - finally - { - Decoder.Dispose(); - Decoder = null; - } - } - if (AudioDecoder is not null) - { - try - { - if (!AudioDecoder.HasExited) - { - AudioDecoder.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) - { - } - finally - { - AudioDecoder.Dispose(); - AudioDecoder = null; - } - } } public void ResetPlayback() @@ -110,6 +80,7 @@ public static class AvPlayerExports PlaybackClock.Reset(); NextFrameIndex = 0; NextAudioFrameIndex = 0; + SkippedFrameDebt = 0; EndOfStream = false; } } @@ -137,6 +108,7 @@ public static class AvPlayerExports AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0, AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0, AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0, + AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0, EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0, EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0, }); @@ -191,6 +163,7 @@ public static class AvPlayerExports AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0, AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0, AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0, + AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0, EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0, EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0, }); @@ -356,7 +329,7 @@ public static class AvPlayerExports } player.Paused = false; - if (player.Decoder is not null) + if (player.DecoderOutput is not null) { player.PlaybackClock.Start(); } @@ -388,7 +361,13 @@ public static class AvPlayerExports ExportName = "sceAvPlayerEnableStream", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAvPlayer")] - public static int AvPlayerEnableStream(CpuContext ctx) => ValidatePlayer(ctx); + public static int AvPlayerEnableStream(CpuContext ctx) + { + TraceOnce( + $"enable_stream_{ctx[CpuRegister.Rsi]}", + $"enable_stream index={ctx[CpuRegister.Rsi]}"); + return ValidatePlayer(ctx); + } [SysAbiExport( Nid = "k-q+xOxdc3E", @@ -445,10 +424,13 @@ public static class AvPlayerExports { lock (StateGate) { - return SetReturn( - ctx, - Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) && - player.Started && !player.EndOfStream ? 1 : 0); + var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player); + var active = found && player!.Started && !player.EndOfStream; + TraceOnce( + "is_active", + $"is_active found={found} started={(found && player!.Started)} " + + $"eos={(found && player!.EndOfStream)} returned={(active ? 1 : 0)}"); + return SetReturn(ctx, active ? 1 : 0); } } @@ -476,13 +458,21 @@ public static class AvPlayerExports var infoAddress = ctx[CpuRegister.Rsi]; lock (StateGate) { - if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) || - infoAddress == 0 || !player.Started || player.Paused || player.EndOfStream || - player.SourcePath is null || !EnsureAudioDecoder(player)) + var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player); + if (!found || infoAddress == 0 || !player!.Started || player.Paused || + player.EndOfStream || player.SourcePath is null || !EnsureAudioDecoder(player)) { + TraceOnce( + "audio_data_refused", + $"audio_data refused found={found} info=0x{infoAddress:X16} " + + $"started={(found && player!.Started)} paused={(found && player!.Paused)} " + + $"eos={(found && player!.EndOfStream)} " + + $"decoder={(found && player!.SourcePath is not null && EnsureAudioDecoder(player))}"); return SetReturn(ctx, 0); } + TraceOnce("audio_data_ok", "audio_data first delivery"); + const int samplesPerFrame = 1024; const int channelCount = 2; const int sampleRate = 48_000; @@ -560,7 +550,9 @@ public static class AvPlayerExports { lock (StateGate) { - return SetReturn(ctx, Players.ContainsKey(ctx[CpuRegister.Rdi]) ? 2 : InvalidParameters); + var known = Players.ContainsKey(ctx[CpuRegister.Rdi]); + TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}"); + return SetReturn(ctx, known ? 2 : InvalidParameters); } } @@ -637,6 +629,10 @@ public static class AvPlayerExports return SetReturn(ctx, InvalidParameters); } + TraceOnce( + $"stream_info_{streamIndex}_{infoSize}", + $"stream_info index={streamIndex} size={infoSize} " + + $"type={(streamIndex == 0 ? "video" : "audio")} duration_ms={player.DurationMilliseconds}"); return SetReturn(ctx, 0); } } @@ -672,6 +668,8 @@ public static class AvPlayerExports } + EnsureGuestVideoBuffers(ctx, player); + NotifyEvent(ctx, player, 2); // StateReady if (autoStart) { @@ -699,7 +697,20 @@ public static class AvPlayerExports } var fps = Math.Max(1.0, player.FramesPerSecond); - var expectedFrame = (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps); + var expectedFrame = + (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps) - + player.SkippedFrameDebt; + var behind = expectedFrame - player.NextFrameIndex; + if (behind > MaxCatchUpFrames) + { + player.SkippedFrameDebt += behind - MaxCatchUpFrames; + expectedFrame = player.NextFrameIndex + MaxCatchUpFrames; + TraceOnce( + "catch_up_capped", + $"catch_up capped behind={behind} max={MaxCatchUpFrames} " + + $"fps={fps:F3} {player.Width}x{player.Height}"); + } + while (player.NextFrameIndex < expectedFrame) { if (!ReadFrame(player)) @@ -748,62 +759,28 @@ public static class AvPlayerExports return true; } - var ffmpeg = FindFfmpeg(); - if (ffmpeg is null || player.SourcePath is null) + if (player.SourcePath is null) { - Console.Error.WriteLine("[AVPLAYER][ERROR] FFmpeg was not found. Set SHARPEMU_FFMPEG_PATH."); return false; } - var startInfo = new ProcessStartInfo(ffmpeg) + if (!FfmpegMediaStream.TryOpenVideo( + player.SourcePath, + checked((int)player.Width), + checked((int)player.Height), + out var videoStream) || + videoStream is null) { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - startInfo.ArgumentList.Add("-nostdin"); - startInfo.ArgumentList.Add("-hide_banner"); - startInfo.ArgumentList.Add("-loglevel"); - startInfo.ArgumentList.Add("error"); - startInfo.ArgumentList.Add("-i"); - startInfo.ArgumentList.Add(player.SourcePath); - startInfo.ArgumentList.Add("-map"); - startInfo.ArgumentList.Add("0:v:0"); - startInfo.ArgumentList.Add("-an"); - startInfo.ArgumentList.Add("-pix_fmt"); - startInfo.ArgumentList.Add("nv12"); - startInfo.ArgumentList.Add("-f"); - startInfo.ArgumentList.Add("rawvideo"); - startInfo.ArgumentList.Add("pipe:1"); - - try - { - player.Decoder = Process.Start(startInfo); - if (player.Decoder is null) - { - return false; - } - player.Decoder.ErrorDataReceived += (_, eventArgs) => - { - if (!string.IsNullOrWhiteSpace(eventArgs.Data)) - { - Console.Error.WriteLine($"[AVPLAYER][FFMPEG] {eventArgs.Data}"); - } - }; - player.Decoder.BeginErrorReadLine(); - player.DecoderOutput = player.Decoder.StandardOutput.BaseStream; - player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)]; - player.PlaybackClock.Start(); - Trace($"decoder_started pid={player.Decoder.Id} source='{player.SourcePath}'"); - return true; - } - catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) - { - Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg: {exception.Message}"); - player.Dispose(); + Console.Error.WriteLine( + $"[AVPLAYER][ERROR] Could not open a video stream in '{player.SourcePath}'."); return false; } + + player.DecoderOutput = videoStream; + player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)]; + player.PlaybackClock.Start(); + Trace($"decoder_started source='{player.SourcePath}' {player.Width}x{player.Height} nv12"); + return true; } private static bool EnsureAudioDecoder(PlayerState player) @@ -813,65 +790,21 @@ public static class AvPlayerExports return true; } - var ffmpeg = FindFfmpeg(); - if (ffmpeg is null || player.SourcePath is null) + if (player.SourcePath is null) { return false; } - var startInfo = new ProcessStartInfo(ffmpeg) + if (!FfmpegMediaStream.TryOpenAudio(player.SourcePath, out var audioStream) || + audioStream is null) { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - startInfo.ArgumentList.Add("-nostdin"); - startInfo.ArgumentList.Add("-hide_banner"); - startInfo.ArgumentList.Add("-loglevel"); - startInfo.ArgumentList.Add("error"); - startInfo.ArgumentList.Add("-i"); - startInfo.ArgumentList.Add(player.SourcePath); - startInfo.ArgumentList.Add("-map"); - startInfo.ArgumentList.Add("0:a:0"); - startInfo.ArgumentList.Add("-vn"); - startInfo.ArgumentList.Add("-ac"); - startInfo.ArgumentList.Add("2"); - startInfo.ArgumentList.Add("-ar"); - startInfo.ArgumentList.Add("48000"); - startInfo.ArgumentList.Add("-f"); - startInfo.ArgumentList.Add("s16le"); - startInfo.ArgumentList.Add("pipe:1"); - - try - { - player.AudioDecoder = Process.Start(startInfo); - if (player.AudioDecoder is null) - { - return false; - } - player.AudioDecoder.ErrorDataReceived += (_, eventArgs) => - { - if (!string.IsNullOrWhiteSpace(eventArgs.Data)) - { - Console.Error.WriteLine($"[AVPLAYER][FFMPEG-AUDIO] {eventArgs.Data}"); - } - }; - player.AudioDecoder.BeginErrorReadLine(); - player.AudioDecoderOutput = player.AudioDecoder.StandardOutput.BaseStream; - player.RawAudioFrame = new byte[1024 * 2 * sizeof(short)]; - Trace($"audio_decoder_started pid={player.AudioDecoder.Id} source='{player.SourcePath}'"); - return true; - } - catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) - { - Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg audio decoder: {exception.Message}"); - player.AudioDecoderOutput?.Dispose(); - player.AudioDecoderOutput = null; - player.AudioDecoder?.Dispose(); - player.AudioDecoder = null; return false; } + + player.AudioDecoderOutput = audioStream; + player.RawAudioFrame = new byte[1024 * FfmpegMediaStream.AudioChannels * sizeof(short)]; + Trace($"audio_decoder_started source='{player.SourcePath}' s16 stereo 48000"); + return true; } private static bool ReadFrame(PlayerState player) @@ -923,9 +856,9 @@ public static class AvPlayerExports return false; } - var alignedWidth = AlignUp(player.Width, 16); - var alignedHeight = AlignUp(player.Height, 16); - var bufferStride = checked(alignedWidth * alignedHeight * 3 / 2); + var alignedWidth = AlignUp(player.Width, FramePitchAlignment); + var alignedHeight = AlignUp(player.Height, FrameHeightAlignment); + var bufferStride = GetVideoBufferSize(player); if (player.GuestBuffers[0] == 0) { if (!AllocateGuestVideoBuffers(ctx, player, bufferStride)) @@ -981,39 +914,78 @@ public static class AvPlayerExports return ctx.Memory.TryWrite(infoAddress, info); } + private static int GetVideoBufferSize(PlayerState player) => + checked( + AlignUp(player.Width, FramePitchAlignment) * + AlignUp(player.Height, FrameHeightAlignment) * 3 / 2); + + private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player) + { + lock (StateGate) + { + if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0) + { + return; + } + + var bufferSize = GetVideoBufferSize(player); + if (AllocateGuestVideoBuffers(ctx, player, bufferSize)) + { + player.GuestBufferStride = bufferSize; + } + } + } + private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize) { var scheduler = GuestThreadExecution.Scheduler; - if (!player.TextureAllocatorFailed && player.AllocateTextureCallback != 0 && scheduler is not null) + if (!player.TextureAllocatorFailed && scheduler is not null) { - for (var index = 0; index < player.GuestBuffers.Length; index++) + foreach (var (callback, kind) in new[] + { + (player.AllocateTextureCallback, "texture"), + (player.AllocateCallback, "generic"), + }) { - if (!scheduler.TryCallGuestFunction( - ctx, - player.AllocateTextureCallback, - player.AllocatorObject, - 0x100, - checked((ulong)bufferSize), - 0, - 0, - "avplayer_allocate_texture", - out var buffer, - out var error) || buffer == 0) + if (callback == 0) { - Console.Error.WriteLine( - $"[AVPLAYER][ERROR] Guest texture allocation failed index={index} " + - $"callback=0x{player.AllocateTextureCallback:X16}: {error ?? "returned null"}"); - player.TextureAllocatorFailed = true; - Array.Clear(player.GuestBuffers); - break; + continue; + } + + var allocated = true; + for (var index = 0; index < player.GuestBuffers.Length; index++) + { + if (!scheduler.TryCallGuestFunction( + ctx, + callback, + player.AllocatorObject, + TextureAllocationAlignment, + checked((ulong)bufferSize), + 0, + 0, + "avplayer_allocate_" + kind, + out var buffer, + out var error) || buffer == 0) + { + Console.Error.WriteLine( + $"[AVPLAYER][WARN] Guest {kind} allocation failed index={index} " + + $"callback=0x{callback:X16} size={bufferSize} " + + $"align=0x{TextureAllocationAlignment:X}: {error ?? "returned null"}"); + allocated = false; + Array.Clear(player.GuestBuffers); + break; + } + player.GuestBuffers[index] = buffer; + Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}"); + } + + if (allocated) + { + return true; } - player.GuestBuffers[index] = buffer; - Trace($"texture_buffer index={index} data=0x{buffer:X16} size={bufferSize}"); - } - if (!player.TextureAllocatorFailed) - { - return true; } + + player.TextureAllocatorFailed = true; } if (!KernelMemoryCompatExports.TryAllocateHleData( @@ -1043,158 +1015,25 @@ public static class AvPlayerExports height = 0; framesPerSecond = 30.0; durationMilliseconds = 0; - var ffmpeg = FindFfmpeg(); - if (ffmpeg is null) - { - return false; - } - var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows()); - if (!File.Exists(ffprobe)) + + if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration)) { return false; } - var startInfo = new ProcessStartInfo(ffprobe) + if (rate > 0) { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - startInfo.ArgumentList.Add("-v"); - startInfo.ArgumentList.Add("error"); - startInfo.ArgumentList.Add("-select_streams"); - startInfo.ArgumentList.Add("v:0"); - startInfo.ArgumentList.Add("-show_entries"); - startInfo.ArgumentList.Add("stream=width,height,avg_frame_rate,duration"); - startInfo.ArgumentList.Add("-of"); - startInfo.ArgumentList.Add("default=noprint_wrappers=1"); - startInfo.ArgumentList.Add(path); - - try - { - using var process = Process.Start(startInfo); - if (process is null) - { - return false; - } - var output = process.StandardOutput.ReadToEnd(); - var error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - if (process.ExitCode != 0) - { - Console.Error.WriteLine($"[AVPLAYER][FFPROBE] {error.Trim()}"); - return false; - } - - foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - var separator = line.IndexOf('='); - if (separator < 1) - { - continue; - } - var key = line[..separator]; - var value = line[(separator + 1)..]; - switch (key) - { - case "width": - _ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out width); - break; - case "height": - _ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out height); - break; - case "avg_frame_rate": - var parts = value.Split('/'); - if (parts.Length == 2 && - double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator) && - double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) && - denominator != 0) - { - framesPerSecond = numerator / denominator; - } - break; - case "duration": - if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var duration)) - { - durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0))); - } - break; - } - } - return width > 0 && height > 0 && framesPerSecond > 0; + framesPerSecond = rate; } - catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) + + if (duration > 0) { - Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to probe video: {exception.Message}"); - return false; + durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0))); } + + return width > 0 && height > 0 && framesPerSecond > 0; } - internal static string? FindFfmpeg() => - FindFfmpeg( - Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"), - Environment.GetEnvironmentVariable("PATH"), - OperatingSystem.IsWindows(), - AppContext.BaseDirectory); - - internal static string? FindFfmpeg( - string? configured, - string? searchPath, - bool isWindows, - string? baseDirectory = null) - { - if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) - { - return configured; - } - - var executable = isWindows ? "ffmpeg.exe" : "ffmpeg"; - if (!string.IsNullOrWhiteSpace(baseDirectory)) - { - foreach (var candidate in new[] - { - Path.Combine(baseDirectory, executable), - Path.Combine(baseDirectory, "ffmpeg", executable), - }) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - } - - foreach (var directory in (searchPath ?? string.Empty) - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - var candidate = Path.Combine(RemovePathQuotes(directory), executable); - if (File.Exists(candidate)) - { - return candidate; - } - } - - foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" }) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - return null; - } - - internal static string GetFfprobePath(string ffmpeg, bool isWindows) => - Path.Combine( - Path.GetDirectoryName(ffmpeg) ?? string.Empty, - isWindows ? "ffprobe.exe" : "ffprobe"); - - private static string RemovePathQuotes(string directory) => - directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"' - ? directory[1..^1] - : directory; - internal static string? ResolveGuestPath(string guestPath) { if (string.IsNullOrWhiteSpace(guestPath)) @@ -1606,4 +1445,17 @@ public static class AvPlayerExports Console.Error.WriteLine($"[AVPLAYER][INFO] {message}"); } } + + private static void TraceOnce(string key, string message) + { + lock (TracedOnce) + { + if (!TracedOnce.Add(key)) + { + return; + } + } + + Console.Error.WriteLine($"[AVPLAYER][INFO] {message}"); + } } diff --git a/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs b/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs deleted file mode 100644 index f2aae6d9..00000000 --- a/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Diagnostics; -using SharpEmu.Libs.AvPlayer; - -namespace SharpEmu.Libs.Bink; - -internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder -{ - private readonly Process _process; - private readonly Stream _output; - private int _errorLines; - private int _disposed; - - private FfmpegBinkFrameSource( - Process process, - uint width, - uint height, - uint framesPerSecondNumerator, - uint framesPerSecondDenominator) - { - _process = process; - _output = process.StandardOutput.BaseStream; - Width = width; - Height = height; - FramesPerSecondNumerator = framesPerSecondNumerator; - FramesPerSecondDenominator = framesPerSecondDenominator; - } - - public uint Width { get; } - - public uint Height { get; } - - public uint FramesPerSecondNumerator { get; } - - public uint FramesPerSecondDenominator { get; } - - internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null; - - internal static bool TryOpen( - string path, - uint width, - uint height, - uint framesPerSecondNumerator, - uint framesPerSecondDenominator, - out FfmpegBinkFrameSource? source) - { - source = null; - var ffmpeg = AvPlayerExports.FindFfmpeg(); - if (ffmpeg is null) - { - return false; - } - - var startInfo = new ProcessStartInfo(ffmpeg) - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - startInfo.ArgumentList.Add("-nostdin"); - startInfo.ArgumentList.Add("-hide_banner"); - startInfo.ArgumentList.Add("-loglevel"); - startInfo.ArgumentList.Add("error"); - startInfo.ArgumentList.Add("-i"); - startInfo.ArgumentList.Add(path); - startInfo.ArgumentList.Add("-map"); - startInfo.ArgumentList.Add("0:v:0"); - startInfo.ArgumentList.Add("-an"); - startInfo.ArgumentList.Add("-pix_fmt"); - startInfo.ArgumentList.Add("bgra"); - startInfo.ArgumentList.Add("-f"); - startInfo.ArgumentList.Add("rawvideo"); - startInfo.ArgumentList.Add("pipe:1"); - - try - { - var process = Process.Start(startInfo); - if (process is null) - { - return false; - } - - source = new FfmpegBinkFrameSource( - process, - width, - height, - framesPerSecondNumerator, - framesPerSecondDenominator); - process.ErrorDataReceived += source.OnErrorData; - process.BeginErrorReadLine(); - return true; - } - catch (Exception exception) when (exception is IOException or - InvalidOperationException or - System.ComponentModel.Win32Exception) - { - Console.Error.WriteLine( - $"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}"); - return false; - } - } - - public bool TryDecodeNextFrame(Span destination) - { - try - { - var offset = 0; - while (offset < destination.Length) - { - var read = _output.Read(destination[offset..]); - if (read == 0) - { - return false; - } - offset += read; - } - return true; - } - catch (Exception exception) when (exception is IOException or ObjectDisposedException) - { - if (Volatile.Read(ref _disposed) == 0) - { - Console.Error.WriteLine( - $"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}"); - } - return false; - } - } - - private void OnErrorData(object sender, DataReceivedEventArgs eventArgs) - { - if (string.IsNullOrWhiteSpace(eventArgs.Data) || - Interlocked.Increment(ref _errorLines) > 20) - { - return; - } - Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}"); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _output.Dispose(); - try - { - if (!_process.HasExited) - { - _process.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) - { - } - finally - { - _process.Dispose(); - } - } -} diff --git a/src/SharpEmu.Libs/Font/FontExports.cs b/src/SharpEmu.Libs/Font/FontExports.cs index fab39708..57c3ff1f 100644 --- a/src/SharpEmu.Libs/Font/FontExports.cs +++ b/src/SharpEmu.Libs/Font/FontExports.cs @@ -8,7 +8,13 @@ namespace SharpEmu.Libs.Font; public static class FontExports { + private const ushort GlyphMagic = 0x0F03; + private const int GlyphSize = 0x100; + private const int GlyphMetricsSize = 8 * sizeof(float); + private const int RenderOutputSize = 0x40; + private static readonly object AllocationGate = new(); + private static readonly Stack FreeGlyphs = new(); private static ulong _librarySelectionAddress; private static ulong _rendererSelectionAddress; @@ -321,6 +327,151 @@ public static class FontExports return SetSuccess(ctx); } + [SysAbiExport( + Nid = "C-4Qw5Srlyw", + ExportName = "sceFontGenerateCharGlyph", + Target = Generation.Gen5, + LibraryName = "libSceFont")] + public static int GenerateCharGlyph(CpuContext ctx) + { + var outputAddress = ctx[CpuRegister.Rcx]; + if (outputAddress == 0) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + if (!TryRentGlyph(ctx, out var glyph) || + !ctx.TryWriteUInt64(outputAddress, glyph)) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + return SetSuccess(ctx); + } + + [SysAbiExport( + Nid = "8-zmgsxkBek", + ExportName = "sceFontGlyphDefineAttribute", + Target = Generation.Gen5, + LibraryName = "libSceFont")] + public static int GlyphDefineAttribute(CpuContext ctx) => SetSuccess(ctx); + + [SysAbiExport( + Nid = "LHDoRWVFGqk", + ExportName = "sceFontDeleteGlyph", + Target = Generation.Gen5, + LibraryName = "libSceFont")] + public static int DeleteGlyph(CpuContext ctx) + { + var glyphPointerAddress = ctx[CpuRegister.Rsi]; + if (glyphPointerAddress == 0) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + if (!ctx.TryReadUInt64(glyphPointerAddress, out var glyph)) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (glyph != 0) + { + lock (AllocationGate) + { + FreeGlyphs.Push(glyph); + } + } + + return ctx.TryWriteUInt64(glyphPointerAddress, 0) + ? SetSuccess(ctx) + : SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + [SysAbiExport( + Nid = "kAenWy1Zw5o", + ExportName = "sceFontRenderCharGlyphImageHorizontal", + Target = Generation.Gen5, + LibraryName = "libSceFont")] + public static int RenderCharGlyphImageHorizontal(CpuContext ctx) + { + var metricsAddress = ctx[CpuRegister.Rcx]; + var resultAddress = ctx[CpuRegister.R8]; + + if (metricsAddress != 0) + { + var values = new[] { 8.0f, 16.0f, 0.0f, 12.0f, 8.0f, 0.0f, 0.0f, 16.0f }; + for (var index = 0; index < values.Length; index++) + { + if (!TryWriteUInt32( + ctx, + metricsAddress + (ulong)(index * sizeof(float)), + BitConverter.SingleToUInt32Bits(values[index]))) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + } + + if (resultAddress != 0) + { + Span cleared = stackalloc byte[RenderOutputSize]; + cleared.Clear(); + if (!ctx.Memory.TryWrite(resultAddress, cleared)) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + + return SetSuccess(ctx); + } + + [SysAbiExport( + Nid = "vzHs3C8lWJk", + ExportName = "sceFontCloseFont", + Target = Generation.Gen5, + LibraryName = "libSceFont")] + public static int CloseFont(CpuContext ctx) => SetSuccess(ctx); + + [SysAbiExport( + Nid = "1QjhKxrsOB8", + ExportName = "sceFontUnbindRenderer", + Target = Generation.Gen5, + LibraryName = "libSceFont")] + public static int UnbindRenderer(CpuContext ctx) => SetSuccess(ctx); + + [SysAbiExport( + Nid = "exAxkyVLt0s", + ExportName = "sceFontDestroyRenderer", + Target = Generation.Gen5, + LibraryName = "libSceFont")] + public static int DestroyRenderer(CpuContext ctx) + { + var rendererPointerAddress = ctx[CpuRegister.Rdi]; + if (rendererPointerAddress == 0) + { + return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + return ctx.TryWriteUInt64(rendererPointerAddress, 0) + ? SetSuccess(ctx) + : SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + private static bool TryRentGlyph(CpuContext ctx, out ulong glyph) + { + lock (AllocationGate) + { + if (FreeGlyphs.Count > 0) + { + glyph = FreeGlyphs.Pop(); + return TryWriteUInt16(ctx, glyph, GlyphMagic); + } + } + + return TryAllocateOpaque(ctx, GlyphSize, out glyph) && + TryWriteUInt16(ctx, glyph, GlyphMagic); + } + private static int ReturnSelection(CpuContext ctx, ref ulong selectionAddress, uint objectSize) { if (ctx[CpuRegister.Rdi] != 0) diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 319ee53d..2ff1d1f8 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -3,7 +3,7 @@ using SharpEmu.HLE; using SharpEmu.Libs.Ampr; -using SharpEmu.Libs.Bink; +using SharpEmu.Libs.Media; using System.Buffers; using System.Buffers.Binary; using System.Collections.Concurrent; @@ -97,7 +97,7 @@ public static partial class KernelMemoryCompatExports private static readonly object _fdGate = new(); private static readonly Dictionary _openFiles = new(); - private static readonly Dictionary + private static readonly Dictionary _binkGuestCompletionShims = new(); private static readonly Dictionary _observedBinkGuestFiles = new(); private static readonly Dictionary _openDirectories = new(); @@ -1478,7 +1478,7 @@ public static partial class KernelMemoryCompatExports } try { - if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath)) + if (HostMovieBridge.ShouldSkipGuestMovie(hostPath)) { LogOpenTrace( "_open bink-skip path='" + guestPath + "' host='" + hostPath + @@ -1489,10 +1489,10 @@ public static partial class KernelMemoryCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; } - Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default; + HostMovieBridge.BinkGuestCompletionShim binkCompletionShim = default; var observedBinkMovie = false; var useBinkCompletionShim = access == FileAccess.Read && - Bink2MovieBridge.TryTakeOverGuestMovie( + HostMovieBridge.TryTakeOverGuestMovie( hostPath, out binkCompletionShim, out observedBinkMovie); @@ -2227,7 +2227,7 @@ public static partial class KernelMemoryCompatExports if (notifyBinkClose) { - Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!); + HostMovieBridge.NotifyGuestMovieClosed(observedBinkPath!); } stream.Dispose(); ctx[CpuRegister.Rax] = 0; @@ -2256,7 +2256,7 @@ public static partial class KernelMemoryCompatExports } FileStream? stream; - Bink2MovieBridge.BinkGuestCompletionShim completionShim = default; + HostMovieBridge.BinkGuestCompletionShim completionShim = default; var useBinkCompletionShim = false; lock (_fdGate) { @@ -2289,7 +2289,7 @@ public static partial class KernelMemoryCompatExports // logic can't race ahead of what's still on screen. if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read))) { - Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name); + HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name); } } if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read))) diff --git a/src/SharpEmu.Libs/Media/FfmpegMediaStream.cs b/src/SharpEmu.Libs/Media/FfmpegMediaStream.cs new file mode 100644 index 00000000..804a8b34 --- /dev/null +++ b/src/SharpEmu.Libs/Media/FfmpegMediaStream.cs @@ -0,0 +1,489 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.IO; +using System.Threading; +using FFmpeg.AutoGen; + +namespace SharpEmu.Libs.Media; +internal sealed unsafe class FfmpegMediaStream : Stream +{ + internal const int AudioSampleRate = 48000; + internal const int AudioChannels = 2; + + private readonly object _decodeGate = new(); + private readonly bool _isVideo; + private readonly int _videoWidth; + private readonly int _videoHeight; + + private AVFormatContext* _formatContext; + private AVCodecContext* _codecContext; + private AVFrame* _frame; + private AVPacket* _packet; + private SwsContext* _swsContext; + private SwrContext* _swrContext; + private int _streamIndex; + + private byte[] _pending = []; + private int _pendingOffset; + private bool _draining; + private bool _finished; + private int _disposed; + + private FfmpegMediaStream(bool isVideo, int width, int height) + { + _isVideo = isVideo; + _videoWidth = width; + _videoHeight = height; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + internal static bool TryOpenVideo( + string path, + int width, + int height, + out FfmpegMediaStream? stream) => + TryOpen(path, AVMediaType.AVMEDIA_TYPE_VIDEO, width, height, out stream); + + internal static bool TryOpenAudio(string path, out FfmpegMediaStream? stream) => + TryOpen(path, AVMediaType.AVMEDIA_TYPE_AUDIO, 0, 0, out stream); + + private static bool TryOpen( + string path, + AVMediaType mediaType, + int width, + int height, + out FfmpegMediaStream? stream) + { + stream = null; + FfmpegRuntime.EnsureInitialized(); + + var candidate = new FfmpegMediaStream(mediaType == AVMediaType.AVMEDIA_TYPE_VIDEO, width, height); + AVFormatContext* formatContext = null; + try + { + if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0) + { + return false; + } + + if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0) + { + ffmpeg.avformat_close_input(&formatContext); + return false; + } + + AVCodec* decoder = null; + var streamIndex = ffmpeg.av_find_best_stream(formatContext, mediaType, -1, -1, &decoder, 0); + if (streamIndex < 0 || decoder is null) + { + ffmpeg.avformat_close_input(&formatContext); + return false; + } + + var codecContext = ffmpeg.avcodec_alloc_context3(decoder); + if (codecContext is null || + ffmpeg.avcodec_parameters_to_context( + codecContext, + formatContext->streams[streamIndex]->codecpar) < 0 || + ffmpeg.avcodec_open2(codecContext, decoder, null) < 0) + { + if (codecContext is not null) + { + ffmpeg.avcodec_free_context(&codecContext); + } + + ffmpeg.avformat_close_input(&formatContext); + return false; + } + + candidate._formatContext = formatContext; + candidate._codecContext = codecContext; + candidate._streamIndex = streamIndex; + candidate._frame = ffmpeg.av_frame_alloc(); + candidate._packet = ffmpeg.av_packet_alloc(); + if (candidate._frame is null || candidate._packet is null) + { + candidate.Dispose(); + return false; + } + + stream = candidate; + return true; + } + catch (Exception exception) + { + Console.Error.WriteLine( + $"[AVPLAYER][ERROR] in-process decoder failed to open '{path}': {exception.Message}"); + if (formatContext is not null) + { + ffmpeg.avformat_close_input(&formatContext); + } + + candidate.Dispose(); + return false; + } + } + + internal static bool TryProbe( + string path, + out int width, + out int height, + out double frameRate, + out double durationSeconds) + { + width = 0; + height = 0; + frameRate = 0; + durationSeconds = 0; + FfmpegRuntime.EnsureInitialized(); + + AVFormatContext* formatContext = null; + try + { + if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0) + { + return false; + } + + if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0) + { + return false; + } + + var streamIndex = ffmpeg.av_find_best_stream( + formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, null, 0); + if (streamIndex < 0) + { + return false; + } + + var stream = formatContext->streams[streamIndex]; + width = stream->codecpar->width; + height = stream->codecpar->height; + + var rate = stream->avg_frame_rate; + if (rate.den > 0 && rate.num > 0) + { + frameRate = (double)rate.num / rate.den; + } + + if (stream->duration > 0 && stream->time_base.den > 0) + { + durationSeconds = stream->duration * + ((double)stream->time_base.num / stream->time_base.den); + } + else if (formatContext->duration > 0) + { + durationSeconds = (double)formatContext->duration / ffmpeg.AV_TIME_BASE; + } + + return width > 0 && height > 0; + } + finally + { + if (formatContext is not null) + { + ffmpeg.avformat_close_input(&formatContext); + } + } + } + + public override int Read(byte[] buffer, int offset, int count) => + Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) + { + if (buffer.IsEmpty) + { + return 0; + } + + lock (_decodeGate) + { + if (Volatile.Read(ref _disposed) != 0) + { + return 0; + } + + var written = 0; + while (written < buffer.Length) + { + if (_pendingOffset >= _pending.Length) + { + if (_finished || !TryDecodeIntoPending()) + { + break; + } + + continue; + } + + var available = _pending.Length - _pendingOffset; + var take = Math.Min(available, buffer.Length - written); + _pending.AsSpan(_pendingOffset, take).CopyTo(buffer[written..]); + _pendingOffset += take; + written += take; + } + + return written; + } + } + + private bool TryDecodeIntoPending() + { + if (!TryReceiveFrame()) + { + _finished = true; + return false; + } + + var produced = _isVideo ? ConvertVideoFrame() : ConvertAudioFrame(); + ffmpeg.av_frame_unref(_frame); + if (produced is null) + { + _finished = true; + return false; + } + + _pending = produced; + _pendingOffset = 0; + return true; + } + + private byte[]? ConvertVideoFrame() + { + var width = _videoWidth > 0 ? _videoWidth : _frame->width; + var height = _videoHeight > 0 ? _videoHeight : _frame->height; + if (width <= 0 || height <= 0) + { + return null; + } + + _swsContext = ffmpeg.sws_getCachedContext( + _swsContext, + _frame->width, + _frame->height, + (AVPixelFormat)_frame->format, + width, + height, + AVPixelFormat.AV_PIX_FMT_NV12, + ffmpeg.SWS_FAST_BILINEAR, + null, + null, + null); + if (_swsContext is null) + { + return null; + } + var lumaBytes = width * height; + var output = new byte[lumaBytes + lumaBytes / 2]; + fixed (byte* outputPointer = output) + { + var planes = new byte*[4] { outputPointer, outputPointer + lumaBytes, null, null }; + var strides = new int[4] { width, width, 0, 0 }; + var rows = ffmpeg.sws_scale( + _swsContext, + _frame->data, + _frame->linesize, + 0, + _frame->height, + planes, + strides); + return rows == height ? output : null; + } + } + + private byte[]? ConvertAudioFrame() + { + var outputLayout = new AVChannelLayout(); + ffmpeg.av_channel_layout_default(&outputLayout, AudioChannels); + + var inputLayout = _frame->ch_layout; + SwrContext* swrContext = _swrContext; + var configureResult = ffmpeg.swr_alloc_set_opts2( + &swrContext, + &outputLayout, + AVSampleFormat.AV_SAMPLE_FMT_S16, + AudioSampleRate, + &inputLayout, + (AVSampleFormat)_frame->format, + _frame->sample_rate, + 0, + null); + _swrContext = swrContext; + if (configureResult < 0 || _swrContext is null) + { + return null; + } + + if (ffmpeg.swr_is_initialized(_swrContext) == 0 && ffmpeg.swr_init(_swrContext) < 0) + { + return null; + } + + var maxSamples = (int)ffmpeg.av_rescale_rnd( + ffmpeg.swr_get_delay(_swrContext, _frame->sample_rate) + _frame->nb_samples, + AudioSampleRate, + _frame->sample_rate, + AVRounding.AV_ROUND_UP); + if (maxSamples <= 0) + { + return null; + } + + var output = new byte[maxSamples * AudioChannels * sizeof(short)]; + fixed (byte* outputPointer = output) + { + var planes = stackalloc byte*[1]; + planes[0] = outputPointer; + var converted = ffmpeg.swr_convert( + _swrContext, + planes, + maxSamples, + _frame->extended_data, + _frame->nb_samples); + if (converted < 0) + { + return null; + } + + var byteCount = converted * AudioChannels * sizeof(short); + if (byteCount == output.Length) + { + return output; + } + + var trimmed = new byte[byteCount]; + output.AsSpan(0, byteCount).CopyTo(trimmed); + return trimmed; + } + } + + private bool TryReceiveFrame() + { + while (true) + { + var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame); + if (receiveResult >= 0) + { + return true; + } + + if (receiveResult == ffmpeg.AVERROR_EOF || + receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN) || + _draining) + { + return false; + } + + if (!TryFeedPacket()) + { + return false; + } + } + } + + private bool TryFeedPacket() + { + while (true) + { + var readResult = ffmpeg.av_read_frame(_formatContext, _packet); + if (readResult < 0) + { + _draining = true; + return ffmpeg.avcodec_send_packet(_codecContext, null) >= 0; + } + + if (_packet->stream_index != _streamIndex) + { + ffmpeg.av_packet_unref(_packet); + continue; + } + + var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet); + ffmpeg.av_packet_unref(_packet); + return sendResult >= 0; + } + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + base.Dispose(disposing); + return; + } + + lock (_decodeGate) + { + if (_swsContext is not null) + { + ffmpeg.sws_freeContext(_swsContext); + _swsContext = null; + } + + if (_swrContext is not null) + { + var swrContext = _swrContext; + ffmpeg.swr_free(&swrContext); + _swrContext = null; + } + + if (_frame is not null) + { + var frame = _frame; + ffmpeg.av_frame_free(&frame); + _frame = null; + } + + if (_packet is not null) + { + var packet = _packet; + ffmpeg.av_packet_free(&packet); + _packet = null; + } + + if (_codecContext is not null) + { + var codecContext = _codecContext; + ffmpeg.avcodec_free_context(&codecContext); + _codecContext = null; + } + + if (_formatContext is not null) + { + var formatContext = _formatContext; + ffmpeg.avformat_close_input(&formatContext); + _formatContext = null; + } + } + + base.Dispose(disposing); + } +} diff --git a/src/SharpEmu.Libs/Media/FfmpegRuntime.cs b/src/SharpEmu.Libs/Media/FfmpegRuntime.cs new file mode 100644 index 00000000..6c8a0a31 --- /dev/null +++ b/src/SharpEmu.Libs/Media/FfmpegRuntime.cs @@ -0,0 +1,32 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.IO; +using FFmpeg.AutoGen; + +namespace SharpEmu.Libs.Media; +internal static class FfmpegRuntime +{ + private static readonly object _gate = new(); + private static bool _initialized; + internal static void EnsureInitialized() + { + if (_initialized) + { + return; + } + + lock (_gate) + { + if (_initialized) + { + return; + } + + _initialized = true; + ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins"); + DynamicallyLoadedBindings.Initialize(); + } + } +} diff --git a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs b/src/SharpEmu.Libs/Media/FfmpegVideoDecoder.cs similarity index 92% rename from src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs rename to src/SharpEmu.Libs/Media/FfmpegVideoDecoder.cs index 0551ad70..25af92d0 100644 --- a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs +++ b/src/SharpEmu.Libs/Media/FfmpegVideoDecoder.cs @@ -5,7 +5,7 @@ using System.Buffers; using FFmpeg.AutoGen; using SharpEmu.HLE.Host; -namespace SharpEmu.Libs.Bink; +namespace SharpEmu.Libs.Media; /// /// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API @@ -13,7 +13,7 @@ namespace SharpEmu.Libs.Bink; /// libraries published by github.com/sharpemu/ffmpeg-core -- no native C /// bridge of our own to build. See docs/bink2-bridge.md. /// -internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder +internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder { private const int OutputAudioChannels = 2; private const int OutputAudioBytesPerSample = sizeof(short); @@ -48,7 +48,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder public uint FramesPerSecondDenominator { get; } - private FfmpegNativeBinkFrameSource( + private FfmpegVideoDecoder( AVFormatContext* formatContext, AVCodecContext* codecContext, int videoStreamIndex, @@ -77,43 +77,14 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder _packet = ffmpeg.av_packet_alloc(); } - private static bool _rootPathInitialized; - - /// - /// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI - /// downloads next to the executable (see SharpEmu.CLI.csproj's - /// FetchFfmpegRuntime target); kept as loose files rather than embedded - /// in the single-file bundle so the OS loader can resolve the normal - /// inter-library dependencies (avcodec depends on avutil, etc.) itself. - /// - private static void EnsureRootPathInitialized() - { - if (_rootPathInitialized) - { - return; - } - - _rootPathInitialized = true; - // SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a - // "plugins" subfolder next to the executable rather than flat beside - // it (see NativeLibraryFolderName in SharpEmu.CLI.csproj). - ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins"); - - // ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize() - // itself, but that constructor fires on first touch of the ffmpeg type -- - // which is the RootPath assignment above -- so it binds against the - // default (empty) RootPath before the assignment's own setter body runs. - // Every function resolved during that first pass permanently throws - // NotSupportedException. Re-running Initialize() now, with RootPath - // actually set, rebinds everything against the real search path. - DynamicallyLoadedBindings.Initialize(); - } + private static void EnsureRootPathInitialized() => + SharpEmu.Libs.Media.FfmpegRuntime.EnsureInitialized(); internal static bool TryOpen( string path, uint maximumWidth, uint maximumHeight, - out FfmpegNativeBinkFrameSource? source) + out FfmpegVideoDecoder? source) { source = null; EnsureRootPathInitialized(); @@ -222,7 +193,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder outputHeight = Math.Max(1, outputHeight); } - source = new FfmpegNativeBinkFrameSource( + source = new FfmpegVideoDecoder( formatContext, codecContext, videoStreamIndex, diff --git a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs b/src/SharpEmu.Libs/Media/HostMovieBridge.cs similarity index 89% rename from src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs rename to src/SharpEmu.Libs/Media/HostMovieBridge.cs index 907c2f08..bc8ac84e 100644 --- a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs +++ b/src/SharpEmu.Libs/Media/HostMovieBridge.cs @@ -4,29 +4,44 @@ using System.Runtime.InteropServices; using System.Buffers.Binary; -namespace SharpEmu.Libs.Bink; +namespace SharpEmu.Libs.Media; /// -/// Optional host-side Bink 2 bridge for games that ship a static Bink player. +/// Host-side movie bridge for games that decode video inside their own +/// executable instead of going through an HLE decoder. /// -/// The game in that case never imports libSceVideodec, so an HLE video-decoder -/// export cannot see its movie frames. Kernel file opens identify the active -/// .bk2 file and the presenter requests BGRA frames from a tiny native adapter. -/// The adapter is deliberately a separate, user-supplied library: Bink 2 is a -/// proprietary SDK and SharpEmu must neither bundle it nor depend on its ABI. +/// Such a game never imports libSceVideodec or sceAvPlayer, so no HLE export +/// can see its movie frames. Kernel file opens identify the active movie and +/// the presenter requests BGRA frames from — +/// the same decoder sceAvPlayer uses, so every format is handled in one place. /// -internal static class Bink2MovieBridge +internal static class HostMovieBridge { private const uint MaxDimension = 16384; private const uint MaxHostVideoWidth = 1920; private const uint MaxHostVideoHeight = 1080; + private static readonly string[] SelfDecodedMovieExtensions = [".bk2"]; + + private static bool IsSelfDecodedMovie(string hostPath) + { + foreach (var extension in SelfDecodedMovieExtensions) + { + if (hostPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private static readonly object Gate = new(); private static string? _activePath; private static Bink2MovieInfo _activeInfo; private static byte[]? _frameBuffer; private static bool _frameBufferPresented; - private static BinkFramePlayback? _playback; + private static MediaFramePlayback? _playback; private static long _frameSerial; private static uint _presentationWidth = MaxHostVideoWidth; private static uint _presentationHeight = MaxHostVideoHeight; @@ -62,7 +77,7 @@ internal static class Bink2MovieBridge /// statically linked into its executable. /// internal static bool ShouldSkipGuestMovie(string hostPath) => - hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) && + IsSelfDecodedMovie(hostPath) && ResolveMode() == MovieMode.Skip; /// @@ -71,8 +86,7 @@ internal static class Bink2MovieBridge /// internal static bool ObserveGuestMovie(string hostPath) { - if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) || - !File.Exists(hostPath)) + if (!IsSelfDecodedMovie(hostPath) || !File.Exists(hostPath)) { return false; } @@ -186,9 +200,6 @@ internal static class Bink2MovieBridge case MovieMode.Dummy: AttachDummyMovieLocked(hostPath); return; - case MovieMode.Ffmpeg: - AttachFfmpegMovieLocked(hostPath); - return; case MovieMode.Native: AttachNativeMovieLocked(hostPath); return; @@ -197,7 +208,7 @@ internal static class Bink2MovieBridge private static void AttachNativeMovieLocked(string hostPath) { - if (!FfmpegNativeBinkFrameSource.TryOpen( + if (!FfmpegVideoDecoder.TryOpen( hostPath, _presentationWidth, _presentationHeight, out var source) || source is null) { @@ -250,14 +261,13 @@ internal static class Bink2MovieBridge if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase)) { - return MovieMode.Ffmpeg; + return MovieMode.Native; } - // Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades - // gracefully (falls back to the guest's own decode, logging one - // informational line) if the FFmpeg libraries SharpEmu.CLI.csproj - // downloads next to the executable are genuinely unavailable, so - // defaulting to Native unconditionally is safe. + // Native is the default: FfmpegVideoDecoder.TryOpen degrades gracefully + // (falls back to the guest's own decode, logging one informational line) + // if the FFmpeg libraries SharpEmu.CLI.csproj downloads next to the + // executable are genuinely unavailable, so defaulting to it is safe. return MovieMode.Native; } @@ -282,43 +292,15 @@ internal static class Bink2MovieBridge info.Width + "x" + info.Height + "."); } - private static void AttachFfmpegMovieLocked(string hostPath) - { - if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info)) - { - Console.Error.WriteLine( - "[LOADER][WARN] Bink FFmpeg source has an invalid header: " + - Path.GetFileName(hostPath)); - return; - } - - if (!FfmpegBinkFrameSource.TryOpen( - hostPath, - info.Width, - info.Height, - info.FramesPerSecondNumerator, - info.FramesPerSecondDenominator, - out var source) || source is null) - { - return; - } - - AttachPlaybackLocked(hostPath, info, source); - Console.Error.WriteLine( - "[LOADER][INFO] Bink FFmpeg source attached: " + - Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " + - info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps."); - } - private static void AttachPlaybackLocked( string hostPath, Bink2MovieInfo info, - IBinkFrameDecoder decoder) + IMediaFrameDecoder decoder) { CloseActiveLocked(); _activePath = hostPath; _activeInfo = info; - _playback = new BinkFramePlayback(decoder); + _playback = new MediaFramePlayback(decoder); } internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info) @@ -406,7 +388,6 @@ internal static class Bink2MovieBridge Skip, Dummy, Native, - Ffmpeg, } private static readonly Queue PendingMoviePaths = new(); diff --git a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs b/src/SharpEmu.Libs/Media/MediaFramePlayback.cs similarity index 97% rename from src/SharpEmu.Libs/Bink/BinkFramePlayback.cs rename to src/SharpEmu.Libs/Media/MediaFramePlayback.cs index 1f392e6e..8014fab3 100644 --- a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs +++ b/src/SharpEmu.Libs/Media/MediaFramePlayback.cs @@ -4,9 +4,9 @@ using System.Diagnostics; using SharpEmu.HLE.Host; -namespace SharpEmu.Libs.Bink; +namespace SharpEmu.Libs.Media; -internal interface IBinkFrameDecoder : IDisposable +internal interface IMediaFrameDecoder : IDisposable { uint Width { get; } @@ -23,12 +23,12 @@ internal interface IBinkFrameDecoder : IDisposable /// Keeps blocking codec work away from the Vulkan presentation thread and /// releases decoded frames according to the movie time base. /// -internal sealed class BinkFramePlayback : IDisposable +internal sealed class MediaFramePlayback : IDisposable { private const int BufferCount = 5; private readonly object _gate = new(); - private readonly IBinkFrameDecoder _decoder; + private readonly IMediaFrameDecoder _decoder; private readonly Queue _freeBuffers = new(); private readonly Queue _decodedFrames = new(); private readonly Thread _decoderThread; @@ -45,7 +45,7 @@ internal sealed class BinkFramePlayback : IDisposable private bool _finished; private int _disposed; - internal BinkFramePlayback(IBinkFrameDecoder decoder) + internal MediaFramePlayback(IMediaFrameDecoder decoder) { _decoder = decoder; Width = decoder.Width; diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index 6de2f2a3..6417b336 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -5,7 +5,7 @@ using Silk.NET.Core; using Silk.NET.Core.Native; using SharpEmu.HLE; using SharpEmu.Libs.Agc; -using SharpEmu.Libs.Bink; +using SharpEmu.Libs.Media; using SharpEmu.Libs.Gpu; using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler.Vulkan; @@ -4504,7 +4504,7 @@ internal static unsafe class VulkanVideoPresenter _swapchainFormat = surfaceFormat.Format; _swapchainColorSpace = surfaceFormat.ColorSpace; _extent = ChooseExtent(capabilities); - Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height); + HostMovieBridge.SetPresentationSize(_extent.Width, _extent.Height); var presentMode = ChoosePresentMode(); var imageCount = capabilities.MinImageCount + 1; if (capabilities.MaxImageCount != 0) @@ -7727,7 +7727,7 @@ internal static unsafe class VulkanVideoPresenter private void PumpHostMovieFrame() { - if (!Bink2MovieBridge.TryDecodeNextFrame( + if (!HostMovieBridge.TryDecodeNextFrame( advanceClock: _hostMovieLumaTextureAddress != 0 && _hostMovieChromaTextureAddress != 0, out var pixels, diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs index 8c55abff..f7a9c05b 100644 --- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs @@ -75,49 +75,6 @@ public sealed class AvPlayerPathTests : IDisposable AssertPathIsInsideApp0(resolved); } - [Theory] - [InlineData(false, "ffmpeg", "ffprobe")] - [InlineData(true, "ffmpeg.exe", "ffprobe.exe")] - public void MediaToolLookupUsesPlatformNames( - bool isWindows, - string ffmpegName, - string ffprobeName) - { - var toolDirectory = Path.Combine(_tempRoot, "Media Tools"); - Directory.CreateDirectory(toolDirectory); - var ffmpeg = Path.Combine(toolDirectory, ffmpegName); - File.WriteAllBytes(ffmpeg, []); - - var resolved = AvPlayerExports.FindFfmpeg( - configured: null, - searchPath: $"\"{toolDirectory}\"", - isWindows); - - Assert.Equal(ffmpeg, resolved); - Assert.Equal( - Path.Combine(toolDirectory, ffprobeName), - AvPlayerExports.GetFfprobePath(ffmpeg, isWindows)); - } - - [Theory] - [InlineData(false, "ffmpeg")] - [InlineData(true, "ffmpeg.exe")] - public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable) - { - var publishDirectory = Path.Combine(_tempRoot, "publish"); - Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg")); - var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable); - File.WriteAllBytes(ffmpeg, []); - - Assert.Equal( - ffmpeg, - AvPlayerExports.FindFfmpeg( - configured: null, - searchPath: null, - isWindows, - publishDirectory)); - } - [Fact] public void RelativeFileUriCannotEscapeApp0() { diff --git a/tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs similarity index 84% rename from tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs rename to tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs index ca51f9bc..7e921c1e 100644 --- a/tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs +++ b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs @@ -2,18 +2,18 @@ // SPDX-License-Identifier: GPL-2.0-or-later using System.Buffers.Binary; -using SharpEmu.Libs.Bink; +using SharpEmu.Libs.Media; using Xunit; -namespace SharpEmu.Libs.Tests.Bink; +namespace SharpEmu.Libs.Tests.Media; -public sealed class Bink2MovieBridgeTests : IDisposable +public sealed class HostMovieBridgeTests : IDisposable { private readonly string _tempDirectory = Path.Combine( Path.GetTempPath(), $"sharpemu-bink-{Guid.NewGuid():N}"); - public Bink2MovieBridgeTests() + public HostMovieBridgeTests() { Directory.CreateDirectory(_tempDirectory); } @@ -23,7 +23,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable { var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001); - Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info)); + Assert.True(HostMovieBridge.TryReadBinkInfo(path, out var info)); Assert.Equal(3840u, info.Width); Assert.Equal(2160u, info.Height); Assert.Equal(30_000u, info.FramesPerSecondNumerator); @@ -43,7 +43,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable 60, 1); - Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _)); + Assert.True(HostMovieBridge.TryReadBinkInfo(path, out _)); } [Fact] @@ -51,7 +51,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable { var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0); - Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _)); + Assert.False(HostMovieBridge.TryReadBinkInfo(path, out _)); } private string WriteHeader( diff --git a/tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs b/tests/SharpEmu.Libs.Tests/Media/MediaFramePlaybackTests.cs similarity index 86% rename from tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs rename to tests/SharpEmu.Libs.Tests/Media/MediaFramePlaybackTests.cs index 20aefbea..521718cc 100644 --- a/tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs +++ b/tests/SharpEmu.Libs.Tests/Media/MediaFramePlaybackTests.cs @@ -1,17 +1,17 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later -using SharpEmu.Libs.Bink; +using SharpEmu.Libs.Media; using Xunit; -namespace SharpEmu.Libs.Tests.Bink; +namespace SharpEmu.Libs.Tests.Media; -public sealed class BinkFramePlaybackTests +public sealed class MediaFramePlaybackTests { [Fact] public void FramesAdvanceAccordingToMovieClock() { - using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3)); + using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2, 3)); Assert.Equal(1, WaitForAdvancedFrame(playback)[0]); Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced)); @@ -22,7 +22,7 @@ public sealed class BinkFramePlaybackTests Assert.Equal(3, WaitForAdvancedFrame(playback)[0]); } - private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback) + private static byte[] WaitForAdvancedFrame(MediaFramePlayback playback) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); while (DateTime.UtcNow < deadline) @@ -41,7 +41,7 @@ public sealed class BinkFramePlaybackTests [Fact] public void FirstFrameWaitsUntilPresentationStarts() { - using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2)); + using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2)); var first = WaitForFrame(playback, advanceClock: false); Assert.Equal(1, first[0]); @@ -58,7 +58,7 @@ public sealed class BinkFramePlaybackTests } private static byte[] WaitForFrame( - BinkFramePlayback playback, + MediaFramePlayback playback, bool advanceClock) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); @@ -75,7 +75,7 @@ public sealed class BinkFramePlaybackTests throw new TimeoutException("The decoder did not produce a frame."); } - private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder + private sealed class SequenceDecoder(params byte[] values) : IMediaFrameDecoder { private int _index;