perf: remux indexed Cast video at keyframe boundaries

This commit is contained in:
2026-10-17 00:28:10 +02:00
parent 880b596765
commit acb69fbe8a
5 changed files with 299 additions and 35 deletions
+198 -2
View File
@@ -43,7 +43,7 @@ static void writeFile(const std::string &path, const std::vector<uint8_t> &bytes
std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast<const char *>(bytes.data()), bytes.size()); assert(file.good()); std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast<const char *>(bytes.data()), bytes.size()); assert(file.good());
} }
// Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures. // Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures.
static void fixture(const std::string &path, int seconds = 90, int width = 96, int height = 64, int bitrate = 300000) { static void fixture(const std::string &path, int seconds = 90, int width = 96, int height = 64, int bitrate = 300000, bool compatible = false) {
AVFormatContext *format = nullptr; ok(avformat_alloc_output_context2(&format, nullptr, "mpegts", path.c_str())); AVFormatContext *format = nullptr; ok(avformat_alloc_output_context2(&format, nullptr, "mpegts", path.c_str()));
AVCodecContext *video = avcodec_alloc_context3(avcodec_find_encoder_by_name("h264_videotoolbox")); AVCodecContext *video = avcodec_alloc_context3(avcodec_find_encoder_by_name("h264_videotoolbox"));
AVCodecContext *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio); AVCodecContext *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio);
@@ -51,6 +51,10 @@ static void fixture(const std::string &path, int seconds = 90, int width = 96, i
video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = bitrate; video->gop_size = 70; video->max_b_frames = 0; video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = bitrate; video->gop_size = 70; video->max_b_frames = 0;
video->sample_aspect_ratio = {4, 3}; video->sample_aspect_ratio = {4, 3};
av_opt_set(video->priv_data, "allow_sw", "1", 0); av_opt_set(video->priv_data, "realtime", "1", 0); av_opt_set(video->priv_data, "allow_sw", "1", 0); av_opt_set(video->priv_data, "realtime", "1", 0);
if (compatible) {
ok(av_opt_set(video->priv_data, "profile", "main", 0));
ok(av_opt_set(video->priv_data, "level", "4.1", 0));
}
audio->sample_rate = 48000; audio->sample_fmt = AV_SAMPLE_FMT_FLTP; audio->time_base = {1, 48000}; audio->bit_rate = 128000; audio->sample_rate = 48000; audio->sample_fmt = AV_SAMPLE_FMT_FLTP; audio->time_base = {1, 48000}; audio->bit_rate = 128000;
av_channel_layout_default(&audio->ch_layout, 2); av_channel_layout_default(&audio->ch_layout, 2);
ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr)); ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr));
@@ -484,14 +488,206 @@ static void hlsWork(Upstream &upstream, const std::string &segment) {
} }
assert(upstream.foreignRequests == 0); assert(upstream.foreignRequests == 0);
} }
struct CompressedSample {
int64_t dts;
std::vector<uint8_t> bytes;
bool operator==(const CompressedSample &) const = default;
};
using Samples = std::map<std::pair<int, int64_t>, CompressedSample>;
// Compare actual AAC payloads and H.264 slices, excluding container-inserted
// AUD/parameter sets. Also decode each TS independently with strict errors.
static Samples samples(const std::string &path, double origin = NAN) {
AVFormatContext *format = nullptr;
ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr)); ok(avformat_find_stream_info(format, nullptr));
if (std::isnan(origin)) origin = double(format->start_time) / AV_TIME_BASE;
Samples result;
std::map<int, AVCodecContext *> decoders;
std::map<int, int64_t> lastDTS;
std::map<int, int> decoded;
auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc(); assert(packet && frame);
while (av_read_frame(format, packet) >= 0) {
auto *stream = format->streams[packet->stream_index];
const bool video = stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO;
assert(stream->codecpar->codec_id == (video ? AV_CODEC_ID_H264 : AV_CODEC_ID_AAC));
assert(packet->pts != AV_NOPTS_VALUE && packet->dts != AV_NOPTS_VALUE);
const int64_t pts = std::llround((packet->pts * av_q2d(stream->time_base) - origin) * 90000);
const int64_t dts = std::llround((packet->dts * av_q2d(stream->time_base) - origin) * 90000);
if (lastDTS.contains(video)) assert(dts > lastDTS[video]);
lastDTS[video] = dts;
std::vector<uint8_t> payload;
if (video) {
bool idr = false;
for (int at = 0; at + 3 < packet->size;) {
if (packet->data[at] || packet->data[at + 1] || packet->data[at + 2] != 1) { ++at; continue; }
const int begin = at + 3, type = packet->data[begin] & 31;
at = begin + 1;
while (at + 3 < packet->size &&
(packet->data[at] || packet->data[at + 1] || packet->data[at + 2] != 1)) ++at;
int end = at + 3 < packet->size ? at : packet->size;
while (end > begin && packet->data[end - 1] == 0) --end;
if (type == 1 || type == 5) payload.insert(payload.end(), packet->data + begin, packet->data + end);
idr = idr || type == 5;
}
if (!decoders.contains(video)) assert((packet->flags & AV_PKT_FLAG_KEY) && idr);
assert(!payload.empty());
} else payload.assign(packet->data, packet->data + packet->size);
assert(result.emplace(std::pair{int(video), pts}, CompressedSample{dts, std::move(payload)}).second);
if (!decoders.contains(video)) {
auto *decoder = avcodec_alloc_context3(avcodec_find_decoder(stream->codecpar->codec_id)); assert(decoder);
ok(avcodec_parameters_to_context(decoder, stream->codecpar));
decoder->err_recognition = AV_EF_EXPLODE;
ok(avcodec_open2(decoder, decoder->codec, nullptr)); decoders[video] = decoder;
}
ok(avcodec_send_packet(decoders[video], packet));
while (avcodec_receive_frame(decoders[video], frame) >= 0) { ++decoded[video]; av_frame_unref(frame); }
av_packet_unref(packet);
}
for (auto &[type, decoder] : decoders) {
ok(avcodec_send_packet(decoder, nullptr));
while (avcodec_receive_frame(decoder, frame) >= 0) { ++decoded[type]; av_frame_unref(frame); }
assert(decoded[type] == int(std::count_if(result.begin(), result.end(), [&](const auto &row) { return row.first.first == type; })));
avcodec_free_context(&decoder);
}
assert(decoded[0] > 0 && decoded[1] > 0);
av_packet_free(&packet); av_frame_free(&frame); avformat_close_input(&format);
return result;
}
// Indexed, seven-second GOPs used to transcode at every six-second boundary.
static void remuxWork(const std::filesystem::path &directory) {
const auto source = (directory / "remux.ts").string(), indexed = (directory / "remux.mp4").string();
fixture(source, 28, 1280, 720, 3000000, true);
// Keep audio with the indexed video so the check covers both packet lanes.
const char *arguments[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", source.c_str(), "-map", "0", "-c", "copy", "-movflags", "+faststart", indexed.c_str(), nullptr};
pid_t child = 0;
assert(posix_spawnp(&child, arguments[0], nullptr, nullptr, const_cast<char *const *>(arguments), environ) == 0);
int status = 0; assert(waitpid(child, &status, 0) == child);
assert(WIFEXITED(status) && WEXITSTATUS(status) == 0);
std::string url; double duration = 0;
auto began = std::chrono::steady_clock::now();
auto *media = start({indexed.c_str(), "", nullptr, nullptr, true, false, false, 0}, url, duration);
const auto prepared = std::chrono::steady_clock::now();
auto manifest = request(url); assert(manifest.status == 200);
std::string text(manifest.bytes.begin(), manifest.bytes.end()), base = url.substr(0, url.rfind('/') + 1);
std::vector<double> boundaries{0};
for (size_t at = 0; (at = text.find("#EXTINF:", at)) != std::string::npos; ++at)
boundaries.push_back(boundaries.back() + std::stod(text.substr(at + 8)));
size_t bytes = 0;
std::vector<HTTP> outputs;
for (size_t id = 0; id + 1 < boundaries.size(); ++id) {
auto response = request(base + "segment/" + std::to_string(id) + ".ts");
assert(response.status == 200);
bytes += response.bytes.size(); outputs.push_back(std::move(response));
}
const auto finished = std::chrono::steady_clock::now();
double longest = 0;
for (size_t i = 1; i < boundaries.size(); ++i) longest = std::max(longest, boundaries[i] - boundaries[i - 1]);
std::cout << "remux-work prepare_ms=" << std::chrono::duration<double, std::milli>(prepared - began).count()
<< " generate_ms=" << std::chrono::duration<double, std::milli>(finished - prepared).count()
<< " segments=" << outputs.size() << " bytes=" << bytes
<< " max_seconds=" << longest << std::endl;
assert(request(base + "segment/" + std::to_string(outputs.size()) + ".ts").status == 404);
assert(boundaries.size() == 5);
for (size_t i = 0; i < boundaries.size(); ++i) assert(std::abs(boundaries[i] - i * 7.0) < .1);
assert(text.find("#EXT-X-TARGETDURATION:8\n") != std::string::npos);
Samples delivered;
for (size_t id = 0; id < outputs.size(); ++id) {
const auto path = (directory / "output.ts").string(); writeFile(path, outputs[id].bytes);
auto rows = samples(path, 1);
for (auto &[key, value] : rows) {
assert(double(key.second) / 90000 >= boundaries[id] - .00002);
assert(double(key.second) / 90000 < boundaries[id + 1] + .00002);
assert(delivered.emplace(key, std::move(value)).second);
}
}
assert(delivered == samples(source));
cast_media_destroy(media);
const auto video = (directory / "indexed-video.mp4").string(), audio = (directory / "indexed-audio.m4a").string();
splitFixture(source, video, AVMEDIA_TYPE_VIDEO); splitFixture(source, audio, AVMEDIA_TYPE_AUDIO);
Upstream upstream(readFile(indexed), wav());
upstream.resources["/video.mp4"] = readFile(video); upstream.resources["/audio.m4a"] = readFile(audio);
upstream.start();
for (bool split : {false, true}) {
const auto videoURL = upstream.url(split ? "/video.mp4" : "/source.ts"), audioURL = upstream.url("/audio.m4a");
media = start({videoURL.c_str(), "X-Video: video-secret\r\n", split ? audioURL.c_str() : nullptr,
split ? "X-Audio: audio-secret\r\n" : nullptr, false, false, false, 0}, url, duration);
base = url.substr(0, url.rfind('/') + 1);
manifest = request(url); assert(manifest.status == 200);
text.assign(manifest.bytes.begin(), manifest.bytes.end());
std::vector<double> networkBoundaries{0};
for (size_t at = 0; (at = text.find("#EXTINF:", at)) != std::string::npos; ++at)
networkBoundaries.push_back(networkBoundaries.back() + std::stod(text.substr(at + 8)));
const int64_t videoOrigin = split ? delivered.lower_bound({1, INT64_MIN})->first.second : 0;
for (int id : {3, 0, 2, 1}) {
auto response = request(base + "segment/" + std::to_string(id) + ".ts");
assert(response.status == 200);
const auto path = (directory / "network-output.ts").string(); writeFile(path, response.bytes);
auto rows = samples(path, 1);
Samples expected;
for (const auto &[key, value] : delivered) {
const int64_t shift = key.first ? videoOrigin : 0;
const auto adjusted = std::pair{key.first, key.second - shift};
if (adjusted.second / 90000.0 >= networkBoundaries[id] - .00002 &&
adjusted.second / 90000.0 < networkBoundaries[id + 1] - .00002)
expected.emplace(adjusted, CompressedSample{value.dts - shift, value.bytes});
}
if (rows != expected)
std::cerr << "remux-work split=" << split << " id=" << id << " actual=" << rows.size()
<< " expected=" << expected.size() << std::endl;
assert(rows == expected);
assert(request(base + "segment/" + std::to_string(id) + ".ts").bytes == response.bytes);
}
assert(upstream.foreignRequests == 0);
cast_media_destroy(media);
}
// AAC transcoding must stay continuous across non-six-second boundaries.
const auto videoURL = upstream.url("/video.mp4"), audioURL = upstream.url("/audio.wav");
media = start({videoURL.c_str(), "X-Video: video-secret\r\n", audioURL.c_str(), "X-Audio: audio-secret\r\n",
false, false, false, 0}, url, duration);
base = url.substr(0, url.rfind('/') + 1);
auto before = audioSpan(request(base + "segment/0.ts"), (directory / "audio-output.ts").string());
auto after = audioSpan(request(base + "segment/1.ts"), (directory / "audio-output.ts").string());
assert(std::abs(after.first - before.second) < 1.0 / 48000);
cast_media_destroy(media);
// Reordered video and GOPs beyond the duration ceiling keep the fixed-grid
// fallback, including random-access independently decodable output.
for (bool reordered : {true, false}) {
const auto fallback = (directory / "fallback.mp4").string();
const char *args[] = {"ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", source.c_str(),
"-c:v", "libx264", "-profile:v", "main", "-level:v", "4.1",
"-bf", reordered ? "2" : "0", "-g", reordered ? "70" : "140",
"-keyint_min", reordered ? "70" : "140", "-sc_threshold", "0",
"-c:a", "copy", "-movflags", "+faststart", fallback.c_str(), nullptr};
assert(posix_spawnp(&child, args[0], nullptr, nullptr, const_cast<char *const *>(args), environ) == 0);
assert(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0);
media = start({fallback.c_str(), "", nullptr, nullptr, true, false, false, 0}, url, duration);
manifest = request(url); text.assign(manifest.bytes.begin(), manifest.bytes.end());
assert(text.find("#EXT-X-TARGETDURATION:6\n") != std::string::npos);
base = url.substr(0, url.rfind('/') + 1);
auto response = request(base + "segment/3.ts"); assert(response.status == 200);
const auto path = (directory / "fallback-output.ts").string(); writeFile(path, response.bytes);
const auto rows = samples(path, 1);
for (const auto &row : rows) assert(row.first.second >= 18 * 90000 && row.first.second < 24 * 90000);
cast_media_destroy(media);
}
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
bool liveCheck = false, seekCheck = false, hlsCheck = false; bool liveCheck = false, seekCheck = false, hlsCheck = false, remuxCheck = false;
for (int argument = 1; argument < argc; ++argument) { for (int argument = 1; argument < argc; ++argument) {
if (std::string(argv[argument]) == "--live") liveCheck = true; if (std::string(argv[argument]) == "--live") liveCheck = true;
if (std::string(argv[argument]) == "--seek-work") seekCheck = true; if (std::string(argv[argument]) == "--seek-work") seekCheck = true;
if (std::string(argv[argument]) == "--hls-work") hlsCheck = true; if (std::string(argv[argument]) == "--hls-work") hlsCheck = true;
if (std::string(argv[argument]) == "--remux-work") remuxCheck = true;
} }
char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp); char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp);
if (remuxCheck) {
remuxWork(directory);
std::filesystem::remove_all(directory);
std::cout << "cast-media: keyframe-aligned indexed remux passed\n";
return 0;
}
const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string(); const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string();
fixture(video); writeFile(audio, wav()); fixture(video); writeFile(audio, wav());
const auto splitVideo = (directory / "video.mp4").string(), splitAudio = (directory / "audio.m4a").string(); const auto splitVideo = (directory / "video.mp4").string(), splitAudio = (directory / "audio.m4a").string();
+1
View File
@@ -248,6 +248,7 @@ $(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift $(SWIFT_SOURCES) $(
check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures
$(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-session-check
$(BUILD_DIR)/cast-media-check --live $(BUILD_DIR)/cast-media-check --live
$(BUILD_DIR)/cast-media-check --remux-work
$(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4 $(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
+12 -1
View File
@@ -221,7 +221,7 @@ TLS connection establishment has a twelve-second deadline; cold receiver launch
The Mac serves session-only capability URLs over HTTP. Keep it awake and connected, and allow incoming connections through its firewall. The relay exposes only the chosen media and subtitle resources, never arbitrary filesystem paths or a general URL proxy. Disconnect, replacement and quit cancel delivery and invalidate the listening endpoint. The Mac serves session-only capability URLs over HTTP. Keep it awake and connected, and allow incoming connections through its firewall. The relay exposes only the chosen media and subtitle resources, never arbitrary filesystem paths or a general URL proxy. Disconnect, replacement and quit cancel delivery and invalidate the listening endpoint.
Local files, resolved Miruro HLS and YouTube's combined/separate video/audio inputs are delivered as six-second HLS segments. Compatible, independently decodable H.264/AAC is remuxed; other supported inputs use VideoToolbox H.264 (including its platform software fallback) and stereo AAC. Output targets at most **1920×1080 at 30 fps**, H.264 Main/High level 4.1 or lower, with preserved display aspect ratio. Recorded segments are generated on demand, including distant seeks; there is no full-movie download or preprocessing step. Live output keeps eight segments (about 48 seconds) and is not seekable. Audio-only sources are supported. Local files, resolved Miruro HLS and YouTube's combined/separate video/audio inputs are delivered as HLS segments targeting six seconds. Compatible H.264/AAC is remuxed; complete sample indexes let recorded, non-reordered H.264 use keyframe-aligned segments up to twelve seconds. Unindexed inputs (including Miruro HLS), B-frame video, longer GOPs and incompatible formats retain the fixed six-second fallback: VideoToolbox H.264 (including its platform software fallback) and stereo AAC where needed. Output targets at most **1920×1080 at 30 fps**, H.264 Main/High level 4.1 or lower, preserving display aspect ratio. Recorded segments are generated on demand, including distant seeks; no full-movie download or preprocessing. Live output keeps eight segments (about 48 seconds) and is not seekable. Audio-only sources are supported.
Recorded network delivery uses the native player's bounded compressed-packet buffer: up to 128 MiB shared between inputs, targeting sixty seconds ahead and thirty seconds behind (byte pressure can shorten retention). Combined HLS supplies video/audio from one selected-stream reader; unused quality variants are disabled. Sequential and nearby seeks reuse packets, including decoder/AAC preroll; true cache misses seek upstream once. Nearby forward requests wait for existing read-ahead instead of cancelling and restarting its fetch. Eight completed output segments are also cached (at most 256 MiB), so repeated, HEAD and range requests reuse encoded bytes. Uncached generation is serialized; GOP preroll, transcoding, provider slowness and receiver buffering can still delay a fresh Cast seek. Recorded network delivery uses the native player's bounded compressed-packet buffer: up to 128 MiB shared between inputs, targeting sixty seconds ahead and thirty seconds behind (byte pressure can shorten retention). Combined HLS supplies video/audio from one selected-stream reader; unused quality variants are disabled. Sequential and nearby seeks reuse packets, including decoder/AAC preroll; true cache misses seek upstream once. Nearby forward requests wait for existing read-ahead instead of cancelling and restarting its fetch. Eight completed output segments are also cached (at most 256 MiB), so repeated, HEAD and range requests reuse encoded bytes. Uncached generation is serialized; GOP preroll, transcoding, provider slowness and receiver buffering can still delay a fresh Cast seek.
@@ -233,6 +233,17 @@ The Cast channel accepts the selected receiver's self-signed TLS certificate: en
Authenticated multi-variant HLS regressions gate upstream responses and verify no unused-quality downloads or duplicate retained-media reads, nearby rewinds, distant cache misses, decoded A/V positions and AAC continuity, and cancellation while upstream remains blocked. Native network playback checks cover the shared reader's existing callers. Authenticated multi-variant HLS regressions gate upstream responses and verify no unused-quality downloads or duplicate retained-media reads, nearby rewinds, distant cache misses, decoded A/V positions and AAC continuity, and cancellation while upstream remains blocked. Native network playback checks cover the shared reader's existing callers.
`make check-cast` also checks indexed remux packet preservation, independent decoding, random seeks through combined/split inputs, non-grid AAC continuity, and fallback for B-frames/long GOPs. One local synthetic 28-second, 1280×720/10 fps H.264/AAC recording with seven-second GOPs measured:
| Relay generation (fixture creation/probing excluded) | Before | After |
| --- | ---: | ---: |
| All output segments, HTTP loopback | 959 ms | 2.7 ms |
| Segment count | 5 | 4 |
| Longest segment | 6 s | 7.021 s |
| Total output bytes | 813,288 | 812,912 |
This measures avoided transcoding, not CDN latency or physical-receiver performance. Segment/cache byte caps are unchanged; longer indexed segments can retain more playback time within those caps. A production-build HLS consumer matched source frame MD5s after forward/backward seeks to 10.5, 22.3 and 2.6 seconds. Hardware receiver seeking and A/V synchronization still need manual verification.
The player handoff checks also gate receiver launch and LOAD to exercise cancellation, LOAD rejection after local pause, replacement/close/shutdown during pending and active Cast, and late receiver replies. Disconnect recovery preserves the latest playback intent, acknowledged position, stable bookmarks and independent local volume even with an unacknowledged remote seek. The player handoff checks also gate receiver launch and LOAD to exercise cancellation, LOAD rejection after local pause, replacement/close/shutdown during pending and active Cast, and late receiver replies. Disconnect recovery preserves the latest playback intent, acknowledged position, stable bookmarks and independent local volume even with an unacknowledged remote seek.
## Resume playback ## Resume playback
+80 -31
View File
@@ -164,7 +164,9 @@ struct Input {
buffered->start(indices, offset(), true); buffered->start(indices, offset(), true);
} }
void seek(double seconds, double audioTarget = NAN) { void seek(double seconds, double audioTarget = NAN) {
const auto timestamp = int64_t((seconds + offset()) * AV_TIME_BASE); // Round forward so a sub-microsecond truncation cannot select the
// previous GOP when a playlist boundary is an exact stream timestamp.
const auto timestamp = int64_t(std::ceil((seconds + offset()) * AV_TIME_BASE));
if (buffered) { if (buffered) {
// Probing retains the opening packets. Do not reopen their HLS media // Probing retains the opening packets. Do not reopen their HLS media
// resource just to establish the initial zero-position cursors. // resource just to establish the initial zero-position cursors.
@@ -196,6 +198,15 @@ bool remuxAudio(const AVCodecParameters &codec) {
return codec.codec_id == AV_CODEC_ID_AAC && codec.ch_layout.nb_channels <= 2 && return codec.codec_id == AV_CODEC_ID_AAC && codec.ch_layout.nb_channels <= 2 &&
(codec.profile == AV_PROFILE_AAC_LOW || codec.profile == AV_PROFILE_AAC_HE || codec.profile == AV_PROFILE_AAC_HE_V2); (codec.profile == AV_PROFILE_AAC_LOW || codec.profile == AV_PROFILE_AAC_HE || codec.profile == AV_PROFILE_AAC_HE_V2);
} }
bool remuxVideo(Input &input, AVStream &stream) {
const auto &codec = *stream.codecpar;
const int profile = codec.profile & ~AV_PROFILE_H264_CONSTRAINED;
const auto rate = av_guess_frame_rate(input.format, &stream, nullptr);
return codec.codec_id == AV_CODEC_ID_H264 && codec.video_delay == 0 &&
(profile == AV_PROFILE_H264_BASELINE || profile == AV_PROFILE_H264_MAIN || profile == AV_PROFILE_H264_HIGH) &&
codec.level > 0 && codec.level <= 41 && codec.width > 0 && codec.width <= 1920 &&
codec.height > 0 && codec.height <= 1080 && rate.num > 0 && av_q2d(rate) <= 30.001;
}
struct Track { struct Track {
Input *input; Input *input;
AVStream *stream = nullptr; AVStream *stream = nullptr;
@@ -204,31 +215,33 @@ struct Track {
Frame decoded; Frame decoded;
bool eof = false, draining = false, remux = false; bool eof = false, draining = false, remux = false;
double nextTime = 0; double nextTime = 0;
Track(Input &in, int index, AVMediaType type, double start, bool live) : input(&in) { Track(Input &in, int index, AVMediaType type, double start, bool live, bool aligned = false) : input(&in) {
require(index >= 0); require(index >= 0);
stream = input->format->streams[index]; stream = input->format->streams[index];
remux = stream->codecpar->codec_id == (type == AVMEDIA_TYPE_VIDEO ? AV_CODEC_ID_H264 : AV_CODEC_ID_AAC); remux = type == AVMEDIA_TYPE_VIDEO ? remuxVideo(in, *stream) : remuxAudio(*stream->codecpar);
if (type == AVMEDIA_TYPE_VIDEO) {
const int profile = stream->codecpar->profile & ~AV_PROFILE_H264_CONSTRAINED;
auto rate = av_guess_frame_rate(input->format, stream, nullptr);
remux = remux && (profile == AV_PROFILE_H264_BASELINE || profile == AV_PROFILE_H264_MAIN || profile == AV_PROFILE_H264_HIGH) &&
stream->codecpar->level > 0 && stream->codecpar->level <= 41 &&
stream->codecpar->width <= 1920 && stream->codecpar->height <= 1080 &&
rate.num > 0 && av_q2d(rate) <= 30.001;
} else {
remux = remuxAudio(*stream->codecpar);
}
const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ? const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ?
std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) / std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) /
std::max(1, stream->codecpar->sample_rate)) : start; std::max(1, stream->codecpar->sample_rate)) : start;
if (!live && !input->buffered) input->seek(seekTarget); if (!live && !input->buffered) input->seek(seekTarget);
nextPacket(); nextPacket();
// Every VOD segment must start at its advertised time and on an IDR. If the // Indexed VOD can advertise source GOP boundaries; every actual start
// source GOP doesn't align, decode preroll and use VideoToolbox, not overlap. // still has to be an IDR. The opening frame may follow AAC priming.
if (type == AVMEDIA_TYPE_VIDEO) if (type == AVMEDIA_TYPE_VIDEO) {
remux = remux && !live && stream->codecpar->video_delay == 0 && pending && // Cached seeks can return the preceding GOP while read-ahead has
(pending->flags & AV_PKT_FLAG_KEY) && hasIDR(*pending, *stream->codecpar) && // reached this boundary but not yet delivered its IDR packet.
std::abs(packetTime() - start) < .001; if (aligned && remux)
while (pending && packetTime() < start - .001) nextPacket();
const auto rate = av_guess_frame_rate(input->format, stream, nullptr);
const double openingGap = rate.num > 0 ? av_q2d(av_inv_q(rate)) : 0;
remux = remux && !live && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
hasIDR(*pending, *stream->codecpar) &&
(std::abs(packetTime() - start) < .001 ||
(start == 0 && packetTime() >= 0 && packetTime() <= openingGap));
if (aligned && !remux) {
// A keyframe index is not proof of an IDR; recover full decoder preroll.
input->seek(0); eof = false; nextPacket();
}
}
if (!live && type == AVMEDIA_TYPE_VIDEO && pending && (pending->flags & AV_PKT_FLAG_KEY) && if (!live && type == AVMEDIA_TYPE_VIDEO && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
!hasIDR(*pending, *stream->codecpar) && stream->codecpar->codec_id == AV_CODEC_ID_H264) { !hasIDR(*pending, *stream->codecpar) && stream->codecpar->codec_id == AV_CODEC_ID_H264) {
// ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow. // ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow.
@@ -499,6 +512,7 @@ struct Pipeline {
bool live; bool live;
std::atomic<bool> &cancel; std::atomic<bool> &cancel;
double duration = 0; double duration = 0;
std::vector<double> boundaries;
Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) { Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) {
videoInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live); videoInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
duration = std::max(source.duration, videoInput->duration()); duration = std::max(source.duration, videoInput->duration());
@@ -519,9 +533,32 @@ struct Pipeline {
} }
} }
require(videoIndex >= 0 || audioIndex >= 0); require(videoIndex >= 0 || audioIndex >= 0);
// Read only a complete, already-loaded sample index, before read-ahead
// owns the demuxer. No media scan or upstream seeks to build a playlist.
if (!live && videoIndex >= 0) {
auto *stream = videoInput->format->streams[videoIndex];
const int count = avformat_index_get_entries_count(stream);
if (remuxVideo(*videoInput, *stream) && count > 0 && stream->nb_frames == count) {
boundaries.push_back(0);
double previous = -INFINITY;
for (int i = 0; i < count; ++i) {
const auto *entry = avformat_index_get_entry(stream, i);
const double time = entry->timestamp * av_q2d(stream->time_base) - videoInput->offset();
if (entry->timestamp == AV_NOPTS_VALUE || time < previous) { boundaries.clear(); break; }
previous = time;
if (!(entry->flags & AVINDEX_KEYFRAME) || time < boundaries.back() + segmentSeconds) continue;
// ponytail: cap indexed GOP segments at twelve seconds; longer GOPs use the fixed-grid transcode.
if (time - boundaries.back() > 2 * segmentSeconds || time >= duration) break;
boundaries.push_back(time);
}
if (boundaries.size() < 2 || duration - boundaries.back() > 2 * segmentSeconds ||
duration <= boundaries.back()) boundaries.clear();
else boundaries.push_back(duration);
}
}
videoInput->select({audioSource == videoInput.get() ? audioIndex : -1, videoIndex}, cacheBudget); videoInput->select({audioSource == videoInput.get() ? audioIndex : -1, videoIndex}, cacheBudget);
if (audioInput) audioInput->select({audioIndex, -1}, cacheBudget); if (audioInput) audioInput->select({audioIndex, -1}, cacheBudget);
reset(start); reset(start, !boundaries.empty());
if (!source.audio.empty()) require(audio && audio->stream); if (!source.audio.empty()) require(audio && audio->stream);
require(video || audio); require(video || audio);
if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14); if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14);
@@ -537,18 +574,18 @@ struct Pipeline {
videoInput->operationDeadline = std::numeric_limits<int64_t>::max(); videoInput->operationDeadline = std::numeric_limits<int64_t>::max();
if (audioInput) audioInput->operationDeadline = std::numeric_limits<int64_t>::max(); if (audioInput) audioInput->operationDeadline = std::numeric_limits<int64_t>::max();
} }
void reset(double start) { void reset(double start, bool aligned = false) {
armOperation(); armOperation();
videoOutput.reset(); audioOutput.reset(); videoOutput.reset(); audioOutput.reset();
video.reset(); audio.reset(); video.reset(); audio.reset();
// Fresh codecs retain independent IDRs/AAC priming. One recorded network // Fresh codecs retain independent IDRs/AAC priming. One recorded network
// demuxer atomically rewinds both packet lanes, seeking upstream only on misses. // demuxer atomically rewinds both packet lanes, seeking upstream only on misses.
const double audioTarget = audioIndex >= 0 && !remuxAudio(*audioSource->format->streams[audioIndex]->codecpar) ? const double audioTarget = audioIndex >= 0 && !remuxAudio(*audioSource->format->streams[audioIndex]->codecpar) ?
aacPreroll(start) : start; aacPreroll(start) : NAN;
if (videoInput->buffered) if (videoInput->buffered)
videoInput->seek(start, audioSource == videoInput.get() ? audioTarget : NAN); videoInput->seek(start, audioSource == videoInput.get() ? audioTarget : NAN);
if (audioInput && audioInput->buffered) audioInput->seek(start, audioTarget); if (audioInput && audioInput->buffered) audioInput->seek(start, audioTarget);
if (videoIndex >= 0) video = std::make_unique<Track>(*videoInput, videoIndex, AVMEDIA_TYPE_VIDEO, start, live); if (videoIndex >= 0) video = std::make_unique<Track>(*videoInput, videoIndex, AVMEDIA_TYPE_VIDEO, start, live, aligned);
if (audioIndex >= 0) audio = std::make_unique<Track>(*audioSource, audioIndex, AVMEDIA_TYPE_AUDIO, start, live); if (audioIndex >= 0) audio = std::make_unique<Track>(*audioSource, audioIndex, AVMEDIA_TYPE_AUDIO, start, live);
} }
Segment segment(int64_t id, double start, double end) { Segment segment(int64_t id, double start, double end) {
@@ -606,6 +643,7 @@ struct CastMedia {
std::thread producer; std::thread producer;
std::string base, token; std::string base, token;
double duration = 0; double duration = 0;
std::vector<double> boundaries;
std::deque<std::shared_ptr<const Segment>> segments; std::deque<std::shared_ptr<const Segment>> segments;
std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles; std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles;
#ifdef CAST_MEDIA_CHECK #ifdef CAST_MEDIA_CHECK
@@ -681,16 +719,26 @@ struct CastMedia {
} }
} catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); } } catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); }
} }
int64_t segmentCount() const {
return boundaries.empty() ? int64_t(std::ceil(duration / segmentSeconds)) : int64_t(boundaries.size() - 1);
}
double boundary(int64_t id) const {
return boundaries.empty() ? std::min(duration, id * segmentSeconds) : boundaries[size_t(id)];
}
std::vector<uint8_t> playlist() { std::vector<uint8_t> playlist() {
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n"; double longest = segmentSeconds;
int64_t first = 0, count = int64_t(std::ceil(duration / segmentSeconds)); for (size_t i = 1; i < boundaries.size(); ++i)
longest = std::max(longest, boundaries[i] - boundaries[i - 1]);
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:" +
std::to_string(int(std::ceil(longest))) + "\n";
int64_t first = 0, count = segmentCount();
std::lock_guard guard(lock); std::lock_guard guard(lock);
if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); } if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); }
else text += "#EXT-X-PLAYLIST-TYPE:VOD\n"; else text += "#EXT-X-PLAYLIST-TYPE:VOD\n";
text += "#EXT-X-MEDIA-SEQUENCE:" + std::to_string(first) + "\n"; text += "#EXT-X-MEDIA-SEQUENCE:" + std::to_string(first) + "\n";
if (source.live) text += "#EXT-X-DISCONTINUITY-SEQUENCE:" + std::to_string(first) + "\n"; if (source.live) text += "#EXT-X-DISCONTINUITY-SEQUENCE:" + std::to_string(first) + "\n";
for (int64_t id = first; id < first + count; ++id) { for (int64_t id = first; id < first + count; ++id) {
char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : std::min(segmentSeconds, duration - id * segmentSeconds); char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : boundary(id + 1) - boundary(id);
std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length); std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length);
// Each independently generated segment resets codec/mux state. // Each independently generated segment resets codec/mux state.
if (id > first) text += "#EXT-X-DISCONTINUITY\n"; if (id > first) text += "#EXT-X-DISCONTINUITY\n";
@@ -712,10 +760,10 @@ struct CastMedia {
require(!cancelled); require(!cancelled);
if (auto segment = cached()) return segment; if (auto segment = cached()) return segment;
try { try {
if (vodPipeline) vodPipeline->reset(id * segmentSeconds); const double start = boundary(id), end = boundary(id + 1);
else vodPipeline = std::make_unique<Pipeline>(source, cancelled, id * segmentSeconds); if (vodPipeline) vodPipeline->reset(start, !boundaries.empty());
auto segment = std::make_shared<const Segment>(vodPipeline->segment( else vodPipeline = std::make_unique<Pipeline>(source, cancelled, start);
id, id * segmentSeconds, std::min(duration, (id + 1) * segmentSeconds))); auto segment = std::make_shared<const Segment>(vodPipeline->segment(id, start, end));
{ std::lock_guard guard(lock); segments.push_back(segment); while (segments.size() > windowSegments) segments.pop_front(); } { std::lock_guard guard(lock); segments.push_back(segment); while (segments.size() > windowSegments) segments.pop_front(); }
return segment; return segment;
} catch (...) { } catch (...) {
@@ -798,7 +846,7 @@ struct CastMedia {
else if (source.live) { else if (source.live) {
std::lock_guard guard(lock); auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; }); std::lock_guard guard(lock); auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes); if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes);
} else if (double(id) * segmentSeconds >= duration) status = 404; } else if (id >= segmentCount()) status = 404;
else { else {
auto segment = vodSegment(id); auto segment = vodSegment(id);
retained = std::shared_ptr<const std::vector<uint8_t>>(segment, &segment->bytes); retained = std::shared_ptr<const std::vector<uint8_t>>(segment, &segment->bytes);
@@ -840,6 +888,7 @@ extern "C" bool cast_media_start(CastMedia *media, const char *host, char url[51
require(media && host && url && duration && !media->cancelled); require(media && host && url && duration && !media->cancelled);
auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0); auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0);
media->duration = pipeline->duration; media->duration = pipeline->duration;
media->boundaries = std::move(pipeline->boundaries);
media->bindReceiver(host); media->bindReceiver(host);
if (media->source.live) { if (media->source.live) {
media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); }); media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); });
+8 -1
View File
@@ -1,9 +1,16 @@
- [ ] fix the readme:
- clear out redundant and irrelevant overly detailed crap from the readme.
- we don't need every minute detail (already described perfectly by the code btw) described in painstakingly verbose paragraphs in the readme.
- implementation details are not appropriate in a readme file (without a very good reason to be there).
- the readme should be compressed to actually useful information - nothing more - and verbose, irrelevant, or redundant information deleted.
- [ ] evaluate playback optimization opportunities for YouTube and Miruro — measure benefits before changing behavior - [ ] evaluate playback optimization opportunities for YouTube and Miruro — measure benefits before changing behavior
- [x] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering - [x] coalesce superseded, not-yet-started seeks in the player and Cast controls; preserve accumulated relative skips and play/pause ordering
- [ ] expand Cast H.264/AAC remux coverage with keyframe-aligned segments; preserve seek accuracy, timestamps and receiver compatibility - [x] expand Cast H.264/AAC remux coverage with keyframe-aligned segments; preserve seek accuracy, timestamps and receiver compatibility
- [ ] reuse decoder/encoder state during sequential Cast playback; reset on random seeks and preserve independent keyframes and AAC continuity - [ ] reuse decoder/encoder state during sequential Cast playback; reset on random seeks and preserve independent keyframes and AAC continuity
- [ ] evaluate VideoToolbox hardware video decoding for native and Cast playback; retain software fallback for unsupported formats - [ ] evaluate VideoToolbox hardware video decoding for native and Cast playback; retain software fallback for unsupported formats
- [ ] benchmark cold start, cached/uncached seeks and sustained playback separately to prioritize the actual bottlenecks - [ ] benchmark cold start, cached/uncached seeks and sustained playback separately to prioritize the actual bottlenecks
- do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs - do not enable Miruro HTTP persistence without preserving origin/header checks, or increase caches/shorten Cast segments without measuring the memory/request/codec tradeoffs
- [ ] add playlist support
- maybe by allowing multiple file selection? 🤔
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly) - we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)