feat(mp4): add dvdsub/VobSub support to FFmpeg MP4 demuxer (#2269)

* feat(mp4): add dvdsub/VobSub support to FFmpeg MP4 demuxer

Wire the existing vobsub_decoder (OCR-based bitmap subtitle decoder)
into the FFmpeg MP4 demuxer path. Previously, dvdsub tracks in MP4
containers were documented as unsupported — GPAC could extract them
but the FFmpeg path could not.

Changes:
- Add DvdSub track type detection (AV_CODEC_ID_DVD_SUBTITLE) in mp4.rs
- Add C bridge functions (ccx_mp4_vobsub_init/process/free) that call
  the existing vobsub_decoder module
- Buffer dvdsub packets to compute end times from next-packet PTS
- Add --undefined linker flags for bridge symbols

Tested on sample 1f3e951d516b.mp4 (dvdsub in MP4):
- GPAC: 5405 bytes output
- FFmpeg + this patch: 5405 bytes, byte-identical to GPAC

With this change, the FFmpeg path extracts captions from every sample
that GPAC can, plus one additional sample (ad9f9e03240e.m4v) that
GPAC cannot handle.

* style: apply clang-format to vobsub bridge

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Fernandez Sanz
2026-04-19 17:01:32 -07:00
committed by GitHub
parent e4443a74ba
commit 283bfd9896
4 changed files with 159 additions and 7 deletions

View File

@@ -307,6 +307,9 @@ if (NOT WIN32 AND NOT APPLE)
-Wl,--undefined=ccx_mp4_process_tx3g_packet
-Wl,--undefined=ccx_mp4_flush_tx3g
-Wl,--undefined=ccx_mp4_report_progress
-Wl,--undefined=ccx_mp4_vobsub_init
-Wl,--undefined=ccx_mp4_vobsub_process
-Wl,--undefined=ccx_mp4_vobsub_free
-Wl,--undefined=mprint
-Wl,--undefined=update_decoder_list
-Wl,--undefined=update_encoder_list)

View File

@@ -15,6 +15,7 @@
#include "ccx_encoders_mcc.h"
#include "ccx_mp4.h"
#include "mp4_rust_bridge.h"
#include "vobsub_decoder.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
@@ -280,4 +281,70 @@ void ccx_mp4_report_progress(struct lib_ccx_ctx *ctx, unsigned int cur, unsigned
}
}
/* ── VobSub / DVD subtitle bridge ───────────────────────────────── */
void *ccx_mp4_vobsub_init(void)
{
if (!vobsub_ocr_available())
{
mprint("VOBSUB to text conversion requires OCR support.\n"
"Please rebuild CCExtractor with -DWITH_OCR=ON\n");
return NULL;
}
return init_vobsub_decoder();
}
int ccx_mp4_vobsub_process(void *vob_opaque, struct lib_ccx_ctx *ctx,
unsigned char *data, unsigned int data_length,
long long start_ms, long long end_ms,
struct cc_subtitle *sub)
{
struct vobsub_ctx *vob_ctx = (struct vobsub_ctx *)vob_opaque;
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, start_ms * MPEG_CLOCK_FREQ / 1000);
set_fts(dec_ctx->timing);
struct cc_subtitle vob_sub;
memset(&vob_sub, 0, sizeof(vob_sub));
int ret = vobsub_decode_spu(vob_ctx, data, (size_t)data_length,
start_ms, end_ms, &vob_sub);
if (ret == 0 && vob_sub.got_output)
{
encode_sub(enc_ctx, &vob_sub);
sub->got_output = 1;
if (vob_sub.data)
{
struct cc_bitmap *rect = (struct cc_bitmap *)vob_sub.data;
for (int j = 0; j < vob_sub.nb_data; j++)
{
if (rect[j].data0)
free(rect[j].data0);
if (rect[j].data1)
free(rect[j].data1);
#ifdef ENABLE_OCR
if (rect[j].ocr_text)
free(rect[j].ocr_text);
#endif
}
free(vob_sub.data);
}
}
return ret;
}
void ccx_mp4_vobsub_free(void *vob_opaque)
{
struct vobsub_ctx *vob_ctx = (struct vobsub_ctx *)vob_opaque;
if (vob_ctx)
{
delete_vobsub_decoder(&vob_ctx);
}
}
#endif /* ENABLE_FFMPEG_MP4 */

View File

@@ -91,6 +91,17 @@ extern "C"
*/
void ccx_mp4_report_progress(struct lib_ccx_ctx *ctx, unsigned int cur, unsigned int total);
/*
* VobSub (DVD subtitle) bridge.
* Wraps vobsub_decoder.c for use from the Rust FFmpeg path.
*/
void *ccx_mp4_vobsub_init(void);
int ccx_mp4_vobsub_process(void *vob_ctx, struct lib_ccx_ctx *ctx,
unsigned char *data, unsigned int data_length,
long long start_ms, long long end_ms,
struct cc_subtitle *sub);
void ccx_mp4_vobsub_free(void *vob_ctx);
#ifdef __cplusplus
}
#endif

View File

@@ -11,12 +11,7 @@
//! - `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.
//! - `dvdsub` (DVD bitmap subtitles) via OCR through vobsub_decoder
#[cfg(feature = "enable_mp4_ffmpeg")]
use rsmpeg::avformat::AVFormatContextInput;
@@ -67,6 +62,18 @@ extern "C" {
fn ccx_mp4_report_progress(ctx: *mut lib_ccx_ctx, cur: c_uint, total: c_uint);
fn ccx_mp4_vobsub_init() -> *mut std::ffi::c_void;
fn ccx_mp4_vobsub_process(
vob_ctx: *mut std::ffi::c_void,
ctx: *mut lib_ccx_ctx,
data: *mut u8,
data_length: c_uint,
start_ms: i64,
end_ms: i64,
sub: *mut cc_subtitle,
) -> c_int;
fn ccx_mp4_vobsub_free(vob_ctx: *mut std::ffi::c_void);
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;
@@ -83,6 +90,7 @@ enum TrackType {
Cea608,
Cea708,
Tx3g,
DvdSub,
}
/// Information about a track we want to process
@@ -195,6 +203,8 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s
Some(TrackType::Cea708)
} 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_DVD_SUBTITLE {
Some(TrackType::DvdSub)
} else {
None
}
@@ -209,6 +219,7 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s
TrackType::Cea608 => "CEA-608",
TrackType::Cea708 => "CEA-708",
TrackType::Tx3g => "tx3g",
TrackType::DvdSub => "dvdsub",
};
let msg = format!(
"Track {}, type={} timescale={}\n\0",
@@ -238,7 +249,7 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s
.filter(|t| {
matches!(
t.track_type,
TrackType::Cea608 | TrackType::Cea708 | TrackType::Tx3g
TrackType::Cea608 | TrackType::Cea708 | TrackType::Tx3g | TrackType::DvdSub
)
})
.count();
@@ -279,6 +290,14 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s
}
}
// Init vobsub decoder if we have dvdsub tracks
let has_dvdsub = tracks.iter().any(|t| t.track_type == TrackType::DvdSub);
let vob_ctx = if has_dvdsub {
ccx_mp4_vobsub_init()
} else {
std::ptr::null_mut()
};
// Read packets and dispatch
let mut mp4_ret: c_int = 0;
let mut pkt: ffi::AVPacket = std::mem::zeroed();
@@ -286,6 +305,8 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s
let mut packet_count: u32 = 0;
let mut has_tx3g = false;
let mut prev_dvdsub_pts: i64 = -1;
let mut prev_dvdsub_data: Vec<u8> = Vec::new();
loop {
let ret = ffi::av_read_frame(fmt_ctx.as_ptr() as *mut _, &mut pkt);
@@ -386,6 +407,37 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s
}
}
}
TrackType::DvdSub => {
if pkt.size > 0 && !pkt.data.is_null() && !vob_ctx.is_null() {
let stream = *(*fmt_ctx.as_ptr()).streams.add(track.stream_index);
let tb = (*stream).time_base;
let cur_pts_ms = if pts != AV_NOPTS_VALUE && tb.den > 0 {
pts * 1000 * tb.num as i64 / tb.den as i64
} else {
0
};
// Flush previous dvdsub packet now that we know its end time
if !prev_dvdsub_data.is_empty() && prev_dvdsub_pts >= 0 {
let end_ms = cur_pts_ms;
ccx_mp4_vobsub_process(
vob_ctx,
ctx,
prev_dvdsub_data.as_mut_ptr(),
prev_dvdsub_data.len() as c_uint,
prev_dvdsub_pts,
end_ms,
sub,
);
mp4_ret = 1;
}
// Buffer current packet
let data_slice = std::slice::from_raw_parts(pkt.data, pkt.size as usize);
prev_dvdsub_data = data_slice.to_vec();
prev_dvdsub_pts = cur_pts_ms;
}
}
}
}
@@ -402,6 +454,25 @@ pub unsafe fn processmp4_rust(ctx: *mut lib_ccx_ctx, path: &CStr, sub: *mut cc_s
ccx_mp4_flush_tx3g(ctx, sub);
}
// Flush last dvdsub packet (use 5s default duration)
if !prev_dvdsub_data.is_empty() && prev_dvdsub_pts >= 0 && !vob_ctx.is_null() {
let end_ms = prev_dvdsub_pts + 5000;
ccx_mp4_vobsub_process(
vob_ctx,
ctx,
prev_dvdsub_data.as_mut_ptr(),
prev_dvdsub_data.len() as c_uint,
prev_dvdsub_pts,
end_ms,
sub,
);
}
// Free vobsub decoder
if !vob_ctx.is_null() {
ccx_mp4_vobsub_free(vob_ctx);
}
// 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.