Repository navigation
[BUG] store_hdcc() silently overwrites buffered caption data instead of concatenating when the same seq_index is reused with an unchanged timestamp #2350
Description
Summary
store_hdcc() in src/lib_ccx/sequencing.c:23-83 is meant to buffer caption blocks extracted from video for later reordering (B-frames can arrive out of temporal order, so captions get parked by sequence index and flushed in the correct order by process_hdcc()). A comment in the function states the intent explicitly: "Changed by CFS to concat, i.e. don't assume there's no data already for this seq_index."
The code no longer does this. When store_hdcc() is called twice for the same seq_index with the same timestamp (cc_fts[seq_index] == current_fts_now, so the flush-before-overwrite check doesn't trigger), the second call silently erases the first call's buffered caption data instead of appending to it.
Root cause
if (dec_ctx->cc_data_count[seq_index] > 0)
{
if (dec_ctx->has_ccdata_buffered && dec_ctx->cc_fts[seq_index] != current_fts_now)
{
process_hdcc(enc_ctx, dec_ctx, sub); // only runs if fts differs — NOT this case
}
}
dec_ctx->cc_fts[seq_index] = current_fts_now;
dec_ctx->cc_data_count[seq_index] = 0; // reset happens unconditionally, before the write
memcpy(dec_ctx->cc_data_pkts[seq_index] + dec_ctx->cc_data_count[seq_index] * 3, cc_data, cc_count * 3 + 1);
// offset is always 0 * 3 = 0, since count was just zeroed above — always writes to the start of the buffer
dec_ctx->cc_data_count[seq_index] += cc_count; // = 0 + new count, since count was reset — functionally a plain assignment, not an accumulationcc_data_count[seq_index] is reset to 0 unconditionally, immediately before that same value is used to compute the memcpy write offset. The offset is therefore always 0, so any second write to the same slot overwrites from the start of the buffer rather than appending after existing data. The += cc_count on the following line is effectively dead code in this path, since the count was just zeroed — it always evaluates to a plain assignment of the new count, never a true accumulation.
This wasn't always the behavior — traced via git history
The original 2014 implementation (src/sequencing.cpp, commit 6e0db8aa) had this guard:
if (stream_mode != CCX_SM_MP4) // CFS: Very ugly hack, but looks like overwriting is needed for at least some ES
cc_data_count[seq_index] = 0;
memcpy(cc_data_pkts[seq_index] + cc_data_count[seq_index] * 3, cc_data, cc_count * 3 + 1);The reset was conditional: only for non-MP4 (elementary stream) sources. For MP4, the count was deliberately left alone, producing a real concat, the offset naturally landed after existing data. This matches the comment ("concat... needed at least for MP4 samples") exactly, and isn't contradictory in this original form.
The guard was disabled in commit 6aac9dad433a (Anshul Maheshwari, 2015-07-21, commit message: "not working multiprogram"). That commit changed store_hdcc's context parameter from lib_ccx_ctx* to lib_cc_decode* as part of a multiprogram refactor. lib_cc_decode had no demux_ctx, so stream_mode could no longer be computed, and rather than re-plumbing it through, the stream_mode computation and the guard around the reset were both commented out, leaving the reset unconditional:
- stream_mode = ctx->demux_ctx->get_stream_mode(ctx->demux_ctx);
+ //stream_mode = ctx->demux_ctx->get_stream_mode(ctx->demux_ctx);
...
- if (stream_mode!=CCX_SM_MP4) // CFS: Very ugly hack, but looks like overwriting is needed for at least some ES
+ //if (stream_mode!=CCX_SM_MP4) // CFS: Very ugly hack, but looks like overwriting is needed for at least some ES
cc_data_count[seq_index] = 0;Every later commit touching these lines since (2019, 2020, 2022) is a rename or a clang-format pass, none touch this logic. This has been unconditional for roughly 11 years.
I don't think this was a deliberate decision to change the concat behavior, the commit's own message describes it as not working / incomplete, and it touches only the reset guard as a side effect of an unrelated struct-type change, not a considered removal of concat support. I want to be careful not to overstate that though, I can't verify intent with certainty, only what the commit history shows.
stream_mode/CCX_SM_MP4 no longer exists as a field on lib_cc_decode today (grepped lib_ccx.h, zero hits), so restoring the original guard verbatim isn't directly possible without re-plumbing that information back in, or finding a different way to distinguish the cases that guard was protecting.
Reachability, this isn't theoretical
The live code path today is Rust's user_data() in src/rust/src/es/userdata.rs, called from extension_and_user_data() in eau.rs (the C equivalent, src/lib_ccx/es_userdata.c's user_data(), is dead/unreferenced, confirmed by grep). extension_and_user_data() loops over every consecutive 0xB2 user-data start code within a single picture, calling user_data() once per occurrence. Multiple caption-producing formats can coexist and are each handled in this same dispatcher (GA94/HDTV, SCTE-20, Dish headers), and each ends in its own store_hdcc(...) call using the same picture-scoped current_tref/fts_now, the same seq_index and fts.
Any single picture carrying two such caption-producing user_data blocks (duplicate/redundant GA94 insertion, or GA94 combined with SCTE-20 or a Dish header in the same picture) hits exactly this same-seq_index/same-fts collision. This is a structurally reachable path in the currently-shipping decoder.
Caveat: I've confirmed this mechanism by tracing the source, but I have not yet caught it against a real captured file showing visibly missing captions. I'd like to try to build or find a repro, but wanted to file this now with the mechanism and root cause fully evidenced rather than wait.
Open question I haven't resolved
If store_hdcc is fixed to properly concat, I don't yet know whether the decoder side (process_cc_data and downstream) correctly handles a buffer containing two concatenated caption envelopes worth of triplets in one slot, versus expecting exactly one envelope's worth. process_cc_data iterates through cc_data_count[seq] triplets in a simple loop, which may just work regardless of how many logical envelopes contributed the bytes, since 608/708 data is fundamentally a stream of 3-byte units, but I haven't confirmed this. If the decoder can't cleanly handle that, fixing the writer alone might just move where the data gets mishandled rather than actually fixing it.
Questions for you
- Does this match your memory of why the MP4-vs-ES distinction existed in the original guard? Given
stream_modeno longer exists on this struct, would the right fix be re-plumbing that information back through, or is there a simpler condition that captures what that guard was actually protecting against? - Should I first confirm whether the decoder side correctly handles concatenated multi-envelope data before proposing a fix to the writer side, or are these independent enough to fix separately?
Happy to keep digging on either the empirical repro or the decoder-side question, whichever would be more useful.
Activity
kaihere14 commentedon Sep 17, 2026
@cfsmp3 tagging you for a read whenever you get a chance, this one has a couple of open direction questions I'd rather get your input on before writing any fix. If it looks right, I'd like to work on it, happy to start with the decoder-side check first since that seems like the safer starting point regardless of which way the writer fix goes
lib_ccx: restore hdcc concat semantics for MP4-sourced data (CCExtrac…
shanyuduo commentedon Sep 19, 2026
@kaihere14 As promised in #2351, I'm closing that PR and handing the fix back to you. Everything below is built on your analysis in this issue — the git archaeology, the overwrite-vs-concat framing, and your decoder-side open question were all yours.
Leaving three things that might save you some time:
1. A negative result worth having: the Windows CI asymmetry is real and not an FFI layout artifact.
I pushed two versions of the concat restore to #2351 (first with the new
stream_modefield mid-struct, then moved to the tail in case the Rust FFI mirror broke) — the sampleplatform comparison against master was identical both times: Linux shows zero behaviour changes, Windows shows the same 17 tests flipping from pass to fail (ten plain--out=srt --latin1runs, five--ucla,--in=mp4 5df914ce77,--hardsubx 1a0302f7fd). Same 17, byte-for-byte, regardless of field position. So the mid-struct layout theory is ruled out; the asymmetry is a property of the concat change itself interacting with something platform-specific. Given your open question #2 (decoder side handling concatenated multi-envelope data), and that the Windows build resolves MP4 via GPAC while Linux runs the Rust demux path, the platform split feels like a lead rather than a coincidence — but that's your call to chase.2. Working WSL/ASan build recipe (Linux CI equivalent, in case it's useful for the decoder-side investigation):
docker/Dockerfile, plustesseract-ocr libtesseract-dev libleptonica-dev(linux/build wants them even for a debug build) and Rust via rustup../configure --static-mp4box && make -j lib && sudo make install-lib), thensudo cp -r /usr/local/include/gpac /usr/include/and copylibgpac_static.ato/usr/lib/libgpac.a— the linker looks for-lgpac.-lnghttp2 -lssl -lcrypto -lzappended after-lgpac(static-GPAC transitive deps).linux/buildand the .sh scripts arrive with CRLF and fail withenv: 'bash '—sed -i 's/ $//'them first (that's [BUG] No .gitattributes: Windows clones get CRLF shebangs and the documented Linux/Docker build fails with "/usr/bin/env: 'bash\r': No such file or directory" #2345)../build -debugproduces an ASan binary; a plain TS run through it is clean apart from the exit-time leak report.3. Possibly an unrelated find: under that ASan build, any MP4 input dies with a heap-buffer-overflow (READ of size 8) at
start_ccx→ccxr_processmp4(src/ccextractor.c:252, i.e. the Rust FFI boundary), allocation from Rustalloc. Reproduces on a trivially generatedffmpeg -f lavfi testsrc2MP4, before any of the hdcc logic is reached. Might be worth its own issue if it's not known.Sorry again for jumping ahead of you here — good luck with the fix, it's a great piece of analysis to land.
fix: drop store_hdcc truncate/clamp blocks
[FIX] Raise ccxr_process_cc_data cap from 31 to the real hdcc slot ca…
Document why store_hdcc overwrites the slot (#2392)
cfsmp3 commentedon Oct 18, 2026
Investigated before fixing, and the overwrite turns out to be load-bearing. Instrumenting
store_hdcc()across all 41 local samples with 608/708 captions: 40 never store twice into one slot with the same timestamp. The one that does (an ATSC MPEG-2 stream) carries each picture's 608 pairs twice — SCTE-20 user data followed by GA94 — and in all 455 cases the overwritten block's 608 pairs are identical to the GA94 block's. Appending duplicates every character (♪ S Standtandining g initinstead of♪ Standing in front). That's what the original "overwriting is needed for at least some ES" note was protecting. #2392 replaces the misleading comments with that explanation. Closing; if you have a real stream where two different caption blocks land in the same picture, please reopen with it.