When a stream switches from pop-on to roll-up mode and ends before
check_roll_up() reports changes=1 (fewer lines than the roll-up window
requires), the CR handler never runs the backfill at line 836 and
current_visible_start_ms is still zero when flush_608_context fires
EraseDisplayedMemory. Result: the first emitted caption is timestamped
at 00:00:00,000.
Capture the FTS of the first character after the transition in a new
field ts_first_char_rollup_transition. Unlike ts_start_of_current_line,
this field is not overwritten by intermediate changes=0 CRs, so the
first-char time survives to the end-of-stream flush path.
write_cc_buffer uses it to backfill current_visible_start_ms when
rollup_from_popon is still set at emit time, and clears it together
with the flag. The normal popon->roll-up flow (where a scrolling CR
eventually fires) and every other caption path are unchanged.
Verified on c4dd893cb9d6...ts: first subtitle now starts at
00:00:13,913 (was 00:00:00,000). Regression tested against six master
samples the mentor flagged (0069dffd.mpg 03,603; 5cbb21ad.dvr-ms
00,534; 132d7df7.mov 12,812; 6395b281.asf 02,436; 8849331d.mp4 03,771;
b22260d0.ts 04,838) - all match the reference timings unchanged.
Co-authored-by: GAURAV KARMAKAR <gaurav.k@graeon.ai>
* 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>
* 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.
* build: wire FFmpeg libs into ccx_rust link order
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=<symbol> 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.
* feat(mp4): FFmpeg MP4 demuxer with GPAC-parity caption extraction
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).
* chore(rust): fix clippy 1.95 warnings blocking CI
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.
---------
Co-authored-by: GAURAV KARMAKAR <gaurav02081@users.noreply.github.com>
When building with Docker on an Apple Silicon Mac (M4), the Rust build
fails with type mismatch errors in common.rs and ctorust.rs.
The lang field in CapInfo is hardcoded as [i8; 4], which only matches the
bindgen-generated c_char on x86 (where c_char = i8). On ARM platforms like
Apple Silicon, c_char = u8, so the types don't match and compilation fails
with:
error[E0308]: mismatched types - expected `[u8; 4]`, found `[i8; 4]`...
Change to [c_char; 4] which resolves to the correct type on both architectures.
* fix: move C0 bounds check before match, improve C1 warn message
- C0 handler: bounds check was after the match, meaning process_p16(&block[1..])
could panic with an index out of bounds before the guard ran. Moved the check
before the match to prevent this.
- C1 handler: improved the warn message to include command code, name, and
lengths for easier debugging.
Fixes#1407
* docs: update CHANGES.TXT for #1407 fix
* style: cargo fmt
---------
Co-authored-by: Dhanush Varma <your@email.com>
* fix(build): decouple WITH_FFMPEG from ENABLE_HARDSUBX, fix FFmpeg 5.0+ API
Since 2017 (commit 4f5f564b), WITH_FFMPEG=ON automatically defined
ENABLE_HARDSUBX. In 2018 (PR #966), WITH_HARDSUBX was added as a
separate option but the old coupling was never removed. This made
-DWITH_FFMPEG=ON fail to build because it compiled hardsubx code
without linking tesseract/leptonica.
Fixes:
- Remove ENABLE_HARDSUBX from both WITH_FFMPEG blocks (src/CMakeLists.txt
and src/lib_ccx/CMakeLists.txt). ENABLE_HARDSUBX is now only defined
by the WITH_HARDSUBX block, where it belongs.
- WITH_HARDSUBX now auto-enables WITH_OCR and WITH_FFMPEG since it
requires both. Previously -DWITH_HARDSUBX=ON without -DWITH_OCR=ON
also failed to link.
- Fix const AVCodec* in ffmpeg_intgr.c for FFmpeg 5.0+ API (the
av_find_best_stream signature changed in libavcodec 59).
Tested all 7 build combinations:
1. Default OK (was OK)
2. WITH_FFMPEG OK (was BROKEN)
3. WITH_HARDSUBX + WITH_OCR OK (was OK)
4. WITH_FFMPEG + WITH_HARDSUBX + OCR OK (was BROKEN)
5. WITH_FFMPEG + WITH_OCR OK (was BROKEN)
6. WITH_OCR OK (was OK)
7. WITH_HARDSUBX only OK (was BROKEN)
* fix: MKV subtitle track .(null) extension for KATE and unknown codec IDs
The matroska_track_text_subtitle_id_extensions array had 7 entries for
an 8-value enum, leaving MATROSKA_TRACK_SUBTITLE_CODEC_ID_KATE (index 7)
out of bounds. On most platforms this read NULL, which then caused
strlen(NULL) UB and snprintf to emit .(null) in the output filename.
Two fixes:
- Add "kate" at index 7 in the extensions array so KATE tracks
produce correct .kate output filenames
- Add a NULL guard in generate_filename_from_track() so any future
unknown codec ID safely falls back to .bin instead of crashing or
producing .(null)
Fixes#972
* fix: MKV subtitle track .(null) extension for KATE and unknown codec IDs
The matroska_track_text_subtitle_id_extensions array had 7 entries for
an 8-value enum, leaving MATROSKA_TRACK_SUBTITLE_CODEC_ID_KATE (index 7)
out of bounds. On most platforms this read NULL, which then caused
strlen(NULL) UB and snprintf to emit .(null) in the output filename.
Two fixes:
- Add "kate" at index 7 in the extensions array so KATE tracks
produce correct .kate output filenames
- Add a NULL guard in generate_filename_from_track() so any future
unknown codec ID safely falls back to .bin instead of crashing or
producing .(null)
Fixes#972
* fix: MKV subtitle track .(null) extension for KATE and unknown codec IDs
The matroska_track_text_subtitle_id_extensions array had 7 entries for
an 8-value enum, leaving MATROSKA_TRACK_SUBTITLE_CODEC_ID_KATE (index 7)
out of bounds. On most platforms this read NULL, which then caused
strlen(NULL) UB and snprintf to emit .(null) in the output filename.
Two fixes:
- Add "kate" at index 7 in the extensions array so KATE tracks
produce correct .kate output filenames
- Add a NULL guard in generate_filename_from_track() so any future
unknown codec ID safely falls back to .bin instead of crashing or
producing .(null)
Fixes#972
---------
Co-authored-by: Dhanush Varma <your@email.com>
cc_count * 3 used i32 arithmetic with no upper-bound check. For cc_count
> i32::MAX / 3 (~715 million), debug builds panic on overflow detection
and release builds silently wrap around to a negative range, discarding
all CC data for the frame. Both are triggerable from malformed media files.
Fix:
1. Cast cc_count to usize immediately after the existing <= 0 guard,
before any arithmetic — eliminates the overflow entirely
2. Add MAX_CC_COUNT = 31 upper-bound guard — CEA-708/ATSC A/53 encodes
cc_count in a 5-bit bitstream field (0x1F mask in avc_functions.c:514),
making 31 the spec-defined per-frame maximum; this value is also
independently documented in es/userdata.rs ("Maximum cc_count is 31").
Returns -1 with a warn!() log for out-of-range values, consistent with
existing error-handling style in the function.
3. Remove the now-redundant `x as usize` cast in the map closure since
the range is already usize..usize
Fixes#2234
show_timestamp.to_srt_time().expect() and hide_timestamp.to_srt_time().expect()
in TeletextContext::process_page() panicked for any negative Timestamp value.
Negative timestamps are common in broadcast captures with wrap-around or
uninitialized PTS — crashing after potentially processing an entire file.
to_srt_time() → as_hms_millis() → i64::try_into::<u64>() returns
OutOfRangeError for negative values; .expect() made this fatal.
Fix: process_page() already returns Option<Subtitle>, so replace both
.expect() calls with .ok()? — silently skipping the subtitle when the
timestamp is out of range, matching the function's existing None-on-empty
contract.
Fixes#2233
The Rust FFI function copy_from_rust() computed num_input_files by filtering
empty strings from the inputfiles Vec, but passed an unfiltered clone to
string_to_c_chars() to build the C inputfile[] array. This mismatch made the
C array length and num_input_files disagree: switch_to_next_file() could index
inputfile[current_file] where current_file < num_input_files but >= array size,
reading one slot past the end of the allocated array — confirmed by
AddressSanitizer (heap-buffer-overflow at file_functions.c:183).
The same count/size mismatch also caused free_rust_c_string_array() to
reconstruct the Vec with an incorrect capacity, producing heap corruption on
every clean shutdown.
Fix: filter empty strings into a single Vec<String> first, then derive both
num_input_files (filtered.len()) and the C array (string_to_c_chars(filtered))
from that same source, eliminating the mismatch entirely.
Fixes#2182
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Two related strdup bugs across multiple lib_ccx files:
1. strdup(variable) return not checked for NULL — use after potential
NULL dereference causes undefined behavior / segfault on OOM.
Fixed by adding NULL check + fatal(EXIT_NOT_ENOUGH_MEMORY, ...).
2. strdup("literal") in get_buffer_type_str returned directly as
function result — unchecked and leaks memory on every call since
the function has no callers that free it. Fixed by removing strdup
and returning string literals directly; return type changed from
char * to const char * (no callers exist, no header declaration).
Files changed:
src/lib_ccx/ccx_common_common.c
src/lib_ccx/ccx_encoders_common.c
src/lib_ccx/ccx_encoders_helpers.c
src/lib_ccx/configuration.c
src/lib_ccx/hardsubx.c
src/lib_ccx/hardsubx_decoder.c
src/lib_ccx/ocr.c
src/lib_ccx/output.c
src/lib_ccx/ts_functions.c
Fixes#2194
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs: (1) general_loop.c dereferences NULL dec_ctx in the
live_stream progress code when no data was processed. (2) Rust
switch_to_next_file calls demux_ctx.open() for stdin, triggering
null pointer dereference via ptr::null().
Fix: add NULL check for dec_ctx in general_loop.c, and return
early from switch_to_next_file for stdin/network/tcp sources
instead of calling demux_ctx.open().
Co-authored-by: Dhanush Varma <your@email.com>
* fix(general_loop): Add NULL checks for fopen() and alloc_demuxer_data() in process_hex()
* docs: update changelog for process_hex NULL checks fix
* fix: use EXIT_READ_ERROR for fopen failure and remove CHANGES.TXT entry
Replace all 6 hardcoded 1000/29.97 frame delay calculations in
dtvcc_write_scc() with 1000/current_fps so that CEA-708 SCC output
uses the actual stream framerate instead of assuming NTSC 29.97.
Fixes#2172
detect_stream_type() reads up to 1MB (STARTBYTESLENGTH) via
buffered_read_opt() for format detection. For input files smaller
than 1MB, the read hits EOF and—because binary_concat defaults to
enabled—buffered_read_opt() calls switch_to_next_file(). This
increments current_file past the valid range and closes the file
descriptor, leaving format-specific handlers (matroska_loop, MP4,
etc.) to crash when they access inputfile[current_file].
Fix: temporarily disable binary_concat around detect_stream_type()
so that hitting EOF during detection never triggers file switching.
Fixes the root cause of the crash reported in PR #2206 (which
proposed a band-aid of using current_file-1).
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add $(ExtraDefines) to PreprocessorDefinitions in all 4 configurations
of the vcxproj. This allows passing /p:ExtraDefines=DISABLE_RUST from
the MSBuild command line to use C code paths for switchable modules.
The Windows CI now produces two Release artifacts per architecture:
- "CCExtractor Windows x64 Release build" — min Rust (DISABLE_RUST)
- "CCExtractor Windows x64 Release build (with migrations)" — max Rust
The migrations build uses /t:Rebuild to do a clean rebuild without
DISABLE_RUST after the min-rust build completes.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: flush pending EIT sections in EPG_free() before freeing buffers
* ci: add dual build artifacts to compare C vs Rust code paths
Add -min-rust flag to linux/build that passes -DDISABLE_RUST to gcc,
causing switchable modules (DTVCC, demuxer, AVC, networking, hex utils)
to use their C implementations instead of Rust. The Rust library still
compiles since many modules are Rust-only.
The Linux CI now produces two artifacts:
- "CCExtractor Linux build" — min Rust (C paths where available)
- "CCExtractor Linux build (with migrations)" — max Rust
Both should produce identical output on the sample platform. If they
diverge, it means a Rust port introduced a behavioral difference.
The sample platform will need a corresponding update to recognize and
test the new "with migrations" artifact.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Varadraj75 <agrawalvaradraj2007@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix ownership of default Matroska track language
* matroska: restore lang_ietf preference in filename gen and lang matching
Restore the working IETF language preference that was accidentally
removed in the previous commit:
- generate_filename_from_track(): use lang_ietf ? lang_ietf : lang
for buffer sizing and both snprintf calls
- save_vobsub_track(): same lang_tag pattern for base filename
- matroska_save_all(): check lang_ietf first (BCP-47), fall back to
lang (ISO-639-2) when --mkvlang filter is active; remove now-unused
char *match variable
The strdup NULL check and broken switch-case removal from the prior
commit are unchanged.
* matroska: fix clang-format style
* matroska: fix filename underscores, LLD macro, braces, param name
pkg_check_modules provides library names without paths.
Without link_directories, the linker cannot find tesseract
and leptonica on systems where they are not in default
search paths (e.g. Homebrew on macOS arm64).
Co-authored-by: Dhanush Varma <your@email.com>
* feat(ssa): add guarded ASS \pos positioning for CEA-608 captions
* fix(ssa): correct ASS positioning anchor, validate row adjacency, and clean up variable placement
* fix(ssa): adjust top margin to prevent clipping of top-positioned CEA-608 captions
* ssa: map CEA-608 row+col to ASS coords using FFmpeg safe-area formula and fix \an2→\an7 anchor
create_file() returns the result of fopen() which can be NULL if the
file cannot be opened. matroska_loop() never checked this, passing
the NULL pointer into matroska_parse() where it is immediately used
in feof(), causing a crash.
Add a NULL check that calls fatal(EXIT_READ_ERROR, ...) on failure,
consistent with other file-open error handling in the codebase.
* fix: memory leaks and invalid CSS in WebVTT encoder
- Remove 6 unnecessary strdup() calls on string literals in
write_cc_buffer_as_webvtt() — literals are passed directly to
write_wrapped() which takes void*, no heap allocation needed.
This runs in a per-character inner loop and leaked on every
styled subtitle in a broadcast.
- Fix invalid CSS: rgba(0, 256, 0, 0.5) -> rgba(0, 255, 0, 0.5)
CSS color channels are 0-255; 256 is out of range.
- Fix missing free(unescaped) on write-error path in
write_stringz_as_webvtt() — matched the existing pattern on
the adjacent error path which correctly freed both el and unescaped.
Fixes#2154
* fix: move WebVTT changelog entry to unreleased 0.96.7 section
* Feat(rust): Implement WebVTT-specific timestamp format and layout anchor
* style: apply rustfmt to g608.rs
* fix(rust): use WebVTT-spec dot separator for milliseconds in timestamp line