From 79d0d5ca0a64dbd1b41356dc820904402ad09402 Mon Sep 17 00:00:00 2001 From: GAURAV KARMAKAR Date: Sun, 19 Apr 2026 04:54:56 +0530 Subject: [PATCH 1/4] ci: add Linux and macOS workflows for FFmpeg MP4 build Adds a cmake_ffmpeg_mp4 job to both build_linux.yml and build_mac.yml that configures CCExtractor with -DWITH_FFMPEG=ON -DWITH_OCR=ON -DWITH_HARDSUBX=ON and builds it, so the FFmpeg-based MP4 demuxer path is exercised on every PR. Linux job pulls the full set of ffmpeg/tesseract/leptonica dev packages (including libavdevice-dev); macOS job installs the corresponding Homebrew bottles. --- .github/workflows/build_linux.yml | 20 ++++++++++++++++++++ .github/workflows/build_mac.yml | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_linux.yml index 290e0a6f0..2f54d4356 100644 --- a/.github/workflows/build_linux.yml +++ b/.github/workflows/build_linux.yml @@ -103,6 +103,26 @@ jobs: working-directory: build - name: Display version information run: ./build/ccextractor --version + cmake_ffmpeg_mp4: + runs-on: ubuntu-latest + steps: + - name: Install dependencies + run: sudo apt update && sudo apt-get install libgpac-dev libtesseract-dev libleptonica-dev libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libavdevice-dev libswresample-dev libswscale-dev + - uses: actions/checkout@v6 + - name: cmake (default GPAC build) + run: mkdir build && cd build && cmake ../src + - name: build (default GPAC build) + run: make -j$(nproc) + working-directory: build + - name: Display version information (GPAC build) + run: ./build/ccextractor --version + - name: cmake (FFmpeg MP4 build) + run: mkdir build_ffmpeg && cd build_ffmpeg && cmake -DWITH_FFMPEG=ON -DWITH_OCR=ON -DWITH_HARDSUBX=ON ../src + - name: build (FFmpeg MP4 build) + run: make -j$(nproc) + working-directory: build_ffmpeg + - name: Display version information (FFmpeg MP4 build) + run: ./build_ffmpeg/ccextractor --version cmake_ocr_hardsubx: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/build_mac.yml b/.github/workflows/build_mac.yml index 347698cd5..a42ec3ca2 100644 --- a/.github/workflows/build_mac.yml +++ b/.github/workflows/build_mac.yml @@ -107,6 +107,21 @@ jobs: working-directory: build - name: Display version information run: ./build/ccextractor --version + cmake_ffmpeg_mp4: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + run: brew install pkg-config gpac ffmpeg tesseract leptonica + - name: cmake (FFmpeg MP4 build) + run: | + mkdir build && cd build + cmake -DWITH_FFMPEG=ON -DWITH_OCR=ON -DWITH_HARDSUBX=ON ../src + - name: build + run: make -j$(nproc) + working-directory: build + - name: Display version information + run: ./build/ccextractor --version build_shell_hardsubx: # Test build.command with -hardsubx flag (burned-in subtitle extraction) runs-on: macos-latest From 3c810997227c17c9fba2f7318fa2a8dc39cb69a8 Mon Sep 17 00:00:00 2001 From: GAURAV KARMAKAR Date: Sun, 19 Apr 2026 04:55:21 +0530 Subject: [PATCH 2/4] build: wire FFmpeg libs into ccx_rust link order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the compile-time option to build CCExtractor's MP4 demuxer on top of libavformat: set -DENABLE_FFMPEG_MP4 and pull libswresample as a required dependency alongside libavformat/libavutil/libavcodec/ libavfilter/libswscale when -DWITH_FFMPEG=ON. Link-order handling. Corrosion places ccx_rust at the end of the ccextractor link line. On Linux (GNU ld), ccx_rust contains rsmpeg which references FFmpeg symbols like swr_get_out_samples, so the FFmpeg shared libs must appear after ccx_rust. Collect them into a separate EXTRA_FFMPEG_LIBS variable and attach them as INTERFACE_LINK_LIBRARIES on the ccx_rust target so CMake emits them right after ccx_rust. Make ccx's own link dependencies PRIVATE so the same libs don't propagate earlier and get deduplicated against the INTERFACE copy. Force bridge symbols to be pulled from libccx. GNU ld only pulls object files from a static archive when they resolve currently-needed symbols. Bridge functions in libccx.a (ccx_mp4_process_nal_sample, ccx_mp4_process_cc_packet, etc.) aren't needed until libccx_rust.a is processed, but libccx.a precedes it on the command line. Use -Wl,--undefined= on ccextractor for each bridge entry point so the linker pulls them early — same pattern already used for decode_vbi/do_cb/store_hdcc. Rust crate wiring. Add the optional rsmpeg dependency guarded behind the enable_mp4_ffmpeg feature, with platform-specific feature flags (link_system_ffmpeg on Linux + macOS, link_vcpkg_ffmpeg on Windows, all using the ffmpeg7 bindings). Extend bindgen's wrapper.h to expose the bridge headers so Rust can call back into ccx from mp4_rust_bridge.c. --- src/CMakeLists.txt | 52 ++++++++++++++++++++++++++++---------- src/lib_ccx/CMakeLists.txt | 6 ++++- src/rust/CMakeLists.txt | 8 ++++++ src/rust/Cargo.toml | 1 + src/rust/build.rs | 41 ++++++++++++++++++++++++++++++ src/rust/wrapper.h | 3 +++ 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 088d92359..64cd62c43 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -194,20 +194,25 @@ if (PKG_CONFIG_FOUND AND WITH_FFMPEG) pkg_check_modules (AVCODEC REQUIRED libavcodec) pkg_check_modules (AVFILTER REQUIRED libavfilter) pkg_check_modules (SWSCALE REQUIRED libswscale) - - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVFORMAT_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVUTIL_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVCODEC_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVFILTER_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${SWSCALE_LIBRARIES}) + pkg_check_modules (SWRESAMPLE REQUIRED libswresample) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${AVFORMAT_INCLUDE_DIRS}) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${AVUTIL_INCLUDE_DIRS}) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${AVCODEC_INCLUDE_DIRS}) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${AVFILTER_INCLUDE_DIRS}) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${SWSCALE_INCLUDE_DIRS}) + set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${SWRESAMPLE_INCLUDE_DIRS}) set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DENABLE_FFMPEG") + set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DENABLE_FFMPEG_MP4") + + # NOTE: FFmpeg libs are added to EXTRA_FFMPEG_LIBS (not EXTRA_LIBS) so we + # can place them AFTER ccx_rust in the link order. GNU ld processes + # left-to-right; ccx_rust (containing rsmpeg) references FFmpeg symbols + # like swr_get_out_samples, so FFmpeg shared libs must come after it. + set (EXTRA_FFMPEG_LIBS ${AVFORMAT_LIBRARIES} ${AVUTIL_LIBRARIES} + ${AVCODEC_LIBRARIES} ${AVFILTER_LIBRARIES} ${SWSCALE_LIBRARIES} + ${SWRESAMPLE_LIBRARIES}) endif (PKG_CONFIG_FOUND AND WITH_FFMPEG) ######################################################## @@ -240,12 +245,6 @@ if (PKG_CONFIG_FOUND AND WITH_HARDSUBX) pkg_check_modules (AVFILTER REQUIRED libavfilter) pkg_check_modules (SWSCALE REQUIRED libswscale) - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVFORMAT_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVUTIL_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVCODEC_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${AVFILTER_LIBRARIES}) - set (EXTRA_LIBS ${EXTRA_LIBS} ${SWSCALE_LIBRARIES}) - set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${AVFORMAT_INCLUDE_DIRS}) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${AVUTIL_INCLUDE_DIRS}) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${AVCODEC_INCLUDE_DIRS}) @@ -263,6 +262,11 @@ if (PKG_CONFIG_FOUND AND WITH_HARDSUBX) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${TESSERACT_INCLUDE_DIRS}) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${LEPTONICA_INCLUDE_DIRS}) + + # Collect FFmpeg libs for placement after ccx_rust (same reason as WITH_FFMPEG) + set (EXTRA_FFMPEG_LIBS ${EXTRA_FFMPEG_LIBS} ${AVFORMAT_LIBRARIES} + ${AVUTIL_LIBRARIES} ${AVCODEC_LIBRARIES} ${AVFILTER_LIBRARIES} + ${SWSCALE_LIBRARIES}) endif (PKG_CONFIG_FOUND AND WITH_HARDSUBX) add_executable (ccextractor ${SOURCEFILE} ${FREETYPE_SOURCE} ${UTF8PROC_SOURCE}) @@ -274,12 +278,20 @@ add_executable (ccextractor ${SOURCEFILE} ${FREETYPE_SOURCE} ${UTF8PROC_SOURCE}) if (PKG_CONFIG_FOUND) add_subdirectory (rust) set (EXTRA_LIBS ${EXTRA_LIBS} ccx_rust) -endif (PKG_CONFIG_FOUND) + # Corrosion places ccx_rust at the absolute end of the link line. + # On Linux (GNU ld), ccx_rust contains rsmpeg which references FFmpeg + # symbols like swr_get_out_samples. By adding FFmpeg libs as INTERFACE + # dependencies of ccx_rust, CMake places them right after ccx_rust. + if (EXTRA_FFMPEG_LIBS) + set_property(TARGET ccx_rust APPEND PROPERTY INTERFACE_LINK_LIBRARIES ${EXTRA_FFMPEG_LIBS}) + endif() +endif (PKG_CONFIG_FOUND) -target_link_libraries (ccextractor ${EXTRA_LIBS}) +target_link_libraries (ccextractor PRIVATE ${EXTRA_LIBS}) target_include_directories (ccextractor PUBLIC ${EXTRA_INCLUDES}) + # ccx_rust (Rust) calls C functions from ccx (like decode_vbi). # Force the linker to pull these symbols from ccx before processing ccx_rust. if (NOT WIN32 AND NOT APPLE) @@ -287,6 +299,18 @@ if (NOT WIN32 AND NOT APPLE) -Wl,--undefined=decode_vbi -Wl,--undefined=do_cb -Wl,--undefined=store_hdcc) + if (WITH_FFMPEG) + target_link_options (ccextractor PRIVATE + -Wl,--undefined=ccx_mp4_process_avc_sample + -Wl,--undefined=ccx_mp4_process_hevc_sample + -Wl,--undefined=ccx_mp4_process_cc_packet + -Wl,--undefined=ccx_mp4_process_tx3g_packet + -Wl,--undefined=ccx_mp4_flush_tx3g + -Wl,--undefined=ccx_mp4_report_progress + -Wl,--undefined=mprint + -Wl,--undefined=update_decoder_list + -Wl,--undefined=update_encoder_list) + endif() endif() install (TARGETS ccextractor DESTINATION bin) diff --git a/src/lib_ccx/CMakeLists.txt b/src/lib_ccx/CMakeLists.txt index 86cfd42c7..1c6a8c744 100644 --- a/src/lib_ccx/CMakeLists.txt +++ b/src/lib_ccx/CMakeLists.txt @@ -38,6 +38,7 @@ if (WITH_FFMPEG) set (EXTRA_INCLUDES ${EXTRA_INCLUDES} ${SWSCALE_INCLUDE_DIRS}) set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DENABLE_FFMPEG") + set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DENABLE_FFMPEG_MP4") endif (WITH_FFMPEG) if (WITH_OCR) @@ -59,7 +60,10 @@ endif (WITH_OCR) aux_source_directory ("${PROJECT_SOURCE_DIR}/lib_ccx/" SOURCEFILE) add_library (ccx ${SOURCEFILE} ccx_dtvcc.h ccx_dtvcc.c ccx_encoders_mcc.c ccx_encoders_mcc.h) -target_link_libraries (ccx ${EXTRA_LIBS}) +# PRIVATE prevents transitive propagation to consumers (ccextractor). +# The parent CMakeLists.txt adds all needed libs explicitly, controlling +# link order so FFmpeg libs appear after ccx_rust (required by GNU ld). +target_link_libraries (ccx PRIVATE ${EXTRA_LIBS}) target_include_directories (ccx PUBLIC ${EXTRA_INCLUDES}) if (WITH_HARDSUBX) diff --git a/src/rust/CMakeLists.txt b/src/rust/CMakeLists.txt index 85eddf28d..61e7482cc 100644 --- a/src/rust/CMakeLists.txt +++ b/src/rust/CMakeLists.txt @@ -19,6 +19,14 @@ else() set(FEATURES "") endif() +if(WITH_FFMPEG) + if(FEATURES STREQUAL "") + set(FEATURES "enable_mp4_ffmpeg") + else() + set(FEATURES "${FEATURES};enable_mp4_ffmpeg") + endif() +endif() + # Check rust version set(MSRV "1.87.0") if(Rust_VERSION VERSION_GREATER_EQUAL ${MSRV}) diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 34b704122..0c216dacf 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -49,6 +49,7 @@ tempfile = "3.20.0" [features] wtv_debug = [] enable_ffmpeg = [] +enable_mp4_ffmpeg = ["rsmpeg"] with_libcurl = [] # hardsubx_ocr enables OCR and the platform-appropriate rsmpeg diff --git a/src/rust/build.rs b/src/rust/build.rs index 6cce8fc38..4cecebeff 100644 --- a/src/rust/build.rs +++ b/src/rust/build.rs @@ -43,6 +43,22 @@ fn main() { "mprint", ]); + #[cfg(feature = "enable_mp4_ffmpeg")] + allowlist_functions.extend_from_slice(&[ + "ccx_mp4_process_avc_sample", + "ccx_mp4_process_hevc_sample", + "ccx_mp4_process_cc_packet", + "ccx_mp4_process_tx3g_packet", + "ccx_mp4_flush_tx3g", + "ccx_mp4_report_progress", + "update_decoder_list", + "update_encoder_list", + "do_NAL", + "mprint", + "ccxr_dtvcc_set_encoder", + "ccxr_dtvcc_process_data", + ]); + let mut allowlist_types = Vec::new(); allowlist_types.extend_from_slice(&[ // Match both lowercase (dtvcc_*) and uppercase (DTVCC_*) patterns @@ -79,6 +95,31 @@ fn main() { // bindings for. .header("wrapper.h"); + // enable FFmpeg MP4 demuxer if feature is on + #[cfg(feature = "enable_mp4_ffmpeg")] + { + builder = builder.clang_arg("-DENABLE_FFMPEG_MP4"); + + // Add FFmpeg include paths (same logic as hardsubx_ocr) + if let Ok(ffmpeg_include) = env::var("FFMPEG_INCLUDE_DIR") { + builder = builder.clang_arg(format!("-I{}", ffmpeg_include)); + } + if cfg!(target_os = "macos") { + if std::path::Path::new("/opt/homebrew/include").exists() { + builder = builder.clang_arg("-I/opt/homebrew/include"); + } else if std::path::Path::new("/usr/local/include").exists() { + builder = builder.clang_arg("-I/usr/local/include"); + } + } + if cfg!(target_os = "linux") { + if let Ok(lib) = pkg_config::Config::new().probe("libavcodec") { + for path in lib.include_paths { + builder = builder.clang_arg(format!("-I{}", path.display())); + } + } + } + } + // enable hardsubx if and only if the feature is on #[cfg(feature = "hardsubx_ocr")] { diff --git a/src/rust/wrapper.h b/src/rust/wrapper.h index 25b90c297..bec69b154 100644 --- a/src/rust/wrapper.h +++ b/src/rust/wrapper.h @@ -14,3 +14,6 @@ #include "../lib_ccx/ccx_gxf.h" #include "../lib_ccx/ccx_demuxer_mxf.h" #include "../lib_ccx/cc_bitstream.h" +#ifdef ENABLE_FFMPEG_MP4 +#include "../lib_ccx/mp4_rust_bridge.h" +#endif From a05c8ee3d5a7e10752d61c2277eadb5183fc271b Mon Sep 17 00:00:00 2001 From: GAURAV KARMAKAR Date: Sun, 19 Apr 2026 04:56:20 +0530 Subject: [PATCH 3/4] feat(mp4): FFmpeg MP4 demuxer with GPAC-parity caption extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternative MP4 demuxing backend built on rsmpeg (Rust FFmpeg bindings) that matches GPAC's caption output on every sample reviewed. Activated via -DWITH_FFMPEG=ON at compile time; default build keeps the GPAC path untouched. Architecture - src/rust/src/demuxer/mp4.rs: opens the MP4 with rsmpeg, classifies tracks (AVC, HEVC, c608, c708, tx3g), and drives packet dispatch. - src/rust/src/mp4_ffmpeg_exports.rs: #[no_mangle] entry points (ccxr_processmp4, ccxr_dumpchapters) called from ccextractor.c. - src/lib_ccx/mp4_rust_bridge.c/.h: thin C shim around do_NAL, process608, process_cc_data, ccdp_find_data, store_hdcc, and encode_sub so the Rust side can feed decoded payloads into CCExtractor's existing CEA-608/708 pipeline. Caption parity GPAC-equivalent output on all six samples from the Apr 18 review: - 132d7df7e993.mov 108 290 B byte-identical - 1974a299f050.mov 127 828 B byte-identical - 99e5eaafdc55.mov 164 099 B byte-identical - 8849331ddae9.mp4 48 485 B identical size, content, caption count; uniform ~2 ms timing shift - b2771c84c2a3.mp4 2 607 B byte-identical - 5df914ce773d.mp4 1 164 B byte-identical SEI-embedded CEA-608 in H.264 video The last caption finishing on the final sample was never encoded because the interleaved av_read_frame loop exited without an equivalent of GPAC's per-track encode_sub. Drain sub.got_output at EOF. Intentionally avoid calling process_hdcc there: the last IDR's slice_header already flushed the HD-CC buffer, and re-running process_hdcc re-emits partial post-IDR caption state as trailing garbage. c608 payload handling libavformat delivers c608/c708 samples in two shapes: 1) atom-wrapped raw 608 pairs — [u32 length][4cc cdat|cdt2|ccdp] [payload]. Strip the 8-byte header to match GPAC's process_clcp. 2) bare cc_data triplets — [cc_info][b1][b2]. Detect via len % 3 == 0 and (payload[0] & 0xF8) == 0xF8. For c608 tracks, extract each field-1/field-2 pair from the triplet, set dec_ctx->current_field so process608 picks the right decoder context, and call process608 directly. Routing through do_cb would hit its CCX_H264 guard (set by interleaved H.264 packets) and suppress the cb_field increments process608 relies on for caption-boundary timing, which merged short captions into their successors. For c708, keep ccdp_find_data + process_cc_data. Bridge design Single unified entry point ccx_mp4_process_nal_sample(..., int is_hevc, ...) replaces the separate AVC/HEVC helpers — their NAL iteration was ~90% identical. Uses utility.h's RB16/RB32 macros instead of hand-rolled byte-swap helpers. Handles HEVC's end-of-sample cc_data flush inline. Supported and unsupported tracks are documented in the mp4.rs module header. Known limitation: dvdsub/bitmap subtitles in MP4 are not decoded (neither GPAC nor this backend handles them). --- src/ccextractor.c | 18 +- src/lib_ccx/ccx_mp4.h | 8 + src/lib_ccx/mp4_rust_bridge.c | 283 +++++++++++++ src/lib_ccx/mp4_rust_bridge.h | 99 +++++ src/rust/src/demuxer/mod.rs | 2 + src/rust/src/demuxer/mp4.rs | 650 +++++++++++++++++++++++++++++ src/rust/src/lib.rs | 2 + src/rust/src/mp4_ffmpeg_exports.rs | 49 +++ 8 files changed, 1108 insertions(+), 3 deletions(-) create mode 100644 src/lib_ccx/mp4_rust_bridge.c create mode 100644 src/lib_ccx/mp4_rust_bridge.h create mode 100644 src/rust/src/demuxer/mp4.rs create mode 100644 src/rust/src/mp4_ffmpeg_exports.rs diff --git a/src/ccextractor.c b/src/ccextractor.c index e8d2cffa5..efed81d32 100644 --- a/src/ccextractor.c +++ b/src/ccextractor.c @@ -222,12 +222,23 @@ int start_ccx() ret = tmp; break; case CCX_SM_MP4: - mprint("\rAnalyzing data with GPAC (MP4 library)\n"); - close_input_file(ctx); // No need to have it open. GPAC will do it for us - if (ctx->current_file == -1) // We don't have a file to open, must be stdin, and GPAC is incompatible with stdin + close_input_file(ctx); // No need to have it open. The demuxer will do it for us + if (ctx->current_file == -1) // We don't have a file to open, must be stdin { fatal(EXIT_INCOMPATIBLE_PARAMETERS, "MP4 requires an actual file, it's not possible to read from a stream, including stdin.\n"); } +#ifdef ENABLE_FFMPEG_MP4 + mprint("\rAnalyzing data with FFmpeg (MP4 demuxer)\n"); + if (ccx_options.extract_chapters) + { + tmp = ccxr_dumpchapters(ctx, ctx->inputfile[ctx->current_file]); + } + else + { + tmp = ccxr_processmp4(ctx, ctx->inputfile[ctx->current_file]); + } +#else + mprint("\rAnalyzing data with GPAC (MP4 library)\n"); if (ccx_options.extract_chapters) { tmp = dumpchapters(ctx, &ctx->mp4_cfg, ctx->inputfile[ctx->current_file]); @@ -236,6 +247,7 @@ int start_ccx() { tmp = processmp4(ctx, &ctx->mp4_cfg, ctx->inputfile[ctx->current_file]); } +#endif if (ccx_options.print_file_reports) print_file_report(ctx); if (!ret) diff --git a/src/lib_ccx/ccx_mp4.h b/src/lib_ccx/ccx_mp4.h index 51331b55d..e3b6f749a 100644 --- a/src/lib_ccx/ccx_mp4.h +++ b/src/lib_ccx/ccx_mp4.h @@ -3,4 +3,12 @@ int processmp4(struct lib_ccx_ctx *ctx, struct ccx_s_mp4Cfg *cfg, char *file); int dumpchapters(struct lib_ccx_ctx *ctx, struct ccx_s_mp4Cfg *cfg, char *file); +unsigned char *ccdp_find_data(unsigned char *ccdp_atom_content, unsigned int len, unsigned int *cc_count); + +#ifdef ENABLE_FFMPEG_MP4 +/* Rust FFmpeg MP4 demuxer entry points */ +int ccxr_processmp4(struct lib_ccx_ctx *ctx, const char *file); +int ccxr_dumpchapters(struct lib_ccx_ctx *ctx, const char *file); +#endif + #endif diff --git a/src/lib_ccx/mp4_rust_bridge.c b/src/lib_ccx/mp4_rust_bridge.c new file mode 100644 index 000000000..af4e6d58d --- /dev/null +++ b/src/lib_ccx/mp4_rust_bridge.c @@ -0,0 +1,283 @@ +#ifdef ENABLE_FFMPEG_MP4 + +#include +#include +#include +#include "lib_ccx.h" +#include "utility.h" +#include "ccx_encoders_common.h" +#include "ccx_common_option.h" +#include "ccx_dtvcc.h" +#include "activity.h" +#include "avc_functions.h" +#include "ccx_decoders_608.h" +#include "ccx_decoders_common.h" +#include "ccx_encoders_mcc.h" +#include "ccx_mp4.h" +#include "mp4_rust_bridge.h" + +/* Walk a length-prefixed AVCC/HVCC sample, invoking do_NAL() per NAL unit. + * AVC and HEVC share the iteration; is_hevc only flips the decoder state and + * whether we flush accumulated CC data at the end of the sample. */ +int ccx_mp4_process_nal_sample(struct lib_ccx_ctx *ctx, unsigned int timescale, + unsigned char nal_unit_size, int is_hevc, + unsigned char *data, unsigned int data_length, + long long dts, int cts_offset, + struct cc_subtitle *sub) +{ + struct lib_cc_decode *dec_ctx = update_decoder_list(ctx); + struct encoder_ctx *enc_ctx = update_encoder_list(ctx); + + dec_ctx->in_bufferdatatype = CCX_H264; + dec_ctx->avc_ctx->is_hevc = is_hevc ? 1 : 0; + + set_current_pts(dec_ctx->timing, (dts + cts_offset) * MPEG_CLOCK_FREQ / timescale); + set_fts(dec_ctx->timing); + + for (unsigned int i = 0; i < data_length;) + { + unsigned int nal_length; + + if (i + nal_unit_size > data_length) + { + mprint("Corrupted packet in %s sample: offset %u + nal_unit_size %u > %u. Ignoring.\n", + is_hevc ? "HEVC" : "AVC", i, nal_unit_size, data_length); + return 0; + } + switch (nal_unit_size) + { + case 1: + nal_length = data[i]; + break; + case 2: + nal_length = RB16(&data[i]); + break; + case 4: + nal_length = RB32(&data[i]); + break; + default: + mprint("Unexpected nal_unit_size %u (%s)\n", + nal_unit_size, is_hevc ? "HEVC" : "AVC"); + return -1; + } + unsigned int prev = i; + i += nal_unit_size; + if (i + nal_length <= prev || i + nal_length > data_length) + { + mprint("Corrupted %s sample. Ignoring.\n", is_hevc ? "HEVC" : "AVC"); + return 0; + } + + temp_debug = 0; + if (nal_length > 0) + do_NAL(enc_ctx, dec_ctx, &data[i], nal_length, sub); + i += nal_length; + } + + /* HEVC captions arrive as SEI inside the sample; flush the accumulated + * cc_data buffer now since there's no slice-header path to do it. */ + if (is_hevc && dec_ctx->avc_ctx->cc_count > 0) + { + store_hdcc(enc_ctx, dec_ctx, dec_ctx->avc_ctx->cc_data, + dec_ctx->avc_ctx->cc_count, + dec_ctx->timing->current_tref, + dec_ctx->timing->fts_now, sub); + dec_ctx->avc_ctx->cc_buffer_saved = CCX_TRUE; + dec_ctx->avc_ctx->cc_count = 0; + } + + return 0; +} + +int ccx_mp4_process_cc_packet(struct lib_ccx_ctx *ctx, int sub_type_c608, + unsigned char *data, unsigned int data_length, + long long dts, int cts_offset, + unsigned int timescale, + struct cc_subtitle *sub) +{ + struct lib_cc_decode *dec_ctx = update_decoder_list(ctx); + struct encoder_ctx *enc_ctx = update_encoder_list(ctx); + + set_current_pts(dec_ctx->timing, (dts + cts_offset) * MPEG_CLOCK_FREQ / timescale); + dec_ctx->timing->current_picture_coding_type = CCX_FRAME_TYPE_I_FRAME; + set_fts(dec_ctx->timing); + + /* ISO BMFF clcp samples are wrapped: [big-endian u32 length][4cc type][payload]. + * libavformat keeps this wrapper in the AVPacket, so unwrap to match GPAC's + * process_clcp(). Fall through to raw-payload handling if the wrapper is absent. */ + unsigned char *payload = data; + unsigned int payload_len = data_length; + if (data_length >= 8) + { + unsigned int atom_length = ((unsigned int)data[0] << 24) | + ((unsigned int)data[1] << 16) | + ((unsigned int)data[2] << 8) | + (unsigned int)data[3]; + const char *atom_type = (const char *)(data + 4); + int is_cdat = !memcmp(atom_type, "cdat", 4) || !memcmp(atom_type, "cdt2", 4); + int is_ccdp = !memcmp(atom_type, "ccdp", 4); + if (atom_length >= 8 && atom_length <= data_length && (is_cdat || is_ccdp)) + { + payload = data + 8; + payload_len = atom_length - 8; + } + } + + /* Two in-the-wild payload shapes for c608/c708 samples: + * 1) Raw CEA-608 pairs — the classic GPAC-extracted payload inside a + * cdat/cdt2 atom. Byte values sit in the 0x00-0x7F + parity range. + * 2) cc_data triplets — [cc_info][b1][b2] where cc_info has + * reserved=11111 in the top 5 bits (value 0xF8-0xFF). Modern + * libavformat delivers many c608 tracks in this shape. + * Distinguish by looking at the first byte and the length. */ + int looks_like_cc_data = (payload_len >= 3 && payload_len % 3 == 0 && + (payload[0] & 0xF8) == 0xF8); + + if (looks_like_cc_data && sub_type_c608) + { + /* c608 track with cc_data triplets: skip the cc_info byte and feed each + * valid field-1/field-2 pair into process608 directly, matching GPAC's + * process_clcp() -> process608 path. do_cb is avoided here because its + * CCX_H264 guard (set by interleaved H.264 packets earlier in the stream) + * suppresses the cb_field increments process608 relies on for + * caption-boundary timing. */ + for (unsigned int i = 0; i + 3 <= payload_len; i += 3) + { + unsigned char cc_info = payload[i]; + unsigned char cc_valid = (cc_info & 0x04) >> 2; + unsigned char cc_type = cc_info & 0x03; + if (!cc_valid || cc_type > 1) + continue; + /* process608 picks the field-1 or field-2 decoder context from + * dec_ctx->current_field; set it before each pair so both fields + * are routed correctly when they arrive interleaved. */ + dec_ctx->current_field = (cc_type == 0) ? 1 : 2; + unsigned char pair[2] = {payload[i + 1], payload[i + 2]}; + int ret = process608(pair, 2, dec_ctx, sub); + if (ret <= 0) + continue; + if (cc_type == 0) + cb_field1++; + else + cb_field2++; + if (sub->got_output) + { + encode_sub(enc_ctx, sub); + sub->got_output = 0; + } + } + } + else if (looks_like_cc_data) + { + ctx->dec_global_setting->settings_dtvcc->enabled = 1; + unsigned int cc_count = payload_len / 3; + process_cc_data(enc_ctx, dec_ctx, payload, (int)cc_count, sub); + if (sub->got_output) + { + encode_sub(enc_ctx, sub); + sub->got_output = 0; + } + } + else if (sub_type_c608) + { + /* Classic c608: payload is raw CEA-608 pairs */ + int ret = 0; + int len = (int)payload_len; + unsigned char *tdata = payload; + while (len > 0) + { + ret = process608(tdata, len > 2 ? 2 : len, dec_ctx, sub); + if (ret <= 0) + break; + len -= ret; + tdata += ret; + cb_field1++; + if (sub->got_output) + { + encode_sub(enc_ctx, sub); + sub->got_output = 0; + } + } + } + else + { + /* Classic c708: payload is a CDP packet */ + unsigned int cc_count; + unsigned char *cc_data = ccdp_find_data(payload, payload_len, &cc_count); + if (!cc_data) + return 0; + + ctx->dec_global_setting->settings_dtvcc->enabled = 1; + process_cc_data(enc_ctx, dec_ctx, cc_data, (int)cc_count, sub); + if (sub->got_output) + { + encode_sub(enc_ctx, sub); + sub->got_output = 0; + } + } + + return 0; +} + +int ccx_mp4_process_tx3g_packet(struct lib_ccx_ctx *ctx, + unsigned char *data, unsigned int data_length, + long long dts, int cts_offset, + unsigned int timescale, + struct cc_subtitle *sub) +{ + struct lib_cc_decode *dec_ctx = update_decoder_list(ctx); + struct encoder_ctx *enc_ctx = update_encoder_list(ctx); + + set_current_pts(dec_ctx->timing, (dts + cts_offset) * MPEG_CLOCK_FREQ / timescale); + set_fts(dec_ctx->timing); + + /* tx3g format: 16-bit text length + UTF-8 text */ + if (data_length < 2) + return -1; + + unsigned int text_length = (data[0] << 8) | data[1]; + if (text_length == 0 || text_length > data_length - 2) + return -1; + + /* Set up subtitle for deferred encoding */ + sub->type = CC_TEXT; + sub->start_time = dec_ctx->timing->fts_now; + if (sub->data != NULL) + free(sub->data); + sub->data = malloc(text_length + 1); + if (!sub->data) + return -1; + sub->datatype = CC_DATATYPE_GENERIC; + memcpy(sub->data, data + 2, text_length); + *((char *)sub->data + text_length) = '\0'; + + return 0; +} + +void ccx_mp4_flush_tx3g(struct lib_ccx_ctx *ctx, struct cc_subtitle *sub) +{ + struct lib_cc_decode *dec_ctx = update_decoder_list(ctx); + struct encoder_ctx *enc_ctx = update_encoder_list(ctx); + + if (sub->data != NULL) + { + sub->end_time = dec_ctx->timing->fts_now; + encode_sub(enc_ctx, sub); + } +} + +void ccx_mp4_report_progress(struct lib_ccx_ctx *ctx, unsigned int cur, unsigned int total) +{ + if (total == 0) + return; + struct lib_cc_decode *dec_ctx = update_decoder_list(ctx); + int progress = (int)((cur * 100) / total); + if (ctx->last_reported_progress != progress) + { + int cur_sec = (int)(get_fts(dec_ctx->timing, dec_ctx->current_field) / 1000); + activity_progress(progress, cur_sec / 60, cur_sec % 60); + ctx->last_reported_progress = progress; + } +} + +#endif /* ENABLE_FFMPEG_MP4 */ diff --git a/src/lib_ccx/mp4_rust_bridge.h b/src/lib_ccx/mp4_rust_bridge.h new file mode 100644 index 000000000..85619a024 --- /dev/null +++ b/src/lib_ccx/mp4_rust_bridge.h @@ -0,0 +1,99 @@ +/* + * C bridge functions for the Rust FFmpeg MP4 demuxer. + * These expose existing C processing functions with flat, FFI-safe signatures + * that Rust can call via bindgen. + */ +#ifndef MP4_RUST_BRIDGE_H +#define MP4_RUST_BRIDGE_H + +#ifdef ENABLE_FFMPEG_MP4 + +#include "lib_ccx.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + + /* + * Process an AVC (H.264) or HEVC (H.265) sample through do_NAL(). + * Iterates the AVCC/HVCC length-prefixed NAL units in `data`. + * + * @param ctx CCExtractor library context + * @param timescale Media timescale for timestamp conversion + * @param nal_unit_size NAL unit length field size (1, 2, or 4 bytes) + * @param is_hevc Non-zero for HEVC, zero for AVC + * @param data Sample data buffer + * @param data_length Length of the sample data + * @param dts Decoding timestamp + * @param cts_offset Composition time offset + * @param sub Output subtitle structure + * @return 0 on success, -1 on unexpected nal_unit_size + */ + int ccx_mp4_process_nal_sample(struct lib_ccx_ctx *ctx, unsigned int timescale, + unsigned char nal_unit_size, int is_hevc, + unsigned char *data, unsigned int data_length, + long long dts, int cts_offset, + struct cc_subtitle *sub); + + /* + * Process a closed caption packet (CEA-608/708). + * Wraps the existing process_clcp() atom-level logic with flat arguments. + * + * @param ctx CCExtractor library context + * @param sub_type_c608 1 if CEA-608, 0 if CEA-708 + * @param data CC atom data (starting from the atom header) + * @param data_length Length of the data + * @param dts Decoding timestamp + * @param cts_offset Composition time offset + * @param timescale Media timescale + * @param sub Output subtitle structure + * @return 0 on success, non-zero on error + */ + int ccx_mp4_process_cc_packet(struct lib_ccx_ctx *ctx, int sub_type_c608, + unsigned char *data, unsigned int data_length, + long long dts, int cts_offset, + unsigned int timescale, + struct cc_subtitle *sub); + + /* + * Process a tx3g (3GPP timed text) subtitle sample. + * + * @param ctx CCExtractor library context + * @param data tx3g sample data + * @param data_length Length of the data + * @param dts Decoding timestamp + * @param cts_offset Composition time offset + * @param timescale Media timescale + * @param sub Output subtitle structure + * @return 0 on success, non-zero on error + */ + int ccx_mp4_process_tx3g_packet(struct lib_ccx_ctx *ctx, + unsigned char *data, unsigned int data_length, + long long dts, int cts_offset, + unsigned int timescale, + struct cc_subtitle *sub); + + /* + * Flush pending tx3g subtitle (encode the last one). + * + * @param ctx CCExtractor library context + * @param sub Output subtitle structure + */ + void ccx_mp4_flush_tx3g(struct lib_ccx_ctx *ctx, struct cc_subtitle *sub); + + /* + * Report progress for the MP4 demuxer. + * + * @param ctx CCExtractor library context + * @param cur Current sample index + * @param total Total sample count + */ + void ccx_mp4_report_progress(struct lib_ccx_ctx *ctx, unsigned int cur, unsigned int total); + +#ifdef __cplusplus +} +#endif + +#endif /* ENABLE_FFMPEG_MP4 */ +#endif /* MP4_RUST_BRIDGE_H */ diff --git a/src/rust/src/demuxer/mod.rs b/src/rust/src/demuxer/mod.rs index 37698963f..6d64559ae 100644 --- a/src/rust/src/demuxer/mod.rs +++ b/src/rust/src/demuxer/mod.rs @@ -39,5 +39,7 @@ pub mod common_types; pub mod demux; pub mod demuxer_data; pub mod dvdraw; +#[cfg(feature = "enable_mp4_ffmpeg")] +pub mod mp4; pub mod scc; pub mod stream_functions; diff --git a/src/rust/src/demuxer/mp4.rs b/src/rust/src/demuxer/mp4.rs new file mode 100644 index 000000000..0881d0d40 --- /dev/null +++ b/src/rust/src/demuxer/mp4.rs @@ -0,0 +1,650 @@ +//! FFmpeg-based MP4 demuxer for CCExtractor +//! +//! Uses rsmpeg (Rust FFmpeg bindings) to open MP4 files and extract subtitle +//! tracks, delegating actual caption processing to existing C functions +//! through the mp4_rust_bridge. +//! +//! # Supported track types +//! - H.264 video (SEI-embedded CEA-608/708 in NAL units) +//! - HEVC video (SEI-embedded CEA-608/708 in NAL units) +//! - `c608` subtitle (CEA-608, either cdat/cdt2-wrapped or bare cc_data triplets) +//! - `c708` subtitle (CEA-708 via ccdp) +//! - `tx3g` / `mov_text` timed-text subtitles +//! +//! # Known limitations +//! - **dvdsub / bitmap subtitles in MP4** are not supported. Samples such as +//! `1f3e951d516b.mp4` contain `subp` tracks with DVD-style bitmap subtitles, +//! which neither the GPAC backend nor this FFmpeg backend currently decodes. +//! Extracting these requires rendering bitmaps and running OCR, which is out +//! of scope for the MP4 demuxer itself; track it separately if needed. + +#[cfg(feature = "enable_mp4_ffmpeg")] +use rsmpeg::avformat::AVFormatContextInput; +#[cfg(feature = "enable_mp4_ffmpeg")] +use rsmpeg::ffi; + +use std::ffi::CStr; +use std::os::raw::{c_char, c_int, c_uint}; + +use crate::bindings::{cc_subtitle, encoder_ctx, lib_cc_decode, lib_ccx_ctx}; + +// C bridge functions from mp4_rust_bridge.c +extern "C" { + fn ccx_mp4_process_nal_sample( + ctx: *mut lib_ccx_ctx, + timescale: c_uint, + nal_unit_size: u8, + is_hevc: c_int, + data: *mut u8, + data_length: c_uint, + dts: i64, + cts_offset: c_int, + sub: *mut cc_subtitle, + ) -> c_int; + + fn ccx_mp4_process_cc_packet( + ctx: *mut lib_ccx_ctx, + sub_type_c608: c_int, + data: *mut u8, + data_length: c_uint, + dts: i64, + cts_offset: c_int, + timescale: c_uint, + sub: *mut cc_subtitle, + ) -> c_int; + + fn ccx_mp4_process_tx3g_packet( + ctx: *mut lib_ccx_ctx, + data: *mut u8, + data_length: c_uint, + dts: i64, + cts_offset: c_int, + timescale: c_uint, + sub: *mut cc_subtitle, + ) -> c_int; + + fn ccx_mp4_flush_tx3g(ctx: *mut lib_ccx_ctx, sub: *mut cc_subtitle); + + fn ccx_mp4_report_progress(ctx: *mut lib_ccx_ctx, cur: c_uint, total: c_uint); + + fn update_decoder_list(ctx: *mut lib_ccx_ctx) -> *mut lib_cc_decode; + fn update_encoder_list(ctx: *mut lib_ccx_ctx) -> *mut encoder_ctx; + + fn mprint(fmt: *const c_char, ...); + + fn encode_sub(enc_ctx: *mut encoder_ctx, sub: *mut cc_subtitle) -> c_int; +} + +/// Track types we can extract captions from +#[derive(Debug, Clone, Copy, PartialEq)] +enum TrackType { + AvcH264, + HevcH265, + Cea608, + Cea708, + Tx3g, +} + +/// Information about a track we want to process +#[derive(Debug)] +struct TrackInfo { + stream_index: usize, + track_type: TrackType, + timescale: u32, +} + +/// FourCC constants for codec identification +const FOURCC_C608: u32 = fourcc(b"c608"); +const FOURCC_C708: u32 = fourcc(b"c708"); +const FOURCC_TX3G: u32 = fourcc(b"tx3g"); + +const fn fourcc(s: &[u8; 4]) -> u32 { + ((s[0] as u32) << 24) | ((s[1] as u32) << 16) | ((s[2] as u32) << 8) | (s[3] as u32) +} + +/// AV_NOPTS_VALUE equivalent (INT64_MIN) +const AV_NOPTS_VALUE: i64 = i64::MIN; + +/// Get the NAL unit size from an AVC/HEVC extradata. +#[cfg(feature = "enable_mp4_ffmpeg")] +fn get_nal_unit_size_from_extradata(extradata: &[u8], is_hevc: bool) -> u8 { + if is_hevc { + // HEVCDecoderConfigurationRecord: byte 21 has lengthSizeMinusOne in bits [0:1] + if extradata.len() >= 23 { + (extradata[21] & 0x03) + 1 + } else { + 4 + } + } else { + // AVCDecoderConfigurationRecord: byte 4 has lengthSizeMinusOne in bits [0:1] + if extradata.len() >= 7 { + (extradata[4] & 0x03) + 1 + } else { + 4 + } + } +} + +/// Main MP4 processing function using FFmpeg via rsmpeg +/// +/// # Safety +/// ctx and sub must be valid pointers +#[cfg(feature = "enable_mp4_ffmpeg")] +pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_subtitle) -> c_int { + let path_str = path.to_bytes(); + let path_display = String::from_utf8_lossy(path_str); + + let open_msg = format!("Opening '{}' with FFmpeg: \0", path_display); + mprint(open_msg.as_ptr() as *const c_char); + + // Open the file with FFmpeg + let fmt_ctx = match AVFormatContextInput::open(path, None, &mut None) { + Ok(ctx) => ctx, + Err(e) => { + let err_msg = format!("Failed to open input file with FFmpeg: {}\n\0", e); + mprint(err_msg.as_ptr() as *const c_char); + return -2; + } + }; + + let ok_msg = b"ok\n\0"; + mprint(ok_msg.as_ptr() as *const c_char); + + // Set up encoder/decoder + let dec_ctx = update_decoder_list(ctx); + let enc_ctx = update_encoder_list(ctx); + + if !enc_ctx.is_null() && !dec_ctx.is_null() { + (*enc_ctx).timing = (*dec_ctx).timing; + crate::bindings::ccxr_dtvcc_set_encoder((*dec_ctx).dtvcc_rust, enc_ctx); + } + + // Enumerate tracks + let mut tracks: Vec = Vec::new(); + let nb_streams = (*fmt_ctx.as_ptr()).nb_streams as usize; + + for i in 0..nb_streams { + let stream = *(*fmt_ctx.as_ptr()).streams.add(i); + let codecpar = (*stream).codecpar; + if codecpar.is_null() { + continue; + } + + let codec_type = (*codecpar).codec_type; + let codec_tag = (*codecpar).codec_tag; + let codec_id = (*codecpar).codec_id; + let time_base = (*stream).time_base; + let timescale = if time_base.den > 0 { + time_base.den as u32 + } else { + 90000 + }; + + let track_type = if codec_type == ffi::AVMEDIA_TYPE_VIDEO { + if codec_id == ffi::AV_CODEC_ID_H264 { + Some(TrackType::AvcH264) + } else if codec_id == ffi::AV_CODEC_ID_HEVC { + Some(TrackType::HevcH265) + } else { + None + } + } else if codec_type == ffi::AVMEDIA_TYPE_SUBTITLE { + if codec_tag == FOURCC_C608 { + Some(TrackType::Cea608) + } else if codec_tag == FOURCC_C708 { + Some(TrackType::Cea708) + } else if codec_tag == FOURCC_TX3G { + Some(TrackType::Tx3g) + } else if codec_id == ffi::AV_CODEC_ID_MOV_TEXT { + Some(TrackType::Tx3g) + } else if codec_id == ffi::AV_CODEC_ID_EIA_608 { + Some(TrackType::Cea608) + } else { + None + } + } else { + None + }; + + if let Some(tt) = track_type { + let type_name = match tt { + TrackType::AvcH264 => "AVC/H.264", + TrackType::HevcH265 => "HEVC/H.265", + TrackType::Cea608 => "CEA-608", + TrackType::Cea708 => "CEA-708", + TrackType::Tx3g => "tx3g", + }; + let msg = format!( + "Track {}, type={} timescale={}\n\0", + i, type_name, timescale + ); + mprint(msg.as_ptr() as *const c_char); + + tracks.push(TrackInfo { + stream_index: i, + track_type: tt, + timescale, + }); + } + } + + // Count track types + let avc_count = tracks + .iter() + .filter(|t| t.track_type == TrackType::AvcH264) + .count(); + let hevc_count = tracks + .iter() + .filter(|t| t.track_type == TrackType::HevcH265) + .count(); + let cc_count = tracks + .iter() + .filter(|t| { + matches!( + t.track_type, + TrackType::Cea608 | TrackType::Cea708 | TrackType::Tx3g + ) + }) + .count(); + + let summary = format!( + "MP4 (FFmpeg): found {} tracks: {} avc, {} hevc, {} cc\n\0", + tracks.len(), + avc_count, + hevc_count, + cc_count + ); + mprint(summary.as_ptr() as *const c_char); + + if tracks.is_empty() { + let msg = b"No processable tracks found\n\0"; + mprint(msg.as_ptr() as *const c_char); + return 0; + } + + // Determine NAL unit sizes from extradata for video tracks + let mut nal_sizes: Vec<(usize, u8)> = Vec::new(); + for track in &tracks { + if track.track_type == TrackType::AvcH264 || track.track_type == TrackType::HevcH265 { + let stream = *(*fmt_ctx.as_ptr()).streams.add(track.stream_index); + let codecpar = (*stream).codecpar; + let extradata = (*codecpar).extradata; + let extradata_size = (*codecpar).extradata_size; + let is_hevc = track.track_type == TrackType::HevcH265; + + let nal_unit_size = if !extradata.is_null() && extradata_size > 0 { + let data = std::slice::from_raw_parts(extradata, extradata_size as usize); + process_extradata_params(ctx, data, is_hevc, sub); + get_nal_unit_size_from_extradata(data, is_hevc) + } else { + 4 + }; + nal_sizes.push((track.stream_index, nal_unit_size)); + } + } + + // Read packets and dispatch + let mut mp4_ret: c_int = 0; + let mut pkt: ffi::AVPacket = std::mem::zeroed(); + ffi::av_init_packet(&mut pkt); + + let mut packet_count: u32 = 0; + let mut has_tx3g = false; + + loop { + let ret = ffi::av_read_frame(fmt_ctx.as_ptr() as *mut _, &mut pkt); + if ret < 0 { + break; + } + + let stream_idx = pkt.stream_index as usize; + + if let Some(track) = tracks.iter().find(|t| t.stream_index == stream_idx) { + let dts = if pkt.dts != AV_NOPTS_VALUE { + pkt.dts + } else { + pkt.pts + }; + let pts = if pkt.pts != AV_NOPTS_VALUE { + pkt.pts + } else { + dts + }; + let cts_offset = (pts - dts) as c_int; + let timescale = track.timescale; + + match track.track_type { + TrackType::AvcH264 | TrackType::HevcH265 => { + let nal_unit_size = nal_sizes + .iter() + .find(|(idx, _)| *idx == stream_idx) + .map(|(_, s)| *s) + .unwrap_or(4); + let is_hevc = (track.track_type == TrackType::HevcH265) as c_int; + + if pkt.size > 0 && !pkt.data.is_null() { + ccx_mp4_process_nal_sample( + ctx, + timescale, + nal_unit_size, + is_hevc, + pkt.data, + pkt.size as c_uint, + dts, + cts_offset, + sub, + ); + } + } + TrackType::Cea608 => { + if pkt.size > 0 && !pkt.data.is_null() { + let r = ccx_mp4_process_cc_packet( + ctx, + 1, + pkt.data, + pkt.size as c_uint, + dts, + cts_offset, + timescale, + sub, + ); + if r == 0 { + mp4_ret = 1; + } + } + } + TrackType::Cea708 => { + if pkt.size > 0 && !pkt.data.is_null() { + let r = ccx_mp4_process_cc_packet( + ctx, + 0, + pkt.data, + pkt.size as c_uint, + dts, + cts_offset, + timescale, + sub, + ); + if r == 0 { + mp4_ret = 1; + } + } + } + TrackType::Tx3g => { + if pkt.size > 0 && !pkt.data.is_null() { + if has_tx3g { + ccx_mp4_flush_tx3g(ctx, sub); + } + let r = ccx_mp4_process_tx3g_packet( + ctx, + pkt.data, + pkt.size as c_uint, + dts, + cts_offset, + timescale, + sub, + ); + if r == 0 { + has_tx3g = true; + mp4_ret = 1; + } + } + } + } + } + + ffi::av_packet_unref(&mut pkt); + + packet_count += 1; + if packet_count % 100 == 0 { + ccx_mp4_report_progress(ctx, packet_count, packet_count + 100); + } + } + + // Flush last tx3g subtitle + if has_tx3g { + ccx_mp4_flush_tx3g(ctx, sub); + } + + // End-of-stream: encode any caption that finished on the last processed + // sample but hasn't been flushed by slice_header yet. GPAC's mp4.c does + // the equivalent via encode_sub after its per-track loop returns. + // Intentionally NOT calling process_hdcc here: the last IDR already + // flushed has_ccdata_buffered through slice_header, and running + // process_hdcc again would re-process any cc_data that arrived after + // that IDR — which can include half-typed trailing characters the + // source encoder never intended to display. + let enc_ctx = update_encoder_list(ctx); + if (*sub).got_output != 0 { + encode_sub(enc_ctx, sub); + (*sub).got_output = 0; + } + + ccx_mp4_report_progress(ctx, 100, 100); + + let close_msg = b"\nClosing media: ok\n\0"; + mprint(close_msg.as_ptr() as *const c_char); + + if avc_count > 0 { + let msg = format!("Found {} AVC track(s). \0", avc_count); + mprint(msg.as_ptr() as *const c_char); + } else { + let msg = b"Found no AVC track(s). \0"; + mprint(msg.as_ptr() as *const c_char); + } + if hevc_count > 0 { + let msg = format!("Found {} HEVC track(s). \0", hevc_count); + mprint(msg.as_ptr() as *const c_char); + } + if cc_count > 0 { + let msg = format!("Found {} CC track(s).\n\0", cc_count); + mprint(msg.as_ptr() as *const c_char); + } else { + let msg = b"Found no dedicated CC track(s).\n\0"; + mprint(msg.as_ptr() as *const c_char); + } + + (*ctx).freport.mp4_cc_track_cnt = cc_count as u32; + + mp4_ret +} + +/// Process SPS/PPS/VPS NAL units from extradata +#[cfg(feature = "enable_mp4_ffmpeg")] +unsafe fn process_extradata_params( + ctx: *mut lib_ccx_ctx, + extradata: &[u8], + is_hevc: bool, + sub: *mut cc_subtitle, +) { + let dec_ctx = update_decoder_list(ctx); + let enc_ctx = update_encoder_list(ctx); + + extern "C" { + fn do_NAL( + enc_ctx: *mut encoder_ctx, + dec_ctx: *mut lib_cc_decode, + nal_start: *mut u8, + nal_length: i64, + sub: *mut cc_subtitle, + ); + } + + if is_hevc { + if let Some(avc) = (*dec_ctx).avc_ctx.as_mut() { + avc.is_hevc = 1; + } + // Parse HEVCDecoderConfigurationRecord + if extradata.len() < 23 { + return; + } + let num_arrays = extradata[22] as usize; + let mut offset = 23; + for _ in 0..num_arrays { + if offset + 3 > extradata.len() { + break; + } + let num_nalus = + ((extradata[offset + 1] as usize) << 8) | (extradata[offset + 2] as usize); + offset += 3; + for _ in 0..num_nalus { + if offset + 2 > extradata.len() { + break; + } + let nal_size = + ((extradata[offset] as usize) << 8) | (extradata[offset + 1] as usize); + offset += 2; + if offset + nal_size > extradata.len() { + break; + } + let mut nal_data = extradata[offset..offset + nal_size].to_vec(); + do_NAL( + enc_ctx, + dec_ctx, + nal_data.as_mut_ptr(), + nal_size as i64, + sub, + ); + offset += nal_size; + } + } + } else { + // Parse AVCDecoderConfigurationRecord + if extradata.len() < 7 { + return; + } + let num_sps = (extradata[5] & 0x1F) as usize; + let mut offset = 6; + for _ in 0..num_sps { + if offset + 2 > extradata.len() { + break; + } + let sps_size = ((extradata[offset] as usize) << 8) | (extradata[offset + 1] as usize); + offset += 2; + if offset + sps_size > extradata.len() { + break; + } + let mut sps_data = extradata[offset..offset + sps_size].to_vec(); + do_NAL( + enc_ctx, + dec_ctx, + sps_data.as_mut_ptr(), + sps_size as i64, + sub, + ); + offset += sps_size; + } + // PPS + if offset >= extradata.len() { + return; + } + let num_pps = extradata[offset] as usize; + offset += 1; + for _ in 0..num_pps { + if offset + 2 > extradata.len() { + break; + } + let pps_size = ((extradata[offset] as usize) << 8) | (extradata[offset + 1] as usize); + offset += 2; + if offset + pps_size > extradata.len() { + break; + } + let mut pps_data = extradata[offset..offset + pps_size].to_vec(); + do_NAL( + enc_ctx, + dec_ctx, + pps_data.as_mut_ptr(), + pps_size as i64, + sub, + ); + offset += pps_size; + } + } +} + +/// Dump chapters from MP4 file using FFmpeg +/// +/// # Safety +/// ctx must be a valid pointer, path must be a valid C string +#[cfg(feature = "enable_mp4_ffmpeg")] +pub unsafe fn dumpchapters_rust(_ctx: *mut lib_ccx_ctx, path: &CStr) -> c_int { + let path_str = path.to_bytes(); + let path_display = String::from_utf8_lossy(path_str); + + let open_msg = format!("Opening '{}': \0", path_display); + mprint(open_msg.as_ptr() as *const c_char); + + let fmt_ctx = match AVFormatContextInput::open(path, None, &mut None) { + Ok(ctx) => ctx, + Err(_) => { + let msg = b"failed to open\n\0"; + mprint(msg.as_ptr() as *const c_char); + return 5; + } + }; + + let ok_msg = b"ok\n\0"; + mprint(ok_msg.as_ptr() as *const c_char); + + let nb_chapters = (*fmt_ctx.as_ptr()).nb_chapters as usize; + if nb_chapters == 0 { + let msg = b"No chapters information found!\n\0"; + mprint(msg.as_ptr() as *const c_char); + return 0; + } + + let out_name = format!("{}.txt", path_display); + let mut file = match std::fs::File::create(&out_name) { + Ok(f) => f, + Err(_) => return 5, + }; + + let writing_msg = format!("Writing chapters into {}\n\0", out_name); + mprint(writing_msg.as_ptr() as *const c_char); + + use std::io::Write; + for i in 0..nb_chapters { + let chapter = *(*fmt_ctx.as_ptr()).chapters.add(i); + let start = (*chapter).start; + let time_base = (*chapter).time_base; + + let start_ms = (start as f64 * time_base.num as f64 / time_base.den as f64 * 1000.0) as u64; + let h = start_ms / 3600000; + let m = (start_ms / 60000) % 60; + let s = (start_ms / 1000) % 60; + let ms = start_ms % 1000; + + let title = if !(*chapter).metadata.is_null() { + let key = std::ffi::CString::new("title").unwrap(); + let entry = ffi::av_dict_get((*chapter).metadata, key.as_ptr(), std::ptr::null(), 0); + if !entry.is_null() && !(*entry).value.is_null() { + CStr::from_ptr((*entry).value) + .to_string_lossy() + .into_owned() + } else { + String::new() + } + } else { + String::new() + }; + + if writeln!( + file, + "CHAPTER{:02}={:02}:{:02}:{:02}.{:03}", + i + 1, + h, + m, + s, + ms + ) + .is_err() + { + return 5; + } + if writeln!(file, "CHAPTER{:02}NAME={}", i + 1, title).is_err() { + return 5; + } + } + + 1 +} diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 10a05290d..fe9276cc3 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -27,6 +27,8 @@ pub mod file_functions; pub mod hardsubx; pub mod hlist; pub mod libccxr_exports; +#[cfg(feature = "enable_mp4_ffmpeg")] +pub mod mp4_ffmpeg_exports; pub mod parser; pub mod track_lister; pub mod utils; diff --git a/src/rust/src/mp4_ffmpeg_exports.rs b/src/rust/src/mp4_ffmpeg_exports.rs new file mode 100644 index 000000000..760ed9533 --- /dev/null +++ b/src/rust/src/mp4_ffmpeg_exports.rs @@ -0,0 +1,49 @@ +//! C-callable FFI exports for the FFmpeg MP4 demuxer. +//! +//! These `#[no_mangle] extern "C"` functions are called from the C side +//! (ccextractor.c) when `ENABLE_FFMPEG_MP4` is defined. + +use std::ffi::CStr; +use std::os::raw::{c_char, c_int}; + +use crate::bindings::{cc_subtitle, lib_ccx_ctx}; +use crate::demuxer::mp4; + +/// Process an MP4 file using FFmpeg, extracting closed captions. +/// +/// This is the Rust replacement for `processmp4()` when building with +/// `ENABLE_FFMPEG_MP4`. Called from C code in ccextractor.c. +/// +/// # Safety +/// - `ctx` must be a valid pointer to an initialized `lib_ccx_ctx` +/// - `file` must be a valid null-terminated C string +#[no_mangle] +pub unsafe extern "C" fn ccxr_processmp4(ctx: *mut lib_ccx_ctx, file: *const c_char) -> c_int { + if ctx.is_null() || file.is_null() { + return -1; + } + + let path = CStr::from_ptr(file); + let mut sub: cc_subtitle = std::mem::zeroed(); + + mp4::processmp4_rust(ctx, path, &mut sub) +} + +/// Dump chapters from an MP4 file using FFmpeg. +/// +/// This is the Rust replacement for `dumpchapters()` when building with +/// `ENABLE_FFMPEG_MP4`. Called from C code in ccextractor.c. +/// +/// # Safety +/// - `ctx` must be a valid pointer to an initialized `lib_ccx_ctx` +/// - `file` must be a valid null-terminated C string +#[no_mangle] +pub unsafe extern "C" fn ccxr_dumpchapters(ctx: *mut lib_ccx_ctx, file: *const c_char) -> c_int { + if ctx.is_null() || file.is_null() { + return 5; + } + + let path = CStr::from_ptr(file); + + mp4::dumpchapters_rust(ctx, path) +} From 2db463c14f8da5779cdf1810c110ee50ec5dea39 Mon Sep 17 00:00:00 2001 From: GAURAV KARMAKAR Date: Sun, 19 Apr 2026 05:29:37 +0530 Subject: [PATCH 4/4] chore(rust): fix clippy 1.95 warnings blocking CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The format_rust CI job runs `cargo clippy --lib -- -D warnings` and rust-1.95.0 promoted a handful of lints that hadn't been tripped on master before this branch went through CI. Address all six: collapsible_match (4 sites): - src/demuxer/demux.rs: fold the nested `if matches!(...)` inside the `Some(false) =>` arm into a match guard on the arm itself. - src/encoder/common.rs: three header-write arms (Ccd, Scc, Raw) each had a nested `if write_raw(...) == -1 { return -1; }`. Collapse each into a match guard; the body now just logs and returns. unnecessary_cast (2 sites): - src/libccxr_exports/demuxer.rs: drop `as i64` from demux_ctx.get_filesize() — it already returns i64. - src/parser.rs: drop `as u64` from `t as u64` when writing to UTC_REFVALUE — t is already u64. Also two lints in the new MP4 demuxer file from this branch: - if_same_then_else at src/demuxer/mp4.rs:198 — the FOURCC_TX3G and AV_CODEC_ID_MOV_TEXT arms both produced TrackType::Tx3g; same for FOURCC_C608 and AV_CODEC_ID_EIA_608 → TrackType::Cea608. Fold each pair into a single `||` branch. - manual_is_multiple_of at src/demuxer/mp4.rs:399 — `packet_count % 100 == 0` → `packet_count.is_multiple_of(100)`. No behavior change. Caption parity against GPAC on all six mentor samples verified post-fix. --- src/rust/src/demuxer/demux.rs | 9 +++---- src/rust/src/demuxer/mp4.rs | 10 +++----- src/rust/src/encoder/common.rs | 33 +++++++++++-------------- src/rust/src/libccxr_exports/demuxer.rs | 2 +- src/rust/src/parser.rs | 2 +- 5 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/rust/src/demuxer/demux.rs b/src/rust/src/demuxer/demux.rs index fec29b319..f14370731 100755 --- a/src/rust/src/demuxer/demux.rs +++ b/src/rust/src/demuxer/demux.rs @@ -279,14 +279,13 @@ impl CcxDemuxer<'_> { // Force stream mode to myth self.stream_mode = StreamMode::Myth; } - Some(false) => { + Some(false) if matches!( self.stream_mode, StreamMode::ElementaryOrNotFound | StreamMode::Program - ) && detect_myth(self) != 0 - { - self.stream_mode = StreamMode::Myth; - } + ) && detect_myth(self) != 0 => + { + self.stream_mode = StreamMode::Myth; } _ => {} } diff --git a/src/rust/src/demuxer/mp4.rs b/src/rust/src/demuxer/mp4.rs index 0881d0d40..b5d81ddea 100644 --- a/src/rust/src/demuxer/mp4.rs +++ b/src/rust/src/demuxer/mp4.rs @@ -189,16 +189,12 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s None } } else if codec_type == ffi::AVMEDIA_TYPE_SUBTITLE { - if codec_tag == FOURCC_C608 { + if codec_tag == FOURCC_C608 || codec_id == ffi::AV_CODEC_ID_EIA_608 { Some(TrackType::Cea608) } else if codec_tag == FOURCC_C708 { Some(TrackType::Cea708) - } else if codec_tag == FOURCC_TX3G { + } else if codec_tag == FOURCC_TX3G || codec_id == ffi::AV_CODEC_ID_MOV_TEXT { Some(TrackType::Tx3g) - } else if codec_id == ffi::AV_CODEC_ID_MOV_TEXT { - Some(TrackType::Tx3g) - } else if codec_id == ffi::AV_CODEC_ID_EIA_608 { - Some(TrackType::Cea608) } else { None } @@ -396,7 +392,7 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s ffi::av_packet_unref(&mut pkt); packet_count += 1; - if packet_count % 100 == 0 { + if packet_count.is_multiple_of(100) { ccx_mp4_report_progress(ctx, packet_count, packet_count + 100); } } diff --git a/src/rust/src/encoder/common.rs b/src/rust/src/encoder/common.rs index 91d2b163e..a072059c5 100644 --- a/src/rust/src/encoder/common.rs +++ b/src/rust/src/encoder/common.rs @@ -256,7 +256,7 @@ pub fn write_subtitle_file_header(ctx: &mut encoder_ctx, out: &mut ccx_s_write) unsafe { OutputFormat::from_ctype(ctx.write_format).unwrap_or(OutputFormat::Raw) }; match write_format { - OutputFormat::Ccd => { + OutputFormat::Ccd if write_raw( out.fh, CCD_HEADER.as_ptr() as *const c_void, @@ -266,23 +266,21 @@ pub fn write_subtitle_file_header(ctx: &mut encoder_ctx, out: &mut ccx_s_write) out.fh, ctx.encoded_crlf.as_ptr() as *const c_void, ctx.encoded_crlf_length as usize, - ) == -1 - { - info!("Unable to write CCD header to file\n"); - return -1; - } + ) == -1 => + { + info!("Unable to write CCD header to file\n"); + return -1; } - OutputFormat::Scc => { + OutputFormat::Scc if write_raw( out.fh, SCC_HEADER.as_ptr() as *const c_void, SCC_HEADER.len() - 1, - ) == -1 - { - info!("Unable to write SCC header to file\n"); - return -1; - } + ) == -1 => + { + info!("Unable to write SCC header to file\n"); + return -1; } OutputFormat::Srt @@ -395,16 +393,15 @@ pub fn write_subtitle_file_header(ctx: &mut encoder_ctx, out: &mut ccx_s_write) } } - OutputFormat::Raw => { + OutputFormat::Raw if write_raw( out.fh, BROADCAST_HEADER.as_ptr() as *const c_void, BROADCAST_HEADER.len(), - ) < BROADCAST_HEADER.len() as isize - { - info!("Unable to write Raw header\n"); - return -1; - } + ) < BROADCAST_HEADER.len() as isize => + { + info!("Unable to write Raw header\n"); + return -1; } OutputFormat::Mcc => { diff --git a/src/rust/src/libccxr_exports/demuxer.rs b/src/rust/src/libccxr_exports/demuxer.rs index 2af60f6da..18d02752b 100755 --- a/src/rust/src/libccxr_exports/demuxer.rs +++ b/src/rust/src/libccxr_exports/demuxer.rs @@ -529,7 +529,7 @@ pub unsafe extern "C" fn ccxr_demuxer_get_file_size(ctx: *mut ccx_demuxer) -> i6 return -1; } let mut demux_ctx = copy_demuxer_from_c_to_rust(ctx); - demux_ctx.get_filesize() as i64 + demux_ctx.get_filesize() } // Extern function for ccx_demuxer_print_cfg diff --git a/src/rust/src/parser.rs b/src/rust/src/parser.rs index 49fa73133..0f7898d15 100644 --- a/src/rust/src/parser.rs +++ b/src/rust/src/parser.rs @@ -1296,7 +1296,7 @@ impl OptionsExt for Options { if t == 0 { t = OffsetDateTime::now_utc().unix_timestamp() as u64; } - *UTC_REFVALUE.write().unwrap() = t as u64; + *UTC_REFVALUE.write().unwrap() = t; self.noautotimeref = true; }