Shaka Packager SDK
Loading...
Searching...
No Matches
text_chunker.cc
1// Copyright 2017 Google LLC. All rights reserved.
2//
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file or at
5// https://developers.google.com/open-source/licenses/bsd
6
7#include <packager/media/chunking/text_chunker.h>
8
9#include <cstddef>
10#include <cstdint>
11#include <memory>
12#include <utility>
13
14#include <absl/log/check.h>
15#include <absl/log/log.h>
16
17#include <packager/chunking_params.h>
18#include <packager/macros/status.h>
19#include <packager/media/base/media_handler.h>
20#include <packager/media/base/stream_info.h>
21#include <packager/media/base/text_sample.h>
22#include <packager/media/base/timestamp_util.h>
23#include <packager/status.h>
24
25namespace shaka {
26namespace media {
27namespace {
28const size_t kStreamIndex = 0;
29} // namespace
30
31TextChunker::TextChunker(double segment_duration_in_seconds,
32 int64_t start_segment_number)
33 : segment_duration_in_seconds_(segment_duration_in_seconds),
34 segment_number_(start_segment_number),
35 ts_ttx_heartbeat_shift_(kDefaultTtxHeartbeatShift),
36 use_segment_coordinator_(false) {};
37
38TextChunker::TextChunker(double segment_duration_in_seconds,
39 int64_t start_segment_number,
40 int64_t ts_ttx_heartbeat_shift)
41 : segment_duration_in_seconds_(segment_duration_in_seconds),
42 segment_number_(start_segment_number),
43 ts_ttx_heartbeat_shift_(ts_ttx_heartbeat_shift),
44 use_segment_coordinator_(false) {};
45
46TextChunker::TextChunker(double segment_duration_in_seconds,
47 int64_t start_segment_number,
48 int64_t ts_ttx_heartbeat_shift,
49 bool use_segment_coordinator)
50 : segment_duration_in_seconds_(segment_duration_in_seconds),
51 segment_number_(start_segment_number),
52 ts_ttx_heartbeat_shift_(ts_ttx_heartbeat_shift),
53 use_segment_coordinator_(use_segment_coordinator) {};
54
55Status TextChunker::Process(std::unique_ptr<StreamData> data) {
56 switch (data->stream_data_type) {
57 case StreamDataType::kStreamInfo:
58 return OnStreamInfo(std::move(data->stream_info));
59 case StreamDataType::kTextSample:
60 return OnTextSample(data->text_sample);
61 case StreamDataType::kCueEvent:
62 return OnCueEvent(data->cue_event);
63 case StreamDataType::kSegmentInfo:
64 if (use_segment_coordinator_) {
65 return OnSegmentInfo(std::move(data->segment_info));
66 } else {
67 // Pass through for non-teletext streams
68 return DispatchSegmentInfo(kStreamIndex, std::move(data->segment_info));
69 }
70 default:
71 return Status(error::INTERNAL_ERROR,
72 "Invalid stream data type for this handler");
73 }
74}
75
76Status TextChunker::OnFlushRequest(size_t /*input_stream_index*/) {
77 // Keep outputting segments until all the samples leave the system. Calling
78 // |DispatchSegment| will remove samples over time.
79 //
80 // In coordinator mode, the final SegmentInfo from video/audio should have
81 // already triggered the last segment dispatch with the correct duration.
82 // This loop handles any remaining samples (edge cases or non-coordinator
83 // mode).
84 while (samples_in_current_segment_.size()) {
85 if (segment_start_ < 0) {
86 // No segments were ever started - nothing to flush
87 break;
88 }
89 int64_t segment_end = segment_start_ + segment_duration_;
90 AddOngoingCuesToCurrentSegment(segment_end);
91 RETURN_IF_ERROR(DispatchSegment(segment_duration_));
92 }
93
94 return FlushAllDownstreams();
95}
96
97Status TextChunker::OnStreamInfo(std::shared_ptr<const StreamInfo> info) {
98 time_scale_ = info->time_scale();
99 segment_duration_ = ScaleTime(segment_duration_in_seconds_);
100
101 return DispatchStreamInfo(kStreamIndex, std::move(info));
102}
103
104Status TextChunker::OnCueEvent(std::shared_ptr<const CueEvent> event) {
105 // We are going to end the current segment prematurely using the cue event's
106 // time as the new segment end.
107
108 // Because the cue should have been inserted into the stream such that no
109 // later sample could start before it does, we know that there should
110 // be no later samples starting before the cue event.
111
112 // Convert the event's time to be scaled to the time of each sample.
113 const int64_t event_time = ScaleTime(event->time_in_seconds);
114 // Output all full segments before the segment that the cue event interrupts.
115 while (segment_start_ + segment_duration_ < event_time) {
116 RETURN_IF_ERROR(DispatchSegment(segment_duration_));
117 }
118
119 const int64_t shorten_duration = event_time - segment_start_;
120 RETURN_IF_ERROR(DispatchSegment(shorten_duration));
121 return DispatchCueEvent(kStreamIndex, std::move(event));
122}
123
124Status TextChunker::OnTextSample(std::shared_ptr<const TextSample> sample) {
125 // Output all segments that come before our new sample start_time.
126 // However, if role is MediaHeartBeat, remove 2s to avoid premature segment
127 // generation.
128
129 int64_t sample_start = sample->start_time();
130 const auto role = sample->role();
131 DVLOG(2) << "OnTextSample: role=" << static_cast<int>(role)
132 << " pts=" << sample_start << " end=" << sample->EndTime()
133 << " is_empty=" << sample->is_empty()
134 << " sub_stream_index=" << sample->sub_stream_index();
135
136 // If we have not seen a sample yet, base all segments off the first sample's
137 // start time. In coordinator mode, we wait for SegmentInfo to initialize
138 // segment_start_ so that we align with video/audio boundaries.
139 if (segment_start_ < 0 && !use_segment_coordinator_) {
140 // Force the first segment to start at the segment that would have started
141 // before the sample. This should allow segments from different streams to
142 // align.
143 segment_start_ = (sample_start / segment_duration_) * segment_duration_;
144 DVLOG(1) << "first segment start=" << segment_start_;
145 }
146
147 switch (role) {
148 case TextSampleRole::kCue: {
149 DVLOG(2) << "PTS=" << sample_start << " cue with end "
150 << sample->EndTime();
151 break;
152 }
153 case TextSampleRole::kCueStart: {
154 DVLOG(2) << "PTS=" << sample_start << " cue start wo end";
155 break;
156 }
157 case TextSampleRole::kCueEnd: {
158 DVLOG(2) << "PTS=" << sample_start << " cue end";
159 // Convert any cues without end to full cues (but only once)
160 auto end_time = sample->EndTime();
161 for (auto s : samples_without_end_) {
162 int64_t cue_start = s->start_time();
163 if (cue_start < segment_start_) {
164 cue_start = segment_start_;
165 }
166 auto nS =
167 std::make_shared<TextSample>("", cue_start, end_time, s->settings(),
168 s->body(), TextSampleRole::kCue);
169 DVLOG(3) << "cue shortened. startTime=" << s->start_time()
170 << " endTime=" << end_time;
171 samples_in_current_segment_.push_back(nS);
172 }
173 samples_without_end_.clear();
174 break;
175 }
176 case TextSampleRole::kTextHeartBeat: {
177 break;
178 }
179 case TextSampleRole::kMediaHeartBeat: {
180 sample_start -= ts_ttx_heartbeat_shift_;
181 latest_media_heartbeat_time_ = sample_start;
182 DVLOG(3) << "PTS=" << sample_start << " media heartbeat";
183 break;
184 }
185 default: {
186 // LOG(ERROR) << "Unknown role encountered. pts=" << sample_start;
187 }
188 }
189
190 if (role != TextSampleRole::kMediaHeartBeat) {
191 // Use SignedPtsDiff for wrap-safe comparison
192 if (PtsIsBefore(sample_start, latest_media_heartbeat_time_)) {
193 LOG(WARNING) << "Potentially bad text segment: text pts=" << sample_start
194 << " before latest media pts="
195 << latest_media_heartbeat_time_;
196 }
197 }
198
199 // To avoid waiting for live teletext cues to get an end time/duration
200 // they are triggered with a long fixed duration.
201 // Here we should detect such cues and put them in a special list.
202 // Once an end cue event with duration comes, we should change the duration
203 // to the correct value. If an end of a segment duration is triggered
204 // before that, we should split the segment so that the first copy ends
205 // at the segment boundary, and the second copy starts at the segment
206 // boundary. We could keep the long duration of the second part and
207 // use the long duration as an indication that it is a cue which has
208 // not yet received its proper end time.
209
210 // We need to write all the segments that would have ended before the new
211 // sample started. For segment without end, we check if they have started
212 // and if so, make cropped copy that goes to the end.
213 // We also crop such a cue at the start if needed.
214 //
215 // In coordinator mode, we skip this entirely - segment dispatch is driven
216 // by OnSegmentInfo which receives actual video/audio segment boundaries.
217 // This ensures text segments align perfectly with video/audio segments.
218 if (!use_segment_coordinator_ && segment_start_ >= 0) {
219 int64_t segment_end = segment_start_ + segment_duration_;
220 while (!PtsIsBefore(sample_start, segment_end)) {
221 // Add cropped copies of ongoing cues before dispatching
222 AddOngoingCuesToCurrentSegment(segment_end);
223 // |DispatchSegment| will advance |segment_start_|.
224 RETURN_IF_ERROR(DispatchSegment(segment_duration_));
225 segment_end = segment_start_ + segment_duration_;
226 }
227 }
228
229 switch (role) {
230 case TextSampleRole::kCue: {
231 samples_in_current_segment_.push_back(std::move(sample));
232 break;
233 }
234 case TextSampleRole::kCueStart: {
235 samples_without_end_.push_back(std::move(sample));
236 break;
237 }
238 default: {
239 // Do nothing
240 }
241 }
242
243 return Status::OK;
244}
245
246Status TextChunker::OnSegmentInfo(std::shared_ptr<const SegmentInfo> info) {
247 DCHECK(use_segment_coordinator_)
248 << "OnSegmentInfo should only be called when coordinator mode is enabled";
249
250 // Skip subsegments - only align on full segments
251 if (info->is_subsegment) {
252 DVLOG(3) << "TextChunker: Skipping subsegment SegmentInfo";
253 return Status::OK;
254 }
255
256 // Use start_timestamp + duration as the end boundary. This ensures we
257 // dispatch the segment that just completed, not wait for the next
258 // SegmentInfo. Without this, the final segment would never be dispatched
259 // since there's no subsequent SegmentInfo to trigger it.
260 int64_t segment_end_boundary = info->start_timestamp + info->duration;
261
262 DVLOG(2) << "TextChunker received SegmentInfo: start="
263 << info->start_timestamp << " duration=" << info->duration
264 << " end_boundary=" << segment_end_boundary
265 << " (current segment_start_=" << segment_start_ << ")";
266
267 // If this is the first segment info, initialize segment_start_
268 if (segment_start_ < 0) {
269 segment_start_ = info->start_timestamp;
270 DVLOG(2) << "TextChunker: Initialized segment_start_ from SegmentInfo: "
271 << segment_start_;
272 }
273
274 // Handle PTS wrap-around: if segment_end_boundary appears to be earlier than
275 // segment_start_ by more than half the 33-bit PTS range, it's likely
276 // wrapped around and is actually later.
277 // 33-bit PTS wraps at 2^33 = 8,589,934,592 ticks (~26.5 hours @ 90kHz)
278 // Half range = ~13 hours = 4,294,967,296 ticks
279 const int64_t kPtsWrapThreshold = 4294967296LL; // Half of 2^33
280
281 if (segment_end_boundary < segment_start_) {
282 int64_t diff = segment_start_ - segment_end_boundary;
283 if (diff > kPtsWrapThreshold) {
284 // This looks like a wrap-around - the boundary has wrapped but our
285 // segment_start_ hasn't yet. Treat this boundary as being later.
286 DVLOG(2) << "TextChunker: Detected PTS wrap-around. End boundary "
287 << segment_end_boundary
288 << " appears earlier than segment_start_ " << segment_start_
289 << " by " << diff << " ticks, but is likely "
290 << "later due to wrap-around.";
291
292 // Dispatch one final segment before the wrap and align to the boundary
293 int64_t segment_end = segment_start_ + segment_duration_;
294 AddOngoingCuesToCurrentSegment(segment_end);
295 RETURN_IF_ERROR(DispatchSegment(segment_duration_));
296 segment_start_ = info->start_timestamp;
297 } else {
298 // End boundary is genuinely earlier - this shouldn't happen in normal
299 // flow but we'll log a warning and skip this boundary
300 LOG(WARNING) << "TextChunker: Received SegmentInfo end boundary "
301 << segment_end_boundary << " that is earlier than current "
302 << "segment_start_ " << segment_start_ << " (diff: " << diff
303 << "). Skipping this SegmentInfo.";
304 return Status::OK;
305 }
306 }
307
308 // Dispatch all pending segments up to the end boundary
309 while (segment_start_ < segment_end_boundary) {
310 int64_t segment_end = segment_start_ + segment_duration_;
311
312 // If the next calculated segment would go past the end boundary,
313 // dispatch a shorter segment to align with the actual boundary
314 if (segment_end > segment_end_boundary) {
315 int64_t adjusted_duration = segment_end_boundary - segment_start_;
316 if (adjusted_duration > 0) {
317 DVLOG(3) << "TextChunker: Dispatching adjusted segment to align with "
318 << "end boundary. Duration: " << adjusted_duration
319 << " (normal: " << segment_duration_ << ")";
320 // Add ongoing cues before dispatching (use end boundary as end)
321 AddOngoingCuesToCurrentSegment(segment_end_boundary);
322 RETURN_IF_ERROR(DispatchSegment(adjusted_duration));
323 }
324 break;
325 }
326
327 // Dispatch a full-duration segment
328 DVLOG(3) << "TextChunker: Dispatching full segment aligned to end boundary";
329 // Add ongoing cues before dispatching
330 AddOngoingCuesToCurrentSegment(segment_end);
331 RETURN_IF_ERROR(DispatchSegment(segment_duration_));
332 }
333
334 // Align next segment start to end boundary (start of next segment)
335 segment_start_ = segment_end_boundary;
336
337 return Status::OK;
338}
339
340Status TextChunker::DispatchSegment(int64_t duration) {
341 DCHECK_GT(duration, 0) << "Segment duration should always be positive";
342
343 int64_t segment_end = segment_start_ + duration;
344
345 // Output only the samples that actually belong in this segment.
346 // Use wrap-safe comparison since samples may have wrapped PTS.
347 DVLOG(1) << "DispatchSegment, start=" << segment_start_
348 << " end=" << segment_end;
349 for (const auto& sample : samples_in_current_segment_) {
350 // Only dispatch if sample starts before segment end
351 if (PtsIsBefore(sample->start_time(), segment_end)) {
352 DVLOG(2) << "DispatchTextSample, pts=" << sample->start_time()
353 << " end=" << sample->EndTime();
354 RETURN_IF_ERROR(DispatchTextSample(kStreamIndex, sample));
355 } else {
356 DVLOG(2) << "Skipping sample pts=" << sample->start_time()
357 << " (after segment_end=" << segment_end << ")";
358 }
359 }
360
361 // Output the segment info.
362 std::shared_ptr<SegmentInfo> info = std::make_shared<SegmentInfo>();
363 info->start_timestamp = segment_start_;
364 info->duration = duration;
365 info->segment_number = segment_number_++;
366
367 RETURN_IF_ERROR(DispatchSegmentInfo(kStreamIndex, std::move(info)));
368
369 // Move onto the next segment.
370 const int64_t new_segment_start = segment_start_ + duration;
371 segment_start_ = new_segment_start;
372
373 // Remove all samples that end before the (new) current segment started.
374 // Use wrap-safe comparison.
375 samples_in_current_segment_.remove_if(
376 [new_segment_start](const std::shared_ptr<const TextSample>& sample) {
377 // Remove if sample ends before or at new segment start (wrap-safe)
378 return PtsIsBeforeOrEqual(sample->EndTime(), new_segment_start);
379 });
380
381 return Status::OK;
382}
383
384int64_t TextChunker::ScaleTime(double seconds) const {
385 DCHECK_GT(time_scale_, 0) << "Need positive time scale to scale time.";
386 return static_cast<int64_t>(seconds * time_scale_);
387}
388
389void TextChunker::AddOngoingCuesToCurrentSegment(int64_t segment_end) {
390 // For each ongoing cue (started but no end time yet), create a cropped
391 // copy that ends at the segment boundary and add to current segment.
392 for (const auto& s : samples_without_end_) {
393 if (s->role() == TextSampleRole::kCueStart) {
394 // Only include if the cue started before this segment ends
395 if (PtsIsBefore(s->start_time(), segment_end)) {
396 // Crop the start time to segment_start_ if needed
397 auto cue_start = s->start_time();
398 if (PtsIsBefore(cue_start, segment_start_)) {
399 cue_start = segment_start_;
400 }
401 auto cropped_cue = std::make_shared<TextSample>(
402 "", cue_start, segment_end, s->settings(), s->body());
403 DVLOG(3) << "AddOngoingCuesToCurrentSegment: cropped cue start="
404 << cue_start << " end=" << segment_end;
405 samples_in_current_segment_.push_back(std::move(cropped_cue));
406 }
407 }
408 }
409}
410} // namespace media
411} // namespace shaka
All the methods that are virtual are virtual for mocking.