#include "../Sources/Core/player_macos.h" #import #import extern "C" { #include } #include #include #include #include #include #include #include #include #include using Clock = std::chrono::steady_clock; static void require(bool condition, const char *message) { if (!condition) { std::fprintf(stderr, "FAIL native benchmark: %s\n", message); std::exit(1); } } static void emit(NSDictionary *row) { NSError *error = nil; NSData *json = [NSJSONSerialization dataWithJSONObject:row options:0 error:&error]; require(json != nil, "encode JSON result"); require(std::fwrite(json.bytes, 1, json.length, stdout) == json.length && std::fputc('\n', stdout) != EOF && std::fflush(stdout) == 0, "write JSON result"); // The runner snapshots upstream counters before acknowledging each boundary. // Preparation is excluded; read-ahead concurrent with a measured phase is included. std::string acknowledgement; require(bool(std::getline(std::cin, acknowledgement)) && acknowledgement.empty(), "runner must acknowledge each JSON boundary with an empty line"); } struct Stamp { Clock::time_point wall; rusage usage; }; static Stamp stamp() { Stamp result{Clock::now(), {}}; require(getrusage(RUSAGE_SELF, &result.usage) == 0, "read process resource usage"); return result; } static double cpuMilliseconds(const rusage &usage) { return (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) * 1000.0 + (usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1000.0; } static void begin(NSString *phase) { emit(@{@"event": @"begin", @"phase": phase}); } static void finish(NSString *phase, const Stamp &start, const Stamp &end, PlayerState state, NSDictionary *details) { NSMutableDictionary *row = [@{ @"phase": phase, @"wall_ms": @(std::chrono::duration(end.wall - start.wall).count()), @"cpu_ms": @(cpuMilliseconds(end.usage) - cpuMilliseconds(start.usage)), // Darwin reports ru_maxrss in bytes. This is process-lifetime high-water RSS, // not a per-phase delta; CPU includes every native decoder/output thread. @"peak_rss_mib": @(end.usage.ru_maxrss / (1024.0 * 1024.0)), @"media_seconds": @0, @"output_bytes": @0, @"position_seconds": @(state.position), @"buffered_start": @(state.buffered_start), @"buffered_end": @(state.buffered_end) } mutableCopy]; [row addEntriesFromDictionary:details]; emit(row); } static PlayerState until(Player *player, const std::function &ready, const char *phase, MTKView *view = nil) { const auto deadline = Clock::now() + std::chrono::seconds(60); auto nextDraw = Clock::now(); while (Clock::now() < deadline) { const auto state = player_get_state(player); if (state.status == PLAYER_ERROR) { std::fprintf(stderr, "FAIL native benchmark %s: %s\n", phase, state.error); std::exit(1); } // Exercise the real Metal renderer at the fixture's 30 fps, not at the // 1 kHz state-poll rate. Manual draws also work for this offscreen window. if (view && Clock::now() >= nextDraw) { [view draw]; nextDraw = Clock::now() + std::chrono::duration_cast( std::chrono::duration(1.0 / 30)); } if (ready(state)) return state; CFRunLoopRunInMode(kCFRunLoopDefaultMode, .001, false); } const auto state = player_get_state(player); std::fprintf(stderr, "FAIL native benchmark %s: deadline status=%d position=%.6f duration=%.6f " "pending=%d buffering=%d cache=%.6f..%.6f\n", phase, state.status, state.position, state.duration, state.seek_pending, state.buffering, state.buffered_start, state.buffered_end); std::exit(1); } static bool pausedReady(PlayerState state) { return state.status == PLAYER_PAUSED && !state.seek_pending && !state.buffering && state.has_audio && state.has_video && state.can_seek; } static PlayerState seek(Player *player, double target, const char *phase) { const auto revision = player_get_state(player).revision; require(player_seek(player, target), "paused seek accepted"); return until(player, [=](auto state) { // !buffering is important: seek_pending alone acknowledges reader/command // work, not decoded preview readiness. The paused tick clears buffering // only after presenting a decoded frame and supplying initial audio. return pausedReady(state) && state.revision != revision && std::abs(state.position - target) < .05; }, phase); } static bool cached(PlayerState state, double target) { // Leave room for the preceding two-second GOP and following audio/video. return pausedReady(state) && state.buffered_end > state.buffered_start && state.buffered_start <= target - 2 && state.buffered_end >= target + 1; } static bool uncached(PlayerState state, double target) { return pausedReady(state) && state.buffered_end > state.buffered_start && target > state.buffered_end + 2; } int main(int argc, char **argv) { @autoreleasepool { require(argc == 3, "usage: playback-benchmark "); const std::string base = argv[1], mode = argv[2]; require(base.rfind("http://127.0.0.1:", 0) == 0 && base.size() > 17 && base.size() <= 22 && base.find_first_not_of("0123456789", 17) == std::string::npos && std::strtoul(base.c_str() + 17, nullptr, 10) > 0 && std::strtoul(base.c_str() + 17, nullptr, 10) <= 65535, "fixture base URL must be an http://127.0.0.1: origin"); require(mode == "split" || mode == "hls", "source must be split or hls"); const bool split = mode == "split"; const std::string video = base + (split ? "/media/bench-video.mp4" : "/media/bench.m3u8"); const std::string audio = split ? base + "/media/bench-audio.m4a" : ""; PlayerNetworkSource source{video.c_str(), split ? audio.c_str() : nullptr, split ? "X-Terebi-Fixture: video\r\n" : "X-Terebi-Fixture: combined\r\n", split ? "X-Terebi-Fixture: audio\r\n" : nullptr, 120, false, split ? nullptr : "miruro:YmVuY2g:1", 0}; av_log_set_level(AV_LOG_ERROR); [NSApplication sharedApplication]; // Cold start follows queued autoplay through the first real audio-clock // advancement and pause. Buffering gates audio and decoded video readiness. // It includes player/audio/Metal setup, but excludes process launch and // fixture creation; it is NOT just the initial PLAYER_PAUSED publication. begin(@"cold_start"); const auto coldStart = stamp(); std::unique_ptr player(player_create(), player_destroy); require(bool(player), "create player"); require(player_set_volume(player.get(), 0), "mute real native audio output"); void *native = player_macos_make_video_view(player.get()); require(native != nullptr, "initialize native Metal view"); MTKView *view = (__bridge_transfer MTKView *)native; view.paused = YES; view.autoResizeDrawable = NO; view.drawableSize = CGSizeMake(1280, 720); NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(-4000, -4000, 1280, 720) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; window.contentView = view; [window orderBack:nil]; require(player_open_network(player.get(), &source), "accept network source"); require(player_play(player.get()), "queue autoplay during network open"); until(player.get(), [](auto state) { return state.status == PLAYER_PLAYING && !state.buffering && state.position >= .05; }, "initial real playback readiness", view); require(view.currentDrawable != nil, "initial Metal drawable"); [view draw]; require(player_pause(player.get()), "pause initial playback probe"); auto state = until(player.get(), pausedReady, "initial playback probe paused", view); const auto coldEnd = stamp(); finish(@"cold_start", coldStart, coldEnd, state, @{@"readiness": @"queued_autoplay_then_audio_clock_probe_and_pause", @"probe_media_seconds": @(state.position), @"render_width": @1280, @"render_height": @720}); constexpr double hitTarget = 20.5, missTarget = 100.5; // Waiting for retained packet coverage is preparation, not seek latency. until(player.get(), [](auto s) { return cached(s, hitTarget); }, "cached target coverage"); begin(@"cached_seek"); auto before = player_get_state(player.get()); require(cached(before, hitTarget), "cached seek target and GOP must remain inside retained coverage"); const auto hitStart = stamp(); state = seek(player.get(), hitTarget, "cached paused decoded seek"); [view draw]; const auto hitEnd = stamp(); finish(@"cached_seek", hitStart, hitEnd, state, @{@"target_seconds": @(hitTarget), @"cache_before_start": @(before.buffered_start), @"cache_before_end": @(before.buffered_end), @"cache_precondition": @"retained_packet_hit"}); // Sustained is real paced playback from the cached seek, including // moving-frontier read-ahead, start and final pause acknowledgement. // Position is the actual native audio-master clock. Only watchdogs impose // elapsed-time limits; the completion condition is twelve media seconds. begin(@"sustained"); before = player_get_state(player.get()); require(pausedReady(before) && std::abs(before.position - hitTarget) < .05 && before.duration > before.position + 12, "sustained starts at acknowledged cached target"); const auto sustainedStart = stamp(); auto previousPoll = sustainedStart.wall; bool wasBuffering = true; double bufferingMilliseconds = 0; require(player_play(player.get()), "start paced sustained playback"); state = until(player.get(), [&](auto s) { const auto now = Clock::now(); if (wasBuffering) bufferingMilliseconds += std::chrono::duration(now - previousPoll).count(); previousPoll = now; wasBuffering = s.buffering; return s.status == PLAYER_PLAYING && !s.seek_pending && !s.buffering && s.position >= before.position + 12; }, "twelve media seconds of native playback", view); require(player_pause(player.get()), "pause sustained playback"); state = until(player.get(), pausedReady, "sustained pause acknowledgement", view); const auto sustainedEnd = stamp(); finish(@"sustained", sustainedStart, sustainedEnd, state, @{@"media_seconds": @(state.position - before.position), @"paced": @YES, @"start_position_seconds": @(before.position), @"buffering_ms": @(bufferingMilliseconds), @"buffering_measurement": @"sampled_at_approximately_1ms"}); begin(@"uncached_seek"); before = player_get_state(player.get()); require(uncached(before, missTarget), "uncached seek must lie beyond retained packet coverage"); const auto missStart = stamp(); state = seek(player.get(), missTarget, "uncached paused decoded seek"); [view draw]; const auto missEnd = stamp(); finish(@"uncached_seek", missStart, missEnd, state, @{@"target_seconds": @(missTarget), @"cache_before_start": @(before.buffered_start), @"cache_before_end": @(before.buffered_end), @"cache_precondition": @"outside_retained_packets"}); [window orderOut:nil]; window.contentView = nil; player.reset(); } }