diff --git a/CMakeLists.txt b/CMakeLists.txt index 3df8d30..7a54df0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -240,13 +240,19 @@ add_test(NAME parser_fallback_check COMMAND parser_fallback_check) set_tests_properties(parser_fallback_check PROPERTIES FIXTURES_REQUIRED assets) set_tests_properties(parser_fallback_check PROPERTIES LABELS "core") -# Safety regression: refuse absurd atom sizes / overflows without crashing. +# Safety regressions: reject malformed sizes and accept large media payloads without crashing. add_executable(parser_safety_check tests/parser_safety.cpp) target_link_libraries(parser_safety_check PRIVATE chapterforge) target_include_directories(parser_safety_check PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) add_test(NAME parser_safety_check COMMAND parser_safety_check) set_tests_properties(parser_safety_check PROPERTIES LABELS "core") +add_executable(aac_extractor_unit tests/aac_extractor_unit.cpp) +target_link_libraries(aac_extractor_unit PRIVATE chapterforge) +target_include_directories(aac_extractor_unit PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) +add_test(NAME aac_extractor_unit COMMAND aac_extractor_unit) +set_tests_properties(aac_extractor_unit PROPERTIES LABELS "unit") + if(nlohmann_json_FOUND) add_executable(image_fixtures tests/image_fixtures.cpp) target_link_libraries(image_fixtures PRIVATE chapterforge nlohmann_json::nlohmann_json) diff --git a/README.md b/README.md index be024e9..a8f9fd2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ [![Docs](https://img.shields.io/badge/docs-Doxygen-blueviolet.svg)](https://tillt.github.io/ChapterForge/) [![Homebrew](https://img.shields.io/badge/homebrew-tap-181717.svg)](#macos-homebrew) -ChapterForge is a library and CLI to mux chapters (text and optional images) into AAC/M4A files while preserving metadata and handling Apple-compatible chapter tracks. +

+ +ChapterForge is a library and CLI to mux chapters (text and optional images) into MP4 files while preserving metadata and handling Apple-compatible chapter tracks. ## Table of Contents @@ -217,12 +219,6 @@ Notes: - If top-level metadata fields are omitted and the input file already contains metadata (`ilst`), that metadata is preserved automatically. - Paths for `cover` and per-chapter `image` are resolved relative to the JSON file location. -> **First chapter behavior (Apple/VLC)** -> The chapter tracks are duration-based (`stts`), but most players force the first sample to start -> at t=0. A non-zero first `start_ms` will be snapped to 0 in QuickTime, Music.app, AVFoundation, -> and VLC. If you need silence/blank time before your “real” first chapter, add a leading placeholder -> chapter that covers 0..gap_ms and then start your first “real” chapter after that. - ## Output diff --git a/include/aac_extractor.hpp b/include/aac_extractor.hpp index b4f111e..68c949f 100644 --- a/include/aac_extractor.hpp +++ b/include/aac_extractor.hpp @@ -15,6 +15,7 @@ struct AacExtractResult { std::vector> frames; // raw AAC frames (ADTS header stripped) std::vector sizes; // raw frame sizes + std::vector chunk_sizes; // samples per source chunk uint32_t sample_rate = 0; uint8_t sampling_index = 0; @@ -27,6 +28,7 @@ struct AacExtractResult { std::vector stsc_payload; std::vector stsz_payload; std::vector stco_payload; + std::vector co64_payload; // Optional: original meta/ilst payloads (when source is MP4/M4A) std::vector meta_payload; @@ -41,7 +43,7 @@ AacExtractResult extract_adts_frames(const std::vector &data); /** * @brief Extract AAC frames and related tables from an MP4/M4A source. * - * Preferred when the input is already an MP4 container so we can reuse stsd/stts/stsc/stsz/stco and - * any meta/ilst payloads. + * Preferred when the input is already an MP4 container so we can reuse its audio sample tables + * (including stco or co64) and any meta/ilst payloads. */ std::optional extract_from_mp4(const std::string &path); diff --git a/include/mdat_writer.hpp b/include/mdat_writer.hpp index de63bf7..657fff4 100644 --- a/include/mdat_writer.hpp +++ b/include/mdat_writer.hpp @@ -13,11 +13,11 @@ #include "mp4_atoms.hpp" -// Stores final chunk offsets per track, used for STCO patching. +// Stores final chunk offsets per track, used for stco/co64 patching. struct MdatOffsets { - std::vector audio_offsets; - std::vector> text_offsets; // one entry per text track - std::vector image_offsets; + std::vector audio_offsets; + std::vector> text_offsets; // one entry per text track + std::vector image_offsets; uint64_t payload_start = 0; // absolute file offset where mdat payload begins }; @@ -30,14 +30,20 @@ MdatOffsets write_mdat(std::ofstream &out, const std::vector> &text_chunk_sizes, const std::vector &image_chunk_sizes); -// Patch a single stco atom. -void patch_stco_table(Atom *stco, const std::vector &offsets, - uint64_t mdat_payload_start); +// Promote all 32-bit chunk-offset tables before layout when output offsets may exceed 32 bits. +void promote_stco_to_co64(Atom *root); -// Patch stco boxes in moov (audio, text tracks, image). If patch_audio is false, -// audio stco (first one) is left untouched. +// Patch stco/co64 boxes in moov (audio, text tracks, image). If patch_audio is false, +// the first audio offset table is left untouched. void patch_all_stco(Atom *moov, const MdatOffsets &offs, bool patch_audio = true); +uint64_t media_payload_size( + const std::vector> &audio_samples, + const std::vector>> &text_tracks_samples, + const std::vector> &image_samples); + +uint64_t mdat_header_size(uint64_t payload_size); + // Compute chunk offsets without writing, given starting payload offset. MdatOffsets compute_mdat_offsets(uint64_t payload_start, const std::vector> &audio_samples, diff --git a/include/parser.hpp b/include/parser.hpp index ed64b67..99ceee5 100644 --- a/include/parser.hpp +++ b/include/parser.hpp @@ -16,9 +16,10 @@ #include struct Mp4AtomInfo { - uint32_t type; - uint64_t size; // total atom size. - uint64_t offset; // offset in file. + uint32_t type = 0; + uint64_t size = 0; // Total atom size, including its header. + uint64_t offset = 0; // Offset of the atom header in the file or enclosing stream. + uint64_t header_size = 8; // 8 normally, 16 when a 64-bit extended size is present. }; namespace parser_detail { @@ -35,12 +36,13 @@ struct TrackParseResult { std::vector stsc; std::vector stsz; std::vector stco; + std::vector co64; }; } // namespace parser_detail // Minimal parsed MP4 data for our authoring needs. struct ParsedMp4 { - bool used_fallback_stbl = false; // true if stbl atoms were recovered via flat scan. + bool used_fallback_stbl = false; // Reserved for reporting parser recovery paths. // All parsed tracks (audio/text/video). std::vector tracks; @@ -60,6 +62,7 @@ struct ParsedMp4 { std::vector stsc; std::vector stsz; std::vector stco; + std::vector co64; }; // Utility: read big-endian 32-bit value. diff --git a/include/stbl_audio_builder.hpp b/include/stbl_audio_builder.hpp index a138b67..00f6ebe 100644 --- a/include/stbl_audio_builder.hpp +++ b/include/stbl_audio_builder.hpp @@ -19,9 +19,10 @@ std::unique_ptr build_audio_stbl(const Mp4aConfig &cfg, uint32_t num_samples, const std::vector *raw_stsd = nullptr); -// Build stbl from pre-existing box payloads (stsd/stts/stsc/stsz/stco) +// Build stbl from pre-existing box payloads (stsd/stts/stsc/stsz and stco or co64). std::unique_ptr build_audio_stbl_raw(const std::vector &stsd_payload, const std::vector &stts_payload, const std::vector &stsc_payload, const std::vector &stsz_payload, - const std::vector &stco_payload); + const std::vector &stco_payload, + const std::vector &co64_payload = {}); diff --git a/site/images/chapterforge_logo.png b/site/images/chapterforge_logo.png new file mode 100644 index 0000000..bde471a Binary files /dev/null and b/site/images/chapterforge_logo.png differ diff --git a/src/aac_extractor.cpp b/src/aac_extractor.cpp index 37ce8dc..1314d6e 100644 --- a/src/aac_extractor.cpp +++ b/src/aac_extractor.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -84,7 +85,6 @@ AacExtractResult extract_adts_frames(const std::vector &data) { return out; } - // Helpers for MP4 extraction (from container) static std::optional> parse_stsz_sizes( const std::vector &stsz_payload) { @@ -161,7 +161,7 @@ static void parse_esds_audio_cfg(const std::vector &stsd_payload, Mp4aC } static std::vector derive_chunk_plan(const std::vector &stsc_payload, - uint32_t sample_count) { + uint32_t chunk_count) { std::vector plan; if (stsc_payload.size() < kStscHeaderSize + kStscEntrySize) { return plan; @@ -169,7 +169,6 @@ static std::vector derive_chunk_plan(const std::vector &stsc_ uint32_t entry_count = (stsc_payload[4] << 24) | (stsc_payload[5] << 16) | (stsc_payload[6] << 8) | stsc_payload[7]; size_t pos = kStscHeaderSize; - uint32_t consumed = 0; for (uint32_t i = 0; i < entry_count; ++i) { if (pos + kStscEntrySize > stsc_payload.size()) { break; @@ -183,21 +182,57 @@ static std::vector derive_chunk_plan(const std::vector &stsc_ next_first = (stsc_payload[pos + 12] << 24) | (stsc_payload[pos + 13] << 16) | (stsc_payload[pos + 14] << 8) | (stsc_payload[pos + 15]); } - uint32_t chunk_count = (next_first > 0) ? (next_first - first_chunk) : 0; - if (chunk_count == 0) { - while (consumed < sample_count) { - plan.push_back(samples_per_chunk); - consumed += samples_per_chunk; - } - } else { - for (uint32_t c = 0; c < chunk_count && consumed < sample_count; ++c) { - plan.push_back(samples_per_chunk); - consumed += samples_per_chunk; - } + if (first_chunk == 0 || first_chunk > chunk_count || samples_per_chunk == 0) { + return {}; + } + const uint32_t run_end = next_first > 0 ? next_first - 1 : chunk_count; + if (run_end < first_chunk || run_end > chunk_count) { + return {}; + } + for (uint32_t chunk = first_chunk; chunk <= run_end; ++chunk) { + plan.push_back(samples_per_chunk); } pos += 12; } - return plan; + return plan.size() == chunk_count ? plan : std::vector{}; +} + +static uint32_t stts_sample_count(const std::vector &stts_payload) { + if (stts_payload.size() < 8) { + return 0; + } + const uint32_t entry_count = (stts_payload[4] << 24) | (stts_payload[5] << 16) | + (stts_payload[6] << 8) | stts_payload[7]; + if (entry_count > (stts_payload.size() - 8) / 8) { + return 0; + } + uint64_t count = 0; + for (uint32_t i = 0; i < entry_count; ++i) { + const size_t pos = 8 + static_cast(i) * 8; + const uint32_t run_count = (stts_payload[pos] << 24) | (stts_payload[pos + 1] << 16) | + (stts_payload[pos + 2] << 8) | stts_payload[pos + 3]; + count += run_count; + if (count > std::numeric_limits::max()) { + return 0; + } + } + return static_cast(count); +} + +static bool normalize_stsz(std::vector &payload, uint32_t sample_count) { + if (payload.size() < kStszHeaderSize) { + return false; + } + payload[8] = static_cast((sample_count >> 24) & 0xFF); + payload[9] = static_cast((sample_count >> 16) & 0xFF); + payload[10] = static_cast((sample_count >> 8) & 0xFF); + payload[11] = static_cast(sample_count & 0xFF); + const uint32_t fixed_size = (payload[4] << 24) | (payload[5] << 16) | (payload[6] << 8) | + payload[7]; + if (fixed_size == 0) { + payload.resize(kStszHeaderSize + static_cast(sample_count) * 4); + } + return true; } std::optional extract_from_mp4(const std::string &path) { @@ -227,19 +262,22 @@ std::optional extract_from_mp4(const std::string &path) { } CH_LOG("debug", "mp4 parsed optional has value for " << path); ParsedMp4 &parsed = *parsed_opt; - CH_LOG("debug", "mp4 parsed: stco=" << parsed.stco.size() << " stsc=" << parsed.stsc.size() + CH_LOG("debug", "mp4 parsed: stco=" << parsed.stco.size() << " co64=" << parsed.co64.size() + << " stsc=" << parsed.stsc.size() << " stsz=" << parsed.stsz.size() << " stsd=" << parsed.stsd.size()); - if (parsed.stco.empty() || parsed.stsc.empty() || parsed.stsz.empty() || parsed.stsd.empty()) { - CH_LOG("error", "Missing required stbl atoms (stco/stsc/stsz/stsd) in " << path); + if ((parsed.stco.empty() && parsed.co64.empty()) || parsed.stsc.empty() || + parsed.stsz.empty() || parsed.stsd.empty() || parsed.stts.empty()) { + CH_LOG("error", "Missing required stbl atoms (stco/co64/stsc/stsz/stsd/stts) in " + << path); return std::nullopt; } auto sizes_opt = parse_stsz_sizes(parsed.stsz); - if (!sizes_opt || parsed.stco.empty() || parsed.stsc.empty()) { + if (!sizes_opt) { return std::nullopt; } - const auto &sizes = *sizes_opt; + auto sizes = std::move(*sizes_opt); if (sizes.empty()) { return std::nullopt; } @@ -250,33 +288,67 @@ std::optional extract_from_mp4(const std::string &path) { return std::nullopt; } - CH_LOG("debug", "mp4 reuse: sizes=" << sizes.size() << " stco_bytes=" << parsed.stco.size() - << " stsc_bytes=" << parsed.stsc.size() - << " file_size=" << file_size); - - std::vector chunk_plan = - derive_chunk_plan(parsed.stsc, static_cast(sizes.size())); - if (chunk_plan.empty()) { + const auto read_be32 = [](const std::vector &payload, size_t pos) { + return (static_cast(payload[pos]) << 24) | + (static_cast(payload[pos + 1]) << 16) | + (static_cast(payload[pos + 2]) << 8) | payload[pos + 3]; + }; + const auto read_be64 = [&](const std::vector &payload, size_t pos) { + return (static_cast(read_be32(payload, pos)) << 32) | + read_be32(payload, pos + 4); + }; + + const bool uses_co64 = !parsed.co64.empty(); + const auto &offset_payload = uses_co64 ? parsed.co64 : parsed.stco; + if (offset_payload.size() < 8) { return std::nullopt; } - if (chunk_plan.size() > sizes.size() * 4 || chunk_plan.size() > 1000000) { - CH_LOG("error", "Unreasonable chunk plan size=" << chunk_plan.size() - << " samples=" << sizes.size()); + const uint32_t chunk_count = read_be32(offset_payload, 4); + const uint64_t offset_width = uses_co64 ? 8 : 4; + const uint64_t offsets_expected = 8ull + offset_width * chunk_count; + if (offsets_expected > offset_payload.size()) { + CH_LOG("error", (uses_co64 ? "co64" : "stco") + << " table truncated: size=" << offset_payload.size() + << " expected>=" << offsets_expected); return std::nullopt; } + std::vector chunk_offsets; + chunk_offsets.reserve(chunk_count); + for (uint32_t i = 0; i < chunk_count; ++i) { + const size_t pos = 8 + static_cast(i) * offset_width; + chunk_offsets.push_back(uses_co64 ? read_be64(offset_payload, pos) + : read_be32(offset_payload, pos)); + } - std::vector> frames; - frames.reserve(sizes.size()); - size_t sample_idx = 0; - const uint8_t *pco = parsed.stco.data(); - uint32_t stco_count = (pco[4] << 24) | (pco[5] << 16) | (pco[6] << 8) | pco[7]; - // Validate stco table size matches count and fits in file. - uint64_t stco_expected = 8ull + 4ull * stco_count; - if (parsed.stco.size() < stco_expected || stco_expected > file_size) { - CH_LOG("error", "stco table truncated: size=" << parsed.stco.size() - << " expected>=" << stco_expected); + std::vector chunk_plan = derive_chunk_plan(parsed.stsc, chunk_count); + if (chunk_plan.empty()) { return std::nullopt; } + uint64_t mapped_samples = 0; + for (const uint32_t samples_per_chunk : chunk_plan) { + mapped_samples += samples_per_chunk; + } + const uint32_t timed_samples = stts_sample_count(parsed.stts); + if (mapped_samples == 0 || mapped_samples > std::numeric_limits::max() || + sizes.size() < mapped_samples || timed_samples != mapped_samples) { + CH_LOG("error", "inconsistent audio sample tables: stsz=" << sizes.size() + << " mapped=" << mapped_samples + << " timed=" << timed_samples); + return std::nullopt; + } + if (sizes.size() > mapped_samples) { + CH_LOG("debug", "normalizing " << (sizes.size() - mapped_samples) + << " unaddressed stsz entries"); + sizes.resize(static_cast(mapped_samples)); + if (!normalize_stsz(parsed.stsz, static_cast(mapped_samples))) { + return std::nullopt; + } + } + + CH_LOG("debug", "mp4 reuse: sizes=" << sizes.size() << " chunks=" << chunk_count + << " offsets=" << (uses_co64 ? "co64" : "stco") + << " stsc_bytes=" << parsed.stsc.size() + << " file_size=" << file_size); // Validate stsc table size matches entry count (12 bytes each after header). if (parsed.stsc.size() < 8) { @@ -290,24 +362,19 @@ std::optional extract_from_mp4(const std::string &path) { << " expected>=" << stsc_expected); return std::nullopt; } - CH_LOG("debug", "mp4 reuse: stco_count=" << stco_count << " stsc_entries=" << stsc_entries + CH_LOG("debug", "mp4 reuse: chunk_count=" << chunk_count << " stsc_entries=" << stsc_entries << " chunk_plan=" << chunk_plan.size()); const auto t_parse = std::chrono::steady_clock::now(); - size_t stco_pos = 8; - for (uint32_t chunk_idx = 0; chunk_idx < stco_count && chunk_idx < chunk_plan.size() && - sample_idx < sizes.size(); - ++chunk_idx) { - if (stco_pos + 4 > parsed.stco.size()) { - break; - } - uint32_t chunk_offset = (pco[stco_pos] << 24) | (pco[stco_pos + 1] << 16) | - (pco[stco_pos + 2] << 8) | pco[stco_pos + 3]; - stco_pos += 4; + std::vector> frames; + frames.reserve(sizes.size()); + size_t sample_idx = 0; + for (uint32_t chunk_idx = 0; chunk_idx < chunk_count; ++chunk_idx) { + const uint64_t chunk_offset = chunk_offsets[chunk_idx]; uint32_t samples_in_chunk = chunk_plan[chunk_idx]; uint64_t chunk_size = 0; - for (uint32_t i = 0; i < samples_in_chunk && sample_idx + i < sizes.size(); ++i) { + for (uint32_t i = 0; i < samples_in_chunk; ++i) { chunk_size += sizes[sample_idx + i]; } if (chunk_size == 0) { @@ -315,14 +382,14 @@ std::optional extract_from_mp4(const std::string &path) { } // Bounds guard: chunk must fit in file. - if (static_cast(chunk_offset) + chunk_size > file_size) { + if (chunk_offset > file_size || chunk_size > file_size - chunk_offset) { CH_LOG("error", "Chunk exceeds file size: offset=" << chunk_offset << " size=" << chunk_size << " file_size=" << file_size); break; } - std::vector chunk(chunk_size); + std::vector chunk(static_cast(chunk_size)); f.seekg(static_cast(chunk_offset), std::ios::beg); f.read(reinterpret_cast(chunk.data()), static_cast(chunk_size)); if (f.gcount() != static_cast(chunk_size)) { @@ -341,6 +408,7 @@ std::optional extract_from_mp4(const std::string &path) { offset += s; } } + const auto t_samples = std::chrono::steady_clock::now(); if (frames.size() != sizes.size()) { @@ -350,6 +418,7 @@ std::optional extract_from_mp4(const std::string &path) { AacExtractResult out; out.frames = std::move(frames); out.sizes = sizes; + out.chunk_sizes = chunk_plan; out.sample_rate = parsed.audio_timescale; Mp4aConfig cfg; cfg.sample_rate = parsed.audio_timescale; @@ -365,6 +434,7 @@ std::optional extract_from_mp4(const std::string &path) { out.stsc_payload = parsed.stsc; out.stsz_payload = parsed.stsz; out.stco_payload = parsed.stco; + out.co64_payload = parsed.co64; out.meta_payload = parsed.meta_payload; out.ilst_payload = parsed.ilst_payload; const auto t_done = std::chrono::steady_clock::now(); diff --git a/src/chapterforge.cpp b/src/chapterforge.cpp index a5bb5be..875f215 100644 --- a/src/chapterforge.cpp +++ b/src/chapterforge.cpp @@ -405,6 +405,10 @@ uint32_t read_u32_be(const std::vector &buf, size_t off) { (static_cast(buf[off + 2]) << 8) | (static_cast(buf[off + 3])); } +uint64_t read_u64_be(const std::vector &buf, size_t off) { + return (static_cast(read_u32_be(buf, off)) << 32) | read_u32_be(buf, off + 4); +} + uint16_t read_u16_be(const std::vector &buf, size_t off) { return static_cast((static_cast(buf[off]) << 8) | static_cast(buf[off + 1])); @@ -424,7 +428,7 @@ std::optional build_sample_plan(const parser_detail::TrackParseResul SamplePlan plan; // stsz: fixed or per-sample sizes const auto &stsz = trk.stsz; - if (stsz.size() < 20) { + if (stsz.size() < 12) { return std::nullopt; } // stsz layout (payload only, size/type stripped): @@ -446,19 +450,23 @@ std::optional build_sample_plan(const parser_detail::TrackParseResul plan.sizes.assign(sample_count, sample_size); } - // stco: chunk offsets - const auto &stco = trk.stco; - if (stco.size() < 16) { + // stco/co64: chunk offsets + const bool uses_co64 = !trk.co64.empty(); + const auto &chunk_table = uses_co64 ? trk.co64 : trk.stco; + if (chunk_table.size() < 8) { return std::nullopt; } - uint32_t chunk_count = read_u32_be(stco, 4); - if (stco.size() < 8 + chunk_count * 4) { + uint32_t chunk_count = read_u32_be(chunk_table, 4); + const size_t offset_width = uses_co64 ? 8 : 4; + if (chunk_count > (chunk_table.size() - 8) / offset_width) { return std::nullopt; } std::vector chunk_offsets; chunk_offsets.reserve(chunk_count); for (uint32_t i = 0; i < chunk_count; ++i) { - chunk_offsets.push_back(read_u32_be(stco, 8 + i * 4)); + const size_t offset = 8 + static_cast(i) * offset_width; + chunk_offsets.push_back(uses_co64 ? read_u64_be(chunk_table, offset) + : read_u32_be(chunk_table, offset)); } // stsc: samples per chunk mapping diff --git a/src/mdat_writer.cpp b/src/mdat_writer.cpp index fddeae5..3fa4a32 100644 --- a/src/mdat_writer.cpp +++ b/src/mdat_writer.cpp @@ -8,8 +8,34 @@ #include "mdat_writer.hpp" +#include #include +uint64_t media_payload_size( + const std::vector> &audio_samples, + const std::vector>> &text_tracks_samples, + const std::vector> &image_samples) { + uint64_t size = 0; + auto add_samples = [&](const std::vector> &samples) { + for (const auto &sample : samples) { + if (sample.size() > std::numeric_limits::max() - size) { + throw std::runtime_error("media payload size overflow"); + } + size += sample.size(); + } + }; + add_samples(audio_samples); + for (const auto &track : text_tracks_samples) { + add_samples(track); + } + add_samples(image_samples); + return size; +} + +uint64_t mdat_header_size(uint64_t payload_size) { + return payload_size <= std::numeric_limits::max() - 8 ? 8 : 16; +} + // Write the mdat box and collect relative offsets for each track. MdatOffsets write_mdat( std::ofstream &out, const std::vector> &audio_samples, @@ -23,9 +49,16 @@ MdatOffsets write_mdat( // Start of mdat box. uint64_t mdat_header_pos = out.tellp(); - // Size placeholder (4 bytes) + 'mdat' - uint8_t header[8] = {0, 0, 0, 0, 'm', 'd', 'a', 't'}; - out.write(reinterpret_cast(header), 8); + const uint64_t payload_size = + media_payload_size(audio_samples, text_tracks_samples, image_samples); + const uint64_t header_size = mdat_header_size(payload_size); + if (header_size == 8) { + uint8_t header[8] = {0, 0, 0, 0, 'm', 'd', 'a', 't'}; + out.write(reinterpret_cast(header), 8); + } else { + uint8_t header[16] = {0, 0, 0, 1, 'm', 'd', 'a', 't', 0, 0, 0, 0, 0, 0, 0, 0}; + out.write(reinterpret_cast(header), 16); + } // Payload begins right after 'mdat' uint64_t payload_start = out.tellp(); @@ -33,7 +66,7 @@ MdatOffsets write_mdat( auto write_track = [&](const std::vector> &samples, const std::vector &chunk_sizes, - std::vector &offsets) { + std::vector &offsets) { if (samples.empty()) { return; } @@ -48,7 +81,7 @@ MdatOffsets write_mdat( break; } uint64_t pos = out.tellp(); - uint32_t rel = static_cast(pos - payload_start); + uint64_t rel = static_cast(pos) - payload_start; offsets.push_back(rel); for (uint32_t i = 0; i < chunk_size && sample_index < samples.size(); ++i) { @@ -60,7 +93,7 @@ MdatOffsets write_mdat( // Write any stragglers if plan was shorter than sample count. if (sample_index < samples.size()) { uint64_t pos = out.tellp(); - uint32_t rel = static_cast(pos - payload_start); + uint64_t rel = static_cast(pos) - payload_start; offsets.push_back(rel); for (; sample_index < samples.size(); ++sample_index) { const auto &sample = samples[sample_index]; @@ -72,7 +105,7 @@ MdatOffsets write_mdat( // Apple convention: audio first, then text tracks, then image. write_track(audio_samples, audio_chunk_sizes, result.audio_offsets); for (size_t i = 0; i < text_tracks_samples.size(); ++i) { - std::vector offsets; + std::vector offsets; const auto &samples = text_tracks_samples[i]; const auto &plan = (i < text_chunk_sizes.size()) ? text_chunk_sizes[i] : std::vector(); @@ -85,65 +118,126 @@ MdatOffsets write_mdat( uint64_t end_pos = out.tellp(); uint64_t box_size = end_pos - mdat_header_pos; - if (box_size > 0xFFFFFFFFULL) { - throw std::runtime_error("mdat too large ( > 4 GB )"); - } - - uint32_t size32 = static_cast(box_size); - uint8_t size_bytes[4] = { - static_cast((size32 >> 24) & 0xFF), static_cast((size32 >> 16) & 0xFF), - static_cast((size32 >> 8) & 0xFF), static_cast((size32) & 0xFF)}; - out.seekp(mdat_header_pos); - out.write(reinterpret_cast(size_bytes), 4); + if (header_size == 8) { + uint32_t size32 = static_cast(box_size); + uint8_t size_bytes[4] = { + static_cast((size32 >> 24) & 0xFF), + static_cast((size32 >> 16) & 0xFF), + static_cast((size32 >> 8) & 0xFF), static_cast(size32 & 0xFF)}; + out.write(reinterpret_cast(size_bytes), 4); + } else { + uint8_t size_bytes[8] = { + static_cast((box_size >> 56) & 0xFF), + static_cast((box_size >> 48) & 0xFF), + static_cast((box_size >> 40) & 0xFF), + static_cast((box_size >> 32) & 0xFF), + static_cast((box_size >> 24) & 0xFF), + static_cast((box_size >> 16) & 0xFF), + static_cast((box_size >> 8) & 0xFF), static_cast(box_size & 0xFF)}; + out.seekp(mdat_header_pos + std::streamoff(8)); + out.write(reinterpret_cast(size_bytes), 8); + } out.seekp(end_pos); return result; } -// Update a single stco table with absolute offsets based on the mdat payload start. -void patch_stco_table(Atom *stco, const std::vector &offsets, - uint64_t mdat_payload_start) { - if (!stco) { +namespace { + +void collect_chunk_offset_atoms(Atom *atom, std::vector &out) { + if (!atom) { return; } - auto &p = stco->payload; + if (atom->type == fourcc("stco") || atom->type == fourcc("co64")) { + out.push_back(atom); + } + for (auto &child : atom->children) { + collect_chunk_offset_atoms(child.get(), out); + } +} - if (p.size() < 8) { +uint32_t payload_u32(const std::vector &payload, size_t pos) { + return (static_cast(payload[pos]) << 24) | + (static_cast(payload[pos + 1]) << 16) | + (static_cast(payload[pos + 2]) << 8) | payload[pos + 3]; +} + +void patch_chunk_offset_table(Atom *atom, const std::vector &offsets, + uint64_t mdat_payload_start) { + if (!atom) { return; } + auto &p = atom->payload; - uint32_t entry_count = (p[4] << 24) | (p[5] << 16) | (p[6] << 8) | (p[7]); + if (p.size() < 8) { + return; + } - entry_count = std::min(entry_count, offsets.size()); + const bool is_co64 = atom->type == fourcc("co64"); + const size_t width = is_co64 ? 8 : 4; + const uint32_t declared_count = payload_u32(p, 4); + if (declared_count > (p.size() - 8) / width || offsets.size() < declared_count) { + throw std::runtime_error("chunk offset table/count mismatch"); + } size_t pos = 8; - for (uint32_t i = 0; i < entry_count; ++i) { - uint32_t abs_offset = offsets[i] + static_cast(mdat_payload_start); + for (uint32_t i = 0; i < declared_count; ++i) { + if (offsets[i] > std::numeric_limits::max() - mdat_payload_start) { + throw std::runtime_error("chunk offset overflow"); + } + const uint64_t absolute = offsets[i] + mdat_payload_start; + if (!is_co64 && absolute > std::numeric_limits::max()) { + throw std::runtime_error("stco offset exceeds 32 bits"); + } + for (size_t byte = 0; byte < width; ++byte) { + const unsigned shift = static_cast((width - byte - 1) * 8); + p[pos + byte] = static_cast((absolute >> shift) & 0xFF); + } + pos += width; + } +} - p[pos + 0] = (abs_offset >> 24) & 0xFF; - p[pos + 1] = (abs_offset >> 16) & 0xFF; - p[pos + 2] = (abs_offset >> 8) & 0xFF; - p[pos + 3] = (abs_offset) & 0xFF; +} // namespace - pos += 4; +void promote_stco_to_co64(Atom *root) { + std::vector atoms; + collect_chunk_offset_atoms(root, atoms); + for (Atom *atom : atoms) { + if (atom->type != fourcc("stco") || atom->payload.size() < 8) { + continue; + } + const uint32_t count = payload_u32(atom->payload, 4); + if (count > (atom->payload.size() - 8) / 4) { + throw std::runtime_error("invalid stco table"); + } + std::vector promoted; + promoted.reserve(8 + static_cast(count) * 8); + promoted.insert(promoted.end(), atom->payload.begin(), atom->payload.begin() + 8); + for (uint32_t i = 0; i < count; ++i) { + const uint32_t value = payload_u32(atom->payload, 8 + static_cast(i) * 4); + write_u64(promoted, value); + } + atom->type = fourcc("co64"); + atom->payload = std::move(promoted); } } -// Patch all stco tables (audio, text tracks, images) found under moov. +// Patch all stco/co64 tables (audio, text tracks, images) found under moov. void patch_all_stco(Atom *moov, const MdatOffsets &offs, bool patch_audio) { if (!moov) { return; } - auto stcos = moov->find("stco"); + std::vector offset_atoms; + collect_chunk_offset_atoms(moov, offset_atoms); size_t idx = 0; - if (patch_audio && stcos.size() > idx) { - patch_stco_table(stcos[idx], offs.audio_offsets, offs.payload_start); + if (patch_audio && offset_atoms.size() > idx) { + patch_chunk_offset_table(offset_atoms[idx], offs.audio_offsets, offs.payload_start); } idx += 1; - for (size_t t = 0; t < offs.text_offsets.size() && idx < stcos.size(); ++t, ++idx) { - patch_stco_table(stcos[idx], offs.text_offsets[t], offs.payload_start); + for (size_t t = 0; t < offs.text_offsets.size() && idx < offset_atoms.size(); ++t, ++idx) { + patch_chunk_offset_table(offset_atoms[idx], offs.text_offsets[t], offs.payload_start); } - if (idx < stcos.size() && !offs.image_offsets.empty()) { - patch_stco_table(stcos[idx], offs.image_offsets, offs.payload_start); + if (idx < offset_atoms.size() && !offs.image_offsets.empty()) { + patch_chunk_offset_table(offset_atoms[idx], offs.image_offsets, offs.payload_start); } } @@ -161,26 +255,26 @@ MdatOffsets compute_mdat_offsets( uint64_t payload_start, auto compute_track = [&](const std::vector> &samples, const std::vector &chunk_sizes, - std::vector &offsets) { + std::vector &offsets) { if (samples.empty()) { return; } std::vector plan = chunk_sizes.empty() ? std::vector(samples.size(), 1) : chunk_sizes; size_t sample_index = 0; for (uint32_t chunk_size : plan) { if (sample_index >= samples.size()) { break; } - offsets.push_back(static_cast(cursor - payload_start)); + offsets.push_back(cursor - payload_start); for (uint32_t i = 0; i < chunk_size && sample_index < samples.size(); ++i, ++sample_index) { cursor += samples[sample_index].size(); } } if (sample_index < samples.size()) { - offsets.push_back(static_cast(cursor - payload_start)); + offsets.push_back(cursor - payload_start); for (; sample_index < samples.size(); ++sample_index) { cursor += samples[sample_index].size(); } } }; compute_track(audio_samples, audio_chunk_sizes, result.audio_offsets); for (size_t i = 0; i < text_tracks_samples.size(); ++i) { - std::vector offsets; + std::vector offsets; const auto &samples = text_tracks_samples[i]; const auto &plan = (i < text_chunk_sizes.size()) ? text_chunk_sizes[i] : std::vector(); compute_track(samples, plan, offsets); diff --git a/src/mp4_muxer.cpp b/src/mp4_muxer.cpp index 97a26bd..f0011c5 100644 --- a/src/mp4_muxer.cpp +++ b/src/mp4_muxer.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -70,7 +71,6 @@ static std::vector build_audio_chunk_plan(uint32_t sample_count) { } return chunks; } - // Derive samples-per-chunk plan from stsc payload. static std::vector derive_chunk_plan(const std::vector &stsc_payload, uint32_t sample_count) { @@ -124,6 +124,34 @@ static std::vector derive_chunk_plan(const std::vector &stsc_ return plan; } +static std::optional duration_from_stts(const std::vector &stts_payload) { + if (stts_payload.size() < 8) { + return std::nullopt; + } + const uint32_t entry_count = (stts_payload[4] << 24) | (stts_payload[5] << 16) | + (stts_payload[6] << 8) | stts_payload[7]; + if (entry_count > (stts_payload.size() - 8) / 8) { + return std::nullopt; + } + + uint64_t duration = 0; + size_t pos = 8; + for (uint32_t i = 0; i < entry_count; ++i, pos += 8) { + const uint32_t sample_count = (stts_payload[pos] << 24) | + (stts_payload[pos + 1] << 16) | + (stts_payload[pos + 2] << 8) | stts_payload[pos + 3]; + const uint32_t sample_delta = (stts_payload[pos + 4] << 24) | + (stts_payload[pos + 5] << 16) | + (stts_payload[pos + 6] << 8) | stts_payload[pos + 7]; + if (sample_delta != 0 && + sample_count > (std::numeric_limits::max() - duration) / sample_delta) { + return std::nullopt; + } + duration += static_cast(sample_count) * sample_delta; + } + return duration == 0 ? std::nullopt : std::optional(duration); +} + struct DurationInfo { uint32_t audio_timescale = 0; uint64_t audio_duration_ts = 0; @@ -245,7 +273,10 @@ static DurationInfo compute_durations(const AacExtractResult &aac, Mp4aConfig &a aac.audio_object_type ? aac.audio_object_type : audio_cfg.audio_object_type; info.audio_timescale = audio_cfg.sample_rate; - info.audio_duration_ts = static_cast(aac.frames.size()) * kAacSamplesPerFrame; + // MP4 inputs carry their exact packet timing in stts. This matters for codecs such as ALAC, + // whose packets commonly represent 4096 PCM samples rather than AAC-LC's 1024. + info.audio_duration_ts = duration_from_stts(aac.stts_payload).value_or( + static_cast(aac.frames.size()) * kAacSamplesPerFrame); info.audio_duration_ms = static_cast((info.audio_duration_ts * 1000 + info.audio_timescale - 1) / info.audio_timescale); @@ -309,9 +340,11 @@ static ChunkPlans build_chunk_plans(const AacExtractResult &aac, uint32_t audio_ const PreparedTextTracks &texts, const std::vector> &image_samples) { ChunkPlans plans; - plans.audio = - aac.stsc_payload.empty() ? build_audio_chunk_plan(audio_sample_count) - : derive_chunk_plan(aac.stsc_payload, audio_sample_count); + plans.audio = !aac.chunk_sizes.empty() + ? aac.chunk_sizes + : (aac.stsc_payload.empty() ? build_audio_chunk_plan(audio_sample_count) + : derive_chunk_plan(aac.stsc_payload, + audio_sample_count)); plans.text.push_back(std::vector(texts.primary.size(), 1)); for (const auto &samples : texts.extras) { plans.text.emplace_back(samples.size(), 1); @@ -512,13 +545,13 @@ bool write_mp4(const std::string &output_path, const AacExtractResult &aac, // Pre-build stbls (needed for both fast-start and normal paths) std::unique_ptr stbl_audio; if (!aac.stsd_payload.empty() && !aac.stts_payload.empty() && !aac.stsc_payload.empty() && - !aac.stsz_payload.empty() && !aac.stco_payload.empty()) { + !aac.stsz_payload.empty() && (!aac.stco_payload.empty() || !aac.co64_payload.empty())) { // Golden-aligned path: reuse the source audio stbl verbatim. Rebuilding stbl for audio // triggered decoding issues in Apple players even when the fields were “correct” per spec, // so we preserve the original structure whenever we can. CH_LOG("debug", "Reusing source audio stbl"); stbl_audio = build_audio_stbl_raw(aac.stsd_payload, aac.stts_payload, aac.stsc_payload, - aac.stsz_payload, aac.stco_payload); + aac.stsz_payload, aac.stco_payload, aac.co64_payload); } else { CH_LOG("debug", "Building new audio stbl"); stbl_audio = @@ -613,6 +646,14 @@ bool write_mp4(const std::string &output_path, const AacExtractResult &aac, auto moov = build_moov(mvhd_timescale, mvhd_duration, std::move(trak_audio), std::move(text_traks), std::move(trak_image), std::move(udta)); moov->fix_size_recursive(); + const uint64_t media_bytes = + media_payload_size(audio_samples, all_text_samples, image_samples); + const uint64_t media_header_bytes = mdat_header_size(media_bytes); + if (static_cast(ftyp_size) + moov->size() + media_header_bytes + media_bytes > + std::numeric_limits::max()) { + promote_stco_to_co64(moov.get()); + moov->fix_size_recursive(); + } CH_LOG("debug", "moov size=" << moov->size() << " mvhd_duration=" << mvhd_duration); auto t_moov_end = now(); @@ -625,7 +666,7 @@ bool write_mp4(const std::string &output_path, const AacExtractResult &aac, // other valid layouts have shown sporadic playback regressions despite being // spec-compliant. uint64_t payload_start = - static_cast(ftyp_size) + moov->size() + 8; // +8 for mdat header + static_cast(ftyp_size) + moov->size() + media_header_bytes; MdatOffsets mdat_offs = compute_mdat_offsets(payload_start, audio_samples, all_text_samples, image_samples, chunk_plans.audio, all_text_chunk_plans, chunk_plans.image); diff --git a/src/parser.cpp b/src/parser.cpp index baf154e..e805a3f 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -8,9 +8,11 @@ #include "parser.hpp" +#include #include #include #include +#include #include #include #include @@ -26,7 +28,7 @@ constexpr uint64_t kAtomHeaderSize = 8; constexpr uint64_t kFullBoxBaseHeader = 12; // size+type+version/flags constexpr uint64_t kMetaReservedBytes = 4; constexpr uint64_t kHdlrMinPayload = 20; -constexpr uint64_t kMaxAtomPayload = 512 * 1024 * 1024; // 512 MB safety bound +constexpr uint64_t kMaxBufferedPayload = 512 * 1024 * 1024; } // namespace @@ -47,23 +49,39 @@ uint64_t read_u64(std::istream &in) { (uint64_t(b[6]) << 8) | (uint64_t(b[7])); } -// Read atom header: size + type. -static Mp4AtomInfo read_atom_header(std::istream &in) { +// Read and validate an atom header against its enclosing byte range. +static Mp4AtomInfo read_atom_header(std::istream &in, uint64_t enclosing_end) { Mp4AtomInfo info; - info.offset = in.tellg(); - info.size = read_u32(in); + const std::streampos pos = in.tellg(); + if (pos < 0) { + return info; + } + info.offset = static_cast(pos); + if (info.offset > enclosing_end || enclosing_end - info.offset < kAtomHeaderSize) { + return info; + } + + const uint32_t size32 = read_u32(in); info.type = read_u32(in); + if (!in) { + info.size = 0; + return info; + } - if (info.size == 1) { - // 64-bit extended size. + if (size32 == 1) { + if (enclosing_end - info.offset < 16) { + return info; + } + info.header_size = 16; info.size = read_u64(in); + } else if (size32 == 0) { + // A zero size extends to the end of the enclosing box (or file at top level). + info.size = enclosing_end - info.offset; + } else { + info.size = size32; } - // Hard sanity: reject absurd payloads early (protect against corrupted headers). - if (info.size >= kAtomHeaderSize && - (info.size - kAtomHeaderSize) > kMaxAtomPayload) { - CH_LOG("warn", "atom " << fourcc_to_string(info.type) - << " claims payload " << (info.size - kAtomHeaderSize) - << " bytes; exceeds safety bound, skipping"); + + if (!in || info.size < info.header_size || info.size > enclosing_end - info.offset) { info.size = 0; } return info; @@ -71,65 +89,89 @@ static Mp4AtomInfo read_atom_header(std::istream &in) { // Utility: read an atom payload into a byte buffer. static std::vector read_bytes(std::istream &in, uint64_t size) { - std::vector buf(size); - in.read(reinterpret_cast(buf.data()), size); - return buf; -} - - -static bool grab_atom_from_buffer(const std::vector &buf, const char *fourcc, - std::vector &dst) { - if (!dst.empty()) { - return false; + if (size > kMaxBufferedPayload || size > std::numeric_limits::max() || + size > static_cast(std::numeric_limits::max())) { + CH_LOG("error", "refusing to buffer atom payload of " << size << " bytes"); + skip(in, size); + return {}; } - for (size_t i = 0; i + 8 <= buf.size(); ++i) { - if (buf[i + 4] == static_cast(fourcc[0]) && - buf[i + 5] == static_cast(fourcc[1]) && - buf[i + 6] == static_cast(fourcc[2]) && - buf[i + 7] == static_cast(fourcc[3])) { - uint32_t sz = (buf[i] << 24) | (buf[i + 1] << 16) | (buf[i + 2] << 8) | buf[i + 3]; - uint64_t end = static_cast(i) + static_cast(sz); - if (sz >= 8 && end <= buf.size() && end >= i + 8) { - dst.assign(buf.begin() + i + 8, buf.begin() + end); - CH_LOG("debug", "grabbed " << fourcc << " via raw scan, bytes=" << dst.size()); - return true; - } - } + std::vector buf(static_cast(size)); + in.read(reinterpret_cast(buf.data()), static_cast(size)); + if (in.gcount() != static_cast(size)) { + return {}; } - return false; + return buf; } -// Naive scan for ilst payload (fallback when structured parse misses it). -static std::vector scan_ilst_payload(const std::string &path) { +// Streaming scan for ilst payload inside a validated moov range. +static std::vector scan_ilst_payload(const std::string &path, uint64_t range_offset, + uint64_t range_size) { std::ifstream f(path, std::ios::binary); if (!f.is_open()) { return {}; } f.seekg(0, std::ios::end); - std::streamoff len = f.tellg(); + const std::streamoff len = f.tellg(); if (len <= 0) { return {}; } - size_t sz = static_cast(len); - f.seekg(0, std::ios::beg); - std::vector data(sz); - f.read(reinterpret_cast(data.data()), sz); - for (size_t i = 0; i + 4 <= data.size(); ++i) { - if (data[i] == 'i' && data[i + 1] == 'l' && data[i + 2] == 's' && data[i + 3] == 't') { - if (i < 4) { + const uint64_t file_size = static_cast(len); + if (range_offset > file_size || range_size > file_size - range_offset) { + return {}; + } + const uint64_t range_end = range_offset + range_size; + f.seekg(static_cast(range_offset), std::ios::beg); + + constexpr size_t kScanChunkSize = 1024 * 1024; + constexpr size_t kHeaderOverlap = 7; + std::vector window(kScanChunkSize + kHeaderOverlap); + size_t carry = 0; + uint64_t consumed = range_offset; + + while (consumed < range_end) { + const size_t wanted = + static_cast(std::min(kScanChunkSize, range_end - consumed)); + f.read(reinterpret_cast(window.data() + carry), + static_cast(wanted)); + const size_t got = static_cast(f.gcount()); + if (got == 0) { + break; + } + + const size_t available = carry + got; + const uint64_t window_offset = consumed - carry; + for (size_t i = 0; i + kAtomHeaderSize <= available; ++i) { + if (window[i + 4] != 'i' || window[i + 5] != 'l' || window[i + 6] != 's' || + window[i + 7] != 't') { continue; } - uint32_t size = - (data[i - 4] << 24) | (data[i - 3] << 16) | (data[i - 2] << 8) | (data[i - 1]); - if (size < 8) { + + const uint32_t atom_size = (window[i] << 24) | (window[i + 1] << 16) | + (window[i + 2] << 8) | window[i + 3]; + if (atom_size < kAtomHeaderSize) { continue; } - size_t payload_size = size - 8; - if (i + 4 + payload_size <= data.size()) { - return std::vector(data.begin() + i + 4, - data.begin() + i + 4 + payload_size); + const uint64_t atom_offset = window_offset + i; + const uint64_t payload_size = atom_size - kAtomHeaderSize; + if (payload_size > kMaxBufferedPayload || atom_offset < range_offset || + atom_offset > range_end || atom_size > range_end - atom_offset) { + continue; + } + + std::ifstream payload_in(path, std::ios::binary); + payload_in.seekg(static_cast(atom_offset + kAtomHeaderSize), + std::ios::beg); + std::vector payload(static_cast(payload_size)); + payload_in.read(reinterpret_cast(payload.data()), + static_cast(payload_size)); + if (payload_in.gcount() == static_cast(payload_size)) { + return payload; } } + + consumed += got; + carry = std::min(kHeaderOverlap, available); + std::memmove(window.data(), window.data() + available - carry, carry); } return {}; } @@ -161,6 +203,11 @@ static void parse_mdhd(std::istream &in, uint64_t size, uint32_t ×cale, uin // Extract ilst from meta payload (payload only, after size/type). static void parse_meta_payload(std::istream &in, uint64_t size, ParsedMp4 &out) { const uint64_t start = (uint64_t)in.tellg(); + const uint64_t end = start + size; + if (size < 4) { + in.seekg(end); + return; + } // meta full box header. uint8_t version = in.get(); @@ -192,13 +239,13 @@ static void parse_meta_payload(std::istream &in, uint64_t size, ParsedMp4 &out) remain -= kMetaReservedBytes; } while (remain > kAtomHeaderSize) { - auto child = read_atom_header(in); + auto child = read_atom_header(in, end); if (!in || child.size == 0 || child.size < kAtomHeaderSize || child.size > remain) { CH_LOG("debug", "meta child invalid size=" << child.size << " remain=" << remain); break; } - uint64_t payload_size = child.size - kAtomHeaderSize; - if (payload_size > remain - kAtomHeaderSize) { + uint64_t payload_size = child.size - child.header_size; + if (child.header_size > remain || payload_size > remain - child.header_size) { CH_LOG("debug", "meta child payload exceeds remain; breaking"); break; } @@ -226,7 +273,6 @@ static void parse_meta_payload(std::istream &in, uint64_t size, ParsedMp4 &out) } // Seek to end of meta box. - uint64_t end = start + size; in.seekg(end); } @@ -252,6 +298,10 @@ static void parse_hdlr(std::istream &in, uint64_t payload_size, TrackParseResult std::string name; if (payload_size > consumed) { uint64_t name_len = payload_size - consumed; + if (name_len > 1024 * 1024) { + skip(in, name_len); + return; + } name.resize(static_cast(name_len)); in.read(name.data(), (std::streamsize)name_len); // Trim any trailing nulls. @@ -294,14 +344,15 @@ static void parse_tkhd(std::istream &in, uint64_t payload_size, TrackParseResult static void parse_stbl(std::istream &in, uint64_t size, TrackParseResult &track) { CH_LOG("debug", "parse_stbl size=" << size); uint64_t start = (uint64_t)in.tellg(); + uint64_t end = start + size; uint64_t remain = size; while (remain >= 8) { - auto info = read_atom_header(in); + auto info = read_atom_header(in, end); if (!in || info.size == 0 || info.size < 8) { break; } - uint64_t payload_size = info.size - 8; + uint64_t payload_size = info.size - info.header_size; switch (info.type) { case ('s' << 24 | 't' << 16 | 's' << 8 | 'd'): // stsd @@ -339,6 +390,13 @@ static void parse_stbl(std::istream &in, uint64_t size, TrackParseResult &track) skip(in, payload_size); } break; + case ('c' << 24 | 'o' << 16 | '6' << 8 | '4'): // co64 + if (track.co64.empty()) { + track.co64 = read_bytes(in, payload_size); + } else { + skip(in, payload_size); + } + break; default: skip(in, payload_size); } @@ -346,13 +404,13 @@ static void parse_stbl(std::istream &in, uint64_t size, TrackParseResult &track) remain -= info.size; } - uint64_t end = start + size; in.seekg(end); CH_LOG("debug", "stbl parsed sizes stsd=" << track.stsd.size() << " stts=" << track.stts.size() << " stsc=" << track.stsc.size() << " stsz=" << track.stsz.size() - << " stco=" << track.stco.size()); + << " stco=" << track.stco.size() + << " co64=" << track.co64.size()); } // Parse mdia box of a track. @@ -363,12 +421,12 @@ static bool parse_mdia(std::istream &in, uint64_t mpay, uint64_t mdia_end, while (mdia_remain >= 8) { CH_LOG("debug", " mdia pos=" << (uint64_t)in.tellg() << " remain=" << mdia_remain); - auto m = read_atom_header(in); + auto m = read_atom_header(in, mdia_end); if (!in || m.size == 0 || m.size < 8) { CH_LOG("debug", "mdia break: stream bad or size<8"); break; } - uint64_t mpay_child = m.size - 8; + uint64_t mpay_child = m.size - m.header_size; if (m.size > mdia_remain) { CH_LOG("debug", " mdia child type=" << fourcc_to_string(m.type) << " claims size=" << m.size << " but remain=" << mdia_remain @@ -394,7 +452,7 @@ static bool parse_mdia(std::istream &in, uint64_t mpay, uint64_t mdia_end, } CH_LOG("debug", " mdia child overflow; clamping size " << m.size << " -> " << allowed); m.size = allowed; - mpay_child = m.size - 8; + mpay_child = m.size - m.header_size; } if (m.type == ('m' << 24 | 'd' << 16 | 'h' << 8 | 'd')) { @@ -414,12 +472,12 @@ static bool parse_mdia(std::istream &in, uint64_t mpay, uint64_t mdia_end, CH_LOG("debug", " enter minf end=" << minf_end); while (minf_rem >= 8) { - auto mi = read_atom_header(in); + auto mi = read_atom_header(in, minf_end); if (!in || mi.size < 8) { CH_LOG("debug", " minf break: stream bad or size<8"); break; } - uint64_t mi_pay = mi.size - 8; + uint64_t mi_pay = mi.size - mi.header_size; CH_LOG("debug", " minf child=" << std::hex << mi.type << std::dec << " size=" << mi.size); @@ -478,17 +536,22 @@ static bool parse_mdia(std::istream &in, uint64_t mpay, uint64_t mdia_end, static std::optional parse_trak(std::istream &in, uint64_t c_payload, uint64_t file_size, bool &force_fallback) { (void)file_size; + if (c_payload < kAtomHeaderSize) { + skip(in, c_payload); + force_fallback = true; + return std::nullopt; + } uint64_t trak_end = (uint64_t)in.tellg() + c_payload; uint64_t trak_remain = c_payload; TrackParseResult track; CH_LOG("debug", "trak start end=" << trak_end); while (trak_remain >= 8) { - auto tchild = read_atom_header(in); + auto tchild = read_atom_header(in, trak_end); if (!in || tchild.size == 0 || tchild.size < 8) { break; } - uint64_t tpay = tchild.size - 8; + uint64_t tpay = tchild.size - tchild.header_size; CH_LOG("debug", " trak child=" << fourcc_to_string(tchild.type) << " size=" << tchild.size); if (tchild.type == ('t' << 24 | 'k' << 16 | 'h' << 8 | 'd')) { @@ -533,7 +596,7 @@ static void parse_moov(std::istream &in, const Mp4AtomInfo &atom, uint64_t file_ CH_LOG("debug", "enter moov @0x" << std::hex << atom.offset << std::dec << " end=" << end); while (((uint64_t)in.tellg()) + 8 <= end) { - auto child = read_atom_header(in); + auto child = read_atom_header(in, end); if (!in || child.size == 0 || child.size < 8) { break; } @@ -548,7 +611,7 @@ static void parse_moov(std::istream &in, const Mp4AtomInfo &atom, uint64_t file_ } } - uint64_t c_payload = child.size - 8; + uint64_t c_payload = child.size - child.header_size; CH_LOG("debug", "moov child=" << fourcc_to_string(child.type) << " size=" << child.size << " offset=0x" << std::hex << child.offset << std::dec); @@ -556,15 +619,18 @@ static void parse_moov(std::istream &in, const Mp4AtomInfo &atom, uint64_t file_ case ('u' << 24 | 'd' << 16 | 't' << 8 | 'a'): { uint64_t udta_end = (uint64_t)in.tellg() + c_payload; while (((uint64_t)in.tellg()) + 8 <= udta_end) { - auto u = read_atom_header(in); + auto u = read_atom_header(in, udta_end); if (!in || u.size < 8) { break; } - uint64_t upay = u.size - 8; + uint64_t upay = u.size - u.header_size; if (u.type == ('m' << 24 | 'e' << 16 | 't' << 8 | 'a')) { CH_LOG("debug", "found meta inside udta"); auto meta_buf = read_bytes(in, upay); + if (meta_buf.empty() && upay != 0) { + break; + } if (out.meta_payload.empty()) { out.meta_payload = meta_buf; } @@ -580,6 +646,9 @@ static void parse_moov(std::istream &in, const Mp4AtomInfo &atom, uint64_t file_ case ('m' << 24 | 'e' << 16 | 't' << 8 | 'a'): { CH_LOG("debug", "found meta under moov"); auto meta_buf = read_bytes(in, c_payload); + if (meta_buf.empty() && c_payload != 0) { + break; + } if (out.meta_payload.empty()) { out.meta_payload = meta_buf; } @@ -605,6 +674,7 @@ static void parse_moov(std::istream &in, const Mp4AtomInfo &atom, uint64_t file_ out.stsc = track.stsc; out.stsz = track.stsz; out.stco = track.stco; + out.co64 = track.co64; } } out.tracks.push_back(track); @@ -626,6 +696,7 @@ std::optional parse_mp4(const std::string &path) { ParsedMp4 out; uint32_t best_audio_samples = 0; bool force_fallback = false; + std::vector moov_atoms; CH_LOG("debug", "parse_mp4 enter path=" << path); std::ifstream in(path, std::ios::binary); @@ -635,7 +706,12 @@ std::optional parse_mp4(const std::string &path) { } in.seekg(0, std::ios::end); - const uint64_t file_size = static_cast(in.tellg()); + const std::streampos file_end = in.tellg(); + if (file_end < 0) { + CH_LOG("error", "parse_mp4: cannot determine file size for " << path); + return std::nullopt; + } + const uint64_t file_size = static_cast(file_end); in.seekg(0, std::ios::beg); CH_LOG("debug", "parse_mp4: size=" << file_size << " path=" << path); @@ -643,9 +719,9 @@ std::optional parse_mp4(const std::string &path) { if (force_fallback) { break; } - Mp4AtomInfo atom = read_atom_header(in); + Mp4AtomInfo atom = read_atom_header(in, file_size); if (atom.size == 0) { - CH_LOG("warn", "parse_mp4: atom with zero/invalid size encountered, bailing"); + CH_LOG("error", "parse_mp4: invalid atom size encountered, bailing"); break; } if (atom.size < 8 || atom.offset + atom.size > file_size) { @@ -654,7 +730,7 @@ std::optional parse_mp4(const std::string &path) { << " file=" << file_size); break; } - uint64_t payload_size = atom.size - 8; + uint64_t payload_size = atom.size - atom.header_size; switch (atom.type) { case ('m' << 24 | 'd' << 16 | 'a' << 8 | 't'): { // mdat @@ -663,6 +739,7 @@ std::optional parse_mp4(const std::string &path) { break; } case ('m' << 24 | 'o' << 16 | 'o' << 8 | 'v'): { // moov + moov_atoms.push_back(atom); parse_moov(in, atom, file_size, out, best_audio_samples, force_fallback); break; } @@ -673,43 +750,23 @@ std::optional parse_mp4(const std::string &path) { const auto t_struct_done = std::chrono::steady_clock::now(); - // Fallback: flat scan for sample-table atoms if they were not captured. - if (out.stsz.empty() || out.stco.empty() || out.stsc.empty() || out.stsd.empty()) { - CH_LOG("debug", "fallback flat scan for stbl atoms"); - out.used_fallback_stbl = true; - // load whole file into memory and search for atoms by signature. - in.clear(); - in.seekg(0, std::ios::end); - std::streamoff len = in.tellg(); - if (len <= 0) { - return out; - } - in.seekg(0, std::ios::beg); - std::vector buf(static_cast(len)); - in.read(reinterpret_cast(buf.data()), len); - - grab_atom_from_buffer(buf, "stsd", out.stsd); - grab_atom_from_buffer(buf, "stts", out.stts); - grab_atom_from_buffer(buf, "stsc", out.stsc); - grab_atom_from_buffer(buf, "stsz", out.stsz); - grab_atom_from_buffer(buf, "stco", out.stco); - if (out.ilst_payload.empty()) { - grab_atom_from_buffer(buf, "ilst", out.ilst_payload); - } - } - - // Fallback scan for ilst if still missing. + // Metadata fallback stays within validated moov ranges and never scans media payloads. if (out.ilst_payload.empty()) { - auto ilst = scan_ilst_payload(path); - if (!ilst.empty()) { - out.ilst_payload = std::move(ilst); - CH_LOG("debug", "ilst found via naive scan, bytes=" << out.ilst_payload.size()); + for (const auto &moov : moov_atoms) { + auto ilst = scan_ilst_payload(path, moov.offset, moov.size); + if (!ilst.empty()) { + out.ilst_payload = std::move(ilst); + CH_LOG("debug", "ilst found via bounded moov scan, bytes=" + << out.ilst_payload.size()); + break; + } } } const auto t_fallback_done = std::chrono::steady_clock::now(); - if (out.stco.empty() || out.stsc.empty() || out.stsz.empty() || out.stsd.empty()) { - CH_LOG("error", "parse_mp4: missing stbl atoms stco/stsc/stsz/stsd"); + if ((out.stco.empty() && out.co64.empty()) || out.stsc.empty() || out.stsz.empty() || + out.stsd.empty()) { + CH_LOG("error", "parse_mp4: missing stbl atoms stco/co64/stsc/stsz/stsd"); } const auto t_done = std::chrono::steady_clock::now(); const auto ms_struct = @@ -720,7 +777,8 @@ std::optional parse_mp4(const std::string &path) { const auto ms_total = std::chrono::duration_cast(t_done - t_start).count(); - CH_LOG("debug", "parse_mp4 done stco=" << out.stco.size() << " stsc=" << out.stsc.size() + CH_LOG("debug", "parse_mp4 done stco=" << out.stco.size() << " co64=" << out.co64.size() + << " stsc=" << out.stsc.size() << " stsz=" << out.stsz.size() << " stsd=" << out.stsd.size() << " ilst=" << out.ilst_payload.size() diff --git a/src/stbl_audio_builder.cpp b/src/stbl_audio_builder.cpp index c080072..95d2f25 100644 --- a/src/stbl_audio_builder.cpp +++ b/src/stbl_audio_builder.cpp @@ -123,7 +123,8 @@ std::unique_ptr build_audio_stbl_raw(const std::vector &stsd_payl const std::vector &stts_payload, const std::vector &stsc_payload, const std::vector &stsz_payload, - const std::vector &stco_payload) { + const std::vector &stco_payload, + const std::vector &co64_payload) { auto stbl = Atom::create("stbl"); auto stsd = Atom::create("stsd"); @@ -142,9 +143,15 @@ std::unique_ptr build_audio_stbl_raw(const std::vector &stsd_payl stsz->payload = stsz_payload; stbl->add(std::move(stsz)); - auto stco = Atom::create("stco"); - stco->payload = stco_payload; - stbl->add(std::move(stco)); + if (!co64_payload.empty()) { + auto co64 = Atom::create("co64"); + co64->payload = co64_payload; + stbl->add(std::move(co64)); + } else { + auto stco = Atom::create("stco"); + stco->payload = stco_payload; + stbl->add(std::move(stco)); + } return stbl; } diff --git a/tests/aac_extractor_unit.cpp b/tests/aac_extractor_unit.cpp new file mode 100644 index 0000000..fabc166 --- /dev/null +++ b/tests/aac_extractor_unit.cpp @@ -0,0 +1,107 @@ +#include +#include +#include +#include + +#include "aac_extractor.hpp" +#include "parser_test_utils.hpp" + +using namespace parser_test_utils; + +namespace { + +std::vector make_alac_stsd() { + std::vector entry; + write_u32_be(entry, 16); + write_u32_be(entry, 'alac'); + write_u32_be(entry, 0); + write_u32_be(entry, 0); + + std::vector payload(4, 0); // version/flags + write_u32_be(payload, 1); // entry_count + payload.insert(payload.end(), entry.begin(), entry.end()); + return payload; +} + +std::vector make_stsc(uint32_t samples_per_chunk) { + std::vector payload(4, 0); // version/flags + write_u32_be(payload, 1); // entry_count + write_u32_be(payload, 1); // first_chunk + write_u32_be(payload, samples_per_chunk); + write_u32_be(payload, 1); // sample_description_index + return payload; +} + +std::vector make_stsz(const std::vector &sizes) { + std::vector payload(4, 0); // version/flags + write_u32_be(payload, 0); // variable sample sizes + write_u32_be(payload, static_cast(sizes.size())); + for (const uint32_t size : sizes) { + write_u32_be(payload, size); + } + return payload; +} + +std::vector make_stco(uint32_t offset) { + std::vector payload(4, 0); // version/flags + write_u32_be(payload, 1); // entry_count + write_u32_be(payload, offset); + return payload; +} + +std::vector make_co64(uint64_t offset) { + std::vector payload(4, 0); // version/flags + write_u32_be(payload, 1); // entry_count + write_u64_be(payload, offset); + return payload; +} + +std::vector make_file(uint64_t chunk_offset, bool use_co64) { + std::vector stbl; + append_atom(stbl, 'stsd', make_alac_stsd()); + append_atom(stbl, 'stts', make_stts(2, 4096)); + append_atom(stbl, 'stsc', make_stsc(2)); + append_atom(stbl, 'stsz', make_stsz({3, 4, 8})); + if (use_co64) { + append_atom(stbl, 'co64', make_co64(chunk_offset)); + } else { + append_atom(stbl, 'stco', make_stco(static_cast(chunk_offset))); + } + + const auto mdia = make_mdia(44100, 8192, stbl, 'soun'); + std::vector file; + append_atom(file, 'moov', make_moov(make_trak(mdia))); + const std::vector media = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + append_atom(file, 'mdat', media); + return file; +} + +} // namespace + +int main() { + for (const bool use_co64 : {false, true}) { + auto provisional = make_file(0, use_co64); + const uint64_t mdat_payload_offset = provisional.size() - 15; + const auto fixture = write_temp_file(make_file(mdat_payload_offset, use_co64), + use_co64 ? "alac_extractor_co64_unit.m4a" + : "alac_extractor_stco_unit.m4a"); + + const auto extracted = extract_from_mp4(fixture.string()); + std::filesystem::remove(fixture); + if (!extracted) { + std::cerr << "[aac_extractor_unit] extraction failed\n"; + return 1; + } + if (extracted->frames.size() != 2 || extracted->sizes != std::vector({3, 4}) || + extracted->stsz_payload.size() != 20) { + std::cerr << "[aac_extractor_unit] unaddressed stsz entry was not normalized\n"; + return 2; + } + if (extracted->frames[0] != std::vector({1, 2, 3}) || + extracted->frames[1] != std::vector({4, 5, 6, 7})) { + std::cerr << "[aac_extractor_unit] addressable sample data mismatch\n"; + return 3; + } + } + return 0; +} diff --git a/tests/parser_fallback.cpp b/tests/parser_fallback.cpp index 9a14634..f7bd102 100644 --- a/tests/parser_fallback.cpp +++ b/tests/parser_fallback.cpp @@ -23,8 +23,8 @@ int main() { std::cerr << "parse_mp4 returned nullopt for " << path << "\n"; return 1; } - if (parsed->stco.empty() || parsed->stsc.empty() || parsed->stsz.empty() || - parsed->stsd.empty()) { + if ((parsed->stco.empty() && parsed->co64.empty()) || parsed->stsc.empty() || + parsed->stsz.empty() || parsed->stsd.empty()) { std::cerr << "stbl atoms missing for " << path << "\n"; return 2; } diff --git a/tests/parser_safety.cpp b/tests/parser_safety.cpp index 7071de8..4961b83 100644 --- a/tests/parser_safety.cpp +++ b/tests/parser_safety.cpp @@ -1,10 +1,13 @@ #include "parser.hpp" +#include "parser_test_utils.hpp" #include #include #include #include +using namespace parser_test_utils; + namespace { void write_u32(std::ofstream &out, uint32_t v) { @@ -13,20 +16,79 @@ void write_u32(std::ofstream &out, uint32_t v) { out.write(reinterpret_cast(b), 4); } -// Build a file whose first atom claims an absurd payload (> safety bound). The parser should -// refuse that size and still return without crashing. +void write_u64(std::ofstream &out, uint64_t v) { + write_u32(out, static_cast(v >> 32)); + write_u32(out, static_cast(v & 0xFFFFFFFF)); +} +// Build a truncated file whose first atom claims a payload beyond the end of the file. The parser +// should reject it and still return without crashing. bool make_huge_atom_file(const std::filesystem::path &p) { std::ofstream out(p, std::ios::binary | std::ios::trunc); if (!out.is_open()) { return false; } - const uint32_t huge_size = 0x20000010; // payload >512MB; triggers size guard + const uint32_t huge_size = 0x20000010; write_u32(out, huge_size); out.write("mdat", 4); - // No payload is written; size guard in parser will zero the atom and bail. + // No payload is written; the top-level file bound must reject the atom. return true; } +// Build a valid sparse file with an mdat payload larger than 512 MB followed by a small moov. +// ChapterForge only seeks across mdat, so large lossless media must not trip the buffered-atom +// safety limit. +bool make_large_mdat_file(const std::filesystem::path &p) { + std::ofstream out(p, std::ios::binary | std::ios::trunc); + if (!out.is_open()) { + return false; + } + + constexpr uint64_t payload_size = 512ull * 1024 * 1024 + 4096; + write_u32(out, 1); // extended-size atom + out.write("mdat", 4); + write_u64(out, payload_size + 16); + out.seekp(static_cast(payload_size - 1), std::ios::cur); + out.put(0); + + std::vector stbl; + append_atom(stbl, 'stsd', make_stsd()); + append_atom(stbl, 'stts', make_stts(1, 4096)); + append_atom(stbl, 'stsc', make_stsc_empty()); + append_atom(stbl, 'stsz', make_stsz(1, 1)); + append_atom(stbl, 'stco', make_stco_empty()); + const auto mdia = make_mdia(44100, 4096, stbl, 'soun'); + auto moov_payload = make_moov(make_trak(mdia)); + std::vector meta(4, 0); // version/flags + append_atom(meta, 'ilst', std::vector{0}); + std::vector udta; + append_atom(udta, 'meta', meta); + append_atom(moov_payload, 'udta', udta); + std::vector moov; + append_atom(moov, 'moov', moov_payload); + out.write(reinterpret_cast(moov.data()), + static_cast(moov.size())); + return static_cast(out); +} + +bool make_zero_sized_moov_file(const std::filesystem::path &p) { + std::ofstream out(p, std::ios::binary | std::ios::trunc); + if (!out.is_open()) { + return false; + } + std::vector stbl; + append_atom(stbl, 'stsd', make_stsd()); + append_atom(stbl, 'stts', make_stts(1, 1024)); + append_atom(stbl, 'stsc', make_stsc_empty()); + append_atom(stbl, 'stsz', make_stsz(1, 1)); + append_atom(stbl, 'stco', make_stco_empty()); + const auto payload = make_moov(make_trak(make_mdia(44100, 1024, stbl, 'soun'))); + write_u32(out, 0); // extends to end of file + out.write("moov", 4); + out.write(reinterpret_cast(payload.data()), + static_cast(payload.size())); + return static_cast(out); +} + // Build a moov/trak hierarchy where the child overflows its parent. Parser should clamp and exit. bool make_overflow_child_file(const std::filesystem::path &p) { std::ofstream out(p, std::ios::binary | std::ios::trunc); @@ -50,6 +112,7 @@ int run_case(const std::filesystem::path &p, bool (*builder)(const std::filesyst return 2; } auto parsed = parse_mp4(p.string()); + std::filesystem::remove(p); if (!parsed) { std::cerr << "[parser_safety] parse_mp4 returned nullopt for " << p << "\n"; return 3; @@ -64,11 +127,37 @@ int main() { const auto tmp = std::filesystem::temp_directory_path(); const auto huge_atom = tmp / "parser_huge_atom.mp4"; const auto overflow = tmp / "parser_overflow_child.mp4"; + const auto large_mdat = tmp / "parser_large_mdat.mp4"; + const auto zero_sized_moov = tmp / "parser_zero_sized_moov.mp4"; int rc = run_case(huge_atom, make_huge_atom_file); if (rc != 0) { return rc; } rc = run_case(overflow, make_overflow_child_file); - return rc; + if (rc != 0) { + return rc; + } + + if (!make_large_mdat_file(large_mdat)) { + std::cerr << "[parser_safety] failed to build large-mdat fixture\n"; + return 4; + } + auto parsed = parse_mp4(large_mdat.string()); + std::filesystem::remove(large_mdat); + if (!parsed || parsed->audio_timescale != 44100 || parsed->stsd.empty()) { + std::cerr << "[parser_safety] failed to parse moov after large mdat\n"; + return 5; + } + if (!make_zero_sized_moov_file(zero_sized_moov)) { + std::cerr << "[parser_safety] failed to build zero-sized moov fixture\n"; + return 6; + } + parsed = parse_mp4(zero_sized_moov.string()); + std::filesystem::remove(zero_sized_moov); + if (!parsed || parsed->audio_timescale != 44100 || parsed->stsd.empty()) { + std::cerr << "[parser_safety] failed to parse zero-sized moov\n"; + return 7; + } + return 0; } diff --git a/tests/writer_unit.cpp b/tests/writer_unit.cpp index 02a4466..a1af5e4 100644 --- a/tests/writer_unit.cpp +++ b/tests/writer_unit.cpp @@ -1,6 +1,7 @@ // Unit coverage for writer/builder helpers in mp4_muxer.cpp. #include #include +#include #include #include #include @@ -8,6 +9,7 @@ #include "aac_extractor.hpp" #include "chapter_image_sample.hpp" #include "chapter_text_sample.hpp" +#include "mdat_writer.hpp" #include "mp4a_builder.hpp" #include "mp4_muxer.hpp" @@ -26,7 +28,6 @@ bool check(bool cond, const std::string &msg) { } return cond; } - std::vector make_stsc_payload(const std::vector> &entries) { // version+flags (4 bytes) + entry_count (4 bytes) + N entries of 12 bytes. std::vector out(8, 0); @@ -128,6 +129,42 @@ bool test_compute_durations() { "text durations derived from starts"); ok &= check(info.image_ms == std::vector({10240}), "image duration fills remainder"); + + // MP4 packet timing is codec-specific. ALAC commonly uses 4096 samples per packet, so stts + // must take precedence over the AAC-LC fallback of 1024. + aac.stts_payload = {0, 0, 0, 0, 0, 0, 0, 1, + 0, 0, 0, 10, 0, 0, 16, 0}; + info = compute_durations_for_test(aac, cfg, {t0, t1}, {img0}); + ok &= check(info.audio_duration_ts == 40960, "stts duration used for ALAC packets"); + ok &= check(info.audio_duration_ms == 40960, "ALAC stts duration converted to ms"); + return ok; +} + +bool test_64_bit_chunk_offsets() { + auto moov = Atom::create("moov"); + auto stco = Atom::create("stco"); + stco->payload = {0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}; + moov->add(std::move(stco)); + + promote_stco_to_co64(moov.get()); + auto co64 = moov->find("co64"); + bool ok = check(co64.size() == 1 && co64[0]->payload.size() == 16, + "stco promoted to co64"); + + MdatOffsets offsets; + offsets.payload_start = static_cast(std::numeric_limits::max()) + 100; + offsets.audio_offsets = {5}; + patch_all_stco(moov.get(), offsets); + const auto &p = co64[0]->payload; + uint64_t patched = 0; + for (size_t i = 8; i < 16; ++i) { + patched = (patched << 8) | p[i]; + } + ok &= check(patched == offsets.payload_start + 5, "64-bit chunk offset patched"); + ok &= check(mdat_header_size(std::numeric_limits::max() - 8) == 8, + "32-bit mdat boundary uses compact header"); + ok &= check(mdat_header_size(std::numeric_limits::max() - 7) == 16, + "large mdat uses extended header"); return ok; } @@ -140,5 +177,6 @@ int main() { ok &= test_encode_tx3g(); ok &= test_encode_track_counts(); ok &= test_compute_durations(); + ok &= test_64_bit_chunk_offsets(); return ok ? 0 : 1; }