diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 48644089..bb55bdbf 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..690da4ad --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -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() diff --git a/android/app/src/main/cpp/dovi_bridge.cpp b/android/app/src/main/cpp/dovi_bridge.cpp new file mode 100644 index 00000000..271221a3 --- /dev/null +++ b/android/app/src/main/cpp/dovi_bridge.cpp @@ -0,0 +1,105 @@ +#include +#include +#include + +#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(buf), static_cast(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(buf), static_cast(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(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(out->len)); + if (result != nullptr) { + env->SetByteArrayRegion(result, 0, static_cast(out->len), + reinterpret_cast(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); +} diff --git a/android/app/src/main/cpp/include/libdovi/rpu_parser.h b/android/app/src/main/cpp/include/libdovi/rpu_parser.h new file mode 100644 index 00000000..fe0ef139 --- /dev/null +++ b/android/app/src/main/cpp/include/libdovi/rpu_parser.h @@ -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 +#include +#include +#include + +#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 */ diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt new file mode 100644 index 00000000..8cafb000 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt new file mode 100644 index 00000000..5cb2be14 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt @@ -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> { + val positions = mutableListOf>() + 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 + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt new file mode 100644 index 00000000..ce13c775 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt @@ -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() +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviMatroskaExtractor.kt new file mode 100644 index 00000000..3f3acdb7 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviMatroskaExtractor.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 2f0f52c1..7e84ef8a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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), ) } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 7159e1a0..6257032e 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -35,6 +35,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private val nameToId = mutableMapOf() private var configuredBufferSizeBytes: Int? = null private var configuredTunnelingEnabled: Boolean = true + private var debugLoggingEnabled: Boolean = false // FlutterPlugin diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart index d6304b19..07910a1b 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart @@ -73,6 +73,8 @@ class _PlayerPerformanceOverlayState extends State { _metric('Decoder', _stats.hwdecFormatted), if (_stats.aspectName != null && _stats.aspectName!.isNotEmpty) _metric('Aspect', _stats.aspectName!), if (_stats.rotate != null && _stats.rotate != 0) _metric('Rotation', _stats.rotateFormatted), + if (_stats.dvConversionActive) + _metric('DV', _stats.dvConversionMode == 'DV81' ? '7→8.1' : '7→HEVC'), ]), // Color section - MPV only (ExoPlayer doesn't provide this info) if (isMpv) ...[ diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart index 1610836d..e3dd0cf5 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -48,6 +48,10 @@ class PerformanceStats { final double? cacheSpeed; final double? cacheDuration; + // DV conversion + final bool dvConversionActive; + final String dvConversionMode; // "DV81", "HEVC_STRIP", "DISABLED" + // App metrics final int? appMemoryBytes; final double? uiFps; @@ -84,6 +88,8 @@ class PerformanceStats { this.cacheUsed, this.cacheSpeed, this.cacheDuration, + this.dvConversionActive = false, + this.dvConversionMode = '', this.appMemoryBytes, this.uiFps, }); @@ -121,6 +127,8 @@ class PerformanceStats { cacheUsed = null, cacheSpeed = null, cacheDuration = null, + dvConversionActive = false, + dvConversionMode = '', appMemoryBytes = null, uiFps = null; diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index 478d3b87..e36b267b 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -192,6 +192,9 @@ class PerformanceStatsService { frameDropCount: statsMap['videoDroppedFrames'] as int?, // Buffer metrics - convert ms to seconds for duration cacheDuration: ((statsMap['totalBufferedDurationMs'] as int?) ?? 0) / 1000.0, + // DV conversion + dvConversionActive: statsMap['dvConversionActive'] == true, + dvConversionMode: statsMap['dvConversionMode'] as String? ?? '', // App metrics appMemoryBytes: appMemory, uiFps: _currentUiFps,