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
38 changes: 30 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ jobs:
- name: Install tools (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y xxd gpac vim-common nlohmann-json3-dev unzip doxygen python3-pip
sudo apt-get install -y atomicparsley || true
sudo apt-get update -o Acquire::Retries=5
sudo apt-get install -o Acquire::Retries=5 -y xxd gpac vim-common nlohmann-json3-dev unzip doxygen
sudo apt-get install -o Acquire::Retries=5 -y atomicparsley || true
# Prefer distro bento4; fall back to upstream binaries if missing.
if ! sudo apt-get install -y bento4; then
B4_VER="1-6-0-640"
B4_URL="https://www.bok.net/Bento4/binaries/Bento4-SDK-${B4_VER}.x86_64-unknown-linux.zip"
echo "Fetching Bento4 from ${B4_URL}"
curl -L -o /tmp/bento4.zip "${B4_URL}"
curl -L --retry 5 --retry-delay 5 --fail --retry-all-errors -o /tmp/bento4.zip "${B4_URL}"
sudo unzip -q /tmp/bento4.zip -d /opt/bento4
sudo ln -sf /opt/bento4/Bento4-SDK-${B4_VER}.x86_64-unknown-linux/bin/mp4info /usr/local/bin/mp4info
sudo ln -sf /opt/bento4/Bento4-SDK-${B4_VER}.x86_64-unknown-linux/bin/mp4dump /usr/local/bin/mp4dump
Expand All @@ -57,30 +57,52 @@ jobs:
- name: Install tools (macOS)
if: runner.os == 'macOS'
run: |
export HOMEBREW_CURL_RETRIES=5
brew update
# Prefer Bento4 mp4info/mp4dump; avoid mp4v2 to prevent binary conflicts.
brew install gpac atomicparsley nlohmann-json bento4 doxygen python@3 || true
brew install gpac atomicparsley nlohmann-json bento4 doxygen || true

- name: Set up Python (macOS)
if: runner.os == 'macOS'
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Set up Python (Linux)
if: runner.os == 'Linux'
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install tools (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
# Python is preinstalled on GitHub runners; keep a known version via setup-python instead of Chocolatey.
export chocolateyDownloadRetries=5
export chocolateyDownloadRetrySeconds=5
choco install -y gpac
choco install -y vim || true
choco install -y nlohmann-json || true
choco install -y 7zip || true
choco install -y doxygen || true
choco install -y python || true
# Prefer doxygen.install (available on chocolatey); fall back to doxygen portable if needed.
choco install -y doxygen.install || choco install -y doxygen || true
# Bento4: download prebuilt SDK and expose mp4info/mp4dump
B4_VER="1-6-0-640"
curl -L -o /tmp/bento4.zip https://www.bok.net/Bento4/binaries/Bento4-SDK-${B4_VER}.x86_64-microsoft-win32.zip
curl -L --retry 5 --retry-delay 5 --fail --retry-all-errors -o /tmp/bento4.zip https://www.bok.net/Bento4/binaries/Bento4-SDK-${B4_VER}.x86_64-microsoft-win32.zip
mkdir -p /c/bento4
unzip -q /tmp/bento4.zip -d /c/bento4
echo "/c/bento4/Bento4-SDK-${B4_VER}.x86_64-microsoft-win32/bin" >> $GITHUB_PATH
# xxd available via git or vim; ensure it's on PATH
echo "Checking xxd..."
which xxd || true

- name: Set up Python (Windows)
if: runner.os == 'Windows'
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Configure
shell: bash
run: |
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/update-packaging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
# Always base packaging changes on the default branch (main), not the tag name.
ref: main

- name: Determine tag
id: vars
Expand Down Expand Up @@ -83,4 +84,4 @@ jobs:
- Update Homebrew formula URL/SHA to ${{ steps.vars.outputs.tag }}
- Update vcpkg overlay REF/SHA512 to ${{ steps.vars.outputs.tag }}
branch: "bot/update-packaging-${{ steps.vars.outputs.tag }}"
base: ${{ github.event.repository.default_branch }}
base: main
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,19 @@ The overlay tracks the current commit. Update `REF`/`SHA512` in `ports/chapterfo
```

- 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`.
it is reused by default. Fast-start is ON by default (moov before mdat); use `--no-faststart` if you
need the legacy layout.
- 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.
- `--faststart` (write) Explicitly enable fast-start (default).
- `--no-faststart` (write) Disable fast-start; keep `mdat` before `moov`.
- `--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 All @@ -190,7 +192,7 @@ ChapterForge consumes a simple JSON document:
"chapters": [
{
"title": "Introduction", // required
"start_ms": 0, // required: chapter start time in milliseconds
"start_ms": 0, // required: chapter start time in milliseconds (first snaps to 0)
"image": "chapter1.jpg", // optional; path relative to the JSON file
"url": "https://example.com", // optional; creates a URL text track with HREF
"url_text": "Intro link label" // optional; text payload for the URL track (defaults empty)
Expand Down
47 changes: 47 additions & 0 deletions include/fourcc_utils.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// fourcc_utils.hpp
// ChapterForge
//
// Created by Till Toenshoff on 12/9/25.
// Copyright © 2025 Till Toenshoff. All rights reserved.
//

#pragma once

#include <cstdint>
#include <stdexcept>
#include <string>

// FourCC helpers.
inline constexpr uint32_t fourcc(const char a, const char b, const char c, const char d) {
return (uint32_t(uint8_t(a)) << 24) | (uint32_t(uint8_t(b)) << 16) |
(uint32_t(uint8_t(c)) << 8) | (uint32_t(uint8_t(d)));
}

inline constexpr uint32_t fourcc(const char t[4]) { return fourcc(t[0], t[1], t[2], t[3]); }

inline uint32_t fourcc(const std::string &s) {
if (s.size() < 4) {
throw std::runtime_error("fourcc string too short");
}
return fourcc(s[0], s[1], s[2], s[3]);
}

inline bool is_printable_fourcc(uint32_t type) {
for (int i = 0; i < 4; ++i) {
const uint8_t c = static_cast<uint8_t>(type >> (24 - 8 * i));
if (c < 0x20 || c > 0x7E) {
return false;
}
}
return true;
}

inline std::string fourcc_to_string(uint32_t type) {
std::string s(4, ' ');
s[0] = static_cast<char>((type >> 24) & 0xFF);
s[1] = static_cast<char>((type >> 16) & 0xFF);
s[2] = static_cast<char>((type >> 8) & 0xFF);
s[3] = static_cast<char>(type & 0xFF);
return s;
}
16 changes: 2 additions & 14 deletions include/mp4_atoms.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,13 @@
#include <string>
#include <vector>

#include "fourcc_utils.hpp"

// Forward declaration.
class Atom;

using AtomPtr = std::unique_ptr<Atom>;

// FourCC helpers.
inline uint32_t fourcc(const char a, const char b, const char c, const char d) {
return (uint32_t(uint8_t(a)) << 24) | (uint32_t(uint8_t(b)) << 16) |
(uint32_t(uint8_t(c)) << 8) | (uint32_t(uint8_t(d)));
}

inline uint32_t fourcc(const char t[4]) { return fourcc(t[0], t[1], t[2], t[3]); }

inline uint32_t fourcc(const std::string &s) {
if (s.size() < 4)
throw std::runtime_error("fourcc string too short");
return fourcc(s[0], s[1], s[2], s[3]);
}

class Atom {
public:
uint32_t type = 0; // FourCC
Expand Down
6 changes: 1 addition & 5 deletions src/chapterforge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#include "chapter_text_sample.hpp"
#include "chapter_image_sample.hpp"
#include "mp4a_builder.hpp"
#include "mp4_atoms.hpp"
#include "mp4_muxer.hpp"
#include "parser.hpp"

Expand Down Expand Up @@ -85,11 +86,6 @@ inline uint32_t be32(const uint8_t *p) {
(static_cast<uint32_t>(p[2]) << 8) | static_cast<uint32_t>(p[3]);
}

constexpr uint32_t fourcc(uint8_t a, uint8_t b, uint8_t c, uint8_t d) {
return (static_cast<uint32_t>(a) << 24) | (static_cast<uint32_t>(b) << 16) |
(static_cast<uint32_t>(c) << 8) | static_cast<uint32_t>(d);
}

// Minimal ilst parser to surface top-level metadata into MetadataSet.
static void parse_ilst_metadata(const std::vector<uint8_t> &ilst, MetadataSet &out) {
auto extract_data_box = [](const uint8_t *base, size_t len,
Expand Down
15 changes: 9 additions & 6 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,13 @@ int main(int argc, char **argv) {
// Gather positional arguments (non-option).
std::vector<std::string> positional;
std::filesystem::path export_dir;
bool fast_start = false;
bool fast_start = true; // Default to fast-start layout.
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--faststart") {
fast_start = true;
} else if (arg == "--no-faststart") {
fast_start = false;
} else if (arg == "--log-level" && i + 1 < argc) {
chapterforge::set_log_verbosity(parse_level(argv[i + 1]));
++i;
Expand All @@ -134,14 +136,15 @@ int main(int argc, char **argv) {
if (positional.empty()) {
std::cerr << "ChapterForge " << CHAPTERFORGE_VERSION_DISPLAY << "\n"
<< "Copyright (c) 2025 Till Toenshoff\n\n"
<< "usage for reading:\n"
<< "Usage for reading:\n"
<< " chapterforge <input.m4a> [--export-jpegs DIR] "
<< "[--log-level warn|info|debug]\n"
<< "usage for writing:\n"
<< "[--log-level warn|info|debug]\n\n"
<< "Usage for writing:\n"
<< " chapterforge <input.aac|input.m4a> <chapters.json> <output.m4a> "
<< "[--faststart] [--log-level warn|info|debug]\n"
<< "[--no-faststart|--faststart] [--log-level warn|info|debug]\n\n"
<< "Options:\n"
<< " --faststart Place 'moov' atom before 'mdat' for faster playback start.\n"
<< " --faststart Place 'moov' atom before 'mdat' for faster playback start (default).\n"
<< " --no-faststart Write classic layout with 'mdat' before 'moov'.\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";
Expand Down
15 changes: 4 additions & 11 deletions src/mdat_writer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@

#include <stdexcept>

// -----------------------------------------------------------------------------
// Write mdat and record sample offsets.
// -----------------------------------------------------------------------------
// Write the mdat box and collect relative offsets for each track.
MdatOffsets write_mdat(
std::ofstream &out, const std::vector<std::vector<uint8_t>> &audio_samples,
const std::vector<std::vector<std::vector<uint8_t>>> &text_tracks_samples,
Expand Down Expand Up @@ -103,9 +101,7 @@ MdatOffsets write_mdat(
return result;
}

// -----------------------------------------------------------------------------
// Patch a single stco table.
// -----------------------------------------------------------------------------
// Update a single stco table with absolute offsets based on the mdat payload start.
void patch_stco_table(Atom *stco, const std::vector<uint32_t> &offsets,
uint64_t mdat_payload_start) {
if (!stco) {
Expand Down Expand Up @@ -134,8 +130,7 @@ void patch_stco_table(Atom *stco, const std::vector<uint32_t> &offsets,
}
}

// -----------------------------------------------------------------------------
// Patch all stco atoms (order: audio, text tracks..., image)
// Patch all stco 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");
Expand All @@ -152,9 +147,7 @@ void patch_all_stco(Atom *moov, const MdatOffsets &offs, bool patch_audio) {
}
}

// -----------------------------------------------------------------------------
// Compute offsets only (no writing), given a payload start.
// -----------------------------------------------------------------------------
// Compute offsets without writing an mdat (used for fast layout calculations).
MdatOffsets compute_mdat_offsets( uint64_t payload_start,
const std::vector<std::vector<uint8_t>> &audio_samples,
const std::vector<std::vector<std::vector<uint8_t>>> &text_tracks_samples,
Expand Down
8 changes: 4 additions & 4 deletions src/meta_builder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
// lang (uint32=0)
// <raw data>

// Create a single ilst entry with a data atom of the given type.
std::unique_ptr<Atom> build_ilst_item(const std::string &fourcc, const std::vector<uint8_t> &data,
uint32_t data_type) {
auto item = Atom::create(fourcc.c_str());
Expand All @@ -40,8 +41,8 @@ std::unique_ptr<Atom> build_ilst_item(const std::string &fourcc, const std::vect
return item;
}

// -------------------------------------------------------------------------

// Assemble an ilst atom from the supplied items.
std::unique_ptr<Atom> build_ilst(std::vector<std::unique_ptr<Atom>> items) {
auto ilst = Atom::create("ilst");

Expand All @@ -52,8 +53,8 @@ std::unique_ptr<Atom> build_ilst(std::vector<std::unique_ptr<Atom>> items) {
return ilst;
}

// -------------------------------------------------------------------------

// Wrap ilst in a meta atom containing the required handler.
std::unique_ptr<Atom> build_meta(std::unique_ptr<Atom> ilst) {
auto meta = Atom::create("meta");

Expand Down Expand Up @@ -115,9 +116,8 @@ static std::unique_ptr<Atom> build_cover_item(const std::vector<uint8_t> &cover)
return item;
}

// -----------------------------------------------------------------------------
// Build udta/meta/ilst container (Apple-style)
// -----------------------------------------------------------------------------
// Build the full udta/meta/ilst structure for top-level metadata.
std::unique_ptr<Atom> build_meta_atom(const MetadataSet &meta) {
//
// ilst.
Expand Down
18 changes: 2 additions & 16 deletions src/mp4_atoms.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,15 @@

#include "mp4_atoms.hpp"

// -----------------------------------------------------------------------------
// Factory.
// -----------------------------------------------------------------------------
// Factory helpers.
AtomPtr Atom::create(const char t[4]) { return std::make_unique<Atom>(t); }

AtomPtr Atom::create(uint32_t t) { return std::make_unique<Atom>(t); }

AtomPtr Atom::create(const std::string &t) { return std::make_unique<Atom>(fourcc(t)); }

// -----------------------------------------------------------------------------
// Add child.
// -----------------------------------------------------------------------------
// Child management.
void Atom::add(AtomPtr child) { children.push_back(std::move(child)); }

// -----------------------------------------------------------------------------
// Recursive find.
// -----------------------------------------------------------------------------
std::vector<Atom *> Atom::find(const std::string &t) {
std::vector<Atom *> result;

Expand All @@ -42,9 +34,7 @@ std::vector<Atom *> Atom::find(const std::string &t) {
return result;
}

// -----------------------------------------------------------------------------
// Compute recursive box size.
// -----------------------------------------------------------------------------
void Atom::fix_size_recursive() {
// Start with MP4 header: 8 bytes (size + type)
uint32_t total = 8;
Expand All @@ -61,14 +51,10 @@ void Atom::fix_size_recursive() {
box_size = total;
}

// -----------------------------------------------------------------------------
// Return box size.
// -----------------------------------------------------------------------------
uint32_t Atom::size() const { return box_size; }

// -----------------------------------------------------------------------------
// Write atom to file.
// -----------------------------------------------------------------------------
void Atom::write(std::ofstream &out) const {
uint32_t s = box_size;

Expand Down
Loading