Skip to content

Commit 503a586

Browse files
committed
feat: now support url-text additionally to the href
1 parent 17e915a commit 503a586

10 files changed

Lines changed: 297 additions & 16 deletions

CMakeLists.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,30 @@ if(ENABLE_OUTPUT_TOOL_TESTS)
315315
)
316316
set_tests_properties(offset_first_chapter_bento PROPERTIES FIXTURES_REQUIRED assets LABELS "tooling")
317317

318+
add_test(
319+
NAME url_text_payload
320+
COMMAND ${CMAKE_COMMAND}
321+
-DINPUT_M4A=${INPUT_M4A_PATH}
322+
-DCHAPTER_JSON=${TESTDATA_DIR}/chapters_10s_urltext.json
323+
-DOUTPUT_M4A=${TEST_OUTPUT_DIR}/output_urltext_payload.m4a
324+
-DCHAPTERFORGE_BIN=$<TARGET_FILE:chapterforge_cli>
325+
-DMP4DUMP_PATH=${MP4DUMP_PATH}
326+
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_url_text.cmake
327+
)
328+
set_tests_properties(url_text_payload PROPERTIES FIXTURES_REQUIRED assets LABELS "tooling")
329+
330+
add_test(
331+
NAME url_text_avfoundation
332+
COMMAND ${CMAKE_COMMAND}
333+
-DCHAPTERFORGE_BIN=$<TARGET_FILE:chapterforge_cli>
334+
-DINPUT_M4A=${INPUT_M4A_PATH}
335+
-DCHAPTER_JSON=${TESTDATA_DIR}/chapters_10s_urltext.json
336+
-DOUTPUT_M4A=${TEST_OUTPUT_DIR}/output_urltext_avf.m4a
337+
-DPROJECT_ROOT=${CMAKE_CURRENT_SOURCE_DIR}
338+
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/run_avfoundation_urltext.cmake
339+
)
340+
set_tests_properties(url_text_avfoundation PROPERTIES FIXTURES_REQUIRED assets LABELS "tooling")
341+
318342
add_test(
319343
NAME output_tool_checks
320344
COMMAND ${CMAKE_COMMAND}

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ ChapterForge uses the audio track from the input. It then combines that with a t
5656

5757
Supported players (just a selection of known goods):
5858

59-
| | text | image |
60-
| :---: | :---: | :---: |
61-
| QuickTime.app | X | X |
62-
| Music.app | X | X |
63-
| Books.app | X | X |
64-
| VLC | X | |
59+
| | text | image | url | url-text |
60+
| :---: | :---: | :---: | :---: | :---: |
61+
| QuickTime.app | X | X | | |
62+
| Music.app | X | X | | |
63+
| Books.app | X | X | | |
64+
| VLC | X | | | |
65+
66+
Note that AVFoundation supports all of those attributes for parsing and extraction - on macOS and iOS it is therefor trivial to support them.
6567

6668

6769
## Building
@@ -132,7 +134,8 @@ ChapterForge consumes a simple JSON document:
132134
"title": "Introduction", // required
133135
"start_ms": 0, // required: chapter start time in milliseconds
134136
"image": "chapter1.jpg", // optional; path relative to the JSON file
135-
"url": "https://example.com" // optional; creates a URL text track with HREF
137+
"url": "https://example.com", // optional; creates a URL text track with HREF
138+
"url_text": "Intro link label" // optional; text payload for the URL track (defaults empty)
136139
},
137140
{
138141
"title": "Main Discussion",
@@ -150,11 +153,13 @@ Notes:
150153
`start_ms`. We warn on non-zero first starts; if you truly need a gap, add an explicit
151154
leading “blank” chapter covering 0–gap_ms.
152155
- Chapter images are optional; omit `image` to create a text-only chapter.
156+
- URL track text: `url_text` is optional and defaults to empty (Apple-authored behavior). If set,
157+
it travels in the URL tx3g samples; some players may surface it as visible text.
153158
- Chapter URLs are optional; omit `url` to skip the URL track entirely.
154159
- If top-level metadata fields are omitted and the input file already contains metadata (`ilst`), that metadata is preserved automatically.
155160
- Paths for `cover` and per-chapter `image` are resolved relative to the JSON file location.
156161

157-
> **First chapter behavior (Apple/VLC)**
162+
> **First chapter behavior (Apple/VLC)**
158163
> The chapter tracks are duration-based (`stts`), but most players force the first sample to start
159164
> at t=0. A non-zero first `start_ms` will be snapped to 0 in QuickTime, Music.app, AVFoundation,
160165
> and VLC. If you need silence/blank time before your “real” first chapter, add a leading placeholder

include/chapterforge.hpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ bool mux_file_to_m4a(const std::string &input_audio_path,
8888
/// - Parameters:
8989
/// - input_audio_path: Path to AAC (ADTS) or MP4/M4A containing AAC.
9090
/// - text_chapters: Chapter titles (`text`/`start_ms`; `href` unused here).
91-
/// - url_chapters: Optional URL track; set `href` per sample, `text` may be empty. Leave empty to
92-
/// skip the URL track.
91+
/// - url_chapters: Optional URL track; set `href` per sample; `text` (or `url_text` in JSON) is
92+
/// optional and defaults to empty to match Apple-authored files. Leave empty to skip the URL track.
9393
/// - image_chapters: Optional JPEG data per chapter; leave empty to omit the image track.
9494
/// - output_path: Destination .m4a file.
9595
/// - fast_start: When true, places `moov` ahead of `mdat`.
@@ -107,8 +107,8 @@ bool mux_file_to_m4a(const std::string &input_audio_path,
107107
/// - Parameters:
108108
/// - input_audio_path: Path to AAC (ADTS) or MP4/M4A containing AAC.
109109
/// - text_chapters: Chapter titles (`text`/`start_ms`; `href` unused here).
110-
/// - url_chapters: Optional URL track; set `href` per sample, `text` may be empty. Leave empty to
111-
/// skip the URL track.
110+
/// - url_chapters: Optional URL track; set `href` per sample; `text` (or `url_text` in JSON) is
111+
/// optional and defaults to empty to match Apple-authored files. Leave empty to skip the URL track.
112112
/// - image_chapters: Optional JPEG data per chapter; leave empty to omit the image track.
113113
/// - metadata: Top-level metadata; reused from input `ilst` if empty.
114114
/// - output_path: Destination .m4a file.

include/stbl_text_builder.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313

1414
#include "mp4_atoms.hpp"
1515

16-
/// Represents a chapter title sample.
17-
/// - `text`: UTF-8 chapter title.
16+
/// Represents a chapter title/URL sample.
17+
/// - `text`: UTF-8 chapter title (or URL text, if used on a URL track).
1818
/// - `href`: Optional hyperlink URL (tx3g modifier). Leave empty for plain title.
1919
/// - `start_ms`: Absolute start time in milliseconds.
2020
struct ChapterTextSample {

src/chapterforge.cpp

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ struct PendingChapter {
6262
uint32_t start_ms = 0;
6363
std::string image_path;
6464
std::string url;
65+
std::string url_text;
6566
};
6667

6768
static bool load_chapters_json(
@@ -89,6 +90,7 @@ static bool load_chapters_json(
8990
p.start_ms = c.value("start_ms", 0);
9091
p.image_path = c.value("image", "");
9192
p.url = c.value("url", "");
93+
p.url_text = c.value("url_text", "");
9294
pending.emplace_back(std::move(p));
9395
}
9496
}
@@ -101,8 +103,8 @@ static bool load_chapters_json(
101103
t.start_ms = p.start_ms;
102104
ChapterTextSample url_sample{};
103105
url_sample.start_ms = p.start_ms;
104-
// Keep URL track text empty; golden URL tracks can carry minimal/empty text.
105-
url_sample.text = "";
106+
// Default to empty URL text (Apple “golden” behavior); JSON may supply `url_text`.
107+
url_sample.text = p.url_text;
106108
if (!p.url.empty()) {
107109
any_url = true;
108110
url_sample.href = p.url;

testdata/chapters_10s_urltext.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"title": "URL Text Experiment",
3+
"artist": "Debug Duo",
4+
"album": "URL Texts",
5+
"genre": "Test Audio",
6+
"year": "2025",
7+
"chapters": [
8+
{
9+
"title": "URLText One",
10+
"start_ms": 0,
11+
"url": "https://urltext.test/one",
12+
"url_text": "custom-url-text-one"
13+
},
14+
{
15+
"title": "URLText Two",
16+
"start_ms": 5000,
17+
"url": "https://urltext.test/two",
18+
"url_text": "custom-url-text-two"
19+
}
20+
]
21+
}

tests/avfoundation_urltext.sh

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
# AVFoundation test: ensure URL track tx3g samples contain url_text payloads.
3+
# Usage: AVF_INPUT=path/to/input.m4a AVF_JSON=path/to/chapters.json AVF_OUT=path/to/output.m4a ./tests/avfoundation_urltext.sh
4+
5+
set -euo pipefail
6+
7+
FILE_IN="${AVF_INPUT:-}"
8+
FILE_JSON="${AVF_JSON:-}"
9+
FILE_OUT="${AVF_OUT:-}"
10+
BIN="${AVF_BIN:-./chapterforge_cli}"
11+
TMPDIR_SAFE="${TMPDIR:-/tmp}"
12+
export SWIFT_MODULE_CACHE_PATH="${SWIFT_MODULE_CACHE_PATH:-$TMPDIR_SAFE/swift_module_cache}"
13+
export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-$TMPDIR_SAFE/clang_module_cache}"
14+
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$TMPDIR_SAFE}"
15+
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
16+
mkdir -p "$SWIFT_MODULE_CACHE_PATH" "$CLANG_MODULE_CACHE_PATH"
17+
18+
if [ -z "$FILE_IN" ] || [ -z "$FILE_JSON" ] || [ -z "$FILE_OUT" ]; then
19+
echo "AVF_INPUT, AVF_JSON, AVF_OUT env vars must be set" >&2
20+
exit 1
21+
fi
22+
if [ ! -f "$FILE_IN" ]; then
23+
echo "input not found: $FILE_IN" >&2; exit 1
24+
fi
25+
if [ ! -f "$FILE_JSON" ]; then
26+
echo "json not found: $FILE_JSON" >&2; exit 1
27+
fi
28+
29+
if [ ! -x "$BIN" ]; then
30+
echo "chapterforge_cli not found/executable at: $BIN" >&2
31+
exit 1
32+
fi
33+
34+
"$BIN" "$FILE_IN" "$FILE_JSON" "$FILE_OUT"
35+
36+
SWIFT_SRC="$(mktemp "$TMPDIR_SAFE/avf_urltext.XXXXXX.swift")"
37+
cat >"$SWIFT_SRC" <<'SWIFT'
38+
import AVFoundation
39+
import Foundation
40+
41+
let path = ProcessInfo.processInfo.environment["AVF_FILE"] ?? ""
42+
if path.isEmpty {
43+
fputs("AVF_FILE not set\n", stderr)
44+
exit(1)
45+
}
46+
let url = URL(fileURLWithPath: path)
47+
let asset = AVURLAsset(url: url)
48+
let textTracks = asset.tracks(withMediaType: .text)
49+
if textTracks.isEmpty {
50+
fputs("no text tracks found\n", stderr)
51+
exit(1)
52+
}
53+
54+
var foundOne = false
55+
var foundTwo = false
56+
57+
for t in textTracks {
58+
guard let reader = try? AVAssetReader(asset: asset) else { continue }
59+
let output = AVAssetReaderTrackOutput(track: t, outputSettings: nil)
60+
reader.add(output)
61+
if !reader.startReading() { continue }
62+
while let sb = output.copyNextSampleBuffer() {
63+
if let bb = CMSampleBufferGetDataBuffer(sb) {
64+
let len = CMBlockBufferGetDataLength(bb)
65+
var data = Data(count: len)
66+
data.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in
67+
_ = CMBlockBufferCopyDataBytes(bb, atOffset: 0, dataLength: len, destination: ptr.baseAddress!)
68+
}
69+
if data.contains("custom-url-text-one".data(using: .utf8)!) { foundOne = true }
70+
if data.contains("custom-url-text-two".data(using: .utf8)!) { foundTwo = true }
71+
}
72+
}
73+
if foundOne && foundTwo { break }
74+
}
75+
76+
if !foundOne || !foundTwo {
77+
fputs("URL text payloads not found via AVFoundation: one=\(foundOne) two=\(foundTwo)\n", stderr)
78+
exit(1)
79+
}
80+
81+
print("AVFoundation URL text OK: found custom-url-text-one/two")
82+
SWIFT
83+
84+
trap 'rm -f "$SWIFT_SRC"' EXIT
85+
AVF_FILE="$FILE_OUT" swift "$SWIFT_SRC"

tests/check_url_text.cmake

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
cmake_minimum_required(VERSION 3.16)
2+
3+
set(INPUT_M4A "" CACHE FILEPATH "Input M4A")
4+
set(CHAPTER_JSON "" CACHE FILEPATH "Chapter JSON")
5+
set(OUTPUT_M4A "" CACHE FILEPATH "Output M4A")
6+
set(CHAPTERFORGE_BIN "" CACHE FILEPATH "chapterforge_cli path")
7+
set(MP4DUMP_PATH "" CACHE FILEPATH "mp4dump path")
8+
9+
if(NOT EXISTS "${INPUT_M4A}")
10+
message(FATAL_ERROR "INPUT_M4A not found: ${INPUT_M4A}")
11+
endif()
12+
if(NOT EXISTS "${CHAPTER_JSON}")
13+
message(FATAL_ERROR "CHAPTER_JSON not found: ${CHAPTER_JSON}")
14+
endif()
15+
if(NOT EXISTS "${CHAPTERFORGE_BIN}")
16+
message(FATAL_ERROR "CHAPTERFORGE_BIN not found: ${CHAPTERFORGE_BIN}")
17+
endif()
18+
if(NOT EXISTS "${MP4DUMP_PATH}")
19+
message(FATAL_ERROR "mp4dump not found; set MP4DUMP_PATH")
20+
endif()
21+
22+
execute_process(
23+
COMMAND "${CHAPTERFORGE_BIN}" "${INPUT_M4A}" "${CHAPTER_JSON}" "${OUTPUT_M4A}"
24+
RESULT_VARIABLE mux_res
25+
)
26+
if(NOT mux_res EQUAL 0)
27+
message(FATAL_ERROR "chapterforge_cli failed: ${mux_res}")
28+
endif()
29+
30+
find_program(STRINGS_PATH strings)
31+
if(NOT STRINGS_PATH)
32+
message(FATAL_ERROR "strings utility not found on PATH")
33+
endif()
34+
35+
execute_process(
36+
COMMAND "${STRINGS_PATH}" "${OUTPUT_M4A}"
37+
RESULT_VARIABLE strings_res
38+
OUTPUT_VARIABLE strings_out
39+
)
40+
if(NOT strings_res EQUAL 0)
41+
message(FATAL_ERROR "strings failed: ${strings_res}")
42+
endif()
43+
44+
string(FIND "${strings_out}" "custom-url-text-one" pos_one)
45+
if(pos_one EQUAL -1)
46+
message(FATAL_ERROR "URL text 'custom-url-text-one' not found in output (strings scan)")
47+
endif()
48+
49+
string(FIND "${strings_out}" "custom-url-text-two" pos_two)
50+
if(pos_two EQUAL -1)
51+
message(FATAL_ERROR "URL text 'custom-url-text-two' not found in output (strings scan)")
52+
endif()
53+
54+
string(FIND "${strings_out}" "https://urltext.test/one" pos_href_one)
55+
if(pos_href_one EQUAL -1)
56+
message(FATAL_ERROR "URL href for chapter 1 not found in output (strings scan)")
57+
endif()
58+
59+
string(FIND "${strings_out}" "https://urltext.test/two" pos_href_two)
60+
if(pos_href_two EQUAL -1)
61+
message(FATAL_ERROR "URL href for chapter 2 not found in output (strings scan)")
62+
endif()
63+
64+
message(STATUS "URL text payloads found in tx3g samples")

tests/overload_variants.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,45 @@ int main(int argc, char **argv) {
134134
return 1;
135135
}
136136

137+
// 6) Titles + images (no URL track), metadata reused (4-arg overload).
138+
std::string out_img_nometa =
139+
(std::filesystem::path(out_dir) / "overload_img_nometa.m4a").string();
140+
ok = mux_file_to_m4a(input, titles, images, out_img_nometa, true);
141+
if (!ok) {
142+
std::cerr << "mux (titles+images, 4-arg) failed\n";
143+
return 1;
144+
}
145+
if (!std::filesystem::exists(out_img_nometa) ||
146+
std::filesystem::file_size(out_img_nometa) == 0) {
147+
std::cerr << "output missing or empty: " << out_img_nometa << "\n";
148+
return 1;
149+
}
150+
151+
// 7) JSON-path overload (includes optional url_text field).
152+
std::string json_path = (std::filesystem::path(out_dir) / "overload_inline.json").string();
153+
{
154+
std::ofstream jf(json_path);
155+
jf << R"({
156+
"title": "JSON Overload",
157+
"artist": "ChapterForge",
158+
"chapters": [
159+
{ "start_ms": 0, "title": "J One", "url": "https://json.test/one", "url_text": "json-urltext-1" },
160+
{ "start_ms": 5000, "title": "J Two", "url": "https://json.test/two", "url_text": "json-urltext-2" }
161+
]
162+
})";
163+
}
164+
std::string out_json =
165+
(std::filesystem::path(out_dir) / "overload_json.m4a").string();
166+
ok = mux_file_to_m4a(input, json_path, out_json, true);
167+
if (!ok) {
168+
std::cerr << "mux (json overload) failed\n";
169+
return 1;
170+
}
171+
if (!std::filesystem::exists(out_json) || std::filesystem::file_size(out_json) == 0) {
172+
std::cerr << "output missing or empty: " << out_json << "\n";
173+
return 1;
174+
}
175+
137176
std::cout << "overload variants OK\n";
138177
return 0;
139178
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
cmake_minimum_required(VERSION 3.16)
2+
3+
set(INPUT_M4A "" CACHE FILEPATH "Input M4A")
4+
set(CHAPTER_JSON "" CACHE FILEPATH "Chapter JSON")
5+
set(OUTPUT_M4A "" CACHE FILEPATH "Output M4A")
6+
set(CHAPTERFORGE_BIN "" CACHE FILEPATH "chapterforge_cli path")
7+
set(PROJECT_ROOT "" CACHE FILEPATH "Project root")
8+
9+
if(NOT EXISTS "${INPUT_M4A}")
10+
message(FATAL_ERROR "INPUT_M4A not found: ${INPUT_M4A}")
11+
endif()
12+
if(NOT EXISTS "${CHAPTER_JSON}")
13+
message(FATAL_ERROR "CHAPTER_JSON not found: ${CHAPTER_JSON}")
14+
endif()
15+
if(NOT EXISTS "${CHAPTERFORGE_BIN}")
16+
message(FATAL_ERROR "CHAPTERFORGE_BIN not found: ${CHAPTERFORGE_BIN}")
17+
endif()
18+
19+
execute_process(
20+
COMMAND "${CHAPTERFORGE_BIN}" "${INPUT_M4A}" "${CHAPTER_JSON}" "${OUTPUT_M4A}"
21+
RESULT_VARIABLE mux_res
22+
)
23+
if(NOT mux_res EQUAL 0)
24+
message(FATAL_ERROR "chapterforge_cli failed: ${mux_res}")
25+
endif()
26+
27+
execute_process(
28+
COMMAND "${CMAKE_COMMAND}" -E env
29+
AVF_INPUT=${INPUT_M4A}
30+
AVF_JSON=${CHAPTER_JSON}
31+
AVF_OUT=${OUTPUT_M4A}
32+
AVF_BIN=${CHAPTERFORGE_BIN}
33+
/bin/bash "${PROJECT_ROOT}/tests/avfoundation_urltext.sh"
34+
WORKING_DIRECTORY "${PROJECT_ROOT}"
35+
RESULT_VARIABLE avf_res
36+
)
37+
if(NOT avf_res EQUAL 0)
38+
message(FATAL_ERROR "AVFoundation url_text test failed: ${avf_res}")
39+
endif()
40+
41+
message(STATUS "AVFoundation URL text test passed")

0 commit comments

Comments
 (0)