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