feat: DV Profile 7→8.1 RPU conversion via libdovi

Convert Dolby Vision Profile 7 RPU NALUs to Profile 8.1 using libdovi
native bridge, preserving dynamic tone mapping on devices with DV8
decoders. Falls back to HEVC stripping when no DV8 decoder is present.

- Add JNI bridge (dovi_bridge.cpp) wrapping libdovi's RPU converter
- Download prebuilt libdovi from edde746/libdovi-builds releases
- DoviConvertingTrackOutput: DV81 mode converts RPUs, HEVC_STRIP strips
- Retry logic: if native DV7 decoding fails, auto-retry with conversion
- Performance overlay shows active DV conversion mode
This commit is contained in:
edde746
2026-03-05 11:11:57 +01:00
parent 7f2d548d32
commit 317d5dbdd9
13 changed files with 1744 additions and 5 deletions
+47
View File
@@ -8,6 +8,33 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val doviVersion = "2.3.1"
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
val doviAbis = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86" to "i686-linux-android",
"x86_64" to "x86_64-linux-android",
)
val downloadLibdovi by tasks.registering {
val stamp = File(doviDir, ".version")
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
doLast {
doviDir.mkdirs()
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
doviAbis.forEach { (abi, triple) ->
val archive = File(doviDir, "$triple.tar.gz")
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
val outDir = File(doviDir, "$abi/lib")
outDir.mkdirs()
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
archive.delete()
}
stamp.writeText(doviVersion)
}
}
android {
namespace = "com.edde746.plezy"
compileSdk = flutter.compileSdkVersion
@@ -31,6 +58,15 @@ android {
versionCode = flutter.versionCode
versionName = flutter.versionName
externalNativeBuild {
cmake {
arguments += listOf(
"-DDOVI_ENABLE_LIBDOVI=ON",
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
)
}
}
if (System.getenv("AMAZON") != null) {
versionCode = (flutter.versionCode ?: 0) + 3000
ndk {
@@ -39,6 +75,12 @@ android {
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("key.properties")
@@ -80,6 +122,11 @@ flutter {
source = "../.."
}
// Download libdovi before any CMake/native build task
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(downloadLibdovi)
}
dependencies {
implementation("dev.jdtech.mpv:libmpv:0.5.1")
+19
View File
@@ -0,0 +1,19 @@
cmake_minimum_required(VERSION 3.22.1)
project(dovi_bridge)
option(DOVI_ENABLE_LIBDOVI "Link real libdovi" ON)
set(DOVI_LIBDOVI_PREBUILT_ROOT "" CACHE PATH "Path to prebuilt libdovi")
add_library(dovi_bridge SHARED dovi_bridge.cpp)
if(DOVI_ENABLE_LIBDOVI)
add_library(dovi_static STATIC IMPORTED)
set_target_properties(dovi_static PROPERTIES
IMPORTED_LOCATION "${DOVI_LIBDOVI_PREBUILT_ROOT}/${ANDROID_ABI}/lib/libdovi.a")
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=1)
target_include_directories(dovi_bridge PRIVATE ${CMAKE_SOURCE_DIR}/include)
target_link_libraries(dovi_bridge dovi_static log)
else()
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=0)
target_link_libraries(dovi_bridge log)
endif()
+105
View File
@@ -0,0 +1,105 @@
#include <jni.h>
#include <android/log.h>
#include <cstring>
#define TAG "DoviBridge"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
#if DOVI_REAL_LINKED
#include "include/libdovi/rpu_parser.h"
#endif
static const char* BRIDGE_VERSION = "1.0.0";
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
JNIEnv *env, jclass, jbyteArray payload, jint mode) {
#if !DOVI_REAL_LINKED
return nullptr;
#else
if (payload == nullptr) return nullptr;
jsize len = env->GetArrayLength(payload);
if (len <= 0) return nullptr;
jbyte *buf = env->GetByteArrayElements(payload, nullptr);
if (buf == nullptr) return nullptr;
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
if (rpu == nullptr) {
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
return nullptr;
}
const char *err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
dovi_rpu_free(rpu);
rpu = dovi_parse_rpu(
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
if (rpu == nullptr) {
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
return nullptr;
}
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
return nullptr;
}
}
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
// Convert to target profile (mode 2 = P8.1 with no-op curves)
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
if (ret != 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
dovi_rpu_free(rpu);
return nullptr;
}
// Write back as UNSPEC62 NAL
const DoviData *out = dovi_write_unspec62_nalu(rpu);
if (out == nullptr || out->data == nullptr || out->len == 0) {
err = dovi_rpu_get_error(rpu);
LOGW("RPU write failed: %s", err ? err : "unknown");
if (out != nullptr) dovi_data_free(out);
dovi_rpu_free(rpu);
return nullptr;
}
jbyteArray result = env->NewByteArray(static_cast<jsize>(out->len));
if (result != nullptr) {
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len),
reinterpret_cast<const jbyte *>(out->data));
}
dovi_data_free(out);
dovi_rpu_free(rpu);
return result;
#endif
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady(
JNIEnv *, jclass) {
#if DOVI_REAL_LINKED
return JNI_TRUE;
#else
return JNI_FALSE;
#endif
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion(
JNIEnv *env, jclass) {
return env->NewStringUTF(BRIDGE_VERSION);
}
@@ -0,0 +1,747 @@
// SPDX-License-Identifier: MIT
#ifndef DOVI_H
#define DOVI_H
#define RPU_PARSER_MAJOR 3
#define RPU_PARSER_MINOR 3
#define RPU_PARSER_PATCH 2
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <stdbool.h>
#define DoviNUM_COMPONENTS 3
/**
* Opaque Dolby Vision RPU.
*
* Use dovi_rpu_free to free.
* It should be freed regardless of whether or not an error occurred.
*/
typedef struct DoviRpuOpaque DoviRpuOpaque;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer
*/
const uint8_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviData;
/**
* C struct for rpu_data_header()
*/
typedef struct {
/**
* Profile guessed from the values in the header
*/
uint8_t guessed_profile;
/**
* Enhancement layer type (FEL or MEL) if the RPU is profile 7
* null pointer if not profile 7
*/
const char *el_type;
/**
* Deprecated since 3.2.0
* The field is not actually part of the RPU header
*/
uint8_t rpu_nal_prefix;
uint8_t rpu_type;
uint16_t rpu_format;
uint8_t vdr_rpu_profile;
uint8_t vdr_rpu_level;
bool vdr_seq_info_present_flag;
bool chroma_resampling_explicit_filter_flag;
uint8_t coefficient_data_type;
uint64_t coefficient_log2_denom;
uint8_t vdr_rpu_normalized_idc;
bool bl_video_full_range_flag;
uint64_t bl_bit_depth_minus8;
uint64_t el_bit_depth_minus8;
uint64_t vdr_bit_depth_minus8;
bool spatial_resampling_filter_flag;
uint8_t reserved_zero_3bits;
bool el_spatial_resampling_filter_flag;
bool disable_residual_flag;
bool vdr_dm_metadata_present_flag;
bool use_prev_vdr_rpu_flag;
uint64_t prev_vdr_rpu_id;
} DoviRpuDataHeader;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer. Can be null if length is zero.
*/
const uint16_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviU16Data;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer. Can be null if length is zero.
*/
const uint64_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviU64Data;
/**
* Struct representing a data buffer
*/
typedef struct {
/**
* Pointer to the data buffer
*/
const int64_t *data;
/**
* Data buffer size
*/
size_t len;
} DoviI64Data;
/**
* Struct representing a 2D data buffer
*/
typedef struct {
/**
* Pointer to the list of Data structs
*/
const DoviI64Data *const *list;
/**
* List length
*/
size_t len;
} DoviI64Data2D;
/**
* Struct representing a 2D data buffer
*/
typedef struct {
/**
* Pointer to the list of Data structs
*/
const DoviU64Data *const *list;
/**
* List length
*/
size_t len;
} DoviU64Data2D;
typedef struct {
DoviU64Data poly_order_minus1;
DoviData linear_interp_flag;
DoviI64Data2D poly_coef_int;
DoviU64Data2D poly_coef;
} DoviPolynomialCurve;
/**
* Struct representing a 3D data buffer
*/
typedef struct {
/**
* Pointer to the list of I64Data2D structs
*/
const DoviI64Data2D *const *list;
/**
* List length
*/
size_t len;
} DoviI64Data3D;
/**
* Struct representing a 3D data buffer
*/
typedef struct {
/**
* Pointer to the list of U64Data2D structs
*/
const DoviU64Data2D *const *list;
/**
* List length
*/
size_t len;
} DoviU64Data3D;
typedef struct {
DoviData mmr_order_minus1;
DoviI64Data mmr_constant_int;
DoviU64Data mmr_constant;
DoviI64Data3D mmr_coef_int;
DoviU64Data3D mmr_coef;
} DoviMMRCurve;
typedef struct {
/**
* [2, 9]
*/
uint64_t num_pivots_minus2;
DoviU16Data pivots;
/**
* Consistent for a component
* Luma (component 0): Polynomial = 0
* Chroma (components 1 and 2): MMR = 1
*/
uint8_t mapping_idc;
/**
* mapping_idc = 0, null pointer otherwise
*/
const DoviPolynomialCurve *polynomial;
/**
* mapping_idc = 1, null pointer otherwise
*/
const DoviMMRCurve *mmr;
} DoviReshapingCurve;
/**
* C struct for rpu_data_nlq()
*/
typedef struct {
uint16_t nlq_offset[DoviNUM_COMPONENTS];
uint64_t vdr_in_max_int[DoviNUM_COMPONENTS];
uint64_t vdr_in_max[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_slope_int[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_slope[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_threshold_int[DoviNUM_COMPONENTS];
uint64_t linear_deadzone_threshold[DoviNUM_COMPONENTS];
} DoviRpuDataNlq;
/**
* C struct for rpu_data_mapping()
*/
typedef struct {
uint64_t vdr_rpu_id;
uint64_t mapping_color_space;
uint64_t mapping_chroma_format_idc;
uint64_t num_x_partitions_minus1;
uint64_t num_y_partitions_minus1;
DoviReshapingCurve curves[DoviNUM_COMPONENTS];
/**
* Set to -1 to represent Option::None
*/
int32_t nlq_method_idc;
/**
* Set to -1 to represent Option::None
*/
int32_t nlq_num_pivots_minus2;
/**
* Length of zero when not present. Only present in profile 4 and 7.
*/
DoviU16Data nlq_pred_pivot_value;
/**
* Pointer to `RpuDataNlq` struct, null if not dual layer profile
*/
const DoviRpuDataNlq *nlq;
} DoviRpuDataMapping;
/**
* Statistical analysis of the frame: min, max, avg brightness.
*/
typedef struct {
uint16_t min_pq;
uint16_t max_pq;
uint16_t avg_pq;
} DoviExtMetadataBlockLevel1;
/**
* Creative intent trim passes per target display peak brightness
*/
typedef struct {
uint16_t target_max_pq;
uint16_t trim_slope;
uint16_t trim_offset;
uint16_t trim_power;
uint16_t trim_chroma_weight;
uint16_t trim_saturation_gain;
int16_t ms_weight;
} DoviExtMetadataBlockLevel2;
typedef struct {
/**
* Pointer to the list of ExtMetadataBlockLevel2 structs
*/
const DoviExtMetadataBlockLevel2 *const *list;
/**
* List length
*/
size_t len;
} DoviLevel2BlockList;
/**
* Level 1 offsets.
*/
typedef struct {
uint16_t min_pq_offset;
uint16_t max_pq_offset;
uint16_t avg_pq_offset;
} DoviExtMetadataBlockLevel3;
/**
* Something about temporal stability
*/
typedef struct {
uint16_t anchor_pq;
uint16_t anchor_power;
} DoviExtMetadataBlockLevel4;
/**
* Active area of the picture (letterbox, aspect ratio)
*/
typedef struct {
uint16_t active_area_left_offset;
uint16_t active_area_right_offset;
uint16_t active_area_top_offset;
uint16_t active_area_bottom_offset;
} DoviExtMetadataBlockLevel5;
/**
* ST2086/HDR10 metadata fallback
*/
typedef struct {
uint16_t max_display_mastering_luminance;
uint16_t min_display_mastering_luminance;
uint16_t max_content_light_level;
uint16_t max_frame_average_light_level;
} DoviExtMetadataBlockLevel6;
/**
* Creative intent trim passes per target display peak brightness
* For CM v4.0, L8 metadata only is present and used to compute L2
*
* This block can have varying byte lengths: 10, 12, 13, 19, 25
* Depending on the length, the fields parsed default to zero and may not be set.
* Up to (including):
* - 10: ms_weight
* - 12: target_mid_contrast
* - 13: clip_trim
* - 19: saturation_vector_field[0-5]
* - 25: hue_vector_field[0-5]
*/
typedef struct {
uint64_t length;
uint8_t target_display_index;
uint16_t trim_slope;
uint16_t trim_offset;
uint16_t trim_power;
uint16_t trim_chroma_weight;
uint16_t trim_saturation_gain;
uint16_t ms_weight;
uint16_t target_mid_contrast;
uint16_t clip_trim;
uint8_t saturation_vector_field0;
uint8_t saturation_vector_field1;
uint8_t saturation_vector_field2;
uint8_t saturation_vector_field3;
uint8_t saturation_vector_field4;
uint8_t saturation_vector_field5;
uint8_t hue_vector_field0;
uint8_t hue_vector_field1;
uint8_t hue_vector_field2;
uint8_t hue_vector_field3;
uint8_t hue_vector_field4;
uint8_t hue_vector_field5;
} DoviExtMetadataBlockLevel8;
typedef struct {
/**
* Pointer to the list of ExtMetadataBlockLevel8 structs
*/
const DoviExtMetadataBlockLevel8 *const *list;
/**
* List length
*/
size_t len;
} DoviLevel8BlockList;
/**
* Source/mastering display color primaries
*
* This block can have varying byte lengths: 1 or 17
* Depending on the length, the fields parsed default to zero and may not be set.
* Up to (including):
* - 1: source_primary_index
* - 17: source_primary_{red,green,blue,white}_{x,y}
*/
typedef struct {
uint64_t length;
uint8_t source_primary_index;
uint16_t source_primary_red_x;
uint16_t source_primary_red_y;
uint16_t source_primary_green_x;
uint16_t source_primary_green_y;
uint16_t source_primary_blue_x;
uint16_t source_primary_blue_y;
uint16_t source_primary_white_x;
uint16_t source_primary_white_y;
} DoviExtMetadataBlockLevel9;
/**
* Custom target display information
*
* This block can have varying byte lengths: 5 or 21
* Depending on the length, the fields parsed default to zero and may not be set.
* Up to (including):
* - 5: target_primary_index
* - 21: target_primary_{red,green,blue,white}_{x,y}
*/
typedef struct {
uint64_t length;
uint8_t target_display_index;
uint16_t target_max_pq;
uint16_t target_min_pq;
uint8_t target_primary_index;
uint16_t target_primary_red_x;
uint16_t target_primary_red_y;
uint16_t target_primary_green_x;
uint16_t target_primary_green_y;
uint16_t target_primary_blue_x;
uint16_t target_primary_blue_y;
uint16_t target_primary_white_x;
uint16_t target_primary_white_y;
} DoviExtMetadataBlockLevel10;
typedef struct {
/**
* Pointer to the list of ExtMetadataBlockLevel10 structs
*/
const DoviExtMetadataBlockLevel10 *const *list;
/**
* List length
*/
size_t len;
} DoviLevel10BlockList;
/**
* Content type metadata level
*/
typedef struct {
uint8_t content_type;
uint8_t whitepoint;
bool reference_mode_flag;
uint8_t reserved_byte2;
uint8_t reserved_byte3;
} DoviExtMetadataBlockLevel11;
/**
* Metadata level present in CM v4.0
*/
typedef struct {
uint8_t dm_mode;
uint8_t dm_version_index;
} DoviExtMetadataBlockLevel254;
/**
* Metadata level optionally present in CM v2.9.
* Different display modes (calibration/verify/bypass), debugging
*/
typedef struct {
uint8_t dm_run_mode;
uint8_t dm_run_version;
uint8_t dm_debug0;
uint8_t dm_debug1;
uint8_t dm_debug2;
uint8_t dm_debug3;
} DoviExtMetadataBlockLevel255;
/**
* C struct for the list of ext_metadata_block()
*/
typedef struct {
/**
* Number of metadata blocks
*/
uint64_t num_ext_blocks;
const DoviExtMetadataBlockLevel1 *level1;
DoviLevel2BlockList level2;
const DoviExtMetadataBlockLevel3 *level3;
const DoviExtMetadataBlockLevel4 *level4;
const DoviExtMetadataBlockLevel5 *level5;
const DoviExtMetadataBlockLevel6 *level6;
DoviLevel8BlockList level8;
const DoviExtMetadataBlockLevel9 *level9;
DoviLevel10BlockList level10;
const DoviExtMetadataBlockLevel11 *level11;
const DoviExtMetadataBlockLevel254 *level254;
const DoviExtMetadataBlockLevel255 *level255;
} DoviDmData;
/**
* C struct for vdr_dm_data()
*/
typedef struct {
bool compressed;
uint64_t affected_dm_metadata_id;
uint64_t current_dm_metadata_id;
uint64_t scene_refresh_flag;
int16_t ycc_to_rgb_coef0;
int16_t ycc_to_rgb_coef1;
int16_t ycc_to_rgb_coef2;
int16_t ycc_to_rgb_coef3;
int16_t ycc_to_rgb_coef4;
int16_t ycc_to_rgb_coef5;
int16_t ycc_to_rgb_coef6;
int16_t ycc_to_rgb_coef7;
int16_t ycc_to_rgb_coef8;
uint32_t ycc_to_rgb_offset0;
uint32_t ycc_to_rgb_offset1;
uint32_t ycc_to_rgb_offset2;
int16_t rgb_to_lms_coef0;
int16_t rgb_to_lms_coef1;
int16_t rgb_to_lms_coef2;
int16_t rgb_to_lms_coef3;
int16_t rgb_to_lms_coef4;
int16_t rgb_to_lms_coef5;
int16_t rgb_to_lms_coef6;
int16_t rgb_to_lms_coef7;
int16_t rgb_to_lms_coef8;
uint16_t signal_eotf;
uint16_t signal_eotf_param0;
uint16_t signal_eotf_param1;
uint32_t signal_eotf_param2;
uint8_t signal_bit_depth;
uint8_t signal_color_space;
uint8_t signal_chroma_format;
uint8_t signal_full_range_flag;
uint16_t source_min_pq;
uint16_t source_max_pq;
uint16_t source_diagonal;
DoviDmData dm_data;
} DoviVdrDmData;
/**
* Heap allocated list of valid RPU pointers
*/
typedef struct {
DoviRpuOpaque *const *list;
size_t len;
const char *error;
} DoviRpuOpaqueList;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
/**
* # Safety
* The pointer to the data must be valid.
*
* Parse a Dolby Vision RPU from unescaped byte buffer.
* Adds an error if the parsing fails.
*/
DoviRpuOpaque *dovi_parse_rpu(const uint8_t *buf, size_t len);
/**
* # Safety
* The pointer to the data must be valid.
*
* Parse a Dolby Vision from a AV1 ITU-T T.35 metadata OBU byte buffer.
* Adds an error if the parsing fails.
*/
DoviRpuOpaque *dovi_parse_itu_t35_dovi_metadata_obu(const uint8_t *buf, size_t len);
/**
* # Safety
* The pointer to the data must be valid.
*
* Parse a Dolby Vision from a (possibly) escaped HEVC UNSPEC 62 NAL unit byte buffer.
* Adds an error if the parsing fails.
*/
DoviRpuOpaque *dovi_parse_unspec62_nalu(const uint8_t *buf, size_t len);
/**
* # Safety
* The pointer to the opaque struct must be valid.
* Avoid using on opaque pointers obtained through `dovi_parse_rpu_bin_file`.
*
* Free the RpuOpaque
*/
void dovi_rpu_free(DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the last logged error for the RpuOpaque operations.
*
* On invalid parsing, an error is added.
* The user should manually verify if there is an error, as the parsing does not return an error code.
*/
const char *dovi_rpu_get_error(const DoviRpuOpaque *ptr);
/**
* # Safety
* The data pointer should exist, and be allocated by Rust.
*
* Free a Data buffer
*/
void dovi_data_free(const DoviData *data);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU as a byte buffer.
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_rpu(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU, escapes the bytes for HEVC and prepends the buffer with 0x7C01.
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_unspec62_nalu(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
* The mode must be between 0 and 4.
*
* Converts the RPU to be compatible with a different Dolby Vision profile.
* Possible modes:
* - 0: Don't modify the RPU
* - 1: Converts the RPU to be MEL compatible
* - 2: Converts the RPU to be profile 8.1 compatible. Both luma and chroma mapping curves are set to no-op.
* This mode handles source profiles 5, 7 and 8.
* - 3: Converts to static profile 8.4
* - 4: Converts to profile 8.1 preserving luma and chroma mapping. Old mode 2 behaviour.
*
* If an error occurs, it is logged to RpuOpaque.error.
* Returns 0 if successful, -1 otherwise.
*/
int32_t dovi_convert_rpu_with_mode(DoviRpuOpaque *ptr,
uint8_t mode);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the DoVi RPU header struct.
*/
const DoviRpuDataHeader *dovi_rpu_get_header(const DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the RPU header.
*/
void dovi_rpu_free_header(const DoviRpuDataHeader *ptr);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the DoVi RpuDataMapping struct.
*/
const DoviRpuDataMapping *dovi_rpu_get_data_mapping(const DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the RpuDataMapping.
*/
void dovi_rpu_free_data_mapping(const DoviRpuDataMapping *ptr);
/**
* # Safety
* The pointer to the opaque struct must be valid.
*
* Get the DoVi VdrDmData struct.
*/
const DoviVdrDmData *dovi_rpu_get_vdr_dm_data(const DoviRpuOpaque *ptr);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the VdrDmData struct.
*/
void dovi_rpu_free_vdr_dm_data(const DoviVdrDmData *ptr);
/**
* # Safety
* The pointer to the file path must be valid.
*
* Parses an existing RPU binary file.
*
* Returns the heap allocated `DoviRpuList` as a pointer.
* The returned pointer may be null, or the list could be empty if an error occurred.
*/
const DoviRpuOpaqueList *dovi_parse_rpu_bin_file(const char *path);
/**
* # Safety
* The pointer to the struct must be valid.
*
* Frees the memory used by the DoviRpuOpaqueList struct.
*/
void dovi_rpu_list_free(const DoviRpuOpaqueList *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Sets the L5 metadata active area offsets.
* If there is no L5 block present, it is created with the offsets.
*/
int32_t dovi_rpu_set_active_area_offsets(DoviRpuOpaque *ptr,
uint16_t left,
uint16_t right,
uint16_t top,
uint16_t bottom);
/**
* # Safety
* The struct pointer must be valid.
*
* Converts the existing reshaping/mapping to become no-op.
*/
int32_t dovi_rpu_remove_mapping(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU as `itu_t_t35_payload_bytes` for AV1 ITU-T T.35 metadata OBU
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_av1_rpu_metadata_obu_t35_payload(DoviRpuOpaque *ptr);
/**
* # Safety
* The struct pointer must be valid.
*
* Writes the encoded RPU a complete AV1 `metadata_itut_t35()` OBU
* If an error occurs in the writing, it is logged to RpuOpaque.error
*/
const DoviData *dovi_write_av1_rpu_metadata_obu_t35_complete(DoviRpuOpaque *ptr);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif /* DOVI_H */
@@ -0,0 +1,110 @@
package com.edde746.plezy.exoplayer
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.os.Build
import android.util.Log
enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP }
object DoviBridge {
private const val TAG = "DoviBridge"
private val nativeLoaded: Boolean by lazy {
try {
System.loadLibrary("dovi_bridge")
true
} catch (_: UnsatisfiedLinkError) {
Log.w(TAG, "Native lib not found")
false
}
}
fun isAvailable(): Boolean = nativeLoaded &&
runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
/**
* Check if the device has a hardware decoder that supports Dolby Vision Profile 7.
* Queries MediaCodecList for decoders supporting video/dolby-vision with
* DolbyVisionProfileDvheDtr (profile 7).
*/
val deviceSupportsDvProfile7: Boolean by lazy {
try {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val supported = codecList.codecInfos.any { info ->
!info.isEncoder && info.supportedTypes.any { type ->
type.equals("video/dolby-vision", ignoreCase = true) &&
info.getCapabilitiesForType(type).profileLevels.any { pl ->
pl.profile == MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr
}
}
}
Log.i(TAG, "Device DV Profile 7 support: $supported")
supported
} catch (e: Exception) {
Log.w(TAG, "Failed to query DV7 support", e)
false
}
}
/**
* Check if the device has a hardware decoder that supports Dolby Vision Profile 8
* (DvheSt). DolbyVisionProfileDvheSt constant requires API 27+.
*/
val deviceSupportsDvProfile8: Boolean by lazy {
try {
if (Build.VERSION.SDK_INT < 27) {
Log.i(TAG, "API < 27, cannot check DV Profile 8 support")
return@lazy false
}
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val supported = codecList.codecInfos.any { info ->
!info.isEncoder && info.supportedTypes.any { type ->
type.equals("video/dolby-vision", ignoreCase = true) &&
info.getCapabilitiesForType(type).profileLevels.any { pl ->
pl.profile == MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt
}
}
}
Log.i(TAG, "Device DV Profile 8 support: $supported")
supported
} catch (e: Exception) {
Log.w(TAG, "Failed to query DV8 support", e)
false
}
}
fun getConversionMode(): DvConversionMode = when {
!isAvailable() -> DvConversionMode.DISABLED
deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
/** Get the fallback mode when native DV7 decoding fails. */
fun getDv7FallbackMode(): DvConversionMode = when {
deviceSupportsDvProfile8 -> DvConversionMode.DV81
else -> DvConversionMode.HEVC_STRIP
}
fun convertRpuNalu(payload: ByteArray, mode: Int = 2): ByteArray? {
if (!isAvailable() || payload.isEmpty()) return null
return runCatching { nativeConvertDv7RpuToDv81(payload, mode) }
.onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") }
.getOrNull()
}
fun getVersion(): String? {
if (!nativeLoaded) return null
return runCatching { nativeGetBridgeVersion() }.getOrNull()
}
@JvmStatic
private external fun nativeConvertDv7RpuToDv81(payload: ByteArray, mode: Int): ByteArray?
@JvmStatic
private external fun nativeIsConversionPathReady(): Boolean
@JvmStatic
private external fun nativeGetBridgeVersion(): String
}
@@ -0,0 +1,400 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
/**
* TrackOutput wrapper that processes DV Profile 7 HEVC samples based on conversion mode:
*
* - DV81: Convert RPU NALs via libdovi to Profile 8.1, present as video/dolby-vision
* with dvhe.08.XX codec string. Preserves dynamic tone mapping metadata.
* - HEVC_STRIP: Strip all DV enhancement layers, present as plain video/hevc.
*
* Two modes of NAL framing (auto-detected):
* - Annex B (MKV path): MatroskaExtractor outputs 00 00 00 01 start codes
* - Length-prefixed (MP4 path): Mp4Extractor outputs 4-byte big-endian lengths
*
* NAL processing:
* - Type 62 (UNSPEC62): DV RPU → convert (DV81) or strip (HEVC_STRIP)
* - Type 63 (UNSPEC63): DV Enhancement Layer → strip
* - nuh_layer_id > 0: Enhancement layer NAL → strip
* - All retained NALs: normalize nuh_layer_id to 0
*/
class DoviConvertingTrackOutput(
private val delegate: TrackOutput,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
) : TrackOutput {
companion object {
private const val TAG = "DoviConvertTrack"
private const val NAL_TYPE_UNSPEC62 = 62
private const val NAL_TYPE_UNSPEC63 = 63
private const val LIBDOVI_MODE_TO_81 = 2
private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1)
}
var conversionActive = false
private set
var strippedNalCount = 0L
private set
var convertedRpuCount = 0L
private set
// Sample buffering between sampleData() and sampleMetadata()
private val sampleBuffer = ByteArrayOutputStream(256 * 1024)
private var buffering = false
override fun format(format: Format) {
if (!conversionActive) {
val codecs = format.codecs
if (codecs != null && codecs.startsWith("dvhe.07")) {
conversionActive = true
Log.i(TAG, "DV Profile 7 detected ($codecs), mode=$dvMode")
Log.i(TAG, "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " +
"initData=${format.initializationData.size} entries " +
"(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})")
val newFormat = when (dvMode) {
DvConversionMode.DV81 -> {
// Parse DV level from codec string: "dvhe.07.06" → 6
val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6
val newCodecs = "dvhe.08.%02d".format(level)
val dvConfigRecord = buildDv81ConfigRecord(level)
Log.i(TAG, "DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(newCodecs)
.setInitializationData(
if (format.initializationData.isNotEmpty())
listOf(format.initializationData[0], dvConfigRecord)
else
listOf(ByteArray(0), dvConfigRecord)
)
.build()
}
else -> {
// HEVC_STRIP: present as plain HEVC
Log.i(TAG, "HEVC_STRIP: rewriting to video/hevc")
format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs(null)
.setInitializationData(
if (format.initializationData.isNotEmpty())
listOf(format.initializationData[0])
else
emptyList()
)
.build()
}
}
Log.i(TAG, "Rewritten format: mime=${newFormat.sampleMimeType}, " +
"codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries")
delegate.format(newFormat)
return
}
}
delegate.format(format)
}
override fun sampleData(
input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int
): Int {
if (!conversionActive) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
// Buffer sample data for processing at sampleMetadata() time
buffering = true
val buf = ByteArray(length)
val bytesRead = input.read(buf, 0, length)
if (bytesRead > 0) {
sampleBuffer.write(buf, 0, bytesRead)
}
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!conversionActive) {
delegate.sampleData(data, length, sampleDataPart)
return
}
// Buffer sample data for processing at sampleMetadata() time
buffering = true
val bytes = ByteArray(length)
data.readBytes(bytes, 0, length)
sampleBuffer.write(bytes, 0, length)
}
override fun sampleMetadata(
timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?
) {
if (!conversionActive || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
buffering = false
val rawSample = sampleBuffer.toByteArray()
sampleBuffer.reset()
val processed = try {
processNalUnits(rawSample)
} catch (e: Exception) {
Log.e(TAG, "NAL processing failed, passing raw sample", e)
rawSample
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
if (processed.isEmpty()) return
// Write processed data to delegate. Offset must be 0 since we write exactly
// the processed amount (no trailing data from next sample in the buffer).
val parsable = ParsableByteArray(processed, processed.size)
delegate.sampleData(parsable, processed.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, processed.size, 0, cryptoData)
}
// Sample counter for periodic logging
private var sampleCount = 0L
/**
* Process NAL units in the sample data. Auto-detects format:
* - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor
* - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor
*
* Strips UNSPEC62 RPU NALs, UNSPEC63 EL NALs, and any NAL with nuh_layer_id > 0.
* Normalizes nuh_layer_id to 0 on all retained NALs.
* Output uses the same format as input.
*/
private fun processNalUnits(data: ByteArray): ByteArray {
if (data.size < 4) return data
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
val isAnnexB = (data.size >= 4 && data[0] == 0.toByte() && data[1] == 0.toByte() &&
data[2] == 0.toByte() && data[3] == 1.toByte()) ||
(data.size >= 3 && data[0] == 0.toByte() && data[1] == 0.toByte() &&
data[2] == 1.toByte())
if (sampleCount == 0L) {
Log.d(TAG, "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${data.take(8).joinToString(" ") { "%02X".format(it) }}")
}
return if (isAnnexB) processAnnexBNals(data) else processLengthPrefixedNals(data)
}
/**
* Find all Annex B start code positions (00 00 01 or 00 00 00 01) in the data.
* Returns list of pairs: (startCodeEnd, startCodeLen) where startCodeEnd is the
* byte index right after the start code, and startCodeLen is 3 or 4.
*/
private fun findAnnexBStartCodes(data: ByteArray): List<Pair<Int, Int>> {
val positions = mutableListOf<Pair<Int, Int>>()
var i = 0
while (i < data.size - 2) {
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) {
if (i + 3 < data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) {
// 4-byte start code: 00 00 00 01
positions.add(Pair(i + 4, 4))
i += 4
continue
} else if (data[i + 2] == 1.toByte()) {
// 3-byte start code: 00 00 01
positions.add(Pair(i + 3, 3))
i += 3
continue
}
}
i++
}
return positions
}
/** Process Annex B formatted NAL units (MKV path). */
private fun processAnnexBNals(data: ByteArray): ByteArray {
val output = ByteArrayOutputStream(data.size)
var kept = 0
var stripped = 0
val startCodes = findAnnexBStartCodes(data)
if (startCodes.isEmpty()) {
sampleCount++
return data // No start codes found, pass through
}
for (idx in startCodes.indices) {
val nalStart = startCodes[idx].first
val nalEnd = if (idx + 1 < startCodes.size) {
// NAL ends where next start code begins (subtract its start code length area)
// Find the start of the next start code pattern
startCodes[idx + 1].first - startCodes[idx + 1].second
} else {
data.size
}
if (nalEnd <= nalStart) continue
val nalData = data.copyOfRange(nalStart, nalEnd)
if (nalData.size >= 2) {
val nalType = (nalData[0].toInt() ushr 1) and 0x3F
val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or
((nalData[1].toInt() ushr 3) and 0x1F)
when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> {
// Convert RPU NAL via libdovi instead of stripping
val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81)
if (converted != null) {
normalizeLayerId(converted)
output.write(ANNEX_B_START_CODE)
output.write(converted)
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
}
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> {
strippedNalCount++
stripped++
}
else -> {
normalizeLayerId(nalData)
// Write with 4-byte start code (consistent output)
output.write(ANNEX_B_START_CODE)
output.write(nalData)
kept++
}
}
} else {
output.write(ANNEX_B_START_CODE)
output.write(nalData)
kept++
}
}
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (AnnexB): ${data.size}B -> ${output.size()}B, " +
"kept=$kept stripped=$stripped NALs")
}
return output.toByteArray()
}
/** Process length-prefixed NAL units (MP4 path). */
private fun processLengthPrefixedNals(data: ByteArray): ByteArray {
val output = ByteArrayOutputStream(data.size)
var pos = 0
var kept = 0
var stripped = 0
while (pos + 4 <= data.size) {
val nalLen = ((data[pos].toInt() and 0xFF) shl 24) or
((data[pos + 1].toInt() and 0xFF) shl 16) or
((data[pos + 2].toInt() and 0xFF) shl 8) or
(data[pos + 3].toInt() and 0xFF)
if (nalLen <= 0 || pos + 4 + nalLen > data.size) {
if (sampleCount < 5) {
Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=${data.size})")
}
break
}
val nalStart = pos + 4
val nalData = data.copyOfRange(nalStart, nalStart + nalLen)
if (nalData.size >= 2) {
val nalType = (nalData[0].toInt() ushr 1) and 0x3F
val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or
((nalData[1].toInt() ushr 3) and 0x1F)
when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> {
// Convert RPU NAL via libdovi instead of stripping
val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81)
if (converted != null) {
normalizeLayerId(converted)
writeLengthPrefixedNal(output, converted)
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
}
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> {
strippedNalCount++
stripped++
}
else -> {
normalizeLayerId(nalData)
writeLengthPrefixedNal(output, nalData)
kept++
}
}
} else {
writeLengthPrefixedNal(output, nalData)
kept++
}
pos += 4 + nalLen
}
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${data.size}B -> ${output.size()}B, " +
"kept=$kept stripped=$stripped NALs")
}
return output.toByteArray()
}
private fun normalizeLayerId(nalData: ByteArray) {
if (nalData.size >= 2) {
nalData[0] = (nalData[0].toInt() and 0xFE).toByte() // Clear bit 0 of byte 0
nalData[1] = (nalData[1].toInt() and 0x07).toByte() // Clear bits 7-3 of byte 1
}
}
private fun writeLengthPrefixedNal(output: ByteArrayOutputStream, nalData: ByteArray) {
val len = nalData.size
output.write((len ushr 24) and 0xFF)
output.write((len ushr 16) and 0xFF)
output.write((len ushr 8) and 0xFF)
output.write(len and 0xFF)
output.write(nalData)
}
/**
* Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1.
*
* Binary layout (from Dolby Vision spec):
* byte[0]: dv_version_major = 1
* byte[1]: dv_version_minor = 0
* byte[2]: dv_profile (7 bits) | dv_level MSB (1 bit)
* byte[3]: dv_level low 5 bits (5 bits) | rpu_present (1) | el_present (1) | bl_present (1)
* byte[4]: bl_compatibility_id (4 bits) | md_compression (2 bits) | reserved (2 bits)
* byte[5-23]: reserved (zeros)
*/
private fun buildDv81ConfigRecord(level: Int): ByteArray {
val record = ByteArray(24)
record[0] = 0x01 // dv_version_major = 1
record[1] = 0x00 // dv_version_minor = 0
record[2] = ((8 shl 1) or ((level ushr 5) and 0x01)).toByte() // profile=8 | level MSB
record[3] = (((level and 0x1F) shl 3) or 0x05).toByte() // level low 5 | rpu=1 el=0 bl=1
record[4] = (1 shl 4).toByte() // bl_compatibility_id=1 (HDR10)
// bytes 5-23 remain 0 (reserved)
return record
}
}
@@ -0,0 +1,52 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
/**
* Extractor decorator for Mp4/FragmentedMp4 containers.
* Wraps the video TrackOutput with DoviConvertingTrackOutput to perform
* DV Profile 7 → 8.1 conversion via inline NAL processing.
*
* For MP4, RPU (UNSPEC62) and EL (UNSPEC63) NALs are interleaved in sample data,
* so no BlockAdditions handling is needed.
*/
class DoviExtractorWrapper(
private val delegate: Extractor,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
) : Extractor {
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(object : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val original = output.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode)
doviTrackOutput = wrapper
return wrapper
}
return original
}
override fun endTracks() = output.endTracks()
override fun seekMap(seekMap: SeekMap) = output.seekMap(seekMap)
})
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int =
delegate.read(input, seekPosition)
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
override fun release() = delegate.release()
}
@@ -0,0 +1,173 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import io.github.peerless2012.ass.media.AssHandler
/**
* MatroskaExtractor subclass that adds:
* 1. ASS subtitle support (font extraction from MKV attachments, video size reporting)
* 2. Dolby Vision Profile 7 → 8.1 conversion (RPU capture from BlockAdditions)
*
* Replaces both AssMatroskaExtractor and adds DV handling on top.
* Font extraction and video size reporting are replicated from AssMatroskaExtractor
* since that class is final and cannot be extended.
*/
class DoviMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
private val assHandler: AssHandler,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
) : MatroskaExtractor(subtitleParserFactory) {
companion object {
private const val TAG = "DoviMkvExtractor"
// Matroska element IDs for attachments
private const val ID_ATTACHMENTS = 0x1941A469
private const val ID_ATTACHED_FILE = 0x61A7
private const val ID_FILE_NAME = 0x466E
private const val ID_FILE_MIME_TYPE = 0x4660
private const val ID_FILE_DATA = 0x465C
// Track video element
private const val ID_VIDEO = 0xE0
// EBML header - hook point for output wrapping
private const val ID_EBML = 0x1A45DFA3
// MKV element type constants (matching MatroskaExtractor internals)
private const val TYPE_MASTER = 1
private const val TYPE_STRING = 3
private const val TYPE_BINARY = 4
private val FONT_MIME_TYPES = setOf(
"application/x-truetype-font",
"application/x-font-truetype",
"application/vnd.ms-opentype",
"font/sfnt",
"font/ttf",
"font/otf",
"font/collection",
)
private val extractorOutputField by lazy {
try {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
} catch (e: Exception) {
Log.w(TAG, "Cannot access extractorOutput field: ${e.message}")
null
}
}
}
// ASS font attachment state
private var currentAttachmentName: String? = null
private var currentAttachmentMime: String? = null
// DV conversion state
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
internal set
// ===== Matroska element overrides =====
override fun getElementType(id: Int): Int = when (id) {
ID_ATTACHMENTS, ID_ATTACHED_FILE -> TYPE_MASTER
ID_FILE_NAME, ID_FILE_MIME_TYPE -> TYPE_STRING
ID_FILE_DATA -> TYPE_BINARY
else -> super.getElementType(id)
}
override fun isLevel1Element(id: Int): Boolean =
super.isLevel1Element(id) || id == ID_ATTACHMENTS
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
when (id) {
ID_EBML -> {
wrapExtractorOutput()
super.startMasterElement(id, contentPosition, contentSize)
}
ID_ATTACHED_FILE -> clearAttachment()
else -> super.startMasterElement(id, contentPosition, contentSize)
}
}
override fun endMasterElement(id: Int) {
when (id) {
ID_VIDEO -> {
val track = getCurrentTrack(id)
assHandler.setVideoSize(track.width, track.height)
super.endMasterElement(id)
}
ID_ATTACHED_FILE -> clearAttachment()
else -> super.endMasterElement(id)
}
}
override fun stringElement(id: Int, value: String) {
when (id) {
ID_FILE_NAME -> currentAttachmentName = value
ID_FILE_MIME_TYPE -> currentAttachmentMime = value
else -> super.stringElement(id, value)
}
}
override fun binaryElement(id: Int, size: Int, input: ExtractorInput) {
if (id == ID_FILE_DATA) {
val mime = currentAttachmentMime
val name = currentAttachmentName
if (mime != null && name != null && mime in FONT_MIME_TYPES) {
val data = ByteArray(size)
input.readFully(data, 0, size)
assHandler.addFont(name, data)
} else {
input.skipFully(size)
}
clearAttachment()
} else {
super.binaryElement(id, size, input)
}
}
// ===== ExtractorOutput wrapping =====
private fun wrapExtractorOutput() {
val field = extractorOutputField ?: return
val output = field.get(this) as? ExtractorOutput ?: return
if (output is DoviExtractorOutputWrapper) return
field.set(this, DoviExtractorOutputWrapper(output, this))
}
private fun clearAttachment() {
currentAttachmentName = null
currentAttachmentMime = null
}
/**
* ExtractorOutput wrapper that intercepts video track creation
* to insert DoviConvertingTrackOutput for DV processing.
*/
class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput,
private val extractor: DoviMatroskaExtractor,
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, extractor.dvMode)
extractor.doviTrackOutput = wrapper
return wrapper
}
return original
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: androidx.media3.extractor.SeekMap) = delegate.seekMap(seekMap)
}
}
@@ -39,6 +39,8 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.extractor.mp4.Mp4Extractor
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.SubtitleView
@@ -191,6 +193,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
}
// DV conversion state
private var dvMode: DvConversionMode = DvConversionMode.DISABLED
private var dv7RetryAttempted = false
@Volatile private var activeDoviMkvExtractor: DoviMatroskaExtractor? = null
@Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null
fun initialize(bufferSizeBytes: Int? = null, tunnelingEnabled: Boolean = true): Boolean {
if (isInitialized) {
Log.d(TAG, "Already initialized")
@@ -198,6 +206,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
tunnelingUserEnabled = tunnelingEnabled
this.dvMode = DoviBridge.getConversionMode()
Log.i(TAG, "DV conversion: mode=$dvMode, bridge=${DoviBridge.isAvailable()}, " +
"deviceDV7=${DoviBridge.deviceSupportsDvProfile7}, deviceDV8=${DoviBridge.deviceSupportsDvProfile8}")
disposing = false
try {
@@ -331,13 +342,30 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val assParserFactory = AssSubtitleParserFactory(handler)
// Wrap extractors to replace MatroskaExtractor with ASS-aware variant
// Wrap extractors: replace MatroskaExtractor with ASS+DV variant,
// wrap MP4 extractors with DV converter when enabled.
// Reads this.dvMode each time (not captured) so DV7→8.1 retry can
// change mode and reload without reinitializing the player.
val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
val currentDvMode = this.dvMode
val doviEnabled = currentDvMode != DvConversionMode.DISABLED
extractorsFactory.createExtractors().map { extractor ->
if (extractor is androidx.media3.extractor.mkv.MatroskaExtractor) {
AssMatroskaExtractor(assParserFactory, handler)
} else {
extractor
when {
extractor is MatroskaExtractor -> {
if (doviEnabled) {
DoviMatroskaExtractor(assParserFactory, handler, currentDvMode).also {
activeDoviMkvExtractor = it
}
} else {
AssMatroskaExtractor(assParserFactory, handler)
}
}
doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
DoviExtractorWrapper(extractor, currentDvMode).also {
activeDoviMp4Wrapper = it
}
}
else -> extractor
}
}.toTypedArray()
}
@@ -553,6 +581,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO }
val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected }
if (hasAnyVideoGroup && !hasSelectedVideo && currentMediaUri != null) {
// Try DV conversion before falling to MPV
if (retryWithDvConversion("video track not selected")) return
emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)")
delegate?.onFormatUnsupported(
uri = currentMediaUri!!,
@@ -573,6 +603,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog()
cancelDecoderHangCheck()
// If native DV7 failed, retry with conversion before falling to MPV
if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return
if (currentMediaUri != null) {
Log.w(TAG, "ExoPlayer error (code ${error.errorCode}) - attempting fallback to MPV")
val handled = delegate?.onFormatUnsupported(
@@ -591,6 +624,32 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
))
}
/**
* When native DV7 decoding fails (device falsely advertises DV7 support),
* upgrade to DV7→8.1 conversion or HEVC strip and reload the media.
* Returns true if retry was initiated.
*/
private fun retryWithDvConversion(reason: String): Boolean {
if (dv7RetryAttempted) return false
if (dvMode != DvConversionMode.DISABLED) return false
if (!DoviBridge.isAvailable()) return false
val uri = currentMediaUri ?: return false
dv7RetryAttempted = true
val newMode = DoviBridge.getDv7FallbackMode()
dvMode = newMode
Log.i(TAG, "Native DV7 playback failed ($reason), retrying with $newMode")
emitLog("info", "dv-fallback", "DV7 native failed ($reason), retrying as $newMode")
open(
uri = uri,
headers = currentHeaders,
startPositionMs = lastPosition,
autoPlay = true,
)
return true
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason")
delegate?.onEvent("file-loaded", null)
@@ -1018,6 +1077,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog()
cancelDecoderHangCheck()
// Reset DV7 retry flag when opening a different file
if (uri != currentMediaUri) {
dv7RetryAttempted = false
}
currentMediaUri = uri
currentHeaders = headers
externalSubtitles.clear()
@@ -1387,6 +1451,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"playbackSpeed" to player.playbackParameters.speed,
"isPlaying" to player.isPlaying,
"playbackState" to player.playbackState,
// DV conversion (query extractor's track output, which is set during extraction)
"dvConversionActive" to ((activeDoviMkvExtractor?.doviTrackOutput?.conversionActive
?: activeDoviMp4Wrapper?.doviTrackOutput?.conversionActive) == true),
"dvConversionMode" to dvMode.name,
"dvStrippedNals" to ((activeDoviMkvExtractor?.doviTrackOutput?.strippedNalCount
?: activeDoviMp4Wrapper?.doviTrackOutput?.strippedNalCount) ?: 0L),
"dvConvertedRpus" to ((activeDoviMkvExtractor?.doviTrackOutput?.convertedRpuCount
?: activeDoviMp4Wrapper?.doviTrackOutput?.convertedRpuCount) ?: 0L),
)
}
@@ -35,6 +35,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private val nameToId = mutableMapOf<String, Int>()
private var configuredBufferSizeBytes: Int? = null
private var configuredTunnelingEnabled: Boolean = true
private var debugLoggingEnabled: Boolean = false
// FlutterPlugin