diff --git a/CMakeLists.txt b/CMakeLists.txt index 6195967..3df8d30 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -334,6 +334,14 @@ target_compile_definitions(parser_unit PRIVATE CHAPTERFORGE_TESTING) add_test(NAME parser_unit COMMAND parser_unit) set_tests_properties(parser_unit PROPERTIES LABELS "unit") +add_executable(read_unit + tests/read_api_unit.cpp +) +target_link_libraries(read_unit PRIVATE chapterforge) +target_compile_definitions(read_unit PRIVATE CHAPTERFORGE_TESTING TESTDATA_DIR=\"${TESTDATA_DIR}\") +add_test(NAME read_unit COMMAND read_unit) +set_tests_properties(read_unit PROPERTIES LABELS "unit") + # macOS Framework packaging (uses the existing static lib). if(APPLE AND ENABLE_MACOS_FRAMEWORK) # Stage a static framework bundle that wraps the built static library. diff --git a/README.md b/README.md index 8be0a45..70809ab 100644 --- a/README.md +++ b/README.md @@ -155,14 +155,22 @@ The overlay tracks the current commit. Update `REF`/`SHA512` in `ports/chapterfo ```bash ./chapterforge_cli +./chapterforge_cli [--export-jpegs DIR] # read/extract ./chapterforge_cli --version ``` -- If the input already has metadata (`ilst`), it is reused by default. -- Fast-start is on by default. +- Write mode: mux chapters/images/URLs into an output M4A. If the input already has metadata (`ilst`), + it is reused by default. Fast-start is off by default; enable with `--faststart`. +- Read mode: extract metadata, chapter titles/URLs/URL-texts, and images from an M4A. The JSON emitted + matches the writer input format and is always printed to stdout. Use `--export-jpegs DIR` to dump cover + + chapter images alongside the JSON and reference them in the output. - Logging: defaults to version + warnings/errors. Set verbosity when embedding via `chapterforge::set_log_verbosity(LogVerbosity::Warn|Info|Debug)` or pass `--log-level warn|info|debug` to the CLI. Debug-only logs stay hidden unless you raise the level. +- Options: + - `--faststart` (write) Place `moov` before `mdat` for faster playback start. + - `--log-level LEVEL` One of `warn|info|debug`. + - `--export-jpegs DIR` (read) Export cover/chapter JPEGs to `DIR` and reference them in the JSON. ## Chapters JSON format @@ -350,56 +358,58 @@ struct ChapterImageSample { uint32_t start_ms = 0; // absolute start time in milliseconds }; -// JSON helper (titles/images/urls/metadata pulled from JSON) -bool mux_file_to_m4a(const std::string& input_audio_path, - const std::string& chapter_json_path, - const std::string& output_path, - bool fast_start = true); - -// Titles + images, metadata provided -bool mux_file_to_m4a(const std::string& input_audio_path, - const std::vector& text_chapters, - const std::vector& image_chapters, - const MetadataSet& metadata, - const std::string& output_path, - bool fast_start = true); - -// Titles + images, metadata reused from source (or empty) -bool mux_file_to_m4a(const std::string& input_audio_path, - const std::vector& text_chapters, - const std::vector& image_chapters, - const std::string& output_path, - bool fast_start = true); - -// Titles only, metadata provided (no image track) -bool mux_file_to_m4a(const std::string& input_audio_path, - const std::vector& text_chapters, - const MetadataSet& metadata, - const std::string& output_path, - bool fast_start = true); - -// Titles only, metadata reused from source (no image track) -bool mux_file_to_m4a(const std::string& input_audio_path, - const std::vector& text_chapters, - const std::string& output_path, - bool fast_start = true); - -// Titles + URLs + images, metadata provided (URL track optional) -bool mux_file_to_m4a(const std::string& input_audio_path, - const std::vector& text_chapters, - const std::vector& url_chapters, - const std::vector& image_chapters, - const MetadataSet& metadata, - const std::string& output_path, - bool fast_start = true); - -// Titles + URLs + images, metadata reused from source (URL track optional) -bool mux_file_to_m4a(const std::string& input_audio_path, - const std::vector& text_chapters, - const std::vector& url_chapters, - const std::vector& image_chapters, - const std::string& output_path, - bool fast_start = true); +struct Status { bool ok; std::string message; }; // status + message on failure + +// JSON-driven (reuses ilst unless JSON metadata overrides it) +Status mux_file_to_m4a(const std::string& input_audio_path, + const std::string& chapter_json_path, + const std::string& output_path, + bool fast_start = true); + +// Titles + images (metadata provided) +Status mux_file_to_m4a(const std::string& input_audio_path, + const std::vector& text_chapters, + const std::vector& image_chapters, + const MetadataSet& metadata, + const std::string& output_path, + bool fast_start = true); + +// Titles + images (metadata reused from source) +Status mux_file_to_m4a(const std::string& input_audio_path, + const std::vector& text_chapters, + const std::vector& image_chapters, + const std::string& output_path, + bool fast_start = true); + +// Titles only (metadata provided) +Status mux_file_to_m4a(const std::string& input_audio_path, + const std::vector& text_chapters, + const MetadataSet& metadata, + const std::string& output_path, + bool fast_start = true); + +// Titles only (metadata reused from source) +Status mux_file_to_m4a(const std::string& input_audio_path, + const std::vector& text_chapters, + const std::string& output_path, + bool fast_start = true); + +// Titles + URLs + images (metadata provided) +Status mux_file_to_m4a(const std::string& input_audio_path, + const std::vector& text_chapters, + const std::vector& url_chapters, + const std::vector& image_chapters, + const MetadataSet& metadata, + const std::string& output_path, + bool fast_start = true); + +// Titles + URLs + images (metadata reused from source) +Status mux_file_to_m4a(const std::string& input_audio_path, + const std::vector& text_chapters, + const std::vector& url_chapters, + const std::vector& image_chapters, + const std::string& output_path, + bool fast_start = true); ``` If `metadata` is empty and the source has an `ilst`, it is reused automatically. @@ -421,8 +431,9 @@ int main(int argc, char** argv) { std::string chapters= argv[2]; std::string output = argv[3]; - if (!chapterforge::mux_file_to_m4a(input, chapters, output)) { - std::cerr << "chapterforge: failed to write output\n"; + auto status = chapterforge::mux_file_to_m4a(input, chapters, output); + if (!status.ok) { + std::cerr << "chapterforge: failed to write output: " << status.message << "\n"; return 1; } std::cout << "Wrote: " << output << "\n"; @@ -432,6 +443,20 @@ int main(int argc, char** argv) { Use the higher-level overload if you already have chapters/material in memory and don’t want to read JSON on disk. +Reading (extract chapters/metadata/images) mirrors the CLI read mode: + +```c++ +auto res = chapterforge::read_m4a("input.m4a"); +if (!res.status.ok) { + std::cerr << "read failed: " << res.status.message << "\n"; +} else { + std::cout << "title: " << res.metadata.title << "\n"; + for (const auto& c : res.titles) { + std::cout << c.start_ms << " ms -> " << c.text << " href=" << c.href << "\n"; + } +} +``` + ## Tests & Dependencies diff --git a/docs/diagrams/chapter_tracks.png b/docs/diagrams/chapter_tracks.png deleted file mode 100644 index 0104d10..0000000 Binary files a/docs/diagrams/chapter_tracks.png and /dev/null differ diff --git a/docs/example/advanced_api_use_in_objectivecpp.mm b/docs/example/advanced_api_use_in_objectivecpp.mm new file mode 100644 index 0000000..3980de0 --- /dev/null +++ b/docs/example/advanced_api_use_in_objectivecpp.mm @@ -0,0 +1,40 @@ +#import "chapterforge.hpp" // public header +#import + +static void BuildChaptersFromDict(NSArray *chapterArray, + std::vector &textChapters) { + textChapters.clear(); + textChapters.reserve([chapterArray count]); + for (NSDictionary *entry in chapterArray) { + NSString *title = entry[@"title"]; + NSNumber *startMs = entry[@"time"]; + if (!title || !startMs) continue; + ChapterTextSample s{}; + s.text = [title UTF8String]; + s.start_ms = [startMs unsignedIntValue]; + textChapters.push_back(std::move(s)); + } +} + +void ExampleMuxFromObjectiveC(NSArray *chaptersDict, + const AacExtractResult &aac, + const std::string &outputPath) { + std::vector textChapters; + std::vector imageChapters; // empty if no images + BuildChaptersFromDict(chaptersDict, textChapters); + + MetadataSet meta{}; // leave empty to reuse source ilst + Mp4aConfig cfg{}; + cfg.sample_rate = aac.sample_rate; + cfg.channel_count = aac.channel_config; + cfg.sampling_index = aac.sampling_index; + cfg.audio_object_type = aac.audio_object_type; + + bool ok = write_mp4(outputPath, aac, textChapters, imageChapters, cfg, meta, + /*fast_start=*/true, nullptr); + if (!ok) { + NSLog(@"Failed to write output: %@", [NSString stringWithUTF8String:outputPath.c_str()]); + } else { + NSLog(@"Wrote %@", [NSString stringWithUTF8String:outputPath.c_str()]); + } +} diff --git a/include/chapterforge.hpp b/include/chapterforge.hpp index be39773..030240b 100644 --- a/include/chapterforge.hpp +++ b/include/chapterforge.hpp @@ -23,57 +23,47 @@ namespace chapterforge { /// @{ /** - * @brief Mux AAC input + chapter/text/image metadata into an M4A file. + * @brief Result object with success flag and optional error message. * - * @param input_audio_path Path to AAC (ADTS) or MP4/M4A containing AAC. - * @param chapter_json_path Path to chapter JSON (titles, timestamps, optional images, optional urls). - * If the input has an ilst, it is reused unless the JSON supplies metadata overrides. - * @param output_path Destination .m4a file. - * @param fast_start When true, places moov ahead of mdat (fast-start/streamable). - * @return true on success, false on failure. + * When `ok == true`, `message` is empty. On failure, `message` contains a short description of + * what went wrong (e.g., failure to parse input, open files, or validate images). */ -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::string &chapter_json_path, - const std::string &output_path, - bool fast_start = true); ///< @ingroup api +struct Status { + bool ok{false}; + std::string message; +}; /** - * @brief Mux AAC input + in-memory chapter data (titles + images). + * @brief Return the ChapterForge library version string. * - * @param input_audio_path Path to AAC (ADTS) or MP4/M4A containing AAC. - * @param text_chapters Chapter title samples (text UTF-8, start_ms absolute). - * @param image_chapters Optional JPEG data per chapter; leave empty to omit the image track. - * @param metadata Top-level metadata; if empty and the input has ilst, it is reused. - * @param output_path Destination .m4a file. - * @param fast_start When true, places moov ahead of mdat. - * @return true on success, false on failure. + * Follows the same formatting as the CLI banner (e.g. `v0.12` or `v0.12+abcd123`). */ -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &image_chapters, - const MetadataSet &metadata, const std::string &output_path, - bool fast_start = true); ///< @ingroup api - -/// @overload -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const MetadataSet &metadata, const std::string &output_path, - bool fast_start = true); ///< @ingroup api +std::string version_string(); ///< @ingroup api -/** - * @brief Same as the primary overload but without providing metadata (reuses source ilst if any). - * - * @param input_audio_path Path to AAC (ADTS) or MP4/M4A containing AAC. - * @param text_chapters Chapter title samples (text UTF-8, start_ms absolute). - * @param image_chapters Optional JPEG data per chapter; leave empty to omit the image track. - * @param output_path Destination .m4a file. - * @param fast_start When true, places moov ahead of mdat. - * @return true on success, false on failure. - */ -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &image_chapters, - const std::string &output_path, bool fast_start = true); ///< @ingroup api +/// Mux AAC input + chapter/text/image metadata into an M4A file (JSON driven). +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::string &chapter_json_path, const std::string &output_path, + bool fast_start = true); ///< @ingroup api + +/// Mux AAC input + in-memory chapter data (titles + images + metadata). +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &image_chapters, + const MetadataSet &metadata, const std::string &output_path, + bool fast_start = true); ///< @ingroup api + +/// @overload status-returning without images. +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const MetadataSet &metadata, const std::string &output_path, + bool fast_start = true); ///< @ingroup api + +/// @overload status-returning without metadata (reuses source ilst if any). +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &image_chapters, + const std::string &output_path, + bool fast_start = true); ///< @ingroup api /** * @brief Mux AAC input + in-memory chapter data (with optional URL track). @@ -85,14 +75,13 @@ bool mux_file_to_m4a(const std::string &input_audio_path, * @param image_chapters Optional JPEG data per chapter; leave empty to omit the image track. * @param output_path Destination .m4a file. * @param fast_start When true, places moov ahead of mdat. - * @return true on success, false on failure. */ -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &url_chapters, - const std::vector &image_chapters, - const std::string &output_path, - bool fast_start = true); ///< @ingroup api +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &url_chapters, + const std::vector &image_chapters, + const std::string &output_path, + bool fast_start = true); ///< @ingroup api /** * @brief Variant with explicit metadata; otherwise identical to the overload above. @@ -104,14 +93,32 @@ bool mux_file_to_m4a(const std::string &input_audio_path, * @param metadata Top-level metadata; reused from input ilst if empty. * @param output_path Destination .m4a file. * @param fast_start When true, places moov ahead of mdat. - * @return true on success, false on failure. */ -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &url_chapters, - const std::vector &image_chapters, - const MetadataSet &metadata, const std::string &output_path, - bool fast_start = true); ///< @ingroup api +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &url_chapters, + const std::vector &image_chapters, + const MetadataSet &metadata, const std::string &output_path, + bool fast_start = true); ///< @ingroup api + +/** + * @brief Parse an existing M4A/MP4 file and return its chapter data. + * + * Extracts chapter titles, optional URL samples (tx3g + href), chapter images (MJPEG samples), + * and top-level metadata (ilst if present). Does not decode audio. + * + * On success `status.ok == true` and the vectors are filled; on failure `status.ok == false` and + * `status.message` contains a short description. + */ +struct ReadResult { + Status status; + std::vector titles; + std::vector urls; + std::vector images; + MetadataSet metadata; +}; + +ReadResult read_m4a(const std::string &path); ///< @ingroup api /// @} diff --git a/include/parser.hpp b/include/parser.hpp index b9ced5e..ed64b67 100644 --- a/include/parser.hpp +++ b/include/parser.hpp @@ -21,10 +21,30 @@ struct Mp4AtomInfo { uint64_t offset; // offset in file. }; +namespace parser_detail { +struct TrackParseResult { + uint32_t track_id = 0; + uint32_t tkhd_flags = 0; + uint32_t handler_type = 0; + std::string handler_name; + uint32_t timescale = 0; + uint64_t duration = 0; + uint32_t sample_count = 0; + std::vector stsd; + std::vector stts; + std::vector stsc; + std::vector stsz; + std::vector stco; +}; +} // 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. + // All parsed tracks (audio/text/video). + std::vector tracks; + // ilst metadata atom payload (optional). std::vector ilst_payload; // raw meta payload (version/flags/reserved + children) to allow verbatim reuse. @@ -51,20 +71,6 @@ uint64_t read_u64(std::istream &in); // Main parsing entry point. std::optional parse_mp4(const std::string &path); -namespace parser_detail { -struct TrackParseResult { - uint32_t handler_type = 0; - uint32_t timescale = 0; - uint64_t duration = 0; - uint32_t sample_count = 0; - std::vector stsd; - std::vector stts; - std::vector stsc; - std::vector stsz; - std::vector stco; -}; -} // namespace parser_detail - #ifdef CHAPTERFORGE_TESTING // Test-only wrappers that allow unit tests to exercise lower-level parsing. std::optional parse_trak_for_test(std::istream &in, diff --git a/src/chapterforge.cpp b/src/chapterforge.cpp index 859c756..c77efbe 100644 --- a/src/chapterforge.cpp +++ b/src/chapterforge.cpp @@ -8,13 +8,17 @@ #include "chapterforge.hpp" #include "chapterforge_version.hpp" +#include #include #include #include #include #include +#include #include #include +#include +#include #include "aac_extractor.hpp" #include "logging.hpp" @@ -27,6 +31,12 @@ using json = nlohmann::json; +namespace chapterforge { + +std::string version_string() { return CHAPTERFORGE_VERSION_DISPLAY; } + +} // namespace chapterforge + namespace { static bool read_file(const std::string &path, std::vector &out) { @@ -70,6 +80,95 @@ static std::optional load_audio(const std::string &path) { return res; } +inline uint32_t be32(const uint8_t *p) { + return (static_cast(p[0]) << 24) | (static_cast(p[1]) << 16) | + (static_cast(p[2]) << 8) | static_cast(p[3]); +} + +constexpr uint32_t fourcc(uint8_t a, uint8_t b, uint8_t c, uint8_t d) { + return (static_cast(a) << 24) | (static_cast(b) << 16) | + (static_cast(c) << 8) | static_cast(d); +} + +// Minimal ilst parser to surface top-level metadata into MetadataSet. +static void parse_ilst_metadata(const std::vector &ilst, MetadataSet &out) { + auto extract_data_box = [](const uint8_t *base, size_t len, + std::vector &payload_out, uint32_t &data_type_out) -> bool { + size_t cursor = 0; + while (cursor + 8 <= len) { + const uint8_t *ptr = base + cursor; + uint32_t sz = be32(ptr); + if (sz < 8 || cursor + sz > len) { + break; + } + uint32_t typ = be32(ptr + 4); + if (typ == fourcc('d', 'a', 't', 'a') && sz >= 16) { + data_type_out = be32(ptr + 8); + const uint8_t *payload = ptr + 16; + size_t payload_len = sz - 16; + payload_out.assign(payload, payload + payload_len); + return true; + } + cursor += sz; + } + return false; + }; + + size_t offset = 0; + while (offset + 8 <= ilst.size()) { + const uint8_t *ptr = ilst.data() + offset; + uint32_t sz = be32(ptr); + if (sz < 8 || offset + sz > ilst.size()) { + break; + } + uint32_t typ = be32(ptr + 4); + const uint8_t *item = ptr + 8; + size_t item_len = sz - 8; + + std::vector payload; + uint32_t data_type = 0; + if (extract_data_box(item, item_len, payload, data_type)) { + auto set_string = [&](std::string &target) { + target.assign(reinterpret_cast(payload.data()), payload.size()); + }; + switch (typ) { + case fourcc(0xa9, 'n', 'a', 'm'): // ©nam + CH_LOG("debug", "ilst title bytes=" << payload.size()); + set_string(out.title); + break; + case fourcc(0xa9, 'A', 'R', 'T'): // ©ART + CH_LOG("debug", "ilst artist bytes=" << payload.size()); + set_string(out.artist); + break; + case fourcc(0xa9, 'a', 'l', 'b'): // ©alb + CH_LOG("debug", "ilst album bytes=" << payload.size()); + set_string(out.album); + break; + case fourcc(0xa9, 'g', 'e', 'n'): // ©gen + CH_LOG("debug", "ilst genre bytes=" << payload.size()); + set_string(out.genre); + break; + case fourcc(0xa9, 'd', 'a', 'y'): // ©day + CH_LOG("debug", "ilst year bytes=" << payload.size()); + set_string(out.year); + break; + case fourcc(0xa9, 'c', 'm', 't'): // ©cmt + CH_LOG("debug", "ilst comment bytes=" << payload.size()); + set_string(out.comment); + break; + case fourcc('c', 'o', 'v', 'r'): { // cover art + CH_LOG("debug", "ilst cover bytes=" << payload.size()); + out.cover = std::move(payload); + break; + } + default: + break; + } + } + offset += sz; + } +} + struct PendingChapter { std::string title; uint32_t start_ms = 0; @@ -162,145 +261,17 @@ static bool metadata_is_empty(const MetadataSet &m) { namespace chapterforge { -bool mux_file_to_m4a(const std::string &input_audio_path, const std::string &chapter_json_path, - const std::string &output_path, bool fast_start) { - CH_LOG("info", "ChapterForge version " << CHAPTERFORGE_VERSION_DISPLAY); - CH_LOG("debug", "mux_file_to_m4a(json) input=" << input_audio_path - << " chapters=" << chapter_json_path - << " output=" << output_path - << " fast_start=" << fast_start); - auto aac = load_audio(input_audio_path); - if (!aac) { - CH_LOG("error", "Failed to load audio from " << input_audio_path); - return false; - } - - std::vector text_chapters; - std::vector image_chapters; - std::vector>> extra_text_tracks; - MetadataSet meta; - if (!load_chapters_json(chapter_json_path, text_chapters, image_chapters, meta, - extra_text_tracks)) { - CH_LOG("error", "Failed to load chapters JSON: " << chapter_json_path); - return false; - } - CH_LOG("debug", "chapters: titles=" << text_chapters.size() - << " urls=" << extra_text_tracks.size() - << " images=" << image_chapters.size()); - - Mp4aConfig cfg{}; - const std::vector *ilst_ptr = nullptr; - const std::vector *meta_ptr = nullptr; - if (!aac->meta_payload.empty()) { - meta_ptr = &aac->meta_payload; - CH_LOG("debug", "Reusing source meta payload (" << meta_ptr->size() << " bytes)"); - } - if (!aac->ilst_payload.empty()) { - ilst_ptr = &aac->ilst_payload; - CH_LOG("debug", "Reusing source ilst metadata (" << ilst_ptr->size() << " bytes)"); - } else if (metadata_is_empty(meta)) { - CH_LOG("warn", "source metadata missing and no metadata provided; output will carry empty ilst"); - } else { - CH_LOG("debug", "Using metadata provided by JSON overrides"); - } - return write_mp4(output_path, *aac, text_chapters, image_chapters, cfg, meta, fast_start, - extra_text_tracks, ilst_ptr, meta_ptr); -} - -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &image_chapters, - const MetadataSet &metadata, const std::string &output_path, - bool fast_start) { - const auto t0 = std::chrono::steady_clock::now(); - CH_LOG("info", "ChapterForge version " << CHAPTERFORGE_VERSION_DISPLAY); - CH_LOG("debug", "mux_file_to_m4a(titles+images) input=" << input_audio_path - << " output=" << output_path - << " fast_start=" << fast_start - << " titles=" << text_chapters.size() - << " images=" << image_chapters.size()); - auto aac = load_audio(input_audio_path); - if (!aac) { - CH_LOG("error", "Failed to load audio from " << input_audio_path); - return false; - } - const auto t_load = std::chrono::steady_clock::now(); - Mp4aConfig cfg{}; - const std::vector *ilst_ptr = nullptr; - const std::vector *meta_ptr = nullptr; - if (!aac->meta_payload.empty()) { - meta_ptr = &aac->meta_payload; - CH_LOG("debug", "Reusing source meta payload (" << meta_ptr->size() << " bytes)"); - } - if (!aac->ilst_payload.empty()) { - ilst_ptr = &aac->ilst_payload; - CH_LOG("debug", "Reusing source ilst metadata (" << ilst_ptr->size() << " bytes)"); - } else if (metadata_is_empty(metadata)) { - CH_LOG("warn", "source metadata missing and no metadata provided; output will carry empty ilst"); - } else { - CH_LOG("debug", "Using metadata provided by caller"); - } - std::vector>> extra_text_tracks; - bool ok = write_mp4(output_path, *aac, text_chapters, image_chapters, cfg, metadata, fast_start, - extra_text_tracks, ilst_ptr, meta_ptr); - const auto t1 = std::chrono::steady_clock::now(); - const auto load_ms = - std::chrono::duration_cast(t_load - t0).count(); - const auto mux_ms = - std::chrono::duration_cast(t1 - t_load).count(); - const auto total_ms = - std::chrono::duration_cast(t1 - t0).count(); - CH_LOG("debug", "mux_file_to_m4a(titles+images) timings ms: load=" << load_ms - << " mux=" << mux_ms - << " total=" << total_ms); - return ok; -} - -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const MetadataSet &metadata, - const std::string &output_path, - bool fast_start) { - std::vector empty_images; - return mux_file_to_m4a(input_audio_path, text_chapters, empty_images, metadata, output_path, - fast_start); -} - -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &image_chapters, - const std::string &output_path, bool fast_start) { - MetadataSet empty{}; - return mux_file_to_m4a(input_audio_path, text_chapters, image_chapters, empty, output_path, - fast_start); -} - -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &url_chapters, - const std::vector &image_chapters, - const std::string &output_path, bool fast_start) { - const MetadataSet metadata{}; - CH_LOG("debug", "mux_file_to_m4a(titles+urls+images,no meta) input=" << input_audio_path - << " output=" << output_path - << " titles=" - << text_chapters.size() - << " urls=" - << url_chapters.size() - << " images=" - << image_chapters.size()); - return mux_file_to_m4a(input_audio_path, text_chapters, url_chapters, image_chapters, - metadata, output_path, fast_start); -} +namespace { +Status make_status(bool ok, std::string msg = {}) { return Status{ok, std::move(msg)}; } +} // namespace -bool mux_file_to_m4a(const std::string &input_audio_path, - const std::vector &text_chapters, - const std::vector &url_chapters, - const std::vector &image_chapters, - const MetadataSet &metadata, const std::string &output_path, - bool fast_start) { +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &url_chapters, + const std::vector &image_chapters, + const MetadataSet &metadata, const std::string &output_path, + bool fast_start) { const auto t0 = std::chrono::steady_clock::now(); - CH_LOG("info", "ChapterForge version " << CHAPTERFORGE_VERSION_DISPLAY); CH_LOG("debug", "mux_file_to_m4a(titles+urls+images+meta) input=" << input_audio_path << " output=" << output_path << " fast_start=" << fast_start @@ -312,32 +283,36 @@ bool mux_file_to_m4a(const std::string &input_audio_path, << image_chapters.size()); auto aac = load_audio(input_audio_path); if (!aac) { - CH_LOG("error", "Failed to load audio from " << input_audio_path); - return false; + std::string msg = "Failed to load audio from " + input_audio_path; + CH_LOG("error", msg); + return make_status(false, msg); } const auto t_load = std::chrono::steady_clock::now(); Mp4aConfig cfg{}; const std::vector *ilst_ptr = nullptr; const std::vector *meta_ptr = nullptr; - if (!aac->meta_payload.empty()) { - meta_ptr = &aac->meta_payload; - CH_LOG("debug", "Reusing source meta payload (" << meta_ptr->size() << " bytes)"); - } - if (!aac->ilst_payload.empty()) { - ilst_ptr = &aac->ilst_payload; - CH_LOG("debug", "Reusing source ilst metadata (" << ilst_ptr->size() << " bytes)"); - } else if (metadata_is_empty(metadata)) { - CH_LOG("warn", - "source metadata missing and no metadata provided; output will carry empty ilst"); + const bool caller_has_meta = !metadata_is_empty(metadata); + if (!caller_has_meta) { + if (!aac->meta_payload.empty()) { + meta_ptr = &aac->meta_payload; + CH_LOG("debug", "Reusing source meta payload (" << meta_ptr->size() << " bytes)"); + } + if (!aac->ilst_payload.empty()) { + ilst_ptr = &aac->ilst_payload; + CH_LOG("debug", "Reusing source ilst metadata (" << ilst_ptr->size() << " bytes)"); + } else { + CH_LOG("warn", + "source metadata missing and no metadata provided; output will carry empty ilst"); + } } else { - CH_LOG("debug", "Using metadata provided by caller"); + CH_LOG("debug", "Using metadata provided by caller (overrides source ilst/meta)"); } std::vector>> extra_text_tracks; if (!url_chapters.empty()) { extra_text_tracks.push_back({"Chapter URLs", url_chapters}); } - bool ok = write_mp4(output_path, *aac, text_chapters, image_chapters, cfg, metadata, fast_start, - extra_text_tracks, ilst_ptr, meta_ptr); + bool ok = write_mp4(output_path, *aac, text_chapters, image_chapters, cfg, metadata, + fast_start, extra_text_tracks, ilst_ptr, meta_ptr); const auto t1 = std::chrono::steady_clock::now(); const auto load_ms = std::chrono::duration_cast(t_load - t0).count(); @@ -349,7 +324,499 @@ bool mux_file_to_m4a(const std::string &input_audio_path, << " mux=" << mux_ms << " total=" << total_ms); - return ok; + if (!ok) { + return make_status(false, "Failed to write M4A to " + output_path); + } + return make_status(true); +} + +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &image_chapters, + const MetadataSet &metadata, const std::string &output_path, + bool fast_start) { + std::vector empty_urls; + return mux_file_to_m4a(input_audio_path, text_chapters, empty_urls, image_chapters, metadata, + output_path, fast_start); +} + +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const MetadataSet &metadata, const std::string &output_path, + bool fast_start) { + std::vector empty_images; + std::vector empty_urls; + return mux_file_to_m4a(input_audio_path, text_chapters, empty_urls, empty_images, metadata, + output_path, fast_start); +} + +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &image_chapters, + const std::string &output_path, bool fast_start) { + MetadataSet empty{}; + std::vector empty_urls; + return mux_file_to_m4a(input_audio_path, text_chapters, empty_urls, image_chapters, empty, + output_path, fast_start); +} + +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::vector &text_chapters, + const std::vector &url_chapters, + const std::vector &image_chapters, + const std::string &output_path, + bool fast_start) { + MetadataSet empty{}; + return mux_file_to_m4a(input_audio_path, text_chapters, url_chapters, image_chapters, empty, + output_path, fast_start); +} + +Status mux_file_to_m4a(const std::string &input_audio_path, + const std::string &chapter_json_path, const std::string &output_path, + bool fast_start) { + CH_LOG("debug", "mux_file_to_m4a(json) input=" << input_audio_path + << " chapters=" << chapter_json_path + << " output=" << output_path + << " fast_start=" << fast_start); + std::vector text_chapters; + std::vector image_chapters; + std::vector>> extra_text_tracks; + MetadataSet meta; + if (!load_chapters_json(chapter_json_path, text_chapters, image_chapters, meta, + extra_text_tracks)) { + std::string msg = "Failed to load chapters JSON: " + chapter_json_path; + CH_LOG("error", msg); + return make_status(false, msg); + } + CH_LOG("debug", "chapters: titles=" << text_chapters.size() + << " urls=" << extra_text_tracks.size() + << " images=" << image_chapters.size()); + + std::vector url_chapters; + if (!extra_text_tracks.empty()) { + url_chapters = std::move(extra_text_tracks.front().second); + } + return mux_file_to_m4a(input_audio_path, text_chapters, url_chapters, image_chapters, meta, + output_path, fast_start); +} + +} // namespace chapterforge + +namespace { + +uint32_t read_u32_be(const std::vector &buf, size_t off) { + return (static_cast(buf[off]) << 24) | (static_cast(buf[off + 1]) << 16) | + (static_cast(buf[off + 2]) << 8) | (static_cast(buf[off + 3])); +} + +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])); +} + +using ::ChapterImageSample; +using ::ChapterTextSample; +using ::ParsedMp4; + +struct SamplePlan { + std::vector offsets; + std::vector sizes; +}; + +std::optional build_sample_plan(const parser_detail::TrackParseResult &trk, + std::istream &) { + SamplePlan plan; + // stsz: fixed or per-sample sizes + const auto &stsz = trk.stsz; + if (stsz.size() < 20) { + return std::nullopt; + } + // stsz layout (payload only, size/type stripped): + // [0..3] version/flags + // [4..7] sample_size (0 means look at table) + // [8..11] sample_count + // [12..] optional entry sizes + uint32_t sample_size = read_u32_be(stsz, 4); + uint32_t sample_count = read_u32_be(stsz, 8); + if (sample_size == 0) { + if (stsz.size() < 12 + sample_count * 4) { + return std::nullopt; + } + plan.sizes.reserve(sample_count); + for (uint32_t i = 0; i < sample_count; ++i) { + plan.sizes.push_back(read_u32_be(stsz, 12 + i * 4)); + } + } else { + plan.sizes.assign(sample_count, sample_size); + } + + // stco: chunk offsets + const auto &stco = trk.stco; + if (stco.size() < 16) { + return std::nullopt; + } + uint32_t chunk_count = read_u32_be(stco, 4); + if (stco.size() < 8 + chunk_count * 4) { + 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)); + } + + // stsc: samples per chunk mapping + const auto &stsc = trk.stsc; + if (stsc.size() < 16) { + return std::nullopt; + } + uint32_t entry_count = read_u32_be(stsc, 4); + if (stsc.size() < 8 + entry_count * 12) { + return std::nullopt; + } + struct StscEntry { + uint32_t first_chunk; + uint32_t samples_per_chunk; + }; + std::vector entries; + entries.reserve(entry_count); + for (uint32_t i = 0; i < entry_count; ++i) { + size_t off = 8 + i * 12; + uint32_t first_chunk = read_u32_be(stsc, off); + uint32_t samples_per_chunk = read_u32_be(stsc, off + 4); + entries.push_back({first_chunk, samples_per_chunk}); + } + + plan.offsets.reserve(plan.sizes.size()); + size_t sample_index = 0; + for (uint32_t chunk_idx = 1; chunk_idx <= chunk_count && sample_index < plan.sizes.size(); + ++chunk_idx) { + // Find current stsc entry + uint32_t samples_per_chunk = entries.back().samples_per_chunk; + for (size_t e = 0; e + 1 < entries.size(); ++e) { + if (chunk_idx >= entries[e].first_chunk && chunk_idx < entries[e + 1].first_chunk) { + samples_per_chunk = entries[e].samples_per_chunk; + break; + } + } + uint64_t base_offset = chunk_offsets[chunk_idx - 1]; + uint64_t cursor = base_offset; + for (uint32_t s = 0; s < samples_per_chunk && sample_index < plan.sizes.size(); ++s) { + plan.offsets.push_back(cursor); + cursor += plan.sizes[sample_index]; + ++sample_index; + } + } + if (plan.offsets.size() != plan.sizes.size()) { + return std::nullopt; + } + return plan; +} + +std::vector build_start_times_ms(const parser_detail::TrackParseResult &trk) { + std::vector starts; + if (trk.timescale == 0 || trk.stts.empty()) { + return starts; + } + // stts (decoding time-to-sample) box: + // [0..3]=version/flags, [4..7]=entry_count, followed by entry_count pairs + // of (sample_count, sample_delta). + uint32_t entry_count = read_u32_be(trk.stts, 4); + if (trk.stts.size() < 8 + entry_count * 8) { + return starts; + } + uint64_t cum = 0; + starts.reserve(trk.sample_count); + for (uint32_t i = 0; i < entry_count; ++i) { + size_t off = 8 + i * 8; + uint32_t count = read_u32_be(trk.stts, off); + uint32_t delta = read_u32_be(trk.stts, off + 4); + for (uint32_t j = 0; j < count; ++j) { + uint32_t ms = static_cast((cum * 1000) / trk.timescale); + starts.push_back(ms); + cum += delta; + } + } + return starts; +} + +struct ExtractedTracks { + std::vector titles; + std::vector urls; + std::vector images; +}; + +bool is_url_track_name(const std::string &name) { + std::string lower = name; + for (auto &c : lower) { + c = static_cast(::tolower(static_cast(c))); + } + return lower.find("url") != std::string::npos; +} + +std::vector parse_tx3g_track(const parser_detail::TrackParseResult &trk, + std::istream &in) { + std::vector out; + auto starts = build_start_times_ms(trk); + auto plan_opt = build_sample_plan(trk, in); + if (!plan_opt) { + return out; + } + const auto &plan = *plan_opt; + size_t sample_count = std::min({plan.offsets.size(), plan.sizes.size(), starts.size()}); + CH_LOG("debug", "tx3g track: plan sizes=" << plan.sizes.size() + << " offsets=" << plan.offsets.size() + << " starts=" << starts.size() + << " sample_count=" << sample_count + << " size[0]=" + << (plan.sizes.empty() ? 0 : plan.sizes[0])); + out.reserve(sample_count); + for (size_t i = 0; i < sample_count; ++i) { + uint64_t off = plan.offsets[i]; + uint32_t sz = plan.sizes[i]; + if (sz < 2) { + continue; + } + std::vector buf(sz); + in.seekg(static_cast(off), std::ios::beg); + in.read(reinterpret_cast(buf.data()), sz); + uint16_t text_len = read_u16_be(buf, 0); + size_t text_bytes = std::min(text_len, sz > 2 ? sz - 2 : 0); + ChapterTextSample chapter_sample{}; + chapter_sample.start_ms = starts[i]; + chapter_sample.text.assign(reinterpret_cast(buf.data() + 2), text_bytes); + size_t cursor = 2 + text_bytes; + // Optional href box + while (cursor + 8 <= sz) { + uint32_t box_size = read_u32_be(buf, cursor); + uint32_t box_type = read_u32_be(buf, cursor + 4); + if (box_size < 8 || cursor + box_size > sz) { + break; + } + if (box_type == 0x68726566) { // 'href' + if (box_size >= 8 + 2 + 2 + 1) { + uint8_t url_len = buf[cursor + 8 + 2 + 2]; + size_t url_off = cursor + 8 + 2 + 2 + 1; + if (url_off + url_len <= cursor + box_size) { + chapter_sample.href.assign( + reinterpret_cast(buf.data() + url_off), url_len); + } + } + } + cursor += box_size; + } + out.push_back(std::move(chapter_sample)); + } + return out; +} + +std::vector parse_image_track(const parser_detail::TrackParseResult &trk, + std::istream &in) { + std::vector out; + auto starts = build_start_times_ms(trk); + auto plan_opt = build_sample_plan(trk, in); + if (!plan_opt) { + return out; + } + const auto &plan = *plan_opt; + size_t sample_count = std::min({plan.offsets.size(), plan.sizes.size(), starts.size()}); + out.reserve(sample_count); + for (size_t i = 0; i < sample_count; ++i) { + uint64_t off = plan.offsets[i]; + uint32_t sz = plan.sizes[i]; + if (sz == 0) { + continue; + } + std::vector buf(sz); + in.seekg(static_cast(off), std::ios::beg); + in.read(reinterpret_cast(buf.data()), sz); + ChapterImageSample img_sample{}; + img_sample.start_ms = starts[i]; + img_sample.data = std::move(buf); + out.push_back(std::move(img_sample)); + } + return out; +} + +ExtractedTracks extract_tracks(const ParsedMp4 &parsed, std::istream &in) { + ExtractedTracks ext; + const parser_detail::TrackParseResult *titles = nullptr; + const parser_detail::TrackParseResult *urls = nullptr; + const parser_detail::TrackParseResult *images = nullptr; + std::vector text_tracks; + + for (const auto &trk : parsed.tracks) { + if (trk.handler_type == 0x74657874) { // 'text' + text_tracks.push_back(&trk); + if (!urls && is_url_track_name(trk.handler_name)) { + urls = &trk; + } else if (!titles) { + titles = &trk; + } + } else if (trk.handler_type == 0x76696465) { // 'vide' + if (!images) { + images = &trk; + } + } + } + + // If we did not conclusively identify a URL track by name, but there are multiple + // text tracks, treat the second one as URLs (mirrors how we author files). + if (!urls && text_tracks.size() > 1) { + urls = text_tracks[1]; + } + + if (titles) { + ext.titles = parse_tx3g_track(*titles, in); + } + if (urls) { + ext.urls = parse_tx3g_track(*urls, in); + } + if (images) { + ext.images = parse_image_track(*images, in); + } + return ext; +} + +// Require exact start alignment between tracks; no drift tolerance. +constexpr uint32_t kStartMatchToleranceMs = 0; + +// Align URL track entries to the title track by start time. URLs that share +// (or are within tolerance of) a title start time are paired; if none is found +// the slot is left empty but preserved to avoid shifting chapter order. Extras +// are reported and discarded. +std::vector align_urls_to_titles(const std::vector &titles, + const std::vector &urls) { + if (titles.empty()) { + return {}; + } + + std::multimap by_start; + for (const auto &u : urls) { + by_start.emplace(u.start_ms, u); + } + + std::vector out; + out.reserve(titles.size()); + + size_t missing = 0; + for (const auto &t : titles) { + const uint32_t lo = + (t.start_ms > kStartMatchToleranceMs) ? t.start_ms - kStartMatchToleranceMs : 0; + const uint32_t hi = t.start_ms + kStartMatchToleranceMs; + auto it = by_start.lower_bound(lo); + + auto best = by_start.end(); + uint32_t best_diff = std::numeric_limits::max(); + while (it != by_start.end() && it->first <= hi) { + uint32_t diff = + (t.start_ms > it->first) ? (t.start_ms - it->first) : (it->first - t.start_ms); + if (diff < best_diff) { + best = it; + best_diff = diff; + } + ++it; + } + + if (best != by_start.end()) { + out.push_back(best->second); + by_start.erase(best); + } else { + ChapterTextSample empty{}; + empty.start_ms = t.start_ms; + out.push_back(std::move(empty)); + ++missing; + } + } + + (void)missing; // Missing entries are normal; placeholders were inserted to align. + (void)by_start; // Extra entries are tolerated silently. + + return out; +} + +std::vector align_images_to_titles(const std::vector &titles, + const std::vector &images) { + if (titles.empty()) { + return {}; + } + + std::multimap by_start; + for (const auto &img : images) { + by_start.emplace(img.start_ms, img); + } + + std::vector out; + out.reserve(titles.size()); + + size_t missing = 0; + for (const auto &t : titles) { + const uint32_t lo = + (t.start_ms > kStartMatchToleranceMs) ? t.start_ms - kStartMatchToleranceMs : 0; + const uint32_t hi = t.start_ms + kStartMatchToleranceMs; + auto it = by_start.lower_bound(lo); + + auto best = by_start.end(); + uint32_t best_diff = std::numeric_limits::max(); + while (it != by_start.end() && it->first <= hi) { + uint32_t diff = + (t.start_ms > it->first) ? (t.start_ms - it->first) : (it->first - t.start_ms); + if (diff < best_diff) { + best = it; + best_diff = diff; + } + ++it; + } + + if (best != by_start.end()) { + out.push_back(best->second); + by_start.erase(best); + } else { + ChapterImageSample empty{}; + empty.start_ms = t.start_ms; + out.push_back(std::move(empty)); + ++missing; + } + } + + (void)missing; // Missing entries are normal; placeholders were inserted to align. + (void)by_start; // Extra entries are tolerated silently. + + return out; +} + +} // namespace + +namespace chapterforge { + +ReadResult read_m4a(const std::string &path) { + ReadResult result{}; + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) { + result.status = {false, "Failed to open " + path}; + return result; + } + auto parsed = parse_mp4(path); + if (!parsed) { + result.status = {false, "Failed to parse " + path}; + return result; + } + auto ext = extract_tracks(*parsed, in); + result.titles = std::move(ext.titles); + result.urls = align_urls_to_titles(result.titles, ext.urls); + result.images = align_images_to_titles(result.titles, ext.images); + if (!parsed->ilst_payload.empty()) { + parse_ilst_metadata(parsed->ilst_payload, result.metadata); + CH_LOG("debug", "parsed metadata title='" << result.metadata.title << "' artist='" + << result.metadata.artist << "' album='" + << result.metadata.album << "' genre='" + << result.metadata.genre << "' year='" + << result.metadata.year << "' comment='" + << result.metadata.comment + << "' cover_bytes=" << result.metadata.cover.size()); + } + result.status = {true, ""}; + return result; } } // namespace chapterforge diff --git a/src/main.cpp b/src/main.cpp index a6e0ed8..8c28688 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,12 +6,16 @@ // Copyright © 2025 Till Toenshoff. All rights reserved. // +#include +#include #include #include +#include #include "chapterforge.hpp" #include "chapterforge_version.hpp" #include "logging.hpp" +#include chapterforge::LogVerbosity parse_level(const std::string &s) { if (s == "debug") return chapterforge::LogVerbosity::Debug; @@ -20,36 +24,158 @@ chapterforge::LogVerbosity parse_level(const std::string &s) { return chapterforge::LogVerbosity::Error; } +bool write_bytes(const std::filesystem::path &p, const std::vector &data) { + if (data.empty()) return false; + std::ofstream out(p, std::ios::binary); + if (!out.is_open()) return false; + out.write(reinterpret_cast(data.data()), + static_cast(data.size())); + return out.good(); +} + +bool emit_json(const chapterforge::ReadResult &res, const std::filesystem::path &image_dir) { + nlohmann::json j; + const auto &m = res.metadata; + j["title"] = m.title; + j["artist"] = m.artist; + j["album"] = m.album; + j["genre"] = m.genre; + j["year"] = m.year; + j["comment"] = m.comment; + + // Optionally export images (cover + chapter images) and reference them in JSON. + std::vector image_paths; + if (!image_dir.empty()) { + std::filesystem::create_directories(image_dir); + // Cover. + if (!m.cover.empty()) { + auto cover_path = image_dir / "cover.jpg"; + if (write_bytes(cover_path, m.cover)) { + j["cover"] = cover_path.string(); + } + } + // Chapter images. + for (size_t i = 0; i < res.images.size(); ++i) { + auto filename = "chapter" + std::to_string(i + 1) + ".jpg"; + auto path = image_dir / filename; + if (write_bytes(path, res.images[i].data)) { + image_paths.push_back(path.string()); + } else { + image_paths.push_back(""); + } + } + } + + nlohmann::json chapters = nlohmann::json::array(); + size_t count = res.titles.size(); + for (size_t i = 0; i < count; ++i) { + nlohmann::json c; + const auto &t = res.titles[i]; + c["start_ms"] = t.start_ms; + c["title"] = t.text; + + // Track URL and URL text only when present. + std::string url_value; + std::string url_text_value; + if (i < res.urls.size()) { + url_text_value = res.urls[i].text; + if (!res.urls[i].href.empty()) { + url_value = res.urls[i].href; + } + } + // Fall back to title track href if URL track did not provide one. + if (url_value.empty() && !t.href.empty()) { + url_value = t.href; + } + if (!url_value.empty()) { + c["url"] = url_value; + } + if (!url_text_value.empty()) { + c["url_text"] = url_text_value; + } + if (!image_paths.empty() && i < image_paths.size()) { + c["image"] = image_paths[i]; + } + chapters.push_back(c); + } + j["chapters"] = chapters; + + std::cout << j.dump(2) << "\n"; + return true; +} + int main(int argc, char **argv) { if (argc == 2 && (std::string(argv[1]) == "--version" || std::string(argv[1]) == "-v")) { std::cout << "ChapterForge " << CHAPTERFORGE_VERSION_DISPLAY << "\n"; return 0; } - if (argc < 4 || argc > 7) { - std::cerr - << "usage: chapterforge " - << "[--faststart] [--log-level warn|info|debug]\n"; - return 2; - } - - const std::string input_path = argv[1]; - const std::string chapters_path = argv[2]; - const std::string output_path = argv[3]; + // Gather positional arguments (non-option). + std::vector positional; + std::filesystem::path export_dir; bool fast_start = false; - - for (int i = 4; i < argc; ++i) { + for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if (arg == "--faststart") { fast_start = true; } else if (arg == "--log-level" && i + 1 < argc) { chapterforge::set_log_verbosity(parse_level(argv[i + 1])); ++i; + } else if (arg == "--export-jpegs" && i + 1 < argc) { + export_dir = argv[++i]; + } else if (!arg.empty() && arg[0] == '-') { + std::cerr << "Unknown option: " << arg << "\n"; + return 2; + } else { + positional.emplace_back(std::move(arg)); + } + } + + if (positional.empty()) { + std::cerr << "ChapterForge " << CHAPTERFORGE_VERSION_DISPLAY << "\n" + << "Copyright (c) 2025 Till Toenshoff\n\n" + << "usage for reading:\n" + << " chapterforge [--export-jpegs DIR] " + << "[--log-level warn|info|debug]\n" + << "usage for writing:\n" + << " chapterforge " + << "[--faststart] [--log-level warn|info|debug]\n" + << "Options:\n" + << " --faststart Place 'moov' atom before 'mdat' for faster playback start.\n" + << " --log-level LEVEL Set logging verbosity (default: info).\n" + << " --export-jpegs DIR When reading, write chapter images (and cover if any) to DIR.\n" + << " JSON is always written to stdout when reading.\n"; + return 2; + } + + // Reading mode: one positional argument (input). + if (positional.size() == 1) { + const std::string input_path = positional[0]; + auto res = chapterforge::read_m4a(input_path); + if (!res.status.ok) { + CH_LOG("error", "chapterforge: failed to read m4a: " << res.status.message); + return 1; + } + if (!emit_json(res, export_dir)) { + CH_LOG("error", "chapterforge: failed to emit JSON or export images"); + return 1; } + return 0; + } + + // Writing mode: three positional arguments. + if (positional.size() != 3) { + std::cerr << "Invalid arguments. See --help for usage.\n"; + return 2; } + const std::string input_path = positional[0]; + const std::string chapters_path = positional[1]; + const std::string output_path = positional[2]; - if (!chapterforge::mux_file_to_m4a(input_path, chapters_path, output_path, fast_start)) { - CH_LOG("error", "chapterforge: failed to write output"); + auto status = + chapterforge::mux_file_to_m4a(input_path, chapters_path, output_path, fast_start); + if (!status.ok) { + CH_LOG("error", "chapterforge: failed to mux m4a: " << status.message); return 1; } diff --git a/src/mp4_muxer.cpp b/src/mp4_muxer.cpp index 822cb4a..4391f47 100644 --- a/src/mp4_muxer.cpp +++ b/src/mp4_muxer.cpp @@ -24,6 +24,7 @@ #include "mdat_writer.hpp" #include "jpeg_info.hpp" #include "metadata_set.hpp" +#include "chapterforge_version.hpp" #include "meta_builder.hpp" #include "moov_builder.hpp" #include "mp4_atoms.hpp" @@ -639,5 +640,7 @@ bool write_mp4(const std::string &output_path, const AacExtractResult &aac, << " layout=" << ms(t_moov_end, t_layout_end) << " write=" << ms(t_layout_end, t_write_end) << " total=" << ms(t_start, t_write_end)); - return true; + CH_LOG("debug", "ChapterForge version " << CHAPTERFORGE_VERSION_DISPLAY); + + return true; } diff --git a/src/parser.cpp b/src/parser.cpp index e695b1d..9987c3f 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -249,11 +249,11 @@ static void parse_meta_payload(std::istream &in, uint64_t size, ParsedMp4 &out) in.seekg(end); } -// Parse hdlr to retrieve handler type. Expects payload size (box size minus 8). -static uint32_t parse_hdlr(std::istream &in, uint64_t payload_size) { +// Parse hdlr to retrieve handler type and name. Expects payload size (box size minus 8). +static void parse_hdlr(std::istream &in, uint64_t payload_size, TrackParseResult &track) { if (payload_size < kHdlrMinPayload) { // too small to contain required fields skip(in, payload_size); - return 0; + return; } uint8_t version = in.get(); @@ -264,14 +264,49 @@ static uint32_t parse_hdlr(std::istream &in, uint64_t payload_size) { // pre_defined + handler_type. skip(in, 4); - uint32_t handler_type = read_u32(in); + track.handler_type = read_u32(in); - // skip reserved + name. + // reserved (12 bytes), then a Pascal-style or null-terminated UTF-8 name. uint64_t consumed = 1 + 3 + 4 + 4 + 12; + std::string name; + if (payload_size > consumed) { + uint64_t name_len = payload_size - consumed; + name.resize(static_cast(name_len)); + in.read(name.data(), (std::streamsize)name_len); + // Trim any trailing nulls. + while (!name.empty() && name.back() == '\0') { + name.pop_back(); + } + } + track.handler_name = std::move(name); +} + +// Parse tkhd to retrieve track id and flags. +static void parse_tkhd(std::istream &in, uint64_t payload_size, TrackParseResult &track) { + if (payload_size < 20) { + skip(in, payload_size); + return; + } + uint8_t version = in.get(); + uint8_t flags_bytes[3]; + in.read((char *)flags_bytes, 3); + track.tkhd_flags = (flags_bytes[0] << 16) | (flags_bytes[1] << 8) | flags_bytes[2]; + // creation + modification time (version dependent); skip 8 or 16 bytes. + uint64_t consumed = 1 + 3; + if (version == 1) { + skip(in, 16); + consumed += 16; + } else { + skip(in, 8); + consumed += 8; + } + track.track_id = read_u32(in); + skip(in, 4); // reserved + consumed += 8; // track_id + reserved + // remainder not needed here. if (payload_size > consumed) { skip(in, payload_size - consumed); } - return handler_type; } // Parse stbl children (stsd, stts, stsc, stsz, stco) @@ -386,8 +421,11 @@ static bool parse_mdia(std::istream &in, uint64_t mpay, uint64_t mdia_end, CH_LOG("debug", " mdhd timescale=" << track.timescale << " duration=" << track.duration); } else if (m.type == ('h' << 24 | 'd' << 16 | 'l' << 8 | 'r')) { - track.handler_type = parse_hdlr(in, m.size); - CH_LOG("debug", " hdlr=" << std::hex << track.handler_type << std::dec); + // Pass the payload size (box size minus header) so the handler name length is read + // correctly. + parse_hdlr(in, mpay_child, track); + CH_LOG("debug", " hdlr=" << std::hex << track.handler_type << std::dec + << " name=" << track.handler_name); } else if (m.type == ('m' << 24 | 'i' << 16 | 'n' << 8 | 'f')) { // find stbl. uint64_t minf_end = (uint64_t)in.tellg() + mpay_child; @@ -472,7 +510,9 @@ static std::optional parse_trak(std::istream &in, uint64_t c_p uint64_t tpay = tchild.size - 8; CH_LOG("debug", " trak child=" << fourcc_to_string(tchild.type) << " size=" << tchild.size); - if (tchild.type == ('m' << 24 | 'd' << 16 | 'i' << 8 | 'a')) { + if (tchild.type == ('t' << 24 | 'k' << 16 | 'h' << 8 | 'd')) { + parse_tkhd(in, tpay, track); + } else if (tchild.type == ('m' << 24 | 'd' << 16 | 'i' << 8 | 'a')) { CH_LOG("debug", "trak: entering mdia"); uint64_t mdia_end = (uint64_t)in.tellg() + tpay; parse_mdia(in, tpay, mdia_end, track, force_fallback); @@ -579,13 +619,14 @@ static void parse_moov(std::istream &in, const Mp4AtomInfo &atom, uint64_t file_ best_audio_samples = track.sample_count; out.audio_timescale = track.timescale; out.audio_duration = track.duration; - out.stsd = std::move(track.stsd); - out.stts = std::move(track.stts); - out.stsc = std::move(track.stsc); - out.stsz = std::move(track.stsz); - out.stco = std::move(track.stco); + out.stsd = track.stsd; + out.stts = track.stts; + out.stsc = track.stsc; + out.stsz = track.stsz; + out.stco = track.stco; } } + out.tracks.push_back(track); break; } default: diff --git a/tests/overload_variants.cpp b/tests/overload_variants.cpp index 0e008b7..6eb0848 100644 --- a/tests/overload_variants.cpp +++ b/tests/overload_variants.cpp @@ -66,9 +66,9 @@ int main(int argc, char **argv) { // 1) Titles + metadata, no images. std::string out_noimg = (std::filesystem::path(out_dir) / "overload_noimg.m4a").string(); - bool ok = mux_file_to_m4a(input, titles, meta, out_noimg, true); - if (!ok) { - std::cerr << "mux (no images) failed\n"; + auto status = mux_file_to_m4a(input, titles, meta, out_noimg, true); + if (!status.ok) { + std::cerr << "mux (no images) failed: " << status.message << "\n"; return 1; } if (!std::filesystem::exists(out_noimg) || std::filesystem::file_size(out_noimg) == 0) { @@ -78,10 +78,10 @@ int main(int argc, char **argv) { // 2) Titles + URLs (no images), metadata empty (reuse source ilst if present). std::string out_url = (std::filesystem::path(out_dir) / "overload_url.m4a").string(); - ok = mux_file_to_m4a(input, titles, urls, std::vector{}, MetadataSet{}, - out_url, true); - if (!ok) { - std::cerr << "mux (urls) failed\n"; + status = mux_file_to_m4a(input, titles, urls, std::vector{}, + MetadataSet{}, out_url, true); + if (!status.ok) { + std::cerr << "mux (urls) failed: " << status.message << "\n"; return 1; } if (!std::filesystem::exists(out_url) || std::filesystem::file_size(out_url) == 0) { @@ -96,9 +96,9 @@ int main(int argc, char **argv) { {.data = img_bytes, .start_ms = 5000}, }; std::string out_img = (std::filesystem::path(out_dir) / "overload_img.m4a").string(); - ok = mux_file_to_m4a(input, titles, images, meta, out_img, true); - if (!ok) { - std::cerr << "mux (titles+images) failed\n"; + status = mux_file_to_m4a(input, titles, images, meta, out_img, true); + if (!status.ok) { + std::cerr << "mux (titles+images) failed: " << status.message << "\n"; return 1; } if (!std::filesystem::exists(out_img) || std::filesystem::file_size(out_img) == 0) { @@ -109,9 +109,9 @@ int main(int argc, char **argv) { // 4) Titles + URLs + images, metadata empty (ilst reuse). std::string out_url_img = (std::filesystem::path(out_dir) / "overload_url_img.m4a").string(); - ok = mux_file_to_m4a(input, titles, urls, images, MetadataSet{}, out_url_img, true); - if (!ok) { - std::cerr << "mux (titles+urls+images) failed\n"; + status = mux_file_to_m4a(input, titles, urls, images, MetadataSet{}, out_url_img, true); + if (!status.ok) { + std::cerr << "mux (titles+urls+images) failed: " << status.message << "\n"; return 1; } if (!std::filesystem::exists(out_url_img) || @@ -123,9 +123,9 @@ int main(int argc, char **argv) { // 5) Titles + URLs + images via the 5-arg overload (metadata reuse). std::string out_url_img_nometa = (std::filesystem::path(out_dir) / "overload_url_img_nometa.m4a").string(); - ok = mux_file_to_m4a(input, titles, urls, images, out_url_img_nometa, true); - if (!ok) { - std::cerr << "mux (titles+urls+images 5-arg) failed\n"; + status = mux_file_to_m4a(input, titles, urls, images, out_url_img_nometa, true); + if (!status.ok) { + std::cerr << "mux (titles+urls+images 5-arg) failed: " << status.message << "\n"; return 1; } if (!std::filesystem::exists(out_url_img_nometa) || @@ -137,9 +137,9 @@ int main(int argc, char **argv) { // 6) Titles + images (no URL track), metadata reused (4-arg overload). std::string out_img_nometa = (std::filesystem::path(out_dir) / "overload_img_nometa.m4a").string(); - ok = mux_file_to_m4a(input, titles, images, out_img_nometa, true); - if (!ok) { - std::cerr << "mux (titles+images, 4-arg) failed\n"; + status = mux_file_to_m4a(input, titles, images, out_img_nometa, true); + if (!status.ok) { + std::cerr << "mux (titles+images, 4-arg) failed: " << status.message << "\n"; return 1; } if (!std::filesystem::exists(out_img_nometa) || @@ -163,9 +163,9 @@ int main(int argc, char **argv) { } std::string out_json = (std::filesystem::path(out_dir) / "overload_json.m4a").string(); - ok = mux_file_to_m4a(input, json_path, out_json, true); - if (!ok) { - std::cerr << "mux (json overload) failed\n"; + status = mux_file_to_m4a(input, json_path, out_json, true); + if (!status.ok) { + std::cerr << "mux (json overload) failed: " << status.message << "\n"; return 1; } if (!std::filesystem::exists(out_json) || std::filesystem::file_size(out_json) == 0) { diff --git a/tests/read_api_unit.cpp b/tests/read_api_unit.cpp new file mode 100644 index 0000000..3dbef51 --- /dev/null +++ b/tests/read_api_unit.cpp @@ -0,0 +1,190 @@ +// Unit test for the public read_m4a API: validates titles, URLs, and images are parsed. +#include +#include +#include +#include + +#include "chapterforge.hpp" +#include "logging.hpp" + +#ifndef TESTDATA_DIR +#error "TESTDATA_DIR must be defined" +#endif + +namespace { + +bool check(bool cond, const std::string &msg) { + if (!cond) { + std::fprintf(stderr, "[read_unit] FAIL: %s\n", msg.c_str()); + } + return cond; +} + +template +std::vector non_empty(const std::vector &in, Pred pred) { + std::vector out; + for (const auto &v : in) { + if (pred(v)) { + out.push_back(v); + } + } + return out; +} + +bool test_read_output_debug() { + const std::filesystem::path testdata(TESTDATA_DIR); + const auto input = (testdata / "input.m4a").string(); + + // Build a fresh output with titles, URL track and images so we can read it back. + std::vector titles; + ChapterTextSample t1{}; + t1.text = "Read Title One"; + t1.href = "https://chapterforge.test/url-one"; + t1.start_ms = 0; + titles.push_back(t1); + ChapterTextSample t2{}; + t2.text = "Read Title Two"; + t2.href = "https://chapterforge.test/url-two"; + t2.start_ms = 5000; + titles.push_back(t2); + + std::vector urls; + ChapterTextSample u1{}; + u1.text = "Read URL text one"; + u1.href = "https://chapterforge.test/url-one"; + u1.start_ms = 0; + urls.push_back(u1); + ChapterTextSample u2{}; + u2.text = "Read URL text two"; + u2.href = "https://chapterforge.test/url-two"; + u2.start_ms = 5000; + urls.push_back(u2); + + auto load_bytes = [](const std::filesystem::path &p) { + std::vector data; + std::FILE *f = std::fopen(p.string().c_str(), "rb"); + if (!f) { + return data; + } + std::fseek(f, 0, SEEK_END); + long len = std::ftell(f); + std::fseek(f, 0, SEEK_SET); + if (len > 0) { + data.resize(static_cast(len)); + std::fread(data.data(), 1, static_cast(len), f); + } + std::fclose(f); + return data; + }; + + std::vector images; + // Reuse existing 400x400 fixtures that we already ship with the repo. + auto img1 = load_bytes(testdata / "images" / "chapter1.jpg"); + auto img2 = load_bytes(testdata / "images" / "chapter2.jpg"); + if (!img1.empty()) { + ChapterImageSample im1{}; + im1.start_ms = 0; + im1.data = std::move(img1); + images.push_back(std::move(im1)); + } + if (!img2.empty()) { + ChapterImageSample im2{}; + im2.start_ms = 5000; + im2.data = std::move(img2); + images.push_back(std::move(im2)); + } + + const auto out_dir = std::filesystem::path("test_outputs"); + std::filesystem::create_directories(out_dir); + const auto out_path = (out_dir / "read_unit.m4a").string(); + + MetadataSet meta{}; + meta.title = "Read Unit Album"; + meta.artist = "ChapterForge Tester"; + meta.album = "Read Unit Suite"; + meta.genre = "Unit Jazz"; + meta.year = "2025"; + meta.comment = "Round-trip read API validation"; + meta.cover = load_bytes(testdata / "images" / "cover.jpg"); + + auto mux_status = + chapterforge::mux_file_to_m4a(input, titles, urls, images, meta, out_path, true); + bool ok = true; + ok &= check(mux_status.ok, "mux status ok"); + if (!mux_status.ok) { + return false; + } + + auto res = chapterforge::read_m4a(out_path); + ok &= check(res.status.ok, "read_m4a ok"); + if (!res.status.ok) { + return false; + } + + ok &= check(res.metadata.title == meta.title, "metadata title"); + ok &= check(res.metadata.artist == meta.artist, "metadata artist"); + ok &= check(res.metadata.album == meta.album, "metadata album"); + ok &= check(res.metadata.genre == meta.genre, "metadata genre"); + ok &= check(res.metadata.year == meta.year, "metadata year"); + ok &= check(res.metadata.comment == meta.comment, "metadata comment"); + ok &= check(!res.metadata.cover.empty(), "metadata cover present"); + ok &= check(res.metadata.cover.size() == meta.cover.size(), "metadata cover size match"); + + auto parsed_titles = non_empty(res.titles, [](const auto &t) { return !t.text.empty(); }); + ok &= check(parsed_titles.size() >= 2, "titles count >=2"); + if (parsed_titles.size() >= 2) { + ok &= check(parsed_titles[0].text == "Read Title One", "title[0] text"); + ok &= check(parsed_titles[0].href == "https://chapterforge.test/url-one", "title[0] href"); + ok &= check(parsed_titles[1].text == "Read Title Two", "title[1] text"); + ok &= check(parsed_titles[1].href == "https://chapterforge.test/url-two", "title[1] href"); + ok &= check(parsed_titles[0].start_ms == 0, "title[0] start"); + ok &= check(parsed_titles[1].start_ms == 5000, "title[1] start"); + } + + auto parsed_urls = non_empty(res.urls, [](const auto &t) { + return !t.text.empty() || !t.href.empty(); + }); + ok &= check(parsed_urls.size() >= 2, "urls count >=2"); + if (parsed_urls.size() >= 2) { + ok &= check(parsed_urls[0].text == "Read URL text one", "url[0] text"); + ok &= check(parsed_urls[0].href == "https://chapterforge.test/url-one", "url[0] href"); + ok &= check(parsed_urls[1].text == "Read URL text two", "url[1] text"); + ok &= check(parsed_urls[1].href == "https://chapterforge.test/url-two", "url[1] href"); + } + + ok &= check(res.images.size() >= 2, "images count >=2"); + if (!res.images.empty()) { + ok &= check(!res.images[0].data.empty(), "first image non-empty"); + } + return ok; +} + +bool test_read_fixture_metadata() { + const std::filesystem::path testdata(TESTDATA_DIR); + const auto input = (testdata / "input.m4a").string(); + + auto res = chapterforge::read_m4a(input); + bool ok = true; + ok &= check(res.status.ok, "read_m4a on fixture ok"); + if (!res.status.ok) { + return false; + } + ok &= check(res.metadata.title == "ChapterForge Sample 10s (Pads)", "fixture title"); + ok &= check(res.metadata.artist == "ChapterForge Bot", "fixture artist"); + ok &= check(res.metadata.album == "Synthetic Pad Chapters", "fixture album"); + ok &= check(res.metadata.genre == "Test Audio", "fixture genre"); + ok &= check(res.metadata.comment == "Synthetic pads with voiceover chapters", + "fixture comment"); + ok &= check(!res.metadata.cover.empty(), "fixture cover present"); + return ok; +} + +} // namespace + +int main() { + chapterforge::set_log_verbosity(chapterforge::LogVerbosity::Debug); + bool ok = true; + ok &= test_read_output_debug(); + ok &= test_read_fixture_metadata(); + return ok ? 0 : 1; +}