723 lines
44 KiB
Plaintext
723 lines
44 KiB
Plaintext
#include "../Sources/Core/player_macos.h"
|
|
#import <AppKit/AppKit.h>
|
|
#import <MetalKit/MetalKit.h>
|
|
#import <dispatch/dispatch.h>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <signal.h>
|
|
#include <sys/resource.h>
|
|
#include <unistd.h>
|
|
|
|
static void require(bool condition, const char *message) {
|
|
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
|
}
|
|
|
|
static bool buffered(PlayerState state) {
|
|
return state.buffered_end > state.buffered_start;
|
|
}
|
|
|
|
static void noBuffer(PlayerState state, const char *message) {
|
|
require(state.buffered_start == 0 && state.buffered_end == 0, message);
|
|
}
|
|
|
|
static void validBuffer(PlayerState state) {
|
|
require(std::isfinite(state.buffered_start) && std::isfinite(state.buffered_end),
|
|
"consumer receives finite buffered timestamps");
|
|
if (state.status == PLAYER_EMPTY || state.status == PLAYER_OPENING ||
|
|
state.status == PLAYER_ERROR || state.status == PLAYER_ENDED || state.seek_pending) {
|
|
noBuffer(state, "unavailable/opening/error/ended/pending timeline has no retained range");
|
|
}
|
|
if (state.buffered_start == 0 && state.buffered_end == 0) return;
|
|
require(std::isfinite(state.duration) && state.duration > 0 &&
|
|
state.buffered_start >= 0 && buffered(state) && state.buffered_end <= state.duration,
|
|
"retained range is nonempty and bounded by finite media duration");
|
|
require(state.buffered_start <= state.position && state.position <= state.buffered_end,
|
|
"the retained interval covers the current playhead");
|
|
}
|
|
|
|
// Only these deadlines use elapsed time: a stuck command/read must fail the check,
|
|
// not hang CI. Playback assertions use fixture presentation timestamps and events.
|
|
static PlayerState until(Player *player, const std::function<bool(PlayerState)> &ready,
|
|
const char *scenario, bool expectError = false) {
|
|
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
auto state = player_get_state(player);
|
|
validBuffer(state);
|
|
if (state.status == PLAYER_ERROR && !expectError) {
|
|
std::fprintf(stderr, "FAIL %s: %s\n", scenario, state.error);
|
|
std::exit(1);
|
|
}
|
|
if (ready(state)) return state;
|
|
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
|
|
}
|
|
auto state = player_get_state(player);
|
|
std::fprintf(stderr, "FAIL %s: command deadline (status=%d position=%.4f duration=%.4f pending=%d buffering=%d retained=%.4f..%.4f)\n",
|
|
scenario, state.status, state.position, state.duration, state.seek_pending, state.buffering,
|
|
state.buffered_start, state.buffered_end);
|
|
std::exit(1);
|
|
}
|
|
|
|
static NSDictionary *control(const std::string &base, const std::string &path) {
|
|
NSString *address = [NSString stringWithUTF8String:(base + path).c_str()];
|
|
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:address]];
|
|
request.timeoutInterval = 5;
|
|
request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
|
dispatch_semaphore_t done = dispatch_semaphore_create(0);
|
|
__block NSData *body = nil;
|
|
__block NSHTTPURLResponse *response = nil;
|
|
__block NSError *failure = nil;
|
|
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
|
|
completionHandler:^(NSData *data, NSURLResponse *result, NSError *error) {
|
|
body = data;
|
|
response = (NSHTTPURLResponse *)result;
|
|
failure = error;
|
|
dispatch_semaphore_signal(done);
|
|
}];
|
|
[task resume];
|
|
if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 6 * NSEC_PER_SEC)) != 0) {
|
|
[task cancel];
|
|
require(false, "loopback control request deadline");
|
|
}
|
|
require(failure == nil && response.statusCode == 200 && body != nil, "loopback control endpoint responds");
|
|
id json = [NSJSONSerialization JSONObjectWithData:body options:0 error:nullptr];
|
|
require([json isKindOfClass:[NSDictionary class]], "loopback control response is a dictionary");
|
|
return (NSDictionary *)json;
|
|
}
|
|
|
|
struct GateState {
|
|
bool entered, released;
|
|
uint64_t waiting, requests, ranges, bytes, total;
|
|
double audioEnd, videoEnd;
|
|
uint64_t connections;
|
|
};
|
|
|
|
static GateState gateState(const std::string &base, const std::string &token) {
|
|
NSDictionary *state = control(base, "/control/status/" + token);
|
|
return {[state[@"entered"] boolValue], [state[@"released"] boolValue],
|
|
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
|
|
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
|
|
[state[@"total"] unsignedLongLongValue],
|
|
[state[@"prefix_ends"][@"audio"] doubleValue], [state[@"prefix_ends"][@"video"] doubleValue],
|
|
[state[@"connections"] unsignedLongLongValue]};
|
|
}
|
|
|
|
static void stillBlocked(const std::string &base, const std::string &token) {
|
|
auto gate = gateState(base, token);
|
|
require(gate.entered && !gate.released && gate.waiting > 0,
|
|
"command completed while original server read remained blocked");
|
|
}
|
|
|
|
static std::string headersFor(const std::string &path) {
|
|
const char *kind = path.find("audio.wav") != std::string::npos ||
|
|
path.find("frame-step-audio.m4a") != std::string::npos ? "audio" :
|
|
path.find("video.mp4") != std::string::npos ? "video" : "combined";
|
|
return std::string("X-Terebi-Fixture: ") + kind + "\r\n";
|
|
}
|
|
|
|
static bool openNetwork(Player *player, const std::string &base, const std::string &videoPath,
|
|
const std::string &audioPath = {}, double duration = 4, bool live = false,
|
|
bool includeHeaders = true, const std::string &mediaURL = {}, double initialPosition = 0) {
|
|
// Locals intentionally die immediately after the call: the asynchronous engine
|
|
// must own both selected URLs and both selected HTTP-header blocks.
|
|
auto video = base + videoPath;
|
|
auto audio = audioPath.empty() ? std::string{} : base + audioPath;
|
|
auto videoHeaders = includeHeaders ? headersFor(videoPath) : std::string{};
|
|
auto audioHeaders = includeHeaders ? headersFor(audioPath) : std::string{};
|
|
PlayerNetworkSource source{video.c_str(), audio.empty() ? nullptr : audio.c_str(),
|
|
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live,
|
|
mediaURL.empty() ? nullptr : mediaURL.c_str(), initialPosition};
|
|
return player_open_network(player, &source);
|
|
}
|
|
|
|
static void closed(Player *player) {
|
|
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source");
|
|
require(state.position == 0 && state.duration == 0 && !state.has_audio && !state.has_video &&
|
|
!state.can_seek && !state.buffering && !state.seek_pending && !state.error[0],
|
|
"close clears all network/media/seek/error state");
|
|
noBuffer(state, "close clears retained network coverage");
|
|
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network close");
|
|
}
|
|
|
|
static PlayerState seekCompleted(Player *player, double target, PlayerStatus status) {
|
|
auto revision = player_get_state(player).revision;
|
|
require(player_seek(player, target), "network seek accepted");
|
|
auto pending = player_get_state(player);
|
|
validBuffer(pending);
|
|
require(pending.seek_pending || std::abs(pending.position - target) < .03,
|
|
"network seek remains pending until target is authoritative");
|
|
return until(player, [=](auto s) {
|
|
return !s.seek_pending && s.revision != revision && s.status == status &&
|
|
std::abs(s.position - target) < (status == PLAYER_PLAYING ? .2 : .03);
|
|
}, "network seek acknowledgement");
|
|
}
|
|
|
|
static void transport(Player *player, MTKView *view, const std::string &base, bool split) {
|
|
require(openNetwork(player, base, split ? "/media/video.mp4" : "/media/av.mp4",
|
|
split ? "/media/audio.wav" : ""), "network open accepted");
|
|
require(player_play(player), "play queues during asynchronous network open");
|
|
auto playing = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .15; },
|
|
"queued network autoplay");
|
|
require(playing.has_audio && playing.has_video && playing.can_seek && !playing.seek_pending,
|
|
"combined and split inputs select both tracks and expose seekability");
|
|
require(playing.duration >= 3.9 && playing.duration <= 4.2, "network media duration preserves source timing");
|
|
require(std::abs(playing.video_aspect_ratio - 16.0 / 9) < .0001, "network video keeps display aspect");
|
|
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network open");
|
|
[view draw];
|
|
require(player_pause(player), "pause streaming playback");
|
|
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "network pause");
|
|
// An acknowledged external request is an event barrier, not a sleep pretending
|
|
// the audio callback or command queue must have completed by now.
|
|
control(base, "/control/stats");
|
|
require(std::abs(player_get_state(player).position - paused.position) < .0001,
|
|
"paused streaming audio clock does not advance");
|
|
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
|
[view draw];
|
|
require(player_seek(player, .8) && player_seek(player, 1.7), "queued seeks accepted on both inputs");
|
|
auto pending = player_get_state(player);
|
|
validBuffer(pending);
|
|
require(pending.seek_pending || std::abs(pending.position - 1.7) < .03,
|
|
"earlier seek cannot acknowledge final split-input seek");
|
|
until(player, [](auto s) {
|
|
return !s.seek_pending && s.status == PLAYER_PAUSED && std::abs(s.position - 1.7) < .03;
|
|
}, "queued final network seek");
|
|
require(player_play(player), "resume both network inputs");
|
|
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.8; }, "resume after split seek");
|
|
seekCompleted(player, 3.2, PLAYER_PLAYING);
|
|
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "drain streaming media");
|
|
require(ended.position >= 3.9 && ended.position < 4.2, "network EOF drains decoded audio and video");
|
|
require(player_play(player), "network replay accepted");
|
|
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1 && s.position < 1; },
|
|
"network replay restarts both streams");
|
|
require(player_close(player), "close playing network source");
|
|
closed(player);
|
|
std::puts(split ? "PASS split stream-index-0 inputs: headers/autoplay/duration/pause/paused+playing seek/acknowledgement/EOF/replay"
|
|
: "PASS combined network: headers/autoplay/duration/pause/paused+playing seek/acknowledgement/EOF/replay");
|
|
}
|
|
|
|
static PlayerState step(Player *player, int direction, double pts) {
|
|
auto revision = player_get_state(player).revision;
|
|
require(player_step_frame(player, direction), "split-source adjacent frame step accepted");
|
|
validBuffer(player_get_state(player));
|
|
auto state = until(player, [revision](auto s) {
|
|
return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED;
|
|
}, "split frame-step acknowledgement");
|
|
if (std::abs(state.position - pts) >= .0001) {
|
|
std::fprintf(stderr, "FAIL split frame PTS: expected %.4f, got %.4f\n", pts, state.position);
|
|
std::exit(1);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
static void frameSteps(Player *player, MTKView *view, const std::string &base) {
|
|
constexpr double pts[] = {0, .1, .2, .3, .4, .65, .9, 1.15, 1.4, 1.5, 1.6, 1.7};
|
|
require(openNetwork(player, base, "/media/frame-step-video.mp4", "/media/audio.wav"),
|
|
"video-only VFR plus independent audio open");
|
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED && s.has_audio && s.has_video; }, "split VFR open");
|
|
require(!player_step_frame(player, 0) && !player_step_frame(player, 2), "invalid step directions rejected");
|
|
step(player, -1, 0);
|
|
for (unsigned i = 1; i < sizeof(pts) / sizeof(pts[0]); ++i) {
|
|
step(player, 1, pts[i]);
|
|
[view draw];
|
|
}
|
|
step(player, 1, 1.7);
|
|
for (int i = 10; i >= 0; --i) step(player, -1, pts[i]);
|
|
step(player, -1, 0);
|
|
seekCompleted(player, .72, PLAYER_PAUSED);
|
|
step(player, 1, 1.15); // The .72 seek previews .9, never the preceding keyframe.
|
|
step(player, -1, .9);
|
|
step(player, -1, .65);
|
|
require(player_play(player), "resume frame-stepped split audio/video");
|
|
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.3; },
|
|
"audio rebuild resumes authoritative stepped timestamp");
|
|
require(player_pause(player), "pause resumed split VFR timeline");
|
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause split VFR after resume");
|
|
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
|
require(player_play(player), "play past independent video EOF");
|
|
auto beyondVideo = until(player, [](auto s) {
|
|
return s.status == PLAYER_PLAYING && s.position > 2.2 && buffered(s);
|
|
}, "audio retains playable coverage after independent video EOF");
|
|
require(beyondVideo.position < 3.9, "video EOF does not end or cap longer audio coverage");
|
|
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer audio input drains");
|
|
require(ended.position >= 3.9 && ended.position < 4.2, "split EOF follows last audio sample");
|
|
require(player_close(player), "close split stepped source");
|
|
closed(player);
|
|
std::puts("PASS split VFR/B-frames: exact bidirectional steps/boundaries/non-keyframe seek/audio resume/independent video EOF");
|
|
|
|
require(openNetwork(player, base, "/media/video.mp4", "/media/frame-step-audio.m4a"),
|
|
"long video plus short independent audio open");
|
|
require(player_play(player), "queued play with shorter audio");
|
|
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "asymmetric split autoplay");
|
|
seekCompleted(player, 1.5, PLAYER_PLAYING);
|
|
until(player, [](auto s) {
|
|
return s.status == PLAYER_PLAYING && s.position > 2.3 && buffered(s);
|
|
}, "video retains playable coverage after independent audio EOF");
|
|
require(player_pause(player), "pause in independent audio tail");
|
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause longer video after audio EOF");
|
|
seekCompleted(player, 3.2, PLAYER_PAUSED);
|
|
require(player_play(player), "resume beyond shorter separate audio endpoint");
|
|
ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer video input drains");
|
|
require(std::abs(ended.position - 4) < .03, "audio EOF does not truncate video presentation duration");
|
|
require(player_close(player), "close asymmetric split source");
|
|
closed(player);
|
|
std::puts("PASS independent audio EOF: video presentation continues and final duration is retained");
|
|
}
|
|
|
|
static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
|
|
require(openNetwork(player, base, "/media/network-vod.m3u8"), "recorded native HLS open");
|
|
require(player_play(player), "recorded HLS autoplay");
|
|
auto playing = until(player, [](auto s) {
|
|
return s.status == PLAYER_PLAYING && !s.buffering && s.position > .2;
|
|
}, "recorded HLS playback");
|
|
require(playing.has_audio && playing.has_video && playing.can_seek,
|
|
"recorded HLS owns its I/O but supports timestamp seeking");
|
|
require(player_pause(player), "pause native HLS");
|
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "native HLS pause");
|
|
seekCompleted(player, 2.5, PLAYER_PAUSED);
|
|
require(player_play(player), "resume native HLS seek");
|
|
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.6; },
|
|
"native HLS seek resumes actual audio/video clock");
|
|
[view draw];
|
|
require(player_close(player), "close native HLS");
|
|
closed(player);
|
|
std::puts("PASS native HLS: selected headers/recorded seekability/paused seek/audio-video resume");
|
|
}
|
|
|
|
static void liveAndReplacement(Player *player, const std::string &base, const std::string &fixtures) {
|
|
require(openNetwork(player, base, "/media/av.mp4", "", 4, true), "live-flag network open");
|
|
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
|
"live-flag source retains media before local replacement");
|
|
require(!live.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1) &&
|
|
!player_step_frame(player, -1), "live source rejects seeks and both frame-step directions");
|
|
require(player_open(player, (fixtures + "/video.mp4").c_str()), "ordinary local source replaces network live source");
|
|
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "local replacement");
|
|
require(local.can_seek && !local.buffering && local.has_video && !local.has_audio && !local.seek_pending,
|
|
"local replacement restores seekability and clears network flags");
|
|
noBuffer(local, "local replacement does not inherit network buffered coverage");
|
|
require(std::abs(player_get_volume(player) - .37f) < .00001f, "local replacement keeps volume");
|
|
noBuffer(seekCompleted(player, 1.5, PLAYER_PAUSED), "ordinary local seek never reports network coverage");
|
|
require(player_play(player), "play ordinary local replacement");
|
|
local = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.6; },
|
|
"local playback after replacing a buffered network source");
|
|
noBuffer(local, "ordinary local playback never reports network coverage");
|
|
require(player_close(player), "close local replacement");
|
|
closed(player);
|
|
require(openNetwork(player, base, "/no-range/streaming.mp4", "", 30), "non-range HTTP open");
|
|
auto noRange = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "non-seekable HTTP open");
|
|
require(!noRange.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1),
|
|
"HTTP input without byte ranges rejects seek and step even without live metadata");
|
|
require(player_play(player), "non-seekable network source still plays");
|
|
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "non-seekable playback");
|
|
require(player_close(player), "close non-seekable HTTP source");
|
|
closed(player);
|
|
std::puts("PASS live/non-range seek rejection/local replacement/volume/state reset");
|
|
}
|
|
|
|
static void blockedOpening(Player *player, const std::string &base, const std::string &token) {
|
|
require(openNetwork(player, base, "/stall-open/" + token + "/av.mp4"), "open deliberately stalled HTTP response");
|
|
until(player, [&](auto s) {
|
|
auto gate = gateState(base, token);
|
|
return s.status == PLAYER_OPENING && gate.entered && gate.waiting > 0 && !gate.released;
|
|
}, "opening response-body read underway");
|
|
}
|
|
|
|
static double prefixEnd(GateState gate, bool splitAudio, bool splitVideo) {
|
|
return splitAudio ? gate.audioEnd : splitVideo ? gate.videoEnd : std::fmin(gate.audioEnd, gate.videoEnd);
|
|
}
|
|
|
|
static void prefixBound(PlayerState state, GateState gate, bool splitAudio = false, bool splitVideo = false) {
|
|
const double end = prefixEnd(gate, splitAudio, splitVideo);
|
|
require(end > 0 && end < 30, "gated thirty-second fixture withholds known presentation timestamps");
|
|
require(!buffered(state) || state.buffered_end <= end + .002,
|
|
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
|
|
}
|
|
|
|
static void cachedWindow(Player *player, const std::string &base) {
|
|
const std::string token = "cached-window";
|
|
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=100", "", 120),
|
|
"open a two-minute source with a controlled future network stall");
|
|
auto initial = until(player, [](auto s) {
|
|
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end >= 59;
|
|
}, "paused playback prefetches a minute without waiting to start");
|
|
require(initial.buffered_end < 65, "read-ahead is time-bounded rather than a full download");
|
|
seekCompleted(player, 50, PLAYER_PAUSED);
|
|
auto retained = until(player, [&](auto s) {
|
|
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end > 95 &&
|
|
gateState(base, token).entered;
|
|
}, "cache follows a new playhead until the future network gate");
|
|
require(retained.position - retained.buffered_start >= 29,
|
|
"a reusable thirty-second rewind window survives forward playback");
|
|
require(retained.buffered_start > 15, "old media is evicted instead of caching the whole movie");
|
|
const auto before = gateState(base, token);
|
|
auto rewind = seekCompleted(player, 20.5, PLAYER_PAUSED);
|
|
require(buffered(rewind), "cached rewind preserves the retained interval");
|
|
auto after = gateState(base, token);
|
|
require(after.requests == before.requests && after.ranges == before.ranges,
|
|
"rewinding within history makes no new HTTP request");
|
|
stillBlocked(base, token);
|
|
seekCompleted(player, 45, PLAYER_PAUSED);
|
|
after = gateState(base, token);
|
|
require(after.requests == before.requests && after.ranges == before.ranges,
|
|
"forward seeking inside retained media makes no new HTTP request");
|
|
stillBlocked(base, token);
|
|
seekCompleted(player, 5, PLAYER_PAUSED);
|
|
until(player, [&](auto s) {
|
|
return s.status == PLAYER_PAUSED && buffered(s) && s.buffered_end > 6 &&
|
|
gateState(base, token).requests > before.requests;
|
|
}, "seeking before retained history refills missing media over HTTP");
|
|
require(player_close(player), "close cache while the original future response remains blocked");
|
|
closed(player);
|
|
stillBlocked(base, token);
|
|
std::puts("PASS sliding cache: minute read-ahead/thirty-second history/keyframe replay/cached bidirectional seeks/miss refill/cancel");
|
|
}
|
|
|
|
static void chunkedFetch(Player *player, const std::string &base, bool split) {
|
|
const std::string videoToken = split ? "chunked-video" : "chunked-combined";
|
|
const std::string audioToken = "chunked-audio";
|
|
auto path = [](const std::string &token) {
|
|
return "/chunked-media/" + token + "/cache.mp4?after_seconds=2";
|
|
};
|
|
require(openNetwork(player, base, path(videoToken) + (split ? "" : "&keep_alive=1"),
|
|
split ? path(audioToken) : "", 120),
|
|
"open recorded media whose CDN throttles oversized requests");
|
|
require(player_play(player), "start playback while the minute reserve fills");
|
|
until(player, [&](auto s) {
|
|
const auto video = gateState(base, videoToken);
|
|
const bool throttled = video.entered || (split && gateState(base, audioToken).entered);
|
|
require(!(s.position > .5 && s.buffering && throttled),
|
|
"oversized HTTP request starves playback before a minute can buffer");
|
|
return s.status == PLAYER_PLAYING && buffered(s) && s.buffered_end - s.position >= 59;
|
|
}, "finite HTTP requests build a minute of playable reserve without CDN throttling");
|
|
require(gateState(base, videoToken).requests >= 2,
|
|
"minute read-ahead continues across the first bounded HTTP response");
|
|
if (split) require(gateState(base, audioToken).requests >= 2,
|
|
"secondary audio also continues bounded HTTP requests");
|
|
else require(gateState(base, videoToken).connections == 1,
|
|
"minute reserve reuses one HTTP connection across bounded responses");
|
|
require(player_pause(player), "pause before an out-of-cache seek");
|
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause chunked source");
|
|
seekCompleted(player, 119.5, PLAYER_PAUSED);
|
|
require(player_play(player), "resume the final partial HTTP range");
|
|
until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "bounded final response reaches true media EOF");
|
|
require(!gateState(base, videoToken).entered &&
|
|
(!split || !gateState(base, audioToken).entered),
|
|
"initial fetch, continuation and seek never use CDN-throttled ranges");
|
|
require(player_close(player), "close chunked source");
|
|
closed(player);
|
|
std::puts(split ? "PASS bounded split fetch: minute reserve/secondary audio/range continuation/seek/EOF"
|
|
: "PASS bounded combined fetch: minute reserve/range continuation/seek/EOF");
|
|
}
|
|
|
|
static void bufferCoverage(Player *player, const std::string &base) {
|
|
|
|
const std::string token = "buffer-release";
|
|
require(openNetwork(player, base, "/stall/" + token + "/streaming.mp4", "", 30),
|
|
"open event-gated retained coverage");
|
|
auto paused = until(player, [&](auto s) {
|
|
auto gate = gateState(base, token);
|
|
if (gate.entered) prefixBound(s, gate);
|
|
return s.status == PLAYER_PAUSED && buffered(s) && gate.entered && gate.waiting > 0;
|
|
}, "paused retained horizon is bounded by supplied fixture timestamps");
|
|
const auto gate = gateState(base, token);
|
|
prefixBound(paused, gate);
|
|
stillBlocked(base, token);
|
|
require(player_play(player), "play gated retained prefix");
|
|
until(player, [&](auto s) {
|
|
prefixBound(s, gate);
|
|
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s);
|
|
}, "gated prefix has real playable-ahead coverage");
|
|
until(player, [&](auto s) {
|
|
prefixBound(s, gate);
|
|
return s.status == PLAYER_PLAYING && s.buffering;
|
|
}, "gated coverage cannot advance into unavailable packets");
|
|
const double suppliedEnd = prefixEnd(gate, false, false);
|
|
stillBlocked(base, token);
|
|
control(base, "/control/release/" + token);
|
|
until(player, [=](auto s) {
|
|
return s.status == PLAYER_PLAYING && !s.buffering && buffered(s) && s.buffered_end > suppliedEnd;
|
|
}, "released server gate advances retained coverage beyond withheld timestamps");
|
|
require(player_close(player), "close released buffered source");
|
|
closed(player);
|
|
std::puts("PASS retained coverage: supplied timestamp bounds/B-frame dependencies/gate-release refill");
|
|
}
|
|
|
|
static void blockedPlayback(Player *player, const std::string &base, const std::string &token,
|
|
bool splitAudio = false, bool splitVideo = false) {
|
|
auto stalled = "/stall/" + token + "/streaming.mp4";
|
|
require(openNetwork(player, base, splitAudio ? "/media/streaming.mp4" : stalled,
|
|
splitAudio ? stalled : splitVideo ? "/media/streaming.mp4" : "", 30),
|
|
"open bounded streaming prefix");
|
|
require(player_play(player), "queue play before gated source opens");
|
|
until(player, [&](auto s) {
|
|
auto gate = gateState(base, token);
|
|
if (gate.entered) prefixBound(s, gate, splitAudio, splitVideo);
|
|
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s) && gate.entered;
|
|
}, "stream retains playable media before entire file arrives");
|
|
until(player, [&](auto s) {
|
|
auto gate = gateState(base, token);
|
|
if (gate.entered) prefixBound(s, gate, splitAudio, splitVideo);
|
|
return s.status == PLAYER_PLAYING && s.buffering && gate.entered && gate.waiting > 0 && !gate.released;
|
|
}, "real demux read blocks at server gate");
|
|
auto gate = gateState(base, token);
|
|
require(gate.bytes < gate.total && gate.total > 131072, "engine streams playable prefix without whole-file download");
|
|
}
|
|
|
|
static void cancellation(Player *player, const std::string &base, const std::string &fixtures) {
|
|
for (int input : {0, 1, 2}) {
|
|
const bool splitAudio = input == 1, splitVideo = input == 2;
|
|
const std::string token = splitAudio ? "split-playing-seek" : splitVideo ? "split-video-seek" : "pause-seek";
|
|
blockedPlayback(player, base, token, splitAudio, splitVideo);
|
|
if (!splitAudio && !splitVideo) {
|
|
require(player_pause(player), "pause while actual network read is blocked");
|
|
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause does not wait for network read");
|
|
stillBlocked(base, token);
|
|
require(std::abs(player_get_state(player).position - paused.position) < .0001, "buffered pause freezes native clock");
|
|
}
|
|
seekCompleted(player, 28, (splitAudio || splitVideo) ? PLAYER_PLAYING : PLAYER_PAUSED);
|
|
stillBlocked(base, token);
|
|
require(player_play(player), "resume at seek target with stale read still blocked");
|
|
until(player, [&](auto s) {
|
|
auto afterSeek = gateState(base, token);
|
|
return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1 && buffered(s) &&
|
|
afterSeek.requests > 1 && afterSeek.ranges > 1;
|
|
}, "new byte-range refill reaches stalled-read seek target");
|
|
stillBlocked(base, token);
|
|
require(player_pause(player), "pause high-timestamp coverage before backward seek");
|
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
|
"retain high-timestamp media before invalidating it");
|
|
seekCompleted(player, .5, PLAYER_PAUSED);
|
|
until(player, [](auto s) {
|
|
return s.status == PLAYER_PAUSED && !s.seek_pending && buffered(s);
|
|
}, "backward seek rebuilds retained coverage at the new target");
|
|
stillBlocked(base, token);
|
|
require(player_close(player), "close resumed formerly stalled source");
|
|
closed(player);
|
|
std::puts(splitAudio
|
|
? "PASS stalled secondary audio: required-track bound/seek acknowledgement/new-target coverage/backward refill"
|
|
: splitVideo
|
|
? "PASS stalled primary video: required-track bound/seek acknowledgement/new-target coverage/backward refill"
|
|
: "PASS stalled combined input: bounded prefix/pause/seek acknowledgement/new-target coverage/backward refill");
|
|
}
|
|
|
|
blockedOpening(player, base, "opening-close");
|
|
require(player_close(player), "close cancels opening read");
|
|
closed(player);
|
|
stillBlocked(base, "opening-close");
|
|
blockedOpening(player, base, "opening-replace");
|
|
require(player_open(player, (fixtures + "/audio.wav").c_str()), "local open replaces blocked network open");
|
|
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED && s.has_audio && !s.has_video; },
|
|
"local replacement cancels blocked opening read");
|
|
require(local.can_seek && !local.buffering && !local.seek_pending && local.position == 0,
|
|
"stale opening read cannot publish over replacement timeline");
|
|
noBuffer(local, "local audio replacement clears retained network media");
|
|
stillBlocked(base, "opening-replace");
|
|
require(player_close(player), "close audio-only replacement");
|
|
closed(player);
|
|
blockedPlayback(player, base, "playing-close");
|
|
require(player_close(player), "close cancels blocked playing read");
|
|
closed(player);
|
|
stillBlocked(base, "playing-close");
|
|
blockedPlayback(player, base, "playing-replace");
|
|
require(openNetwork(player, base, "/media/video.mp4", "/media/audio.wav"),
|
|
"new network open replaces blocked playing read");
|
|
auto replacement = until(player, [](auto s) {
|
|
return s.status == PLAYER_PAUSED && s.has_audio && s.has_video && !s.buffering;
|
|
},
|
|
"network replacement cancels blocked playing read");
|
|
require(replacement.position == 0 && !replacement.buffering && !replacement.seek_pending && replacement.can_seek,
|
|
"replacement ignores stale stream data and buffering state");
|
|
require(!buffered(replacement) || replacement.buffered_end <= 4.2,
|
|
"new network source cannot retain the previous thirty-second horizon");
|
|
stillBlocked(base, "playing-replace");
|
|
require(player_close(player), "close split network replacement");
|
|
closed(player);
|
|
std::puts("PASS stalled opening+playing reads: close/local+network replacement cancel without server release");
|
|
}
|
|
|
|
static void failures(Player *player, const std::string &base) {
|
|
require(openNetwork(player, base, "/media/streaming.mp4", "", 30),
|
|
"open retained network media before failure replacement");
|
|
until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
|
"failure replacement starts with retained coverage to invalidate");
|
|
require(openNetwork(player, base, "/media/av.mp4", "", 4, false, false), "header-denied request accepted asynchronously");
|
|
auto denied = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "required header denial", true);
|
|
require(denied.error[0] && !denied.has_audio && !denied.has_video && !denied.can_seek &&
|
|
!denied.buffering && !denied.seek_pending, "failed network open clears media and reports useful error");
|
|
NSDictionary *stats = control(base, "/control/stats");
|
|
require([stats[@"av.mp4"][@"denied"] unsignedLongLongValue] > 0,
|
|
"fixture server actually rejected request without selected-format header");
|
|
require(openNetwork(player, base, "/failure/av.mp4"), "controlled failed network open accepted");
|
|
auto failed = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "HTTP failure", true);
|
|
require(failed.error[0] && !failed.buffering && !failed.can_seek, "HTTP failure has explicit error and cleared flags");
|
|
require(openNetwork(player, base, "/media/video.mp4", "/media/audio.wav"), "recover from real HTTP failures");
|
|
require(player_play(player), "recovered split source autoplay");
|
|
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "failed network recovery");
|
|
require(player_close(player), "close recovered network source");
|
|
closed(player);
|
|
std::puts("PASS actual HTTP header denial/503 failure/useful errors/cleared state/split-source recovery");
|
|
}
|
|
|
|
static NSArray<NSDictionary *> *progressRecords(const std::string &path) {
|
|
NSString *text = [NSString stringWithContentsOfFile:[NSString stringWithUTF8String:path.c_str()]
|
|
encoding:NSUTF8StringEncoding error:nullptr];
|
|
if (!text) return @[];
|
|
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
|
NSMutableArray<NSDictionary *> *records = [NSMutableArray array];
|
|
for (NSUInteger index = 0; index + 1 < lines.count; ++index) {
|
|
id record = [NSJSONSerialization JSONObjectWithData:[lines[index] dataUsingEncoding:NSUTF8StringEncoding]
|
|
options:0 error:nullptr];
|
|
require([record isKindOfClass:[NSDictionary class]], "native reporting produces complete JSON records");
|
|
[records addObject:record];
|
|
}
|
|
return records;
|
|
}
|
|
|
|
static void reporting(const std::string &base, const std::string &fixtures) {
|
|
char temporary[] = "/tmp/terebi-native-progress-XXXXXX";
|
|
require(mkdtemp(temporary) != nullptr, "isolated native report directory");
|
|
const std::string path = std::string(temporary) + "/native.jsonl";
|
|
const std::string first = "https://www.youtube.com/watch?v=AAAAAAAAAAA";
|
|
const std::string second = "https://www.youtube.com/watch?v=BBBBBBBBBBB";
|
|
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
|
char error[512]{};
|
|
NSString *nativePath = [NSString stringWithUTF8String:path.c_str()];
|
|
require([@"original" writeToFile:nativePath atomically:NO encoding:NSUTF8StringEncoding error:nullptr],
|
|
"create existing destination sentinel");
|
|
require(!player_set_progress_file(player.get(), path.c_str(), error) && error[0] &&
|
|
player_get_state(player.get()).status == PLAYER_EMPTY,
|
|
"report initialization error leaves playback state usable");
|
|
require([[NSString stringWithContentsOfFile:nativePath encoding:NSUTF8StringEncoding error:nullptr]
|
|
isEqualToString:@"original"], "native initialization cannot overwrite an existing destination");
|
|
require(unlink(path.c_str()) == 0, "remove only the test-owned sentinel");
|
|
require(player_set_progress_file(player.get(), path.c_str(), error), "initialize native reporting before playback");
|
|
require(player_set_volume(player.get(), 0), "mute native reporting fixture");
|
|
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first, 50),
|
|
"open canonical source with initial resume intent");
|
|
until(player.get(), [&](auto state) {
|
|
auto rows = progressRecords(path);
|
|
return state.status == PLAYER_PAUSED && !state.seek_pending && rows.count > 0;
|
|
}, "backend acknowledges initial restoration before report");
|
|
auto rows = progressRecords(path);
|
|
require([rows[0][@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]] &&
|
|
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
|
|
[rows[0][@"completion_eligible"] boolValue],
|
|
"initial report is restored native position, not pre-resume zero");
|
|
require(openNetwork(player.get(), base, "/failure/av.mp4", "", 4, false, true, second),
|
|
"failed requested identity accepted asynchronously");
|
|
until(player.get(), [](auto state) { return state.status == PLAYER_ERROR; }, "failed reported open", true);
|
|
require(openNetwork(player.get(), base, "/media/audio.wav", "", 4, false, true, first),
|
|
"audio-only input with canonical identity opens");
|
|
until(player.get(), [&](auto state) {
|
|
auto current = progressRecords(path);
|
|
return state.status == PLAYER_PAUSED && state.has_audio && !state.has_video &&
|
|
[current.lastObject[@"duration_seconds"] doubleValue] == 4 &&
|
|
[current.lastObject[@"event"] isEqualToString:@"progress"];
|
|
}, "audio-only native report");
|
|
rows = progressRecords(path);
|
|
require(![rows.lastObject[@"completion_eligible"] boolValue], "actual audio-only input cannot mark watched");
|
|
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, true, true, first),
|
|
"live source with known duration opens");
|
|
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_video; },
|
|
"reported live input ready");
|
|
require(player_close(player.get()), "close actual reported live input");
|
|
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "reported live close");
|
|
require(openNetwork(player.get(), base, "/stall-open/report-cancelled/streaming.mp4", "", 30,
|
|
false, true, second, 29),
|
|
"gated initial resume opens");
|
|
until(player.get(), [&](auto state) {
|
|
return state.status == PLAYER_OPENING && gateState(base, "report-cancelled").entered;
|
|
}, "reported initial open remains unacknowledged");
|
|
require(player_close(player.get()), "cancel reported open before ready");
|
|
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "cancelled report open close");
|
|
require(player_shutdown(player.get(), error), "normal shutdown drains native reporter");
|
|
rows = progressRecords(path);
|
|
bool sawLive = false;
|
|
for (NSDictionary *record in rows) {
|
|
require([record[@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]],
|
|
"failed/cancelled opens never report their requested identity");
|
|
if ([record[@"duration_seconds"] doubleValue] == 120 &&
|
|
[record[@"position_seconds"] doubleValue] == 0) {
|
|
sawLive = true;
|
|
require(![record[@"completion_eligible"] boolValue], "live known-duration records stay ineligible");
|
|
}
|
|
}
|
|
require(sawLive && [rows.lastObject[@"event"] isEqualToString:@"closed"],
|
|
"native close/quit retain terminal facts for acknowledged media");
|
|
player.reset();
|
|
|
|
const auto failedPath = std::string(temporary) + "/write-failure.jsonl";
|
|
player.reset(player_create());
|
|
require(player_set_progress_file(player.get(), failedPath.c_str(), error), "initialize failure-test reporter");
|
|
require(player_set_volume(player.get(), 0), "mute failure-test playback");
|
|
struct rlimit previous{}, limited{};
|
|
require(getrlimit(RLIMIT_FSIZE, &previous) == 0, "read original file-size limit");
|
|
limited = previous;
|
|
limited.rlim_cur = 1;
|
|
auto previousSignal = signal(SIGXFSZ, SIG_IGN);
|
|
require(previousSignal != SIG_ERR && setrlimit(RLIMIT_FSIZE, &limited) == 0, "force actual reporting write failure");
|
|
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first),
|
|
"playback opens with failing output");
|
|
until(player.get(), [&](auto state) {
|
|
return state.status == PLAYER_PAUSED && player_get_progress_error(player.get(), error);
|
|
}, "write error surfaces independently from decoder status");
|
|
require(setrlimit(RLIMIT_FSIZE, &previous) == 0 && signal(SIGXFSZ, previousSignal) != SIG_ERR,
|
|
"restore resource limit and signal handler");
|
|
require(error[0] && player_play(player.get()), "reporting failure does not prevent playback");
|
|
until(player.get(), [](auto state) { return state.status == PLAYER_PLAYING && state.position > .1; },
|
|
"actual playback continues after reporting failure");
|
|
seekCompleted(player.get(), 5, PLAYER_PLAYING);
|
|
require(player_open(player.get(), (fixtures + "/audio.wav").c_str()), "failed reporter permits media replacement");
|
|
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_audio; },
|
|
"replacement remains functional with disabled reporter");
|
|
require(!player_shutdown(player.get(), error) && error[0], "final reporting failure remains observable");
|
|
player.reset();
|
|
[[NSFileManager defaultManager] removeItemAtPath:[NSString stringWithUTF8String:temporary] error:nullptr];
|
|
std::puts("PASS native reporting: acknowledged initial resume/audio-only/live eligibility/failed+cancelled opens/final drain/write-failure playback+seek+replacement continuity");
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
@autoreleasepool {
|
|
require(argc == 3, "loopback base URL and local fixture directory required");
|
|
std::string base = argv[1], fixtures = argv[2];
|
|
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
|
[NSApplication sharedApplication];
|
|
reporting(base, fixtures);
|
|
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
|
require(bool(player), "player creation");
|
|
require(player_set_volume(player.get(), .37f), "set persistent network playback gain");
|
|
void *native = player_macos_make_video_view(player.get());
|
|
require(native != nullptr, "actual Metal renderer initializes");
|
|
MTKView *view = (__bridge_transfer MTKView *)native;
|
|
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(-2000, -2000, 320, 180)
|
|
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
|
window.contentView = view;
|
|
[window orderBack:nil];
|
|
chunkedFetch(player.get(), base, false);
|
|
chunkedFetch(player.get(), base, true);
|
|
transport(player.get(), view, base, false);
|
|
transport(player.get(), view, base, true);
|
|
frameSteps(player.get(), view, base);
|
|
nativeHLS(player.get(), view, base);
|
|
liveAndReplacement(player.get(), base, fixtures);
|
|
cachedWindow(player.get(), base);
|
|
bufferCoverage(player.get(), base);
|
|
cancellation(player.get(), base, fixtures);
|
|
failures(player.get(), base);
|
|
blockedPlayback(player.get(), base, "destroy-playing");
|
|
player.reset(); // Python's process deadline catches a join waiting for server unblock.
|
|
stillBlocked(base, "destroy-playing");
|
|
[view draw]; // The native view is allowed to outlive its cancelled engine.
|
|
[window orderOut:nil];
|
|
std::unique_ptr<Player, decltype(&player_destroy)> opening(player_create(), player_destroy);
|
|
require(bool(opening), "second player creation for opening cancellation");
|
|
blockedOpening(opening.get(), base, "destroy-opening");
|
|
opening.reset();
|
|
stillBlocked(base, "destroy-opening");
|
|
std::puts("PASS stalled opening+playing destruction: joins without server release/native view lifetime");
|
|
}
|
|
}
|