mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-05-24 14:36:09 -04:00
fix: correct HLS fragment byte-range and duration tracking refs #4757
The m3u8 manifest written by VideoStore had two interlocking bugs that produced duplicate entries with bogus 0.040s durations and a missing or mis-pointed final fragment, which made video.js seek backwards. Root cause: with movflags=frag_keyframe the mov muxer flushes fragment N to disk *inside* av_interleaved_write_frame() when keyframe N+1 arrives. The previous code snapshotted avio_tell() *before* that call, so its last_fragment_offset_ described the next fragment's start while the fragment at that offset hadn't actually been flushed yet. writeM3U8 then push_back'd a tentative entry off that stale state on every live update, and the next keyframe push pushed a second entry at the same offset+size with the correct duration. Now we snapshot avio_tell() *after* av_interleaved_write_frame(), which gives the actual end of the just-flushed fragment, and record fragment N-1 there. writeM3U8 no longer mutates fragments_ — it just emits the list. The final fragment (no later keyframe to close it) is recorded by a new finalize() method on VideoStore that runs av_interleaved_write_frame flush + av_write_trailer, then parses the trailing mfro box to subtract the mfra trailer size from the file length. Event::~Event() calls finalize() before writeM3U8(true); the VideoStore destructor skips its own trailer write when finalize() has already run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -202,9 +202,12 @@ Event::~Event() {
|
||||
/* Close the video file */
|
||||
// We close the videowriter first, because if we finish the event, we might try to view the file, but we aren't done writing it yet.
|
||||
if (videoStore != nullptr) {
|
||||
// Finalize last fragment before closing the video store
|
||||
// Flush the trailer + record the final fragment before writing the m3u8.
|
||||
// finalize() must run before writeM3U8 so the manifest contains every
|
||||
// fragment (including the one no later keyframe was around to close).
|
||||
videoStore->finalize();
|
||||
|
||||
std::string m3u8_path = path + "/index.m3u8";
|
||||
// Write temporary m3u8 with incomplete filename (writeM3U8 finalizes last fragment)
|
||||
std::string video_url_tmp = "index.php?view=view_video&eid=" + std::to_string(id)
|
||||
+ "&file=" + video_incomplete_file;
|
||||
videoStore->writeM3U8(m3u8_path, video_url_tmp, true);
|
||||
|
||||
@@ -76,7 +76,8 @@ VideoStore::VideoStore(
|
||||
reorder_queue_size(0),
|
||||
last_fragment_offset_(0),
|
||||
last_fragment_start_dts_(AV_NOPTS_VALUE),
|
||||
init_segment_end_(0) {
|
||||
init_segment_end_(0),
|
||||
finalized_(false) {
|
||||
FFMPEGInit();
|
||||
swscale.init();
|
||||
opkt = av_packet_ptr{av_packet_alloc()};
|
||||
@@ -706,7 +707,7 @@ VideoStore::~VideoStore() {
|
||||
}
|
||||
}
|
||||
|
||||
if (oc->pb) {
|
||||
if (!finalized_ && oc->pb) {
|
||||
flush_codecs();
|
||||
|
||||
// Flush Queues
|
||||
@@ -1551,26 +1552,31 @@ int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) {
|
||||
Debug(3, "next_dts for stream %d has become %" PRId64 " last_dts %" PRId64,
|
||||
stream->index, next_dts[stream->index], last_dts[stream->index]);
|
||||
|
||||
// HLS fragment tracking: with frag_keyframe movflag, FFmpeg creates a new
|
||||
// moof+mdat at each video keyframe. We record the byte range of each fragment
|
||||
// by checking the file position before and after the write call.
|
||||
//
|
||||
// Strategy: before writing a video keyframe, snapshot the file position.
|
||||
// This marks the end of the previous fragment. We record that fragment and
|
||||
// start tracking the new one.
|
||||
bool is_video_keyframe = (stream == video_out_stream) && (pkt->flags & AV_PKT_FLAG_KEY);
|
||||
// Snapshot the keyframe's dts before the write call may modify the packet.
|
||||
int64_t this_keyframe_dts = is_video_keyframe ? pkt->dts : AV_NOPTS_VALUE;
|
||||
|
||||
int ret = av_interleaved_write_frame(oc, pkt);
|
||||
if (ret != 0) {
|
||||
Error("Error writing packet: %s", av_make_error_string(ret).c_str());
|
||||
} else {
|
||||
Debug(4, "Success writing packet");
|
||||
}
|
||||
|
||||
// HLS fragment tracking: with movflags=frag_keyframe, the muxer flushes the
|
||||
// previous fragment to disk inside av_interleaved_write_frame() when a new
|
||||
// keyframe arrives. So the position *after* this call equals the end of the
|
||||
// just-flushed fragment, and last_fragment_offset_/_dts_ describe that
|
||||
// fragment. Record it, then move tracking to the new fragment.
|
||||
if (is_video_keyframe && oc && oc->pb) {
|
||||
// Force flush any buffered data so the file position reflects all previous writes
|
||||
avio_flush(oc->pb);
|
||||
int64_t pos_now = avio_tell(oc->pb);
|
||||
int64_t pos_after = avio_tell(oc->pb);
|
||||
|
||||
if (last_fragment_start_dts_ != AV_NOPTS_VALUE && pos_now > last_fragment_offset_) {
|
||||
// Record the completed fragment
|
||||
int64_t frag_size = pos_now - last_fragment_offset_;
|
||||
if (last_fragment_start_dts_ != AV_NOPTS_VALUE && pos_after > last_fragment_offset_) {
|
||||
int64_t frag_size = pos_after - last_fragment_offset_;
|
||||
double duration = 0;
|
||||
if (video_out_stream->time_base.den > 0) {
|
||||
duration = static_cast<double>(pkt->dts - last_fragment_start_dts_)
|
||||
duration = static_cast<double>(this_keyframe_dts - last_fragment_start_dts_)
|
||||
* video_out_stream->time_base.num
|
||||
/ video_out_stream->time_base.den;
|
||||
}
|
||||
@@ -1580,49 +1586,101 @@ int VideoStore::write_packet(AVPacket *pkt, AVStream *stream) {
|
||||
fragments_.size() - 1, last_fragment_offset_, frag_size, duration);
|
||||
}
|
||||
}
|
||||
// New fragment starts here
|
||||
last_fragment_offset_ = pos_now;
|
||||
last_fragment_start_dts_ = pkt->dts;
|
||||
}
|
||||
|
||||
// Initialize tracking after init segment is written
|
||||
if (last_fragment_start_dts_ == AV_NOPTS_VALUE && is_video_keyframe) {
|
||||
if (oc && oc->pb) {
|
||||
last_fragment_offset_ = avio_tell(oc->pb);
|
||||
}
|
||||
last_fragment_start_dts_ = pkt->dts;
|
||||
}
|
||||
|
||||
int ret = av_interleaved_write_frame(oc, pkt);
|
||||
if (ret != 0) {
|
||||
Error("Error writing packet: %s", av_make_error_string(ret).c_str());
|
||||
} else {
|
||||
Debug(4, "Success writing packet");
|
||||
last_fragment_offset_ = pos_after;
|
||||
last_fragment_start_dts_ = this_keyframe_dts;
|
||||
}
|
||||
|
||||
return ret;
|
||||
} // end int VideoStore::write_packet(AVPacket *pkt, AVStream *stream)
|
||||
|
||||
void VideoStore::writeM3U8(const std::string &m3u8_path, const std::string &video_url, bool is_complete) {
|
||||
// Finalize last fragment if there's data after the last recorded fragment
|
||||
if (oc && oc->pb) {
|
||||
int64_t file_end = avio_tell(oc->pb);
|
||||
if (file_end > last_fragment_offset_ && last_fragment_start_dts_ != AV_NOPTS_VALUE) {
|
||||
int64_t frag_size = file_end - last_fragment_offset_;
|
||||
// Estimate duration from last known DTS
|
||||
double duration = 0;
|
||||
if (video_out_stream && video_out_stream->time_base.den > 0 &&
|
||||
last_dts.count(video_out_stream->index) && last_dts[video_out_stream->index] != AV_NOPTS_VALUE) {
|
||||
duration = static_cast<double>(last_dts[video_out_stream->index] + last_duration[video_out_stream->index] - last_fragment_start_dts_)
|
||||
* video_out_stream->time_base.num
|
||||
/ video_out_stream->time_base.den;
|
||||
}
|
||||
if (duration > 0 && frag_size > 0) {
|
||||
fragments_.push_back({last_fragment_offset_, frag_size, duration});
|
||||
void VideoStore::finalize() {
|
||||
if (finalized_) return;
|
||||
finalized_ = true;
|
||||
|
||||
if (!oc || !oc->pb) return;
|
||||
|
||||
flush_codecs();
|
||||
|
||||
Debug(4, "Flushing interleaved queues");
|
||||
av_interleaved_write_frame(oc, nullptr);
|
||||
|
||||
Debug(1, "Writing trailer");
|
||||
int rc = av_write_trailer(oc);
|
||||
if (rc < 0) {
|
||||
Error("Error writing trailer %s", av_err2str(rc));
|
||||
} else {
|
||||
Debug(3, "Success Writing trailer");
|
||||
}
|
||||
|
||||
// After av_write_trailer, the file contains init+fragments_1..N + mfra trailer.
|
||||
// Capture the on-disk length so we can size the final fragment.
|
||||
avio_flush(oc->pb);
|
||||
int64_t file_size = avio_tell(oc->pb);
|
||||
|
||||
// Close the output file before reading it back to inspect the mfra box.
|
||||
if (!(out_format->flags & AVFMT_NOFILE)) {
|
||||
Debug(4, "Closing");
|
||||
if ((rc = avio_close(oc->pb)) < 0) {
|
||||
Error("Error closing avio %s", av_err2str(rc));
|
||||
}
|
||||
}
|
||||
oc->pb = nullptr;
|
||||
|
||||
// The MOV muxer writes an mfra (Movie Fragment Random Access) box at the end
|
||||
// of the file when fragmentation is on. Its trailing mfro box is exactly 16
|
||||
// bytes and contains the mfra size, so we can subtract that to find where
|
||||
// the final fragment's mdat actually ends.
|
||||
int64_t fragment_n_end = file_size;
|
||||
if (filename && file_size >= 16) {
|
||||
FILE *fp = fopen(filename, "rb");
|
||||
if (fp) {
|
||||
if (fseeko(fp, file_size - 16, SEEK_SET) == 0) {
|
||||
uint8_t mfro[16];
|
||||
if (fread(mfro, 1, 16, fp) == 16) {
|
||||
uint32_t box_size = (static_cast<uint32_t>(mfro[0]) << 24)
|
||||
| (static_cast<uint32_t>(mfro[1]) << 16)
|
||||
| (static_cast<uint32_t>(mfro[2]) << 8)
|
||||
| static_cast<uint32_t>(mfro[3]);
|
||||
if (box_size == 16
|
||||
&& mfro[4] == 'm' && mfro[5] == 'f' && mfro[6] == 'r' && mfro[7] == 'o') {
|
||||
uint32_t mfra_size = (static_cast<uint32_t>(mfro[12]) << 24)
|
||||
| (static_cast<uint32_t>(mfro[13]) << 16)
|
||||
| (static_cast<uint32_t>(mfro[14]) << 8)
|
||||
| static_cast<uint32_t>(mfro[15]);
|
||||
if (mfra_size > 0 && static_cast<int64_t>(mfra_size) <= file_size) {
|
||||
fragment_n_end = file_size - mfra_size;
|
||||
Debug(1, "mfra trailer is %u bytes; final fragment ends at %" PRId64,
|
||||
mfra_size, fragment_n_end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the final fragment that no subsequent keyframe was around to record.
|
||||
if (last_fragment_start_dts_ != AV_NOPTS_VALUE
|
||||
&& fragment_n_end > last_fragment_offset_
|
||||
&& video_out_stream && video_out_stream->time_base.den > 0
|
||||
&& last_dts.count(video_out_stream->index)
|
||||
&& last_dts[video_out_stream->index] != AV_NOPTS_VALUE) {
|
||||
int64_t frag_size = fragment_n_end - last_fragment_offset_;
|
||||
double duration = static_cast<double>(
|
||||
last_dts[video_out_stream->index]
|
||||
+ last_duration[video_out_stream->index]
|
||||
- last_fragment_start_dts_)
|
||||
* video_out_stream->time_base.num
|
||||
/ video_out_stream->time_base.den;
|
||||
if (duration > 0 && frag_size > 0) {
|
||||
fragments_.push_back({last_fragment_offset_, frag_size, duration});
|
||||
Debug(1, "HLS final fragment: offset=%" PRId64 " size=%" PRId64 " duration=%.3f",
|
||||
last_fragment_offset_, frag_size, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VideoStore::writeM3U8(const std::string &m3u8_path, const std::string &video_url, bool is_complete) {
|
||||
if (fragments_.empty()) return;
|
||||
|
||||
// Calculate max duration for EXT-X-TARGETDURATION (must be integer, rounded up)
|
||||
|
||||
@@ -93,11 +93,17 @@ class VideoStore {
|
||||
size_t reorder_queue_size;
|
||||
std::map<int, std::list<std::shared_ptr<ZMPacket>>> reorder_queues;
|
||||
|
||||
// HLS fragment tracking
|
||||
// HLS fragment tracking. With movflags=frag_keyframe, FFmpeg's mov muxer
|
||||
// doesn't write a fragment to disk until the *next* keyframe arrives (or
|
||||
// until av_write_trailer is called). So when keyframe N arrives, fragment
|
||||
// N-1 is what just got flushed. We snapshot avio_tell *after*
|
||||
// av_interleaved_write_frame() to capture the position past that flush, and
|
||||
// record fragment N-1 then.
|
||||
std::vector<Fragment> fragments_;
|
||||
int64_t last_fragment_offset_; // byte offset where current fragment started
|
||||
int64_t last_fragment_start_dts_; // DTS of first video keyframe in current fragment
|
||||
int64_t last_fragment_offset_; // byte offset where the current (in-progress) fragment starts
|
||||
int64_t last_fragment_start_dts_; // DTS of the keyframe that started the current fragment
|
||||
int64_t init_segment_end_; // byte offset where init segment (ftyp+moov) ends
|
||||
bool finalized_; // true once finalize() has run trailer + last-fragment recording
|
||||
|
||||
bool setup_resampler();
|
||||
int write_packet(AVPacket *pkt, AVStream *stream);
|
||||
@@ -124,6 +130,11 @@ class VideoStore {
|
||||
const std::vector<Fragment> &fragments() const { return fragments_; }
|
||||
int64_t init_segment_end() const { return init_segment_end_; }
|
||||
void writeM3U8(const std::string &path, const std::string &video_url, bool is_complete);
|
||||
// Flush queues, write trailer, close output, and record the final fragment.
|
||||
// Call this before writeM3U8(true) so the manifest contains every fragment.
|
||||
// Safe to call once; subsequent calls are no-ops. The destructor will skip
|
||||
// the trailer write if finalize() has already run.
|
||||
void finalize();
|
||||
|
||||
const char *get_codec() {
|
||||
if (chosen_codec_data)
|
||||
|
||||
Reference in New Issue
Block a user