Skip to content

Commit d2726e9

Browse files
committed
fix: restore live DASH playback
1 parent b2c2981 commit d2726e9

9 files changed

Lines changed: 474 additions & 18 deletions

File tree

docs/quickstart.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ ffmpeg -re \
156156
http://127.0.0.1:8080/ingest/
157157
```
158158

159+
For FFmpeg DASH input, the ingest adapter expands `tfhd` sample defaults into
160+
the outgoing `trun` entries before publishing CMAF objects. Video keyframes
161+
start shared media groups, and audio objects join the active video group;
162+
media received before the first video keyframe is discarded. This keeps the
163+
representations independently addressable while preserving aligned render
164+
groups for downstream playback.
165+
159166
Useful DASH ingest flags:
160167

161168
- `--dash-listen <host:port>` chooses the local HTTP listener address

docs/testing.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ This covers:
2525

2626
Publish-plan numbering notes:
2727

28-
- `group_id` is allocated per track, not across all tracks, so interleaved audio and video fragments can both use `0, 1, 2, ...`
28+
- file-based publish plans allocate `group_id` per track, so interleaved audio and video fragments can both use `0, 1, 2, ...`
29+
- live DASH uses each video keyframe to start a shared media group; audio joins that active group and each track maintains its own `object_id` sequence within it
2930
- by default, `object_id` advances within a group when CMAF content is split into multiple MOQT objects for lower latency
3031
- `--coalesce-cmaf-chunks` forces `object_id = 0` for the current one-object-per-group fallback
3132
- MSF media timeline tracks are disabled by default; add `--msf-timeline` when you want a `timeline` metadata track and object
@@ -115,4 +116,9 @@ cmake --build build --target openmoq-publisher-live-dash-tests openmoq-publisher
115116
./build/openmoq-publisher-transport-tests
116117
```
117118

118-
The DASH test covers separate init and media requests on `/ingest/video0`, `/ingest/video1`, and `/ingest/video2`. The transport test verifies that a draft-16 live publisher advertises those tracks, waits for `SUBSCRIBE`, acknowledges it, and sends only the catalog plus the subscribed representation.
119+
The DASH test covers separate init and media requests, shared audio/video
120+
keyframe groups, and normalization of FFmpeg `tfhd` sample defaults into
121+
explicit `trun` sample fields. The transport test verifies that draft-16 keeps
122+
objects from the same group on one subgroup stream, closes that stream at a
123+
group transition, waits for `SUBSCRIBE`, and preserves the catalog while
124+
waiting for media interest.

docs/webtransport-compliance.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,12 @@ For `--forward 0`, once `PUBLISH_NAMESPACE_OK` has been received:
200200

201201
This behavior is intentional. It keeps interoperability probes from being reported as publish failures when the relay accepted the namespace but no subscriber appeared during the configured wait window.
202202

203-
Automated regressions cover the reported live DASH shape with catalog plus `/ingest/video0`, `/ingest/video1`, and `/ingest/video2` representations. The draft-16 session test verifies that source objects are not consumed before `SUBSCRIBE`, that `SUBSCRIBE_OK` is returned, and that only the catalog plus subscribed representation are sent. Draft-18 session coverage separately verifies that a `SUBSCRIBE` split across multiple request-stream reads is reassembled and answered on that request stream.
203+
Automated regressions cover the reported live DASH shape with catalog plus
204+
multiple representation paths. They verify shared audio/video keyframe groups,
205+
explicit `trun` sample metadata derived from FFmpeg `tfhd` defaults, subgroup
206+
stream reuse within a group, and stream closure at a group transition. The
207+
draft-16 session test also verifies that source objects are not consumed before
208+
`SUBSCRIBE`, that `SUBSCRIBE_OK` is returned, and that the catalog remains
209+
available while waiting for media interest. Draft-18 session coverage
210+
separately verifies that a `SUBSCRIBE` split across multiple request-stream
211+
reads is reassembled and answered on that request stream.

include/openmoq/publisher/live_dash_ingest.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include <memory>
99
#include <mutex>
1010
#include <optional>
11+
#include <set>
1112
#include <span>
1213
#include <string>
1314
#include <vector>
@@ -107,6 +108,11 @@ class LiveDashIngestSession {
107108
// aborting the whole session on an unknown track.
108109
bool tracks_frozen_ = false;
109110
std::size_t catalog_group_id_ = 0;
111+
std::size_t shared_media_group_id_ = 0;
112+
std::uint64_t shared_group_start_time_us_ = 0;
113+
std::map<std::string, std::size_t> shared_object_id_by_track_;
114+
std::set<std::string> video_tracks_started_in_group_;
115+
bool shared_media_group_started_ = false;
110116
bool catalog_dirty_ = false;
111117
bool closed_ = false;
112118
mutable std::mutex mutex_;

src/cmaf_segmenter.cpp

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,124 @@ std::vector<std::uint8_t> concat_boxes(const std::vector<std::vector<std::uint8_
212212
return out;
213213
}
214214

215+
struct LiveTrunSample {
216+
std::uint32_t duration = 0;
217+
std::uint32_t size = 0;
218+
std::uint32_t flags = 0;
219+
std::uint32_t composition_offset = 0;
220+
};
221+
222+
std::vector<std::uint8_t> materialize_live_trun_defaults(
223+
std::span<const std::uint8_t> moof_bytes,
224+
std::uint32_t default_sample_duration,
225+
std::uint32_t default_sample_size,
226+
std::uint32_t default_sample_flags) {
227+
const auto boxes = parse_mp4_boxes(moof_bytes);
228+
if (boxes.empty() || boxes.front().type != "moof") {
229+
throw std::runtime_error("materialize_live_trun_defaults: expected moof box");
230+
}
231+
const Mp4Box& moof = boxes.front();
232+
const Mp4Box* traf = find_child_box(moof, "traf");
233+
const Mp4Box* trun = traf == nullptr ? nullptr : find_child_box(*traf, "trun");
234+
if (traf == nullptr || trun == nullptr || trun->payload.size < 8) {
235+
throw std::runtime_error("materialize_live_trun_defaults: missing traf/trun");
236+
}
237+
238+
const std::uint8_t version = moof_bytes[trun->payload.offset];
239+
const std::uint32_t original_flags = read_full_box_flags(*trun, moof_bytes);
240+
std::size_t cursor = trun->payload.offset + 4;
241+
const std::uint32_t sample_count = read_be32(moof_bytes, cursor);
242+
cursor += 4;
243+
if ((original_flags & 0x000001U) != 0) {
244+
if (cursor + 4 > moof_bytes.size()) {
245+
throw std::runtime_error("materialize_live_trun_defaults: truncated data offset");
246+
}
247+
cursor += 4;
248+
}
249+
250+
std::uint32_t first_sample_flags = default_sample_flags;
251+
if ((original_flags & 0x000004U) != 0) {
252+
if (cursor + 4 > moof_bytes.size()) {
253+
throw std::runtime_error("materialize_live_trun_defaults: truncated first-sample flags");
254+
}
255+
first_sample_flags = read_be32(moof_bytes, cursor);
256+
cursor += 4;
257+
}
258+
259+
std::vector<LiveTrunSample> samples;
260+
samples.reserve(sample_count);
261+
for (std::uint32_t index = 0; index < sample_count; ++index) {
262+
LiveTrunSample sample{
263+
.duration = default_sample_duration,
264+
.size = default_sample_size,
265+
.flags = index == 0 ? first_sample_flags : default_sample_flags,
266+
};
267+
const auto read_optional = [&](std::uint32_t flag, std::uint32_t& value) {
268+
if ((original_flags & flag) == 0) {
269+
return;
270+
}
271+
if (cursor + 4 > moof_bytes.size()) {
272+
throw std::runtime_error("materialize_live_trun_defaults: truncated sample fields");
273+
}
274+
value = read_be32(moof_bytes, cursor);
275+
cursor += 4;
276+
};
277+
read_optional(0x000100U, sample.duration);
278+
read_optional(0x000200U, sample.size);
279+
read_optional(0x000400U, sample.flags);
280+
read_optional(0x000800U, sample.composition_offset);
281+
if (sample.size == 0) {
282+
throw std::runtime_error("materialize_live_trun_defaults: missing sample size");
283+
}
284+
samples.push_back(sample);
285+
}
286+
287+
const std::uint32_t normalized_flags = (original_flags & ~0x000004U) | 0x000701U;
288+
const auto build_trun = [&](std::uint32_t data_offset) {
289+
std::vector<std::uint8_t> payload;
290+
append_be32(payload, sample_count);
291+
append_be32(payload, data_offset);
292+
for (const auto& sample : samples) {
293+
append_be32(payload, sample.duration);
294+
append_be32(payload, sample.size);
295+
append_be32(payload, sample.flags);
296+
if ((normalized_flags & 0x000800U) != 0) {
297+
append_be32(payload, sample.composition_offset);
298+
}
299+
}
300+
return make_full_box("trun", version, normalized_flags, payload);
301+
};
302+
303+
const auto rebuild_moof = [&](const std::vector<std::uint8_t>& normalized_trun) {
304+
std::vector<std::vector<std::uint8_t>> traf_children;
305+
traf_children.reserve(traf->children.size());
306+
for (const auto& child : traf->children) {
307+
if (child.span.offset == trun->span.offset) {
308+
traf_children.push_back(normalized_trun);
309+
} else {
310+
const auto raw = slice_bytes(moof_bytes, child.span);
311+
traf_children.emplace_back(raw.begin(), raw.end());
312+
}
313+
}
314+
const auto normalized_traf = make_box("traf", concat_boxes(traf_children));
315+
316+
std::vector<std::vector<std::uint8_t>> moof_children;
317+
moof_children.reserve(moof.children.size());
318+
for (const auto& child : moof.children) {
319+
if (child.span.offset == traf->span.offset) {
320+
moof_children.push_back(normalized_traf);
321+
} else {
322+
const auto raw = slice_bytes(moof_bytes, child.span);
323+
moof_children.emplace_back(raw.begin(), raw.end());
324+
}
325+
}
326+
return make_box("moof", concat_boxes(moof_children));
327+
};
328+
329+
const auto placeholder_moof = rebuild_moof(build_trun(0));
330+
return rebuild_moof(build_trun(static_cast<std::uint32_t>(placeholder_moof.size() + 8)));
331+
}
332+
215333
const Mp4Box* require_child(const Mp4Box& box, std::string_view type) {
216334
const Mp4Box* child = find_child_box(box, type);
217335
if (child == nullptr) {
@@ -1048,6 +1166,7 @@ MediaFragment build_live_fragment(std::span<const std::uint8_t> moof_bytes,
10481166
}
10491167

10501168
std::uint32_t default_sample_duration = 0;
1169+
std::uint32_t default_sample_size = 0;
10511170
std::uint32_t default_sample_flags = 0x02000000U;
10521171
if (const Mp4Box* tfhd = find_child_box(*traf, "tfhd")) {
10531172
const std::uint32_t flags = read_full_box_flags(*tfhd, moof_bytes);
@@ -1059,6 +1178,7 @@ MediaFragment build_live_fragment(std::span<const std::uint8_t> moof_bytes,
10591178
cursor += 4;
10601179
}
10611180
if ((flags & 0x000010U) != 0 && cursor + 4 <= moof_bytes.size()) {
1181+
default_sample_size = read_be32(moof_bytes, cursor);
10621182
cursor += 4;
10631183
}
10641184
if ((flags & 0x000020U) != 0 && cursor + 4 <= moof_bytes.size()) {
@@ -1131,10 +1251,16 @@ MediaFragment build_live_fragment(std::span<const std::uint8_t> moof_bytes,
11311251
// A video keyframe: video track with sync first sample
11321252
const bool is_video_keyframe = is_video && first_sample_is_sync;
11331253

1254+
// FFmpeg's DASH muxer relies on tfhd defaults and emits minimal trun
1255+
// entries. Materialize those values for CMAF consumers that read sample
1256+
// metadata directly from trun.
1257+
const std::vector<std::uint8_t> normalized_moof = materialize_live_trun_defaults(
1258+
moof_bytes, default_sample_duration, default_sample_size, default_sample_flags);
1259+
11341260
// Combine moof+mdat into a single owned payload (CMSF compliance).
11351261
std::vector<std::uint8_t> payload;
1136-
payload.reserve(moof_bytes.size() + mdat_bytes.size());
1137-
payload.insert(payload.end(), moof_bytes.begin(), moof_bytes.end());
1262+
payload.reserve(normalized_moof.size() + mdat_bytes.size());
1263+
payload.insert(payload.end(), normalized_moof.begin(), normalized_moof.end());
11381264
payload.insert(payload.end(), mdat_bytes.begin(), mdat_bytes.end());
11391265

11401266
return MediaFragment{

src/live_dash_ingest.cpp

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,40 @@ void LiveDashIngestSession::process_box_locked(PathState& path_state,
453453
if (!track_published_locked(fragment.track_name)) {
454454
return;
455455
}
456-
fragment.group_id = path_state.next_group_by_track[fragment.track_name]++;
456+
const auto track_it = std::find_if(path_state.tracks.begin(), path_state.tracks.end(),
457+
[&](const TrackDescription& track) {
458+
return track.track_name == fragment.track_name;
459+
});
460+
const bool is_video = track_it != path_state.tracks.end() && track_it->handler_type == "vide";
461+
const bool has_video = std::any_of(tracks_.begin(), tracks_.end(),
462+
[](const RegisteredTrack& track) {
463+
return track.description.handler_type == "vide";
464+
});
465+
if (has_video) {
466+
if (is_video && fragment.is_video_keyframe) {
467+
if (!shared_media_group_started_) {
468+
shared_media_group_started_ = true;
469+
shared_group_start_time_us_ = fragment.start_time_us;
470+
} else if (fragment.start_time_us > shared_group_start_time_us_) {
471+
++shared_media_group_id_;
472+
shared_group_start_time_us_ = fragment.start_time_us;
473+
shared_object_id_by_track_.clear();
474+
video_tracks_started_in_group_.clear();
475+
} else if (fragment.start_time_us < shared_group_start_time_us_) {
476+
return;
477+
}
478+
video_tracks_started_in_group_.insert(fragment.track_name);
479+
}
480+
if (!shared_media_group_started_ ||
481+
(is_video && !video_tracks_started_in_group_.contains(fragment.track_name))) {
482+
return;
483+
}
484+
fragment.group_id = shared_media_group_id_;
485+
fragment.object_id = shared_object_id_by_track_[fragment.track_name]++;
486+
} else {
487+
fragment.group_id = path_state.next_group_by_track[fragment.track_name]++;
488+
fragment.object_id = 0;
489+
}
457490
enqueue_locked(LiveObject{
458491
.track_name = fragment.track_name,
459492
.group_id = fragment.group_id,
@@ -462,6 +495,7 @@ void LiveDashIngestSession::process_box_locked(PathState& path_state,
462495
.media_time_us = fragment.start_time_us,
463496
.media_duration_us = fragment.duration_us,
464497
.payload = std::move(fragment.payload.owned_bytes),
498+
.final_in_subgroup = false,
465499
});
466500
}
467501

src/transport/moqt_session.cpp

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4596,6 +4596,7 @@ TransportStatus MoqtSession::publish_live_objects(const openmoq::publisher::Live
45964596
bool control_fin = false;
45974597
std::optional<std::chrono::steady_clock::time_point> object_pacing_start;
45984598
std::optional<std::uint64_t> object_first_media_time_us;
4599+
std::map<std::string, std::uint64_t> last_group_id_by_track;
45994600
bool live_object_catalog_sent = !alias_by_track.contains("catalog");
46004601
const bool has_media_tracks = std::any_of(alias_by_track.begin(), alias_by_track.end(),
46014602
[](const auto& entry) { return entry.first != "catalog"; });
@@ -4738,7 +4739,16 @@ TransportStatus MoqtSession::publish_live_objects(const openmoq::publisher::Live
47384739
}
47394740
pace_until(*object_pacing_start, *object_first_media_time_us, object, true);
47404741
}
4741-
status = sender_by_track[next->track_name].serve(
4742+
auto& sender = sender_by_track[next->track_name];
4743+
const auto group_it = last_group_id_by_track.find(next->track_name);
4744+
if (group_it != last_group_id_by_track.end() &&
4745+
group_it->second != static_cast<std::uint64_t>(next->group_id)) {
4746+
status = sender.finish_group(transport_);
4747+
if (!status.ok) {
4748+
return status;
4749+
}
4750+
}
4751+
status = sender.serve(
47424752
transport_,
47434753
draft_version,
47444754
alias_it->second,
@@ -4758,6 +4768,7 @@ TransportStatus MoqtSession::publish_live_objects(const openmoq::publisher::Live
47584768
record_published_object(next->track_name,
47594769
static_cast<std::uint64_t>(next->group_id),
47604770
next->payload.size());
4771+
last_group_id_by_track[next->track_name] = static_cast<std::uint64_t>(next->group_id);
47614772
if (stop_requested_.load(std::memory_order_acquire)) {
47624773
break;
47634774
}

0 commit comments

Comments
 (0)