Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
133 changes: 79 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,22 @@ The overlay tracks the current commit. Update `REF`/`SHA512` in `ports/chapterfo

```bash
./chapterforge_cli <input.m4a|.mp4|.aac> <chapters.json> <output.m4a>
./chapterforge_cli <input.m4a> [--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
Expand Down Expand Up @@ -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<ChapterTextSample>& text_chapters,
const std::vector<ChapterImageSample>& 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<ChapterTextSample>& text_chapters,
const std::vector<ChapterImageSample>& 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<ChapterTextSample>& 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<ChapterTextSample>& 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<ChapterTextSample>& text_chapters,
const std::vector<ChapterTextSample>& url_chapters,
const std::vector<ChapterImageSample>& 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<ChapterTextSample>& text_chapters,
const std::vector<ChapterTextSample>& url_chapters,
const std::vector<ChapterImageSample>& 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<ChapterTextSample>& text_chapters,
const std::vector<ChapterImageSample>& 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<ChapterTextSample>& text_chapters,
const std::vector<ChapterImageSample>& 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<ChapterTextSample>& 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<ChapterTextSample>& 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<ChapterTextSample>& text_chapters,
const std::vector<ChapterTextSample>& url_chapters,
const std::vector<ChapterImageSample>& 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<ChapterTextSample>& text_chapters,
const std::vector<ChapterTextSample>& url_chapters,
const std::vector<ChapterImageSample>& 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.
Expand All @@ -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";
Expand All @@ -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

Expand Down
Binary file removed docs/diagrams/chapter_tracks.png
Binary file not shown.
40 changes: 40 additions & 0 deletions docs/example/advanced_api_use_in_objectivecpp.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#import "chapterforge.hpp" // public header
#import <Foundation/Foundation.h>

static void BuildChaptersFromDict(NSArray<NSDictionary *> *chapterArray,
std::vector<ChapterTextSample> &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<NSDictionary *> *chaptersDict,
const AacExtractResult &aac,
const std::string &outputPath) {
std::vector<ChapterTextSample> textChapters;
std::vector<ChapterImageSample> 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()]);
}
}
123 changes: 65 additions & 58 deletions include/chapterforge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChapterTextSample> &text_chapters,
const std::vector<ChapterImageSample> &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<ChapterTextSample> &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<ChapterTextSample> &text_chapters,
const std::vector<ChapterImageSample> &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<ChapterTextSample> &text_chapters,
const std::vector<ChapterImageSample> &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<ChapterTextSample> &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<ChapterTextSample> &text_chapters,
const std::vector<ChapterImageSample> &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).
Expand All @@ -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<ChapterTextSample> &text_chapters,
const std::vector<ChapterTextSample> &url_chapters,
const std::vector<ChapterImageSample> &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<ChapterTextSample> &text_chapters,
const std::vector<ChapterTextSample> &url_chapters,
const std::vector<ChapterImageSample> &image_chapters,
const std::string &output_path,
bool fast_start = true); ///< @ingroup api

/**
* @brief Variant with explicit metadata; otherwise identical to the overload above.
Expand All @@ -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<ChapterTextSample> &text_chapters,
const std::vector<ChapterTextSample> &url_chapters,
const std::vector<ChapterImageSample> &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<ChapterTextSample> &text_chapters,
const std::vector<ChapterTextSample> &url_chapters,
const std::vector<ChapterImageSample> &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<ChapterTextSample> titles;
std::vector<ChapterTextSample> urls;
std::vector<ChapterImageSample> images;
MetadataSet metadata;
};

ReadResult read_m4a(const std::string &path); ///< @ingroup api

/// @}

Expand Down
Loading
Loading