fix(player): demux Android direct play with media3 again

The in-app FFmpeg container demuxer classified every DTS variant as
plain DTS (profile-blind MIME mapping at the demux boundary), so
DTS-HD MA lost its lossless identity downstream, and it ignored
container display-aspect-ratio overrides. media3's extractors get both
right.

Delete the FFmpeg demuxer (JNI, extractor, and its setting); ExoPlayer
direct play demuxes with media3's DefaultExtractorsFactory again, and
mpv - the Android default - reads container display dimensions itself.

close #2124
close #2115
This commit is contained in:
edde746
2026-08-26 18:59:28 +02:00
parent 465da3e82a
commit 8f893e7a67
99 changed files with 2155 additions and 4675 deletions
+2 -4
View File
@@ -19,22 +19,20 @@ else()
target_link_libraries(dovi_bridge log)
endif()
foreach(ffmpeg_library avcodec avformat avutil swresample)
foreach(ffmpeg_library avcodec avutil swresample)
add_library(${ffmpeg_library} SHARED IMPORTED)
set_target_properties(${ffmpeg_library} PROPERTIES
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
endforeach()
add_library(ffmpegJNI SHARED
media3_ffmpeg_decoder/ffmpeg_jni.cc
media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc)
media3_ffmpeg_decoder/ffmpeg_jni.cc)
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
target_include_directories(ffmpegJNI PRIVATE
"${MPV_FFMPEG_ROOT}/include"
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
target_link_libraries(ffmpegJNI
avcodec
avformat
avutil
swresample
android
@@ -1,983 +0,0 @@
/*
* Copyright (c) 2026 Plezy
*
* libavformat demuxing for media3. Wraps an AVFormatContext whose AVIOContext
* is served by a media3 ExtractorInput, so FFmpeg parses the container while
* media3 keeps owning decoders, renderers, and the whole playback surface.
*
* The seam used to be seeking. media3's Extractor is a pull API: an extractor
* cannot move the input; it returns RESULT_SEEK and the loader re-opens the
* data source. libavformat expects random access and seeks whenever it pleases
* (MP4 moov at end of file, AVI idx1, MKV Cues, MPEG-PS binary search), so
* serving it from the loader alone meant unwinding the in-flight libavformat
* call on every backward jump and replaying the whole open — which a
* one-shot lazy parse like matroska's Cues cannot survive (#2096).
*
* libavformat now gets what it actually requires: the Kotlin side hands this
* shim a random-access byte source (FfmpegRandomAccessSource), so every AVIO
* read succeeds at the position libavformat asked for and nothing is ever
* unwound. Two paths serve reads, chosen per read:
*
* - The loader's ExtractorInput when it already stands at that position. This
* is the steady state for sample delivery, and it keeps media3's byte
* accounting, back-pressure and load-error policy in charge of streaming.
* - The random-access source otherwise (header, index, post-seek jumps).
*
* Kotlin nudges the loader back into step after a jump by returning
* RESULT_SEEK to nativeLogicalPosition(); that is an optimization, never a
* correctness requirement, so no budgets or retries guard it.
*/
#include <android/log.h>
#include <jni.h>
#include <cmath>
#include <cstring>
#include <vector>
extern "C" {
#ifdef __cplusplus
#define __STDC_CONSTANT_MACROS
#ifdef _STDINT_H
#undef _STDINT_H
#endif
#include <stdint.h>
#endif
#include <libavcodec/avcodec.h>
#include <libavcodec/bsf.h>
#include <libavformat/avformat.h>
#include <libavutil/dict.h>
#include <libavutil/display.h>
#include <libavutil/dovi_meta.h>
#include <libavutil/error.h>
#include <libavutil/rational.h>
}
#define LOG_TAG "ffmpeg_demuxer"
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__))
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
namespace {
// readPacket() result codes written to out[0]. Negative values are raw
// AVERROR codes; ERR_JAVA signals the byte source threw and Kotlin must
// rethrow the stored IOException.
const jint CODE_PACKET = 0;
const jint CODE_EOF = 1;
const jint CODE_GROW = 2;
const jint ERR_NOT_OPEN = -101;
const jint ERR_JAVA = -102;
// streamInfo() long[] layout. Keep in sync with FfmpegDemuxerJni.kt.
const int INFO_TRACK_TYPE = 0; // 0 video, 1 audio, 2 text, -1 unsupported
const int INFO_WIDTH = 1;
const int INFO_HEIGHT = 2;
const int INFO_PAR_NUM = 3;
const int INFO_PAR_DEN = 4;
const int INFO_FPS_NUM = 5;
const int INFO_FPS_DEN = 6;
const int INFO_SAMPLE_RATE = 7;
const int INFO_CHANNELS = 8;
const int INFO_PCM_ENCODING = 9; // media3 C.ENCODING_* value, -1 when n/a
const int INFO_ROTATION = 10;
const int INFO_SELECTION_FLAGS = 11;
const int INFO_ROLE_FLAGS = 12;
const int INFO_BITRATE = 13;
const int INFO_DOVI_PROFILE = 14; // DV profile from dvcC/dvvC side data, -1 when none
const int INFO_DOVI_LEVEL = 15;
const int INFO_LATM = 16; // 1 when the audio stream is LOAS/LATM-framed AAC
const int INFO_LENGTH = 17;
// Android AudioFormat encoding constants (media3 C.ENCODING_* mirror them).
const jint PCM_ENCODING_NONE = -1;
const jint PCM_ENCODING_8BIT = 3;
const jint PCM_ENCODING_16BIT = 2;
const jint PCM_ENCODING_FLOAT = 4;
const jint PCM_ENCODING_24BIT_PACKED = 21;
const jint PCM_ENCODING_32BIT = 22;
const jint PCM_ENCODING_ALAW = 11;
const jint PCM_ENCODING_ULAW = 12;
// media3 C.SELECTION_FLAG_* and C.ROLE_FLAG_* values.
const jint SELECTION_FLAG_DEFAULT = 1;
const jint SELECTION_FLAG_FORCED = 2;
const jint ROLE_FLAG_MAIN = 1;
const jint ROLE_FLAG_DESCRIPTION = 64; // C.ROLE_FLAG_DESCRIPTION
const jint ROLE_FLAG_HARD_OF_HEARING = 512; // C.ROLE_FLAG_HARD_OF_HEARING
constexpr jint kReadChunkBytes = 64 * 1024;
struct DemuxState {
JavaVM* vm = nullptr;
jobject input = nullptr;
jmethodID midPosition = nullptr;
jmethodID midRead = nullptr;
jmethodID midReadAt = nullptr;
jmethodID midLength = nullptr;
jbyteArray readBuffer = nullptr;
AVFormatContext* format = nullptr;
AVIOContext* avio = nullptr;
bool opened = false;
// Byte position libavformat will read next. Authoritative: the AVIO seek
// callback moves it, and both read paths serve exactly this offset.
int64_t logicalPos = 0;
// format->start_time in microseconds; subtracted from delivered pts so
// media3's zero-based timeline matches the duration we report (MPEG-PS/TS
// containers commonly start at a nonzero PCR).
int64_t startTimeUs = 0;
AVPacket* packet = nullptr;
AVPacket* filteredPacket = nullptr;
// A packet that outgrew the Java-side buffer: already read and filtered,
// waiting for redelivery after CODE_GROW. Without this flag the retry's
// av_read_frame would silently drop it.
bool packetPending = false;
// Annex-B conversion for H264/HEVC streams stored length-prefixed
// (matroska/mp4 family); one context per stream, null when none needed.
AVBSFContext** bsf = nullptr;
unsigned nbStreams = 0;
bool javaError = false;
};
// One demuxer at a time: a player session owns exactly one progressive source
// and the extractor releases its state before another can open.
DemuxState* gState = nullptr;
JavaVM* gVm = nullptr;
// Serializes JNI entry points against closeState: the fallback path can
// release the player while the loader thread is inside a native call, and
// deleting DemuxState under it is a use-after-free (observed as a scudo
// invalid-chunk abort during MPV fallback teardown).
pthread_mutex_t gStateMutex = PTHREAD_MUTEX_INITIALIZER;
struct StateLock {
StateLock() { pthread_mutex_lock(&gStateMutex); }
~StateLock() { pthread_mutex_unlock(&gStateMutex); }
};
JNIEnv* envFor() {
JNIEnv* env = nullptr;
if (gVm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) return nullptr;
return env;
}
bool javaPending(JNIEnv* env) { return env->ExceptionCheck() == JNI_TRUE; }
void javaClear(JNIEnv* env) {
if (env->ExceptionCheck() == JNI_TRUE) env->ExceptionClear();
}
jlong callPosition(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midPosition); }
jlong callLength(JNIEnv* env, DemuxState* s) { return env->CallLongMethod(s->input, s->midLength); }
// Returns bytes read, 0 at end of input, or -1 when the input failed (the
// Kotlin proxy stored the IOException message).
jint callRead(JNIEnv* env, DemuxState* s, jint length) {
jint n = env->CallIntMethod(s->input, s->midRead, s->readBuffer, length);
if (javaPending(env)) {
javaClear(env);
s->javaError = true;
return -1;
}
return n;
}
// Same contract as callRead, but at an absolute position: the Kotlin source
// reopens its handle when the position is not where it stands.
jint callReadAt(JNIEnv* env, DemuxState* s, int64_t position, jint length) {
jint n = env->CallIntMethod(s->input, s->midReadAt, static_cast<jlong>(position), s->readBuffer, length);
if (javaPending(env)) {
javaClear(env);
s->javaError = true;
return -1;
}
return n;
}
int avioReadPacket(void* opaque, uint8_t* buf, int bufSize) {
DemuxState* s = static_cast<DemuxState*>(opaque);
JNIEnv* env = envFor();
if (env == nullptr) return AVERROR(EIO);
if (bufSize <= 0) return 0;
// After the byte source has failed once in this native call, fail every
// further read immediately: libavformat's internal recovery (matroska
// resync) would drive the dead source again, clobbering the stored
// IOException message — and were the source half-alive, could silently
// resync past the failed range. Failing fast makes the whole entry return
// ERR_JAVA, and media3's retry resumes at the exact failed position.
if (s->javaError) return AVERROR(EIO);
const jint want = bufSize < kReadChunkBytes ? bufSize : kReadChunkBytes;
// Prefer the loader's input when it already stands here: those bytes then
// count as media3's own load progress, which is what drives its buffering
// and back-pressure. Anywhere else, read at the absolute position.
const int64_t inputPos = callPosition(env, s);
if (javaPending(env)) {
javaClear(env);
s->javaError = true;
return AVERROR(EIO);
}
const jint n = inputPos == s->logicalPos ? callRead(env, s, want) : callReadAt(env, s, s->logicalPos, want);
if (n < 0 || s->javaError) {
// A negative count is the Input contract for a byte source that threw:
// the Kotlin proxy caught the IOException and stored its message. Mark
// javaError so the JNI entry point returns ERR_JAVA and media3's
// load-error policy retries the load; a bare AVERROR would read as a
// malformed container, which media3 never retries (#2113).
s->javaError = true;
return AVERROR(EIO);
}
if (n == 0) return AVERROR_EOF;
env->GetByteArrayRegion(s->readBuffer, 0, n, reinterpret_cast<jbyte*>(buf));
s->logicalPos += n;
return n;
}
int64_t avioSeek(void* opaque, int64_t offset, int whence) {
DemuxState* s = static_cast<DemuxState*>(opaque);
JNIEnv* env = envFor();
if (env == nullptr) return AVERROR(EIO);
whence &= ~AVSEEK_FORCE;
if (whence == AVSEEK_SIZE) {
int64_t length = callLength(env, s);
if (javaPending(env)) {
javaClear(env);
s->javaError = true;
return -1;
}
return length < 0 ? -1 : length;
}
int64_t target;
if (whence == SEEK_CUR) {
target = s->logicalPos + offset;
} else if (whence == SEEK_END) {
int64_t length = callLength(env, s);
if (javaPending(env)) {
javaClear(env);
s->javaError = true;
return AVERROR(EIO);
}
if (length < 0) return AVERROR(EIO);
target = length + offset;
} else {
target = offset;
}
// Pure bookkeeping: the next read serves this position from whichever path
// can, so a seek can no longer fail or need reconciling. avio invalidates
// its own buffers after this successful callback.
s->logicalPos = target;
return target;
}
// A failed byte-source read latches AVIOContext error state: fill_buffer
// stores the AVERROR in pb->error, sets pb->eof_reached, and never drives the
// read callback again while either is set (aviobuf.c). The Kotlin side maps
// ERR_JAVA to an IOException that media3's load-error policy retries with the
// source reopened, so the latch must be dropped when the retry re-enters —
// otherwise the first retry re-fails on the stale error without ever reaching
// the byte source (#2113). A genuine end of file latches eof_reached with
// error == 0 and is deliberately left alone.
void clearLatchedIoError(DemuxState* s) {
if (s->format == nullptr || s->format->pb == nullptr) return;
if (s->format->pb->error >= 0) return;
s->format->pb->error = 0;
s->format->pb->eof_reached = 0;
}
// Probing (ffio_ensure_seekback) can swap the AVIO buffer for a larger
// allocation and free the original, so the pointer handed to
// avio_alloc_context may be stale. Free whichever buffer the context
// currently holds, exactly once.
void freeAvio(DemuxState* s) {
if (s->avio == nullptr) return;
av_freep(&s->avio->buffer);
avio_context_free(&s->avio);
}
void closeState() {
DemuxState* s = gState;
if (s == nullptr) return; // Close without (or after) an open is a no-op.
JNIEnv* env = envFor();
if (s->filteredPacket != nullptr) {
av_packet_free(&s->filteredPacket);
}
if (s->bsf != nullptr) {
for (unsigned i = 0; i < s->nbStreams; i++) {
if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]);
}
av_freep(&s->bsf);
}
if (s->packet != nullptr) {
av_packet_free(&s->packet);
}
if (s->format != nullptr) {
avformat_close_input(&s->format);
}
freeAvio(s);
if (env != nullptr && s->input != nullptr) {
if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer);
env->DeleteGlobalRef(s->input);
}
delete s;
gState = nullptr;
}
const char* videoMime(AVCodecID id) {
switch (id) {
case AV_CODEC_ID_H264:
return "video/avc";
case AV_CODEC_ID_HEVC:
return "video/hevc";
case AV_CODEC_ID_MPEG4:
return "video/mp4v-es";
case AV_CODEC_ID_MPEG2VIDEO:
return "video/mpeg2";
case AV_CODEC_ID_MJPEG:
return "video/mjpeg";
case AV_CODEC_ID_VP8:
return "video/x-vnd.on2.vp8";
case AV_CODEC_ID_VP9:
return "video/x-vnd.on2.vp9";
case AV_CODEC_ID_AV1:
return "video/av01";
case AV_CODEC_ID_H263:
return "video/3gpp";
default:
return nullptr;
}
}
const char* audioMime(AVCodecID id, jint* pcmEncoding) {
*pcmEncoding = PCM_ENCODING_NONE;
switch (id) {
case AV_CODEC_ID_AAC:
return "audio/mp4a-latm";
case AV_CODEC_ID_AAC_LATM:
// LOAS/LATM framing. Kotlin wraps the track with LatmTrackOutput, which
// unwraps to raw AAC access units and emits the real decoder Format;
// this mime only marks the stream as supported.
return "audio/mp4a-latm";
case AV_CODEC_ID_MP3:
return "audio/mpeg";
case AV_CODEC_ID_MP2:
return "audio/mpeg-L2";
case AV_CODEC_ID_AC3:
return "audio/ac3";
case AV_CODEC_ID_EAC3:
return "audio/eac3";
case AV_CODEC_ID_DTS:
return "audio/vnd.dts";
case AV_CODEC_ID_TRUEHD:
return "audio/true-hd";
case AV_CODEC_ID_FLAC:
return "audio/flac";
case AV_CODEC_ID_OPUS:
return "audio/opus";
case AV_CODEC_ID_VORBIS:
return "audio/vorbis";
case AV_CODEC_ID_ALAC:
return "audio/alac";
case AV_CODEC_ID_AMR_NB:
return "audio/amr";
case AV_CODEC_ID_AMR_WB:
return "audio/amr-wb";
case AV_CODEC_ID_PCM_S16LE:
*pcmEncoding = PCM_ENCODING_16BIT;
return "audio/raw";
case AV_CODEC_ID_PCM_S16BE:
*pcmEncoding = PCM_ENCODING_16BIT;
return "audio/raw";
case AV_CODEC_ID_PCM_S8:
*pcmEncoding = PCM_ENCODING_8BIT;
return "audio/raw";
case AV_CODEC_ID_PCM_U8:
*pcmEncoding = PCM_ENCODING_8BIT;
return "audio/raw";
case AV_CODEC_ID_PCM_S24LE:
*pcmEncoding = PCM_ENCODING_24BIT_PACKED;
return "audio/raw";
case AV_CODEC_ID_PCM_S32LE:
*pcmEncoding = PCM_ENCODING_32BIT;
return "audio/raw";
case AV_CODEC_ID_PCM_F32LE:
*pcmEncoding = PCM_ENCODING_FLOAT;
return "audio/raw";
case AV_CODEC_ID_PCM_F32BE:
*pcmEncoding = PCM_ENCODING_FLOAT;
return "audio/raw";
case AV_CODEC_ID_PCM_ALAW:
*pcmEncoding = PCM_ENCODING_ALAW;
return "audio/raw";
case AV_CODEC_ID_PCM_MULAW:
*pcmEncoding = PCM_ENCODING_ULAW;
return "audio/raw";
default:
return nullptr;
}
}
const char* subtitleMime(AVCodecID id) {
switch (id) {
case AV_CODEC_ID_SUBRIP:
return "application/x-subrip";
case AV_CODEC_ID_ASS:
case AV_CODEC_ID_SSA:
return "text/x-ssa";
case AV_CODEC_ID_WEBVTT:
return "text/vtt";
case AV_CODEC_ID_HDMV_PGS_SUBTITLE:
return "application/pgs";
case AV_CODEC_ID_DVD_SUBTITLE:
// media3's VobsubParser takes the .idx text (Matroska CodecPrivate,
// surfaced verbatim as extradata) as initialization data and the raw
// SPU payloads as samples — exactly what this path delivers.
return "application/vobsub";
default:
// DVB subtitles are deliberately dropped: media3's DvbParser expects
// the TS/PMT 5-byte config, not Matroska CodecPrivate, and DVB rides
// TS in practice — which stays on media3's extractor.
return nullptr;
}
}
// Maps an attachment ordinal to its stream index. Attachment payloads
// (embedded fonts) ride extradata and are fully parsed during
// avformat_open_input; these streams never produce packets.
int attachmentStreamIndex(AVFormatContext* format, jint ordinal) {
jint seen = 0;
for (unsigned i = 0; i < format->nb_streams; i++) {
AVCodecParameters* params = format->streams[i]->codecpar;
if (params->codec_type != AVMEDIA_TYPE_ATTACHMENT) continue;
if (params->extradata == nullptr || params->extradata_size <= 0) continue;
if (seen++ == ordinal) return static_cast<int>(i);
}
return -1;
}
} // namespace
extern "C" {
JNIEXPORT jstring JNICALL
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeProbeFormat(JNIEnv* env, jobject thiz, jbyteArray header) {
StateLock lock;
jsize size = env->GetArrayLength(header);
std::vector<uint8_t> padded(static_cast<size_t>(size) + AVPROBE_PADDING_SIZE, 0);
env->GetByteArrayRegion(header, 0, size, reinterpret_cast<jbyte*>(padded.data()));
AVProbeData probe{};
probe.buf = padded.data();
probe.buf_size = size;
probe.filename = "";
int score = 0;
const AVInputFormat* format = av_probe_input_format2(&probe, 1, &score);
if (format == nullptr || score <= 0) return nullptr;
return env->NewStringUTF(format->name);
}
// Returns 0 on success or a negative AVERROR (ERR_JAVA when the byte source
// itself threw). The stream count is NOT the return value: positive counts
// would share the result-code namespace, so callers use nativeStreamCount().
JNIEXPORT jint JNICALL
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeOpen(JNIEnv* env, jobject thiz, jobject input) {
StateLock lock;
if (gVm == nullptr) {
env->GetJavaVM(&gVm);
av_log_set_level(AV_LOG_ERROR);
}
if (gState == nullptr) {
gState = new DemuxState();
gState->vm = gVm;
}
DemuxState* s = gState;
// FindClass on the declared interface (not GetObjectClass on the concrete
// proxy): JNI dispatches interface method IDs virtually, and the named
// class is what the shrinker keep in proguard-rules.pro pins.
jclass inputClass = env->FindClass("com/edde746/plezy/exoplayer/FfmpegDemuxerJni$Input");
if (inputClass == nullptr) {
javaClear(env);
return ERR_NOT_OPEN;
}
jmethodID midPosition = env->GetMethodID(inputClass, "position", "()J");
jmethodID midRead = env->GetMethodID(inputClass, "read", "([BI)I");
jmethodID midReadAt = env->GetMethodID(inputClass, "readAt", "(J[BI)I");
jmethodID midLength = env->GetMethodID(inputClass, "length", "()J");
env->DeleteLocalRef(inputClass);
if (midPosition == nullptr || midRead == nullptr || midReadAt == nullptr || midLength == nullptr) {
javaClear(env);
return ERR_NOT_OPEN;
}
// A fresh open replaces any previous proxy binding.
if (s->input != nullptr) {
if (s->readBuffer != nullptr) env->DeleteGlobalRef(s->readBuffer);
env->DeleteGlobalRef(s->input);
s->input = nullptr;
s->readBuffer = nullptr;
}
s->input = env->NewGlobalRef(input);
s->midPosition = midPosition;
s->midRead = midRead;
s->midReadAt = midReadAt;
s->midLength = midLength;
jbyteArray readBuffer = env->NewByteArray(kReadChunkBytes);
s->readBuffer = static_cast<jbyteArray>(env->NewGlobalRef(readBuffer));
env->DeleteLocalRef(readBuffer);
if (s->packet == nullptr) {
s->packet = av_packet_alloc();
s->filteredPacket = av_packet_alloc();
if (s->packet == nullptr || s->filteredPacket == nullptr) return AVERROR(ENOMEM);
}
// One AVIOContext per item: a stale buffer or a sticky error from the
// previous item must not leak into this open, and reallocating is cheaper
// than reasoning about which of libavformat's internal fields to reset.
freeAvio(s);
constexpr int kAvioBufferSize = 64 * 1024;
unsigned char* avioBuffer = static_cast<unsigned char*>(av_malloc(kAvioBufferSize));
if (avioBuffer == nullptr) return AVERROR(ENOMEM);
s->avio = avio_alloc_context(
avioBuffer, kAvioBufferSize, /*write_flag=*/0, s, avioReadPacket,
/*write_packet=*/nullptr, avioSeek);
if (s->avio == nullptr) {
av_freep(&avioBuffer);
return AVERROR(ENOMEM);
}
s->javaError = false;
s->packetPending = false;
s->logicalPos = 0;
// Nothing is ever resumed across calls now: every read succeeds at the
// position libavformat asked for, so open_input and find_stream_info each
// run once, to completion.
avformat_close_input(&s->format);
s->opened = false;
s->format = avformat_alloc_context();
if (s->format == nullptr) return AVERROR(ENOMEM);
s->format->pb = s->avio;
// Generate missing PTS: some raw streams (MPEG-TS after a seek) present
// packets without timestamps that media3 cannot schedule.
s->format->flags |= AVFMT_FLAG_GENPTS;
int err = avformat_open_input(&s->format, "", nullptr, nullptr);
if (s->javaError) return ERR_JAVA;
if (err < 0) {
LOGW("avformat_open_input failed: %d", err);
return err;
}
err = avformat_find_stream_info(s->format, nullptr);
if (s->javaError) return ERR_JAVA;
if (err < 0) {
LOGW("avformat_find_stream_info failed: %d", err);
return err;
}
// Zero-base the timeline: delivered pts subtract this so media3 sees
// [0, duration] even when the container starts at a nonzero PCR/PTS.
s->startTimeUs = s->format->start_time != AV_NOPTS_VALUE ? s->format->start_time : 0;
s->opened = true;
bool anySupported = false;
for (unsigned i = 0; i < s->format->nb_streams; i++) {
AVStream* stream = s->format->streams[i];
AVCodecID codecId = stream->codecpar->codec_id;
jint pcmEncoding;
bool supported = videoMime(codecId) != nullptr || audioMime(codecId, &pcmEncoding) != nullptr ||
subtitleMime(codecId) != nullptr;
// ID3/MKV attached pictures surface as video streams; media3 cannot
// render them as tracks.
if (stream->attached_pic.size > 0) supported = false;
if (!supported) {
LOGW("Dropping stream %u: unsupported codec %s", i, avcodec_get_name(codecId));
stream->discard = AVDISCARD_ALL;
} else {
anySupported = true;
}
}
if (!anySupported) {
// Fail fast so the session falls back (mpv decodes WMV and friends)
// instead of buffering an empty track list forever.
LOGW("No supported streams; refusing container");
avformat_close_input(&s->format);
s->opened = false;
return AVERROR(ENOSYS);
}
// MediaCodec requires Annex-B start-code framing for H264/HEVC, while the
// matroska/mp4 family stores length-prefixed NAL units and libavformat
// hands those through verbatim (ffmpeg muxers insert the same
// *_mp4toannexb bitstream filters in this situation). Filter those streams
// here so every packet leaving the shim is decoder-ready, and adopt the
// filter's rewritten parameters so extradata surfaces as Annex-B csd too.
if (s->bsf != nullptr) {
for (unsigned i = 0; i < s->nbStreams; i++) {
if (s->bsf[i] != nullptr) av_bsf_free(&s->bsf[i]);
}
av_freep(&s->bsf);
}
s->nbStreams = s->format->nb_streams;
s->bsf = static_cast<AVBSFContext**>(av_calloc(s->nbStreams, sizeof(*s->bsf)));
if (s->bsf == nullptr) return AVERROR(ENOMEM);
for (unsigned i = 0; i < s->nbStreams; i++) {
AVCodecID codecId = s->format->streams[i]->codecpar->codec_id;
const char* filterName = codecId == AV_CODEC_ID_H264 ? "h264_mp4toannexb"
: codecId == AV_CODEC_ID_HEVC ? "hevc_mp4toannexb"
// AVI-family MPEG-4 ASP commonly packs several VOPs into one chunk;
// hardware decoders silently drop those stacks (observed as a decoder
// hang with zero output), so unpack them like every ffmpeg pipeline.
: codecId == AV_CODEC_ID_MPEG4 ? "mpeg4_unpack_bframes"
: nullptr;
const AVBitStreamFilter* filter = av_bsf_get_by_name(filterName);
AVBSFContext* ctx = nullptr;
if (filter == nullptr || av_bsf_alloc(filter, &ctx) < 0 ||
avcodec_parameters_copy(ctx->par_in, s->format->streams[i]->codecpar) < 0) {
if (ctx != nullptr) av_bsf_free(&ctx);
continue;
}
ctx->time_base_in = s->format->streams[i]->time_base;
if (av_bsf_init(ctx) < 0) {
av_bsf_free(&ctx);
continue;
}
avcodec_parameters_copy(s->format->streams[i]->codecpar, ctx->par_out);
s->format->streams[i]->time_base = ctx->time_base_out;
s->bsf[i] = ctx;
}
return 0; // Success; prepareTracks() reads the count via nativeStreamCount().
}
JNIEXPORT void JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeClose(JNIEnv* env, jobject thiz) {
StateLock lock;
closeState();
}
JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamCount(JNIEnv* env, jobject thiz) {
StateLock lock;
if (gState == nullptr || gState->format == nullptr) return ERR_NOT_OPEN;
return static_cast<jint>(gState->format->nb_streams);
}
JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeDurationUs(JNIEnv* env, jobject thiz) {
StateLock lock;
if (gState == nullptr || gState->format == nullptr) return -1;
int64_t duration = gState->format->duration;
return duration == AV_NOPTS_VALUE ? -1 : duration;
}
// Returns true when the stream is usable and filled the out arrays.
JNIEXPORT jboolean JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamInfo(
JNIEnv* env, jobject thiz, jint index, jlongArray numbers, jobjectArray strings) {
StateLock lock;
if (gState == nullptr || gState->format == nullptr || index < 0 ||
index >= static_cast<jint>(gState->format->nb_streams)) {
return JNI_FALSE;
}
AVStream* stream = gState->format->streams[index];
AVCodecParameters* params = stream->codecpar;
jint trackType;
const char* mime = nullptr;
jint pcmEncoding = PCM_ENCODING_NONE;
switch (params->codec_type) {
case AVMEDIA_TYPE_VIDEO:
trackType = 0;
mime = videoMime(params->codec_id);
break;
case AVMEDIA_TYPE_AUDIO:
trackType = 1;
mime = audioMime(params->codec_id, &pcmEncoding);
break;
case AVMEDIA_TYPE_SUBTITLE:
trackType = 2;
mime = subtitleMime(params->codec_id);
break;
default:
return JNI_FALSE;
}
if (mime == nullptr) return JNI_FALSE;
jlong info[INFO_LENGTH] = {};
info[INFO_TRACK_TYPE] = trackType;
info[INFO_WIDTH] = params->width;
info[INFO_HEIGHT] = params->height;
AVRational par = params->sample_aspect_ratio;
if (par.num <= 0 || par.den <= 0) par = stream->sample_aspect_ratio;
info[INFO_PAR_NUM] = par.num > 0 ? par.num : 1;
info[INFO_PAR_DEN] = par.num > 0 ? par.den : 1;
AVRational fps = stream->avg_frame_rate;
info[INFO_FPS_NUM] = fps.num > 0 ? fps.num : 0;
info[INFO_FPS_DEN] = fps.num > 0 ? fps.den : 0;
info[INFO_SAMPLE_RATE] = params->sample_rate > 0 ? params->sample_rate : -1;
info[INFO_CHANNELS] = params->ch_layout.nb_channels > 0 ? params->ch_layout.nb_channels : -1;
info[INFO_PCM_ENCODING] = pcmEncoding;
info[INFO_LATM] = params->codec_id == AV_CODEC_ID_AAC_LATM ? 1 : 0;
jint rotation = 0;
AVPacketSideData* sideData = nullptr;
for (int i = 0; i < params->nb_coded_side_data; i++) {
if (params->coded_side_data[i].type == AV_PKT_DATA_DISPLAYMATRIX) {
sideData = &params->coded_side_data[i];
break;
}
}
if (sideData != nullptr) {
double angle = av_display_rotation_get(reinterpret_cast<const int32_t*>(sideData->data));
if (!std::isnan(angle)) rotation = static_cast<jint>(-lround(angle));
}
info[INFO_ROTATION] = rotation;
jint selectionFlags = 0;
jint roleFlags = 0;
if (stream->disposition & AV_DISPOSITION_DEFAULT) selectionFlags |= SELECTION_FLAG_DEFAULT;
if (stream->disposition & AV_DISPOSITION_FORCED) selectionFlags |= SELECTION_FLAG_FORCED;
if (stream->disposition & AV_DISPOSITION_HEARING_IMPAIRED) roleFlags |= ROLE_FLAG_HARD_OF_HEARING;
if (stream->disposition & AV_DISPOSITION_VISUAL_IMPAIRED) roleFlags |= ROLE_FLAG_DESCRIPTION;
if (selectionFlags == 0 && trackType != 2) roleFlags |= ROLE_FLAG_MAIN;
info[INFO_SELECTION_FLAGS] = selectionFlags;
info[INFO_ROLE_FLAGS] = roleFlags;
info[INFO_BITRATE] = params->bit_rate > 0 ? params->bit_rate : -1;
// Dolby Vision configuration (dvcC/dvvC), surfaced by libavformat as an
// unpacked AVDOVIDecoderConfigurationRecord — one byte per field, not the
// bit-packed ISOBMFF box layout. Kotlin mirrors media3's DolbyVisionConfig
// to publish video/dolby-vision plus the RFC 6381 codecs string.
info[INFO_DOVI_PROFILE] = -1;
info[INFO_DOVI_LEVEL] = -1;
for (int i = 0; i < params->nb_coded_side_data; i++) {
AVPacketSideData* sd = &params->coded_side_data[i];
if (sd->type == AV_PKT_DATA_DOVI_CONF && sd->size >= sizeof(AVDOVIDecoderConfigurationRecord)) {
const AVDOVIDecoderConfigurationRecord* dovi =
reinterpret_cast<const AVDOVIDecoderConfigurationRecord*>(sd->data);
info[INFO_DOVI_PROFILE] = dovi->dv_profile;
info[INFO_DOVI_LEVEL] = dovi->dv_level;
break;
}
}
env->SetLongArrayRegion(numbers, 0, INFO_LENGTH, info);
const char* language = nullptr;
const char* label = nullptr;
if (stream->metadata != nullptr) {
AVDictionaryEntry* entry = av_dict_get(stream->metadata, "language", nullptr, 0);
if (entry != nullptr) language = entry->value;
entry = av_dict_get(stream->metadata, "title", nullptr, 0);
if (entry != nullptr) label = entry->value;
}
env->SetObjectArrayElement(strings, 0, env->NewStringUTF(mime));
env->SetObjectArrayElement(strings, 1, language != nullptr ? env->NewStringUTF(language) : nullptr);
env->SetObjectArrayElement(strings, 2, label != nullptr ? env->NewStringUTF(label) : nullptr);
return JNI_TRUE;
}
JNIEXPORT jbyteArray JNICALL
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeStreamExtradata(JNIEnv* env, jobject thiz, jint index) {
StateLock lock;
if (gState == nullptr || gState->format == nullptr || index < 0 ||
index >= static_cast<jint>(gState->format->nb_streams)) {
return nullptr;
}
AVCodecParameters* params = gState->format->streams[index]->codecpar;
// libavformat carries the AVI stream's WAVEFORMATEX header as MP3
// extradata. MediaCodec mp3 decoders expect either no csd or the two-byte
// ISO header, and report a WAVEFORMATEX blob as corrupt input
// (C2_CORRUPTED on the first work item), so drop it.
if (params->codec_id == AV_CODEC_ID_MP3) return nullptr;
if (params->extradata == nullptr || params->extradata_size <= 0) return nullptr;
jbyteArray result = env->NewByteArray(params->extradata_size);
env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast<const jbyte*>(params->extradata));
return result;
}
JNIEXPORT jint JNICALL
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentCount(JNIEnv* env, jobject thiz) {
StateLock lock;
if (gState == nullptr || gState->format == nullptr) return 0;
jint count = 0;
while (attachmentStreamIndex(gState->format, count) >= 0) count++;
return count;
}
// Fills strings with [filename, mimetype] and returns the payload size in
// bytes, or -1 when the ordinal is out of range.
JNIEXPORT jlong JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentInfo(
JNIEnv* env, jobject thiz, jint ordinal, jobjectArray strings) {
StateLock lock;
if (gState == nullptr || gState->format == nullptr) return -1;
int index = attachmentStreamIndex(gState->format, ordinal);
if (index < 0) return -1;
AVStream* stream = gState->format->streams[index];
const char* filename = nullptr;
const char* mimetype = nullptr;
if (stream->metadata != nullptr) {
AVDictionaryEntry* entry = av_dict_get(stream->metadata, "filename", nullptr, 0);
if (entry != nullptr) filename = entry->value;
entry = av_dict_get(stream->metadata, "mimetype", nullptr, 0);
if (entry != nullptr) mimetype = entry->value;
}
env->SetObjectArrayElement(strings, 0, filename != nullptr ? env->NewStringUTF(filename) : nullptr);
env->SetObjectArrayElement(strings, 1, mimetype != nullptr ? env->NewStringUTF(mimetype) : nullptr);
return stream->codecpar->extradata_size;
}
JNIEXPORT jbyteArray JNICALL
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeAttachmentData(JNIEnv* env, jobject thiz, jint ordinal) {
StateLock lock;
if (gState == nullptr || gState->format == nullptr) return nullptr;
int index = attachmentStreamIndex(gState->format, ordinal);
if (index < 0) return nullptr;
AVCodecParameters* params = gState->format->streams[index]->codecpar;
jbyteArray result = env->NewByteArray(params->extradata_size);
if (result == nullptr) return nullptr;
env->SetByteArrayRegion(result, 0, params->extradata_size, reinterpret_cast<const jbyte*>(params->extradata));
return result;
}
// Reads one packet. out[0] carries the result code; on CODE_PACKET the packet
// bytes were written into the byte array and out carries
// [1]=streamIndex [2]=ptsUs (start-time normalized; packets without any
// timestamp are dropped, media3 cannot schedule them) [3]=flags [4]=size
// [5]=packet position in input coordinates [6]=durationUs (-1 unknown).
JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeReadPacket(
JNIEnv* env, jobject thiz, jbyteArray buffer, jlongArray out) {
StateLock lock;
DemuxState* s = gState;
if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN;
jint capacity = env->GetArrayLength(buffer);
jlong result[7] = {};
s->javaError = false;
clearLatchedIoError(s);
while (true) {
if (s->packetPending) {
// Oversized packet held across a CODE_GROW round trip: deliver it now
// instead of reading (and losing) a new frame.
s->packetPending = false;
} else {
av_packet_unref(s->packet);
int err = av_read_frame(s->format, s->packet);
if (err == AVERROR_EOF) {
result[0] = CODE_EOF;
env->SetLongArrayRegion(out, 0, 7, result);
return CODE_EOF;
}
if (err < 0 || s->javaError) {
result[0] = s->javaError ? ERR_JAVA : err;
env->SetLongArrayRegion(out, 0, 7, result);
return s->javaError ? ERR_JAVA : err;
}
if (s->packet->size <= 0) continue;
if (s->format->streams[s->packet->stream_index]->discard >= AVDISCARD_ALL) continue;
AVBSFContext* bsf = s->bsf != nullptr && s->packet->stream_index < static_cast<int>(s->nbStreams)
? s->bsf[s->packet->stream_index]
: nullptr;
if (bsf != nullptr) {
// send consumes the packet reference; move the filtered result back so
// the delivery code below keeps a single code path.
int filterErr = av_bsf_send_packet(bsf, s->packet);
if (filterErr >= 0) {
av_packet_unref(s->packet);
filterErr = av_bsf_receive_packet(bsf, s->filteredPacket);
if (filterErr == AVERROR(EAGAIN)) continue;
if (filterErr < 0) {
result[0] = filterErr;
env->SetLongArrayRegion(out, 0, 7, result);
return filterErr;
}
av_packet_move_ref(s->packet, s->filteredPacket);
} else {
result[0] = filterErr;
env->SetLongArrayRegion(out, 0, 7, result);
return filterErr;
}
}
}
int size = s->packet->size;
if (size > capacity) {
s->packetPending = true;
result[0] = CODE_GROW;
result[4] = size;
env->SetLongArrayRegion(out, 0, 7, result);
return CODE_GROW;
}
env->SetByteArrayRegion(buffer, 0, size, reinterpret_cast<const jbyte*>(s->packet->data));
AVStream* stream = s->format->streams[s->packet->stream_index];
int64_t ptsUs;
if (s->packet->pts != AV_NOPTS_VALUE) {
ptsUs = av_rescale_q(s->packet->pts, stream->time_base, AVRational{1, 1000000});
} else if (s->packet->dts != AV_NOPTS_VALUE) {
ptsUs = av_rescale_q(s->packet->dts, stream->time_base, AVRational{1, 1000000});
} else {
// No timestamp even after GENPTS: media3 cannot schedule the sample,
// and delivering data without metadata would corrupt the sample queue.
continue;
}
ptsUs -= s->startTimeUs;
result[0] = CODE_PACKET;
result[1] = s->packet->stream_index;
result[2] = ptsUs;
result[3] = (s->packet->flags & AV_PKT_FLAG_KEY) != 0 ? 1 : 0;
result[4] = size;
result[5] = s->packet->pos >= 0 ? s->packet->pos : 0;
result[6] =
s->packet->duration > 0 ? av_rescale_q(s->packet->duration, stream->time_base, AVRational{1, 1000000}) : -1;
env->SetLongArrayRegion(out, 0, 7, result);
return CODE_PACKET;
}
}
JNIEXPORT jlong JNICALL
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeLogicalPosition(JNIEnv* env, jobject thiz) {
StateLock lock;
DemuxState* s = gState;
if (s == nullptr || !s->opened) return -1;
return static_cast<jlong>(s->logicalPos);
}
// Seeks to a presentation time (media3 timeline microseconds, start-time
// normalized). Called on the loader thread, where blocking IO belongs:
// libavformat reads its own index through the random-access source, so this
// either lands on the target keyframe or reports that the container has no
// usable index.
JNIEXPORT jint JNICALL
Java_com_edde746_plezy_exoplayer_FfmpegDemuxerJni_nativeSeek(JNIEnv* env, jobject thiz, jlong timeUs) {
StateLock lock;
DemuxState* s = gState;
if (s == nullptr || !s->opened || s->format == nullptr) return ERR_NOT_OPEN;
s->javaError = false;
clearLatchedIoError(s);
// Any packet parked for a CODE_GROW retry belongs to the pre-seek stream.
s->packetPending = false;
if (s->bsf != nullptr) {
for (unsigned i = 0; i < s->nbStreams; i++) {
if (s->bsf[i] != nullptr) av_bsf_flush(s->bsf[i]);
}
}
const int64_t ts = timeUs + s->startTimeUs;
const int err = avformat_seek_file(s->format, -1, INT64_MIN, ts, ts, 0);
if (s->javaError) return ERR_JAVA;
if (err < 0) {
LOGW("seek to %lld failed: %d", (long long)ts, err);
return err;
}
LOGD("seek to %lld: logical=%lld", (long long)ts, (long long)s->logicalPos);
return 0;
}
} // extern "C"