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 @@
[](https://tillt.github.io/ChapterForge/)
[](#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