mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-02 23:45:08 -04:00
finalize() located the mfra by reading the trailing mfro with fopen and fseeko and decoding it by hand, the same job zm_mp4::media_end() does for the sidx scan. Use media_end() for both, so the last HLS fragment and the index agree on where the media ends. media_end() is also stricter: it requires an mfra box of the stated size at the offset the mfro points to, where the old code accepted any size up to the file length. A trailer that does not check out now leaves the final fragment running to EOF, as a missing trailer already did. A new test covers media_end() against the fixture's real mfra and three damaged trailers: an mfro size off by four, one larger than the file, and no mfro at all. refs #5144 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
699 lines
28 KiB
C++
699 lines
28 KiB
C++
/*
|
|
* This file is part of the ZoneMinder Project. See AUTHORS file for contributors.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 2 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include "zm_catch2.h"
|
|
|
|
#include "zm_mp4_sidx.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <fcntl.h>
|
|
#include <filesystem>
|
|
#include <algorithm>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <unistd.h>
|
|
#include <vector>
|
|
|
|
extern "C" {
|
|
#include <libavformat/avformat.h>
|
|
#include <libavutil/opt.h>
|
|
}
|
|
|
|
// The fixtures are the reference tool's own output (tools/mp4sidx in the
|
|
// kiosk repo, mp4.md): a short fragmented recording, written the way
|
|
// VideoStore writes one, already carrying the index this code produces. Each
|
|
// test blanks the index back to the `free` box open() reserves and asks
|
|
// zm_mp4 to fill it again, so "correct" means byte-for-byte what a second,
|
|
// independent implementation arrived at.
|
|
namespace {
|
|
|
|
constexpr int64_t kFixtureReserve = 4096; // small, to keep the fixtures small
|
|
|
|
std::filesystem::path fixture(const std::string &name) {
|
|
return std::filesystem::path(ZM_SOURCE_DIR) / "tests" / "data" / "mp4" / name;
|
|
}
|
|
|
|
// A file of this process's own in the temp directory. ctest runs each test
|
|
// case as a process of its own and in parallel, so a fixed name would have
|
|
// one case overwrite or remove another's file under it.
|
|
std::filesystem::path scratch(const std::string &name) {
|
|
return std::filesystem::temp_directory_path() /
|
|
("zm-sidx-" + std::to_string(getpid()) + "-" + name);
|
|
}
|
|
|
|
std::vector<uint8_t> read_file(const std::filesystem::path &path) {
|
|
std::ifstream in(path, std::ios::binary);
|
|
REQUIRE(in.good());
|
|
return std::vector<uint8_t>((std::istreambuf_iterator<char>(in)),
|
|
std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
void write_file(const std::filesystem::path &path, const std::vector<uint8_t> &bytes) {
|
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
|
REQUIRE(out.good());
|
|
out.write(reinterpret_cast<const char *>(bytes.data()),
|
|
static_cast<std::streamsize>(bytes.size()));
|
|
out.close();
|
|
REQUIRE(std::filesystem::file_size(path) == bytes.size());
|
|
}
|
|
|
|
// Where two files first differ, or npos when they do not. REQUIRE must never
|
|
// be handed the vectors themselves: Catch2 renders both operands, and 70 KB
|
|
// of rendered bytes takes its text wrapper down with it.
|
|
constexpr size_t kSame = static_cast<size_t>(-1);
|
|
|
|
size_t first_difference(const std::vector<uint8_t> &a, const std::vector<uint8_t> &b) {
|
|
const size_t shared = std::min(a.size(), b.size());
|
|
for (size_t i = 0; i < shared; i++) if (a[i] != b[i]) return i;
|
|
return a.size() == b.size() ? kSame : shared;
|
|
}
|
|
|
|
uint32_t be32(const uint8_t *p) {
|
|
return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | p[3];
|
|
}
|
|
|
|
// A box walker of the test's own, so a bug in the code under test cannot hide
|
|
// behind the same bug in the check.
|
|
struct TopBox {
|
|
std::string type;
|
|
size_t offset;
|
|
size_t size;
|
|
};
|
|
|
|
std::vector<TopBox> top_level(const std::vector<uint8_t> &file) {
|
|
std::vector<TopBox> boxes;
|
|
size_t pos = 0;
|
|
while (pos + 8 <= file.size()) {
|
|
size_t size = be32(&file[pos]);
|
|
const std::string type(reinterpret_cast<const char *>(&file[pos + 4]), 4);
|
|
if (size == 0) size = file.size() - pos;
|
|
REQUIRE(size >= 8);
|
|
REQUIRE(pos + size <= file.size());
|
|
boxes.push_back({type, pos, size});
|
|
pos += size;
|
|
}
|
|
return boxes;
|
|
}
|
|
|
|
size_t offset_of(const std::vector<TopBox> &boxes, const std::string &type) {
|
|
for (const TopBox &box : boxes) if (box.type == type) return box.offset;
|
|
FAIL("no " << type << " box in the fixture");
|
|
return 0;
|
|
}
|
|
|
|
// The fixture with its region blanked back to one `free` box: the file as it
|
|
// stands the moment finalize() is about to index it.
|
|
struct Subject {
|
|
std::filesystem::path path;
|
|
std::vector<uint8_t> expected; // the fixture, index and all
|
|
int64_t region_offset = 0;
|
|
int64_t region_size = 0;
|
|
|
|
explicit Subject(const std::string &name) {
|
|
expected = read_file(fixture(name));
|
|
const std::vector<TopBox> boxes = top_level(expected);
|
|
const size_t moov = offset_of(boxes, "moov");
|
|
size_t moov_end = 0;
|
|
for (const TopBox &box : boxes) if (box.offset == moov) moov_end = box.offset + box.size;
|
|
region_offset = static_cast<int64_t>(moov_end);
|
|
region_size = static_cast<int64_t>(offset_of(boxes, "moof") - moov_end);
|
|
|
|
path = scratch(name);
|
|
std::vector<uint8_t> blank = expected;
|
|
std::fill(blank.begin() + region_offset,
|
|
blank.begin() + region_offset + region_size, uint8_t(0));
|
|
blank[region_offset + 0] = uint8_t(region_size >> 24);
|
|
blank[region_offset + 1] = uint8_t(region_size >> 16);
|
|
blank[region_offset + 2] = uint8_t(region_size >> 8);
|
|
blank[region_offset + 3] = uint8_t(region_size);
|
|
memcpy(&blank[region_offset + 4], "free", 4);
|
|
write_file(path, blank);
|
|
}
|
|
|
|
~Subject() {
|
|
std::error_code ignored;
|
|
std::filesystem::remove(path, ignored);
|
|
}
|
|
|
|
int open_read() const {
|
|
int fd = open(path.c_str(), O_RDONLY);
|
|
REQUIRE(fd >= 0);
|
|
return fd;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Mp4SidxReadsTheVideoTrack") {
|
|
Subject subject("sidx-abs.mp4");
|
|
const int fd = subject.open_read();
|
|
zm_mp4::VideoTrack track;
|
|
REQUIRE(zm_mp4::read_video_track(fd, std::filesystem::file_size(subject.path), &track));
|
|
// The fixture is 160x120 at 10 fps with audio, so the video track is 1 and
|
|
// libavformat gives it a 10240 timescale.
|
|
REQUIRE(track.id == 1);
|
|
REQUIRE(track.timescale == 10240);
|
|
close(fd);
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxScanCoversEveryByteOfTheMedia") {
|
|
Subject subject("sidx-abs.mp4");
|
|
const int64_t size = static_cast<int64_t>(std::filesystem::file_size(subject.path));
|
|
const int fd = subject.open_read();
|
|
|
|
zm_mp4::VideoTrack track;
|
|
REQUIRE(zm_mp4::read_video_track(fd, size, &track));
|
|
|
|
const int64_t first_moof = subject.region_offset + subject.region_size;
|
|
const int64_t end = zm_mp4::media_end(fd, size);
|
|
REQUIRE(end < size); // the fixture has an mfra
|
|
|
|
std::vector<zm_mp4::Fragment> fragments;
|
|
REQUIRE(zm_mp4::scan_fragments(fd, first_moof, end, track, &fragments));
|
|
REQUIRE(fragments.size() == 3);
|
|
REQUIRE(fragments.front().offset == first_moof);
|
|
|
|
int64_t covered = 0;
|
|
for (const zm_mp4::Fragment &fragment : fragments) covered += fragment.size;
|
|
// An index that does not reach the mfra exactly is not a complete one, and
|
|
// a player gains nothing from it.
|
|
REQUIRE(covered == end - first_moof);
|
|
close(fd);
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxWritesTheSameIndexAsTheReferenceTool") {
|
|
// Both fixtures: absolute tfhd offsets (what 1.38.4 records) and
|
|
// moof-relative ones (what master's movflags produce).
|
|
for (const char *name : {"sidx-abs.mp4", "sidx-moof.mp4"}) {
|
|
INFO("fixture " << name);
|
|
Subject subject(name);
|
|
REQUIRE(subject.region_size == kFixtureReserve);
|
|
// Really blanked: the difference is inside the region and nowhere else.
|
|
const size_t blanked_at = first_difference(read_file(subject.path), subject.expected);
|
|
REQUIRE(blanked_at >= static_cast<size_t>(subject.region_offset));
|
|
REQUIRE(blanked_at < static_cast<size_t>(subject.region_offset + subject.region_size));
|
|
|
|
REQUIRE(zm_mp4::write_leading_sidx(subject.path.string(),
|
|
subject.region_offset, subject.region_size));
|
|
REQUIRE(first_difference(read_file(subject.path), subject.expected) == kSame);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxIndexesAFileWithNoMfra") {
|
|
// A recording whose writer died leaves no trailer; the index must then run
|
|
// to the end of the file instead.
|
|
Subject subject("sidx-nomfra.mp4");
|
|
const int64_t size = static_cast<int64_t>(std::filesystem::file_size(subject.path));
|
|
const int fd = subject.open_read();
|
|
REQUIRE(zm_mp4::media_end(fd, size) == size);
|
|
close(fd);
|
|
|
|
REQUIRE(zm_mp4::write_leading_sidx(subject.path.string(),
|
|
subject.region_offset, subject.region_size));
|
|
REQUIRE(first_difference(read_file(subject.path), subject.expected) == kSame);
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxMediaEndTrustsOnlyAnMfraThatIsThere") {
|
|
// finalize() sizes the last HLS fragment from this, so an mfro whose size
|
|
// does not land on an mfra of that size must not cut the media short.
|
|
std::vector<uint8_t> file = read_file(fixture("sidx-abs.mp4"));
|
|
const int64_t size = static_cast<int64_t>(file.size());
|
|
const int64_t mfra = static_cast<int64_t>(offset_of(top_level(file), "mfra"));
|
|
const std::filesystem::path path = scratch("mfro.mp4");
|
|
|
|
auto end_of = [&path](const std::vector<uint8_t> &bytes) {
|
|
write_file(path, bytes);
|
|
const int fd = open(path.c_str(), O_RDONLY);
|
|
REQUIRE(fd >= 0);
|
|
const int64_t end = zm_mp4::media_end(fd, static_cast<int64_t>(bytes.size()));
|
|
close(fd);
|
|
return end;
|
|
};
|
|
|
|
REQUIRE(end_of(file) == mfra);
|
|
|
|
SECTION("an mfro size off by four") {
|
|
file[size - 1] = uint8_t(file[size - 1] + 4);
|
|
REQUIRE(end_of(file) == size);
|
|
}
|
|
SECTION("an mfro size larger than the file") {
|
|
file[size - 4] = 0xFF;
|
|
REQUIRE(end_of(file) == size);
|
|
}
|
|
SECTION("no mfro at all") {
|
|
memcpy(&file[size - 12], "xxxx", 4);
|
|
REQUIRE(end_of(file) == size);
|
|
}
|
|
|
|
std::error_code ignored;
|
|
std::filesystem::remove(path, ignored);
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxMergesWhenTheRegionIsTight") {
|
|
std::vector<zm_mp4::Fragment> fragments;
|
|
for (int i = 0; i < 8; i++) {
|
|
zm_mp4::Fragment fragment;
|
|
fragment.offset = 1000 + i * 100;
|
|
fragment.size = 100;
|
|
fragment.tfdt = i * 90000;
|
|
fragment.trun_duration = 90000;
|
|
fragments.push_back(fragment);
|
|
}
|
|
zm_mp4::VideoTrack track;
|
|
track.id = 1;
|
|
track.timescale = 90000;
|
|
|
|
SECTION("room for every fragment") {
|
|
const std::vector<uint8_t> region =
|
|
zm_mp4::build_sidx_region(track, fragments, zm_mp4::kSidxReserve);
|
|
REQUIRE(region.size() == size_t(zm_mp4::kSidxReserve));
|
|
// free padding first, then the sidx: the index has to END where the
|
|
// fragments begin, or a player will not treat it as complete.
|
|
REQUIRE(memcmp(region.data() + 4, "free", 4) == 0);
|
|
const size_t sidx_at = zm_mp4::kSidxReserve - (40 + 12 * 8);
|
|
REQUIRE(memcmp(region.data() + sidx_at + 4, "sidx", 4) == 0);
|
|
REQUIRE(be32(region.data() + sidx_at + 28) == 0); // first_offset, high half
|
|
REQUIRE(be32(region.data() + sidx_at + 32) == 0); // first_offset, low half
|
|
REQUIRE(be32(region.data() + sidx_at + 36) == 8); // reserved + count
|
|
}
|
|
|
|
SECTION("room for three") {
|
|
const int64_t reserve = 40 + 12 * 3 + 8;
|
|
REQUIRE(zm_mp4::max_references(reserve) == 3);
|
|
const std::vector<uint8_t> region = zm_mp4::build_sidx_region(track, fragments, reserve);
|
|
REQUIRE(region.size() == size_t(reserve));
|
|
const uint8_t *sidx = region.data() + 8;
|
|
REQUIRE(be32(sidx + 36) == 3); // reserved + count
|
|
// Eight fragments, three references: groups of three, three, two.
|
|
REQUIRE(be32(sidx + 40) == 300); // referenced_size
|
|
REQUIRE(be32(sidx + 44) == 3 * 90000); // subsegment_duration
|
|
REQUIRE(be32(sidx + 40 + 24) == 200); // the short last group
|
|
}
|
|
|
|
SECTION("no room at all") {
|
|
REQUIRE(zm_mp4::max_references(40) == 0);
|
|
REQUIRE(zm_mp4::build_sidx_region(track, fragments, 40).empty());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxSizesTheRegionForTheEvent") {
|
|
SECTION("unknown length or GOP takes the most") {
|
|
REQUIRE(zm_mp4::reserve_size(0, 1) == zm_mp4::kSidxReserve);
|
|
REQUIRE(zm_mp4::reserve_size(600, 0) == zm_mp4::kSidxReserve);
|
|
REQUIRE(zm_mp4::reserve_size(-1, 1) == zm_mp4::kSidxReserve);
|
|
REQUIRE(zm_mp4::reserve_size(600, std::nan("")) == zm_mp4::kSidxReserve);
|
|
}
|
|
|
|
SECTION("a default 600 s section at a 1 s GOP") {
|
|
// 1200 references, 14448 bytes, rounded up to whole 4 KiB blocks.
|
|
REQUIRE(zm_mp4::reserve_size(600, 1) == 16384);
|
|
}
|
|
|
|
SECTION("short events take the least") {
|
|
REQUIRE(zm_mp4::reserve_size(10, 1) == zm_mp4::kSidxMinReserve);
|
|
}
|
|
|
|
SECTION("long events take no more than the most") {
|
|
REQUIRE(zm_mp4::reserve_size(3600, 1) == zm_mp4::kSidxReserve);
|
|
REQUIRE(zm_mp4::reserve_size(600, 0.001) == zm_mp4::kSidxReserve);
|
|
}
|
|
|
|
SECTION("the region holds twice the expected fragments") {
|
|
for (double seconds : {30.0, 120.0, 600.0, 1200.0}) {
|
|
for (double gop : {0.5, 1.0, 2.0, 4.0}) {
|
|
INFO(seconds << " s at a " << gop << " s GOP");
|
|
const int64_t reserve = zm_mp4::reserve_size(seconds, gop);
|
|
REQUIRE(reserve % 4096 == 0);
|
|
REQUIRE(reserve >= zm_mp4::kSidxMinReserve);
|
|
REQUIRE(reserve <= zm_mp4::kSidxReserve);
|
|
if (reserve < zm_mp4::kSidxReserve) {
|
|
REQUIRE(zm_mp4::max_references(reserve) >= size_t(2 * std::ceil(seconds / gop)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxLeavesTheFileAloneWhenItCannotIndex") {
|
|
Subject subject("sidx-abs.mp4");
|
|
const std::vector<uint8_t> before = read_file(subject.path);
|
|
|
|
// A region size that does not reach the first fragment: there is no moof
|
|
// where one is claimed to be, so nothing may be written.
|
|
REQUIRE_FALSE(zm_mp4::write_leading_sidx(subject.path.string(),
|
|
subject.region_offset,
|
|
subject.region_size - 16));
|
|
REQUIRE(first_difference(read_file(subject.path), before) == kSame);
|
|
|
|
REQUIRE_FALSE(zm_mp4::write_leading_sidx(subject.path.string(), -1, subject.region_size));
|
|
REQUIRE(first_difference(read_file(subject.path), before) == kSame);
|
|
}
|
|
|
|
namespace {
|
|
|
|
// Remux the video of `source` into `out` the way VideoStore writes an event:
|
|
// header, a reserved region on the muxer's own AVIOContext, packets, trailer.
|
|
// `frag_duration` (microseconds, 0 for none) cuts fragments mid-GOP as well.
|
|
struct Remuxed {
|
|
int64_t region_offset = -1;
|
|
int written = 0;
|
|
};
|
|
|
|
Remuxed remux_with_region(const std::filesystem::path &source,
|
|
const std::filesystem::path &out,
|
|
const char *movflags, int64_t frag_duration) {
|
|
std::error_code ignored;
|
|
std::filesystem::remove(out, ignored);
|
|
|
|
AVFormatContext *in = nullptr;
|
|
REQUIRE(avformat_open_input(&in, source.c_str(), nullptr, nullptr) == 0);
|
|
REQUIRE(avformat_find_stream_info(in, nullptr) >= 0);
|
|
int video_in = -1;
|
|
for (unsigned i = 0; i < in->nb_streams; i++) {
|
|
if (in->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) video_in = int(i);
|
|
}
|
|
REQUIRE(video_in >= 0);
|
|
|
|
AVFormatContext *oc = nullptr;
|
|
REQUIRE(avformat_alloc_output_context2(&oc, nullptr, "mp4", out.c_str()) >= 0);
|
|
AVStream *video_out = avformat_new_stream(oc, nullptr);
|
|
REQUIRE(video_out != nullptr);
|
|
REQUIRE(avcodec_parameters_copy(video_out->codecpar, in->streams[video_in]->codecpar) >= 0);
|
|
video_out->codecpar->codec_tag = 0;
|
|
REQUIRE(avio_open(&oc->pb, out.c_str(), AVIO_FLAG_WRITE) >= 0);
|
|
|
|
AVDictionary *opts = nullptr;
|
|
av_dict_set(&opts, "movflags", movflags, 0);
|
|
if (frag_duration > 0) av_dict_set_int(&opts, "frag_duration", frag_duration, 0);
|
|
REQUIRE(avformat_write_header(oc, &opts) >= 0);
|
|
av_dict_free(&opts);
|
|
|
|
Remuxed result;
|
|
result.region_offset = zm_mp4::reserve_region(oc, zm_mp4::kSidxReserve);
|
|
REQUIRE(result.region_offset > 0);
|
|
REQUIRE(avio_tell(oc->pb) == result.region_offset + zm_mp4::kSidxReserve);
|
|
|
|
AVPacket *packet = av_packet_alloc();
|
|
REQUIRE(packet != nullptr);
|
|
while (av_read_frame(in, packet) >= 0) {
|
|
if (packet->stream_index == video_in) {
|
|
av_packet_rescale_ts(packet, in->streams[video_in]->time_base, video_out->time_base);
|
|
packet->stream_index = 0;
|
|
packet->pos = -1;
|
|
REQUIRE(av_interleaved_write_frame(oc, packet) >= 0);
|
|
result.written++;
|
|
}
|
|
av_packet_unref(packet);
|
|
}
|
|
av_packet_free(&packet);
|
|
REQUIRE(result.written > 0);
|
|
REQUIRE(av_write_trailer(oc) >= 0);
|
|
avio_closep(&oc->pb);
|
|
avformat_free_context(oc);
|
|
avformat_close_input(&in);
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Mp4SidxIndexesWhatTheMuxerWritesAfterAReservedRegion") {
|
|
// The sequence VideoStore follows, against the real muxer: write the
|
|
// header, put the `free` region on the muxer's own AVIOContext before any
|
|
// packet, remux a recording through it, write the trailer, then fill the
|
|
// region. What this proves is the part unit tests of the parser cannot:
|
|
// that the muxer's own tfhd and tfra offsets account for the reserved
|
|
// bytes, because it takes every offset from avio_tell.
|
|
const std::filesystem::path out = scratch("remux.mp4");
|
|
std::error_code ignored;
|
|
const Remuxed remuxed = remux_with_region(
|
|
fixture("sidx-abs.mp4"), out, "frag_keyframe+empty_moov+default_base_moof", 0);
|
|
const int64_t region_offset = remuxed.region_offset;
|
|
const int written = remuxed.written;
|
|
|
|
// Before: the demuxer has to walk the fragments. After: it stops.
|
|
const std::vector<uint8_t> muxed = read_file(out);
|
|
const std::vector<TopBox> boxes = top_level(muxed);
|
|
int64_t second_moof = 0;
|
|
bool seen = false;
|
|
for (const TopBox &box : boxes) {
|
|
if (box.type != "moof") continue;
|
|
if (seen) { second_moof = int64_t(box.offset); break; }
|
|
seen = true;
|
|
}
|
|
REQUIRE(second_moof > 0);
|
|
|
|
auto position_after_header = [](const std::filesystem::path &path) {
|
|
AVFormatContext *ctx = nullptr;
|
|
REQUIRE(avformat_open_input(&ctx, path.c_str(), nullptr, nullptr) == 0);
|
|
const int64_t position = avio_tell(ctx->pb);
|
|
avformat_close_input(&ctx);
|
|
return position;
|
|
};
|
|
auto packets_readable = [](const std::filesystem::path &path) {
|
|
AVFormatContext *ctx = nullptr;
|
|
REQUIRE(avformat_open_input(&ctx, path.c_str(), nullptr, nullptr) == 0);
|
|
AVPacket *pkt = av_packet_alloc();
|
|
int count = 0;
|
|
while (av_read_frame(ctx, pkt) >= 0) { count++; av_packet_unref(pkt); }
|
|
av_packet_free(&pkt);
|
|
avformat_close_input(&ctx);
|
|
return count;
|
|
};
|
|
|
|
REQUIRE(position_after_header(out) > second_moof);
|
|
const int before = packets_readable(out);
|
|
REQUIRE(before == written);
|
|
|
|
REQUIRE(zm_mp4::write_leading_sidx(out.string(), region_offset, zm_mp4::kSidxReserve));
|
|
REQUIRE(position_after_header(out) < second_moof);
|
|
REQUIRE(packets_readable(out) == written); // nothing lost by indexing
|
|
|
|
std::filesystem::remove(out, ignored);
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxMarksOnlyKeyframeFragmentsAsSaps") {
|
|
// frag_duration cuts a fragment every 250 ms as well as at each keyframe,
|
|
// so the 3 s, 1 s GOP fixture becomes about a dozen fragments of which
|
|
// exactly the three that begin at a keyframe start with a SAP. A reference
|
|
// that claims one where there is none sends a seek to a frame that cannot
|
|
// be decoded on its own.
|
|
const std::filesystem::path out = scratch("mid-gop.mp4");
|
|
std::error_code ignored;
|
|
const Remuxed remuxed = remux_with_region(
|
|
fixture("sidx-abs.mp4"), out, "frag_keyframe+empty_moov+default_base_moof", 250000);
|
|
REQUIRE(zm_mp4::write_leading_sidx(out.string(), remuxed.region_offset, zm_mp4::kSidxReserve));
|
|
|
|
const std::vector<uint8_t> file = read_file(out);
|
|
const size_t sidx = offset_of(top_level(file), "sidx");
|
|
const size_t count = (size_t(file[sidx + 38]) << 8) | file[sidx + 39];
|
|
REQUIRE(count > 3);
|
|
size_t saps = 0;
|
|
for (size_t i = 0; i < count; i++) {
|
|
const uint32_t sap = be32(&file[sidx + 40 + 12 * i + 8]);
|
|
if (sap == 0x90000000) {
|
|
saps++;
|
|
} else {
|
|
REQUIRE(sap == 0); // no SAP, no type, no delta
|
|
}
|
|
}
|
|
REQUIRE(be32(&file[sidx + 40 + 8]) == 0x90000000); // the event opens on a keyframe
|
|
REQUIRE(saps == 3);
|
|
|
|
std::filesystem::remove(out, ignored);
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxFindsTheSapsOfAKeyframeRecording") {
|
|
Subject subject("sidx-abs.mp4");
|
|
const int fd = subject.open_read();
|
|
const int64_t size = int64_t(std::filesystem::file_size(subject.path));
|
|
zm_mp4::VideoTrack track;
|
|
REQUIRE(zm_mp4::read_video_track(fd, size, &track));
|
|
std::vector<zm_mp4::Fragment> fragments;
|
|
REQUIRE(zm_mp4::scan_fragments(fd, subject.region_offset + subject.region_size,
|
|
zm_mp4::media_end(fd, size), track, &fragments));
|
|
close(fd);
|
|
REQUIRE(fragments.size() == 3);
|
|
for (const zm_mp4::Fragment &fragment : fragments) REQUIRE(fragment.starts_with_sap);
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxMergedReferenceTakesItsFirstFragmentsSap") {
|
|
std::vector<zm_mp4::Fragment> fragments;
|
|
for (int i = 0; i < 4; i++) {
|
|
zm_mp4::Fragment fragment;
|
|
fragment.offset = 1000 + i * 100;
|
|
fragment.size = 100;
|
|
fragment.tfdt = i * 90000;
|
|
fragment.trun_duration = 90000;
|
|
fragment.starts_with_sap = (i == 0 || i == 3);
|
|
fragments.push_back(fragment);
|
|
}
|
|
zm_mp4::VideoTrack track;
|
|
track.id = 1;
|
|
track.timescale = 90000;
|
|
|
|
SECTION("one reference each") {
|
|
const std::vector<uint8_t> region =
|
|
zm_mp4::build_sidx_region(track, fragments, zm_mp4::kSidxMinReserve);
|
|
const uint8_t *entries = region.data() + zm_mp4::kSidxMinReserve - 12 * 4;
|
|
REQUIRE(be32(entries + 8) == 0x90000000);
|
|
REQUIRE(be32(entries + 12 + 8) == 0);
|
|
REQUIRE(be32(entries + 24 + 8) == 0);
|
|
REQUIRE(be32(entries + 36 + 8) == 0x90000000);
|
|
}
|
|
|
|
SECTION("merged in pairs") {
|
|
const int64_t reserve = 40 + 12 * 2 + 8;
|
|
const std::vector<uint8_t> region = zm_mp4::build_sidx_region(track, fragments, reserve);
|
|
REQUIRE(region.size() == size_t(reserve));
|
|
const uint8_t *entries = region.data() + 8 + 40;
|
|
REQUIRE(be32(entries + 8) == 0x90000000); // fragments 0 and 1
|
|
REQUIRE(be32(entries + 12 + 8) == 0); // fragments 2 and 3
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxStopsTheDemuxerAfterTheFirstFragment") {
|
|
// The point of all of it: with the index in place libavformat -- the
|
|
// demuxer inside Chromium and the Android WebView too -- must not read on
|
|
// through the fragments before it can hand over a frame.
|
|
Subject subject("sidx-abs.mp4");
|
|
const std::vector<TopBox> boxes = top_level(subject.expected);
|
|
int64_t second_moof = 0;
|
|
bool seen_first = false;
|
|
for (const TopBox &box : boxes) {
|
|
if (box.type != "moof") continue;
|
|
if (seen_first) { second_moof = static_cast<int64_t>(box.offset); break; }
|
|
seen_first = true;
|
|
}
|
|
REQUIRE(second_moof > 0);
|
|
|
|
auto position_after_header = [](const std::filesystem::path &path) {
|
|
AVFormatContext *ctx = nullptr;
|
|
REQUIRE(avformat_open_input(&ctx, path.c_str(), nullptr, nullptr) == 0);
|
|
const int64_t position = avio_tell(ctx->pb);
|
|
avformat_close_input(&ctx);
|
|
return position;
|
|
};
|
|
|
|
REQUIRE(position_after_header(subject.path) > second_moof); // blanked: reads on
|
|
REQUIRE(zm_mp4::write_leading_sidx(subject.path.string(),
|
|
subject.region_offset, subject.region_size));
|
|
REQUIRE(position_after_header(subject.path) < second_moof); // indexed: stops
|
|
}
|
|
|
|
TEST_CASE("Mp4SidxReservesOnlyWhereFragmentsFollowTheMoov") {
|
|
// VideoStore hands the monitor's encoder options to whatever muxer the
|
|
// container picked, so neither the muxer nor the movflags are a given. A
|
|
// region is only worth its bytes where the header has put the moov down and
|
|
// bare moof+mdat fragments follow it; anywhere else it is padding nothing
|
|
// will fill -- and in Matroska a corrupt stream. `moov` is what the header
|
|
// must look like on disk, checked here against the bytes the muxer wrote,
|
|
// so the flags the decision reads are held to what they produce.
|
|
struct Case {
|
|
const char *muxer;
|
|
const char *movflags; // nullptr: none, the muxer's own default
|
|
bool moov; // the header ends in a moov announcing fragments
|
|
bool reserves;
|
|
};
|
|
const Case cases[] = {
|
|
{"mp4", "frag_keyframe+empty_moov+default_base_moof", true, true}, // VideoStore's default
|
|
{"mp4", "frag_keyframe+empty_moov", true, true}, // absolute tfhd offsets
|
|
{"mp4", "frag_keyframe+empty_moov+faststart", true, true}, // 1.38's; no-op once fragmented
|
|
{"mp4", "cmaf", true, true}, // empty_moov implied
|
|
{"mp4", "dash+skip_sidx", true, true},
|
|
{"mov", "frag_keyframe+empty_moov", true, true},
|
|
{"mp4", nullptr, false, false}, // not fragmented
|
|
{"mp4", "frag_keyframe", false, false}, // moov with the first fragment
|
|
{"mp4", "frag_keyframe+empty_moov+delay_moov", false, false},
|
|
{"mp4", "dash", true, false}, // a sidx before every fragment
|
|
{"mp4", "frag_keyframe+empty_moov+global_sidx", true, false}, // the muxer's own index
|
|
{"mp4", "frag_keyframe+empty_moov+hybrid_fragmented", true, false}, // made non-fragmented at the end
|
|
{"matroska", "frag_keyframe+empty_moov+default_base_moof", false, false},
|
|
};
|
|
|
|
AVFormatContext *in = nullptr;
|
|
REQUIRE(avformat_open_input(&in, fixture("sidx-abs.mp4").c_str(), nullptr, nullptr) == 0);
|
|
REQUIRE(avformat_find_stream_info(in, nullptr) >= 0);
|
|
const int video_in = av_find_best_stream(in, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
|
|
REQUIRE(video_in >= 0);
|
|
|
|
const std::filesystem::path out = scratch("header.out");
|
|
std::error_code ignored;
|
|
for (const Case &c : cases) {
|
|
INFO(c.muxer << " movflags=" << (c.movflags ? c.movflags : "(none)"));
|
|
AVFormatContext *oc = nullptr;
|
|
REQUIRE(avformat_alloc_output_context2(&oc, nullptr, c.muxer, out.c_str()) >= 0);
|
|
|
|
// hybrid_fragmented is FFmpeg 7.1's; an older libavformat cannot be asked.
|
|
bool known = true;
|
|
if (c.movflags && !strcmp(c.muxer, "mp4")) {
|
|
std::string flags = c.movflags;
|
|
for (size_t at = 0; at != std::string::npos;) {
|
|
const size_t plus = flags.find('+', at);
|
|
const std::string flag = flags.substr(at, plus == std::string::npos ? plus : plus - at);
|
|
if (!av_opt_find(oc->priv_data, flag.c_str(), "movflags", 0, 0)) known = false;
|
|
at = plus == std::string::npos ? plus : plus + 1;
|
|
}
|
|
}
|
|
if (!known) {
|
|
avformat_free_context(oc);
|
|
continue;
|
|
}
|
|
|
|
AVStream *video = avformat_new_stream(oc, nullptr);
|
|
REQUIRE(video != nullptr);
|
|
REQUIRE(avcodec_parameters_copy(video->codecpar, in->streams[video_in]->codecpar) >= 0);
|
|
video->codecpar->codec_tag = 0;
|
|
REQUIRE(avio_open(&oc->pb, out.c_str(), AVIO_FLAG_WRITE) >= 0);
|
|
AVDictionary *opts = nullptr;
|
|
if (c.movflags) av_dict_set(&opts, "movflags", c.movflags, 0);
|
|
REQUIRE(avformat_write_header(oc, &opts) >= 0);
|
|
av_dict_free(&opts);
|
|
avio_flush(oc->pb);
|
|
const int64_t header_end = avio_tell(oc->pb);
|
|
|
|
REQUIRE(zm_mp4::fragments_follow_header(oc) == c.reserves);
|
|
|
|
if (strcmp(c.muxer, "matroska") != 0) {
|
|
const std::vector<uint8_t> header = read_file(out);
|
|
REQUIRE(header.size() == size_t(header_end));
|
|
const std::vector<TopBox> boxes = top_level(header);
|
|
bool moov = false;
|
|
if (!boxes.empty() && boxes.back().type == "moov") {
|
|
const TopBox &box = boxes.back();
|
|
const std::vector<uint8_t> body(header.begin() + box.offset + 8,
|
|
header.begin() + box.offset + box.size);
|
|
for (const TopBox &child : top_level(body)) moov = moov || child.type == "mvex";
|
|
}
|
|
REQUIRE(moov == c.moov);
|
|
}
|
|
|
|
// And the decision is what VideoStore acts on: a region exactly where the
|
|
// header ended, or not a byte.
|
|
const int64_t region = zm_mp4::reserve_region(oc, zm_mp4::kSidxReserve);
|
|
avio_flush(oc->pb);
|
|
REQUIRE(region == (c.reserves ? header_end : -1));
|
|
REQUIRE(avio_tell(oc->pb) == header_end + (c.reserves ? zm_mp4::kSidxReserve : 0));
|
|
|
|
avio_closep(&oc->pb);
|
|
avformat_free_context(oc);
|
|
std::filesystem::remove(out, ignored);
|
|
}
|
|
avformat_close_input(&in);
|
|
}
|