mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-01 23:49:44 +08:00
5864328e35
* [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
490 lines
14 KiB
C#
490 lines
14 KiB
C#
// 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<byte> 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);
|
|
}
|
|
}
|