276 lines
14 KiB
Plaintext
276 lines
14 KiB
Plaintext
// Build like seek-queue-check, with -fobjc-arc and the bundled FFmpeg flags,
|
|
// linking the normal player objects EXCEPT player.o (the implementation is below).
|
|
// Build the same source again with -DTEREBI_SOFTWARE_DECODE for the baseline.
|
|
// Run: video-decoding-check [--metal | --preroll] [--frames 360] [--fps 30] fixture ...
|
|
// Fixtures: video-only CFR testsrc2, zero-origin PTS, BT.709 SDR, square SAR.
|
|
// Benchmark excludes opening, pixel checks, seeking and Metal; there is no pacing,
|
|
// audio-device setup or playback clock. Wall/process CPU are measurements only.
|
|
#include "../Sources/Core/player.cpp"
|
|
#import <AppKit/AppKit.h>
|
|
#import <MetalKit/MetalKit.h>
|
|
extern "C" {
|
|
#include <libavutil/pixdesc.h>
|
|
}
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
|
|
static void require(bool condition, const char *message) {
|
|
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
|
}
|
|
|
|
static Frame retain(const AVFrame *source) {
|
|
Frame frame(av_frame_clone(source));
|
|
if (!frame) throw std::bad_alloc();
|
|
return frame;
|
|
}
|
|
|
|
static void metadata(Player &player, const AVFrame *frame) {
|
|
const auto *parameters = player.tracks[1].stream()->codecpar;
|
|
require(frame->width == parameters->width && frame->height == parameters->height,
|
|
"readable frame preserves coded dimensions");
|
|
require(frame->color_primaries == AVCOL_PRI_BT709 && frame->color_trc == AVCOL_TRC_BT709 &&
|
|
frame->colorspace == AVCOL_SPC_BT709, "readable frame preserves BT.709 SDR metadata");
|
|
require(parameters->color_range == AVCOL_RANGE_UNSPECIFIED ||
|
|
frame->color_range == parameters->color_range, "readable frame preserves color range");
|
|
const AVRational aspect = av_guess_sample_aspect_ratio(player.format,
|
|
player.tracks[1].stream(), const_cast<AVFrame *>(frame));
|
|
require(aspect.num > 0 && aspect.num == aspect.den, "consumer sees square sample aspect ratio");
|
|
const auto *description = av_pix_fmt_desc_get(static_cast<AVPixelFormat>(frame->format));
|
|
require(description && !(description->flags & AV_PIX_FMT_FLAG_HWACCEL),
|
|
"queued frames have a CPU-readable pixel format");
|
|
}
|
|
|
|
// Read every visible byte, not padding: this detects a mapped frame whose pixel
|
|
// buffer was unlocked/recycled when a decoder frame, timeline or codec was freed.
|
|
static uint64_t pixels(const AVFrame *frame) {
|
|
const auto format = static_cast<AVPixelFormat>(frame->format);
|
|
int widths[4]{};
|
|
require(av_image_fill_linesizes(widths, format, frame->width) >= 0, "readable plane widths");
|
|
ptrdiff_t strides[4]{};
|
|
for (int plane = 0; plane < 4; ++plane) strides[plane] = widths[plane];
|
|
size_t sizes[4]{};
|
|
require(av_image_fill_plane_sizes(sizes, format, frame->height, strides) >= 0,
|
|
"readable plane heights");
|
|
uint64_t hash = 14695981039346656037ULL;
|
|
unsigned low = 255, high = 0;
|
|
for (int plane = 0; plane < 4; ++plane) {
|
|
if (!sizes[plane]) continue;
|
|
require(widths[plane] > 0 && frame->data[plane] &&
|
|
std::abs(frame->linesize[plane]) >= widths[plane], "real readable pixel planes");
|
|
const size_t rows = sizes[plane] / widths[plane];
|
|
for (size_t y = 0; y < rows; ++y) {
|
|
const uint8_t *row = frame->data[plane] + ptrdiff_t(y) * frame->linesize[plane];
|
|
for (int x = 0; x < widths[plane]; ++x) {
|
|
low = std::min(low, unsigned(row[x])); high = std::max(high, unsigned(row[x]));
|
|
hash = (hash ^ row[x]) * 1099511628211ULL;
|
|
}
|
|
}
|
|
}
|
|
require(low < high, "testsrc2 contains real nonconstant pixel data");
|
|
return hash;
|
|
}
|
|
|
|
static void exactFrame(Player &player, const VideoFrame &frame, int index, int fps) {
|
|
require(bool(frame.frame), "consumer receives a video frame");
|
|
const auto timebase = player.tracks[1].stream()->time_base;
|
|
const int64_t expected = av_rescale_q(index, AVRational{1, fps}, timebase);
|
|
require(presentationTimestamp(frame.frame.get()) == expected, "exact consumer presentation timestamp");
|
|
require(std::abs(frame.pts - expected * av_q2d(timebase)) < 0.000001,
|
|
"queue PTS matches the fixture media timestamp");
|
|
require(frame.end > frame.pts, "queued frame retains a positive presentation duration");
|
|
metadata(player, frame.frame.get());
|
|
}
|
|
|
|
struct DecodedRun {
|
|
Frame first, last;
|
|
int frames = 0;
|
|
bool hardware = false;
|
|
size_t peakFrames = 0, peakBytes = 0;
|
|
};
|
|
|
|
static DecodedRun drain(Player &player, int first, int expectedFrames, int fps) {
|
|
DecodedRun result;
|
|
double previous = -std::numeric_limits<double>::infinity();
|
|
while (!player.tracks[1].drained || !player.video.empty()) {
|
|
auto *codec = player.tracks[1].codec;
|
|
result.hardware |= codec->pix_fmt == AV_PIX_FMT_VIDEOTOOLBOX || codec->hw_frames_ctx != nullptr;
|
|
result.peakFrames = std::max(result.peakFrames, player.video.size());
|
|
result.peakBytes = std::max(result.peakBytes, player.videoBytes);
|
|
require(player.video.size() <= videoFrameLimit && player.videoBytes <= videoByteLimit,
|
|
"real decoded queue stays inside frame and byte budgets");
|
|
if (!player.video.empty() && (player.videoFull() || player.tracks[1].drained)) {
|
|
while (!player.video.empty()) {
|
|
VideoFrame frame = std::move(player.video.front());
|
|
player.video.pop_front();
|
|
player.videoBytes -= frame.bytes;
|
|
require(frame.pts > previous, "consumer observes strictly monotonic PTS including B-frame EOF");
|
|
require(result.frames < expectedFrames, "no extra decoded frames after decoder drain");
|
|
exactFrame(player, frame, first + result.frames, fps);
|
|
previous = frame.pts;
|
|
if (!result.first) result.first = retain(frame.frame.get());
|
|
result.last = std::move(frame.frame);
|
|
++result.frames;
|
|
}
|
|
} else {
|
|
require(player.decodeOne(), "local synchronous decode makes progress without a clock");
|
|
}
|
|
}
|
|
require(result.frames == expectedFrames, "all expected frames survive queueing and delayed EOF drain");
|
|
require(player.demuxEnded && player.tracks[1].sourceEnded && player.tracks[1].drainSent &&
|
|
player.tracks[1].drained && player.videoBytes == 0, "demux and codec EOF fully drain the queue");
|
|
return result;
|
|
}
|
|
|
|
static void draw(playback::Output &output, const AVFrame *frame) {
|
|
void *native = output.make_video_view();
|
|
require(native != nullptr, "real Metal view and shaders initialize");
|
|
MTKView *view = (__bridge_transfer MTKView *)native;
|
|
view.paused = YES;
|
|
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(100, 100, 320, 180)
|
|
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
|
window.contentView = view;
|
|
[window orderFront:nil];
|
|
output.present(frame);
|
|
// AppKit must attach the layer before a native drawable can be acquired.
|
|
// This is an I/O watchdog, not a media clock.
|
|
const auto deadline = Clock::now() + std::chrono::seconds(5);
|
|
while (!view.currentDrawable && Clock::now() < deadline)
|
|
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
|
|
require(view.currentDrawable != nil, "real Metal surface supplies a drawable");
|
|
[view draw];
|
|
[window orderOut:nil];
|
|
}
|
|
|
|
static void fixture(const char *path, int frames, int fps, bool metal, bool preroll) {
|
|
Player player;
|
|
player.shutdown(); // Join the worker: use actual open/decode/seek synchronously.
|
|
player.stopping.store(false);
|
|
player.activeEpoch = player.requestedEpoch.load();
|
|
Command command{Action::Open, path};
|
|
player.open(command);
|
|
require(player.tracks[1].codec && !player.tracks[0].codec, "fixture is video-only (no audio device)");
|
|
require(player.canSeek && std::abs(player.duration - double(frames) / fps) < .002,
|
|
"fixture has the fixed expected media duration and is seekable");
|
|
if (preroll) {
|
|
while (player.video.empty() && !player.tracks[1].drained)
|
|
require(player.decodeOne(), "opening transport stream makes progress");
|
|
require(!player.video.empty(), "transport stream supplies an opening picture");
|
|
auto retained = retain(player.video.front().frame.get());
|
|
const auto hash = pixels(retained.get());
|
|
const double target = (frames * 3 / 5 + .4) / fps;
|
|
player.seek(target, false);
|
|
while (player.video.empty() && !player.tracks[1].drained)
|
|
require(player.decodeOne(), "dependent-packet seek makes progress");
|
|
require(!player.video.empty() && player.video.front().pts >= target &&
|
|
player.video.front().pts < player.duration, "transport seek supplies a usable preview after preroll");
|
|
metadata(player, player.video.front().frame.get());
|
|
pixels(player.video.front().frame.get());
|
|
const bool software = player.tracks[1].codec->pix_fmt != AV_PIX_FMT_VIDEOTOOLBOX;
|
|
player.release();
|
|
require(pixels(retained.get()) == hash, "mapped opening picture survives preroll decoder replacement");
|
|
std::printf("PASS native dependent-packet preroll/preview/lifetime: %s software=%s\n",
|
|
path, software ? "yes" : "no");
|
|
return;
|
|
}
|
|
const auto wallStart = std::chrono::steady_clock::now();
|
|
const auto cpuStart = std::clock();
|
|
auto decoded = drain(player, 0, frames, fps);
|
|
const double cpu = double(std::clock() - cpuStart) / CLOCKS_PER_SEC;
|
|
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now() - wallStart).count();
|
|
#ifdef TEREBI_SOFTWARE_DECODE
|
|
constexpr const char *mode = "software";
|
|
require(!decoded.hardware, "matched software baseline never negotiates hardware");
|
|
#else
|
|
constexpr const char *mode = "preferred";
|
|
#endif
|
|
std::printf("native-decode mode=%s fixture=%s codec=%s videotoolbox=%s format=%s frames=%d "
|
|
"wall=%.6f cpu=%.6f peak_frames=%zu peak_bytes=%zu\n", mode, path,
|
|
player.tracks[1].codec->codec->name, decoded.hardware ? "yes" : "no",
|
|
av_get_pix_fmt_name(static_cast<AVPixelFormat>(decoded.first->format)), decoded.frames,
|
|
wall, cpu, decoded.peakFrames, decoded.peakBytes);
|
|
const auto firstHash = pixels(decoded.first.get());
|
|
const auto lastHash = pixels(decoded.last.get());
|
|
require(firstHash != lastHash, "retained first and last frames contain distinct fixture pictures");
|
|
if (metal) draw(player.output, decoded.first.get());
|
|
|
|
// A non-keyframe seek must discard preroll and expose the exact next PTS.
|
|
const int middle = frames / 2;
|
|
const auto seekStart = Clock::now();
|
|
player.seek((middle + .4) / fps, false);
|
|
require(player.video.empty() && player.videoBytes == 0, "seek clears the old readable queue");
|
|
while (player.video.empty() && !player.tracks[1].drained)
|
|
require(player.decodeOne(), "seek preroll makes progress");
|
|
std::printf("native-seek mode=%s fixture=%s preview_ms=%.3f\n", mode, path,
|
|
std::chrono::duration<double, std::milli>(Clock::now() - seekStart).count());
|
|
require(pixels(decoded.first.get()) == firstHash && pixels(decoded.last.get()) == lastHash,
|
|
"mapped frame references survive decoder flush");
|
|
auto sought = drain(player, middle + 1, frames - middle - 1, fps);
|
|
pixels(sought.first.get());
|
|
|
|
// Seek backward after EOF, then step in both directions without a clock.
|
|
player.seek((middle + .4) / fps, false);
|
|
player.step(1);
|
|
exactFrame(player, player.presented, middle + 2, fps);
|
|
player.step(-1);
|
|
exactFrame(player, player.presented, middle + 1, fps);
|
|
player.step(1);
|
|
exactFrame(player, player.presented, middle + 2, fps);
|
|
player.seek(1.4 / fps, false);
|
|
auto restarted = drain(player, 2, frames - 2, fps);
|
|
const double lastPts = presentationTimestamp(restarted.last.get()) *
|
|
av_q2d(player.tracks[1].stream()->time_base) - player.origin;
|
|
const int lastBytes = av_image_get_buffer_size(static_cast<AVPixelFormat>(restarted.last->format),
|
|
restarted.last->width, restarted.last->height, 1);
|
|
require(lastBytes > 0, "last readable frame has a valid byte size");
|
|
player.presentVideo(VideoFrame{retain(restarted.last.get()), lastPts,
|
|
lastPts + player.videoFrameTime, static_cast<size_t>(lastBytes)});
|
|
player.step(1);
|
|
exactFrame(player, player.presented, frames - 1, fps);
|
|
player.step(-1);
|
|
exactFrame(player, player.presented, frames - 2, fps);
|
|
player.step(1);
|
|
exactFrame(player, player.presented, frames - 1, fps);
|
|
|
|
player.release(); // Retained mapped planes must also outlive the codec/device.
|
|
require(pixels(decoded.first.get()) == firstHash && pixels(decoded.last.get()) == lastHash,
|
|
"mapped frame references survive full decoder release and pool reuse");
|
|
if (metal) draw(player.output, decoded.last.get());
|
|
std::printf("PASS native readable frames/SDR/SAR/seek/exact steps/EOF/lifetimes: %s\n", path);
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
@autoreleasepool {
|
|
int frames = 360, fps = 30;
|
|
bool metal = false, preroll = false;
|
|
std::vector<const char *> paths;
|
|
for (int i = 1; i < argc; ++i) {
|
|
if (std::strcmp(argv[i], "--metal") == 0) { metal = true; continue; }
|
|
if (std::strcmp(argv[i], "--preroll") == 0) { preroll = true; continue; }
|
|
if (std::strcmp(argv[i], "--frames") == 0 || std::strcmp(argv[i], "--fps") == 0) {
|
|
const bool count = std::strcmp(argv[i], "--frames") == 0;
|
|
require(++i < argc, "numeric option needs a value");
|
|
char *end = nullptr;
|
|
const long value = std::strtol(argv[i], &end, 10);
|
|
require(end != argv[i] && !*end && value > 0 && value <= 1000000,
|
|
"frame count and integer frame rate must be positive and bounded");
|
|
(count ? frames : fps) = static_cast<int>(value);
|
|
} else {
|
|
require(argv[i][0] != '-', "unknown option");
|
|
paths.push_back(argv[i]);
|
|
}
|
|
}
|
|
require(!paths.empty() && frames >= 8, "provide fixtures with at least eight frames");
|
|
if (metal) {
|
|
[NSApplication sharedApplication];
|
|
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
|
[NSApp finishLaunching];
|
|
}
|
|
try {
|
|
for (const char *path : paths) fixture(path, frames, fps, metal, preroll);
|
|
} catch (const std::exception &error) {
|
|
std::fprintf(stderr, "FAIL: %s\n", error.what());
|
|
return 1;
|
|
}
|
|
}
|
|
}
|