mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
spike(embedded-mpv): frame-copy pipeline prototype (helper + shm ring + Electron viewer)
Standalone macOS spike for the frame-copy unification direction from the 2026-07-10 analysis: a helper process renders mpv offscreen into a GL FBO, reads frames back through an async PBO ring, and publishes BGRA frames into a 3-slot POSIX shm seqlock ring; a minimal Electron viewer copies the newest frame via a plain-C N-API addon and uploads it to a WebGL canvas per rAF. First numbers on M1 Pro (see spike README): 4K60 HEVC hwdec sustained at 60 fps end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms produce-to-upload age, zero torn frames. Remaining gates: weak hardware, long-run pacing, HDR, latency flash test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb1bfaa474
commit
10cf157f79
9 files changed
+1146
No files matched your search
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -0,0 +1,31 @@
|
||||
# mpv frame-copy spike (macOS only for now).
|
||||
#
|
||||
# Requires: Homebrew libmpv (brew install mpv) and Node headers for the
|
||||
# N-API addon. Override NODE_INC if your Node lives elsewhere:
|
||||
# make NODE_INC=$(node -e 'console.log(process.execPath)')/../../include/node
|
||||
|
||||
BREW_PREFIX ?= /opt/homebrew
|
||||
NODE_INC ?= $(HOME)/.nvm/versions/node/v22.14.0/include/node
|
||||
|
||||
BUILD := build
|
||||
|
||||
all: $(BUILD)/mpv_helper $(BUILD)/shm_reader.node
|
||||
|
||||
$(BUILD):
|
||||
mkdir -p $(BUILD)
|
||||
|
||||
$(BUILD)/mpv_helper: helper/mpv_helper.cpp common/spike_shm.h | $(BUILD)
|
||||
clang++ -std=c++17 -O2 -Wall -Icommon -I$(BREW_PREFIX)/include \
|
||||
-o $@ helper/mpv_helper.cpp \
|
||||
-L$(BREW_PREFIX)/lib -lmpv -Wl,-rpath,$(BREW_PREFIX)/lib \
|
||||
-framework OpenGL
|
||||
|
||||
$(BUILD)/shm_reader.node: viewer/addon/shm_reader.c common/spike_shm.h | $(BUILD)
|
||||
clang -std=c11 -O2 -Wall -Icommon -I$(NODE_INC) \
|
||||
-shared -fPIC -Wl,-undefined,dynamic_lookup \
|
||||
-o $@ viewer/addon/shm_reader.c
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD)
|
||||
|
||||
.PHONY: all clean
|
||||
@@ -0,0 +1,87 @@
|
||||
# mpv frame-copy spike
|
||||
|
||||
Standalone prototype for the frame-copy unification of embedded MPV
|
||||
(analysis: `.plans/2026-07-10-embedded-mpv-frame-copy-unification.md`).
|
||||
Instead of compositing a native mpv surface under a transparent Electron
|
||||
window, the helper renders mpv offscreen and copies frames into the web UI:
|
||||
|
||||
```
|
||||
mpv_helper (links libmpv) Electron viewer
|
||||
decode (hwdec) + offscreen GL FBO shm_reader.node: memcpy newest
|
||||
async PBO readback (3-deep ring) frame -> ArrayBuffer
|
||||
BGRA frames -> shm ring (3 slots) renderer: texSubImage2D in rAF
|
||||
audio -> OS directly -> WebGL canvas (BGRA swizzle)
|
||||
| ^
|
||||
+------ POSIX shm ring buffer ---------+
|
||||
```
|
||||
|
||||
macOS only for now. No Electron-app integration, no controls — this exists
|
||||
to measure whether the copy pipeline meets the go/no-go gates.
|
||||
|
||||
## Build and run
|
||||
|
||||
Requires Homebrew `mpv` (libmpv + headers) and Node headers for the addon
|
||||
build (`NODE_INC` in the Makefile, defaults to nvm's v22.14.0).
|
||||
|
||||
```bash
|
||||
./run.sh /path/to/video.mkv 3840x2160
|
||||
./run.sh 'av://lavfi:testsrc2=size=1920x1080:rate=60' # synthetic source
|
||||
HELPER_ARGS='--hwdec videotoolbox --loop' ./run.sh /tmp/clip.mp4 3840x2160
|
||||
```
|
||||
|
||||
`run.sh` builds both binaries, starts the helper, then opens the Electron
|
||||
viewer (binary auto-detected from the repo `node_modules`, override with
|
||||
`ELECTRON=`). The helper prints producer stats to stderr once per second;
|
||||
the viewer prints `STATS` lines to stdout every 2 s and shows the same in an
|
||||
on-screen HUD. `PIXELPROBE` is a one-shot image sanity check (spread > 0
|
||||
means real frames, not black).
|
||||
|
||||
Key design points implemented here:
|
||||
|
||||
- **Render at viewport size**: `--size WxH` is the FBO size; mpv scales on
|
||||
the GPU before readback, so a 4K source in a 720p viewport costs 720p.
|
||||
- **Async PBO readback**: `glReadPixels` goes into a 3-deep PBO ring; the
|
||||
previous frame's PBO is mapped/copied while the new readback is in flight.
|
||||
- **3-slot shm ring with per-slot seqlock**: the reader takes the newest
|
||||
complete frame and drops stale ones, so latency cannot accumulate.
|
||||
- **Audio never crosses the boundary**: the helper plays it directly.
|
||||
- **mpv keeps its own frame pacing**: the render call blocks for mpv's
|
||||
target time (default `block_for_target_time`), so the helper's `render ms`
|
||||
figure includes that pacing wait — it is not GPU cost.
|
||||
- **The addon must memcpy**: Electron's V8 memory cage forbids external
|
||||
ArrayBuffers over shm, so zero-copy into JS is impossible. The measured
|
||||
path is readback -> shm -> renderer ArrayBuffer -> GPU texture upload.
|
||||
|
||||
## First measurements (2026-07-10, MacBook Pro M1 Pro, 120 Hz display)
|
||||
|
||||
| Scenario | Producer | Viewer new-frame fps | copy ms avg/p95 | upload ms avg/p95 | produce→uploaded age ms avg/p95 | torn |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1080p60 testsrc2 (sw) | 60.0 fps | 59.8 | 0.28 / 0.35 | 0.29 / 0.40 | 4.2 / 6.8 | 0 |
|
||||
| 4K60 testsrc2 (sw) | 60.0 fps | 60.0 | 1.17 / 1.35 | 3.8 / 4.5 | 11.0 / 12.7 | 0 |
|
||||
| 4K60 HEVC 25 Mbit (hwdec=videotoolbox) | 60.0 fps | 59.9 | 1.2 / 1.6 | 3.3 / 4.1 | 9.8 / 11.7 | 0 |
|
||||
|
||||
Helper-side PBO map+copy at 4K: ~1.0–1.8 ms avg. CPU during 4K60 HEVC:
|
||||
helper ~18 %, Electron renderer ~24 % of one core. The only fps dip observed
|
||||
coincided with the `--loop` file restart (decoder reinit), not the copy path.
|
||||
|
||||
Takeaway so far: on Apple Silicon the copy tax is far below the analysis
|
||||
doc's worst-case budget (33 MB memcpy ≈ 1.2 ms, not 6–8 ms; end-to-end added
|
||||
latency ≈ 10 ms, not 40–60 ms). The remaining open gates are weaker hardware
|
||||
and long-run pacing, not raw throughput on modern Macs.
|
||||
|
||||
## Still to measure (go/no-go gates from the analysis doc)
|
||||
|
||||
- Intel Mac and mid-range Windows laptop (iGPU) — the actual risk hardware.
|
||||
- Long-run frame-time variance (judder), not average fps; 50 Hz content on a
|
||||
60 Hz display.
|
||||
- 4K HDR (tonemapping to SDR before readback).
|
||||
- End-to-end latency flash test (photodiode/screen-capture method) and
|
||||
audio/video sync offset to calibrate `--audio-delay`.
|
||||
- Battery drain delta vs the native-surface approach.
|
||||
|
||||
## Known limitations of this spike
|
||||
|
||||
- macOS-only helper (CGL); Windows needs WGL/D3D, Linux EGL — same protocol.
|
||||
- `latestSeq`/`copyLatest` poll in rAF; no wakeup channel (fine at 60 fps).
|
||||
- Viewer canvas redraws every rAF tick even without a new frame.
|
||||
- No reconnect handling if the helper restarts (viewer keeps last mapping).
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Shared-memory frame ring protocol for the mpv frame-copy spike.
|
||||
*
|
||||
* Producer: helper/mpv_helper.cpp (C++) — renders mpv offscreen, writes
|
||||
* BGRA frames into a 3-slot ring inside POSIX shared memory.
|
||||
* Consumer: viewer/addon/shm_reader.c (C, N-API) — copies the newest
|
||||
* complete frame out for WebGL upload in an Electron renderer.
|
||||
*
|
||||
* This header must compile both as C11 and C++17; the atomic fields rely on
|
||||
* _Atomic uint64_t and std::atomic<uint64_t> having identical size/layout
|
||||
* (both are 8-byte lock-free on the targets this spike cares about).
|
||||
*
|
||||
* Slot protocol (classic triple buffer with per-slot seqlock):
|
||||
* writer: slot.seq = 0 -> memcpy frame + fill meta -> slot.seq = seq
|
||||
* -> header.latest_seq = seq (all stores release)
|
||||
* reader: seq = header.latest_seq (acquire); slot = slots[seq % SLOTS];
|
||||
* verify slot.seq == seq, copy, re-check slot.seq for tearing.
|
||||
* The writer always writes slot (seq % SLOTS) with a monotonically growing
|
||||
* seq, so the newest complete slot is never the one being overwritten next.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <atomic>
|
||||
typedef std::atomic<uint64_t> spike_atomic_u64;
|
||||
#else
|
||||
#include <stdatomic.h>
|
||||
typedef _Atomic uint64_t spike_atomic_u64;
|
||||
#endif
|
||||
|
||||
#define SPIKE_SHM_DEFAULT_NAME "/mpv-frame-spike"
|
||||
#define SPIKE_SHM_MAGIC 0x564d5053u /* 'SPMV' little-endian */
|
||||
#define SPIKE_SHM_VERSION 1u
|
||||
#define SPIKE_RING_SLOTS 3u
|
||||
#define SPIKE_DATA_ALIGN 4096u
|
||||
#define SPIKE_PTS_UNKNOWN UINT64_MAX
|
||||
|
||||
typedef struct {
|
||||
spike_atomic_u64 seq; /* 0 while the slot is being (re)written */
|
||||
uint64_t pts_us; /* mpv time-pos in µs, SPIKE_PTS_UNKNOWN if n/a */
|
||||
uint64_t produce_time_ns; /* CLOCK_MONOTONIC_RAW, set after copy is done */
|
||||
} SpikeSlot;
|
||||
|
||||
typedef struct {
|
||||
uint32_t magic; /* written last during init (with release fence) */
|
||||
uint32_t version;
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t stride; /* bytes per row, tightly packed: width * 4 */
|
||||
uint32_t reserved;
|
||||
uint64_t frame_bytes; /* stride * height */
|
||||
uint64_t data_offset; /* from mapping base, SPIKE_DATA_ALIGN aligned */
|
||||
spike_atomic_u64 latest_seq; /* newest complete frame, 0 = none */
|
||||
spike_atomic_u64 producer_fps_milli; /* producer render fps * 1000 */
|
||||
spike_atomic_u64 heartbeat_ns; /* producer liveness */
|
||||
SpikeSlot slots[SPIKE_RING_SLOTS];
|
||||
} SpikeShmHeader;
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* mpv frame-copy spike — helper process (macOS).
|
||||
*
|
||||
* Owns libmpv in a standalone process: decodes + renders offscreen into a GL
|
||||
* FBO (headless CGL context, no window), reads frames back through a 3-deep
|
||||
* async PBO ring, and publishes BGRA frames into a POSIX shared-memory ring
|
||||
* (see common/spike_shm.h). Audio plays directly from this process.
|
||||
*
|
||||
* Prints one stats line per second: render ms, PBO map+copy ms (avg/p95),
|
||||
* effective fps. These are the producer-side numbers for the go/no-go gates.
|
||||
*/
|
||||
#define GL_SILENCE_DEPRECATION
|
||||
#include <OpenGL/OpenGL.h>
|
||||
#include <OpenGL/gl3.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
#include <mpv/render_gl.h>
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <clocale>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "spike_shm.h"
|
||||
|
||||
namespace {
|
||||
|
||||
struct Args {
|
||||
std::string media;
|
||||
std::string shmName = SPIKE_SHM_DEFAULT_NAME;
|
||||
std::string hwdec = "auto";
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
bool audio = true;
|
||||
bool loop = false;
|
||||
};
|
||||
|
||||
uint64_t nowNs() { return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); }
|
||||
|
||||
[[noreturn]] void usage() {
|
||||
std::fprintf(stderr,
|
||||
"usage: mpv_helper <media> [--size WxH] [--shm /name] "
|
||||
"[--hwdec <mode>] [--no-audio] [--loop]\n");
|
||||
std::exit(2);
|
||||
}
|
||||
|
||||
Args parseArgs(int argc, char** argv) {
|
||||
Args args;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const std::string a = argv[i];
|
||||
auto next = [&]() -> std::string {
|
||||
if (i + 1 >= argc) usage();
|
||||
return argv[++i];
|
||||
};
|
||||
if (a == "--size") {
|
||||
const std::string v = next();
|
||||
if (std::sscanf(v.c_str(), "%dx%d", &args.width, &args.height) != 2)
|
||||
usage();
|
||||
} else if (a == "--shm") {
|
||||
args.shmName = next();
|
||||
} else if (a == "--hwdec") {
|
||||
args.hwdec = next();
|
||||
} else if (a == "--no-audio") {
|
||||
args.audio = false;
|
||||
} else if (a == "--loop") {
|
||||
args.loop = true;
|
||||
} else if (!a.empty() && a[0] == '-') {
|
||||
usage();
|
||||
} else if (args.media.empty()) {
|
||||
args.media = a;
|
||||
} else {
|
||||
usage();
|
||||
}
|
||||
}
|
||||
if (args.media.empty() || args.width < 16 || args.height < 16) usage();
|
||||
return args;
|
||||
}
|
||||
|
||||
/* ---- shared memory ----------------------------------------------------- */
|
||||
|
||||
struct Shm {
|
||||
SpikeShmHeader* hdr = nullptr;
|
||||
uint8_t* base = nullptr;
|
||||
size_t size = 0;
|
||||
std::string name;
|
||||
|
||||
uint8_t* slotData(uint64_t seq) const {
|
||||
return base + hdr->data_offset +
|
||||
(seq % SPIKE_RING_SLOTS) * hdr->frame_bytes;
|
||||
}
|
||||
};
|
||||
|
||||
bool shmCreate(Shm& shm, const std::string& name, int width, int height) {
|
||||
shm_unlink(name.c_str()); /* remove a stale ring from a previous run */
|
||||
const int fd = shm_open(name.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600);
|
||||
if (fd < 0) {
|
||||
std::perror("shm_open");
|
||||
return false;
|
||||
}
|
||||
const uint64_t frameBytes = (uint64_t)width * 4u * (uint64_t)height;
|
||||
const uint64_t dataOffset =
|
||||
(sizeof(SpikeShmHeader) + SPIKE_DATA_ALIGN - 1) & ~(uint64_t)(SPIKE_DATA_ALIGN - 1);
|
||||
const size_t total = (size_t)(dataOffset + SPIKE_RING_SLOTS * frameBytes);
|
||||
if (ftruncate(fd, (off_t)total) != 0) {
|
||||
std::perror("ftruncate");
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
void* base = mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (base == MAP_FAILED) {
|
||||
std::perror("mmap");
|
||||
return false;
|
||||
}
|
||||
std::memset(base, 0, sizeof(SpikeShmHeader));
|
||||
auto* hdr = static_cast<SpikeShmHeader*>(base);
|
||||
hdr->version = SPIKE_SHM_VERSION;
|
||||
hdr->width = (uint32_t)width;
|
||||
hdr->height = (uint32_t)height;
|
||||
hdr->stride = (uint32_t)width * 4u;
|
||||
hdr->frame_bytes = frameBytes;
|
||||
hdr->data_offset = dataOffset;
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
hdr->magic = SPIKE_SHM_MAGIC; /* readers treat magic as the ready flag */
|
||||
|
||||
shm.hdr = hdr;
|
||||
shm.base = static_cast<uint8_t*>(base);
|
||||
shm.size = total;
|
||||
shm.name = name;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- stats -------------------------------------------------------------- */
|
||||
|
||||
struct PhaseStats {
|
||||
std::vector<double> samples;
|
||||
void add(double ms) { samples.push_back(ms); }
|
||||
double avg() const {
|
||||
if (samples.empty()) return 0;
|
||||
double s = 0;
|
||||
for (double v : samples) s += v;
|
||||
return s / (double)samples.size();
|
||||
}
|
||||
double p95() {
|
||||
if (samples.empty()) return 0;
|
||||
std::sort(samples.begin(), samples.end());
|
||||
return samples[(size_t)((double)(samples.size() - 1) * 0.95)];
|
||||
}
|
||||
void reset() { samples.clear(); }
|
||||
};
|
||||
|
||||
/* ---- render loop -------------------------------------------------------- */
|
||||
|
||||
void* g_glDylib = nullptr;
|
||||
|
||||
void* getProcAddress(void*, const char* name) {
|
||||
return dlsym(g_glDylib, name);
|
||||
}
|
||||
|
||||
struct RenderShared {
|
||||
mpv_handle* mpv = nullptr;
|
||||
Shm* shm = nullptr;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
CGLContextObj cgl = nullptr;
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
bool updatePending = false;
|
||||
bool stop = false;
|
||||
bool ready = false;
|
||||
bool failed = false;
|
||||
};
|
||||
|
||||
void onMpvRenderUpdate(void* ctx) {
|
||||
auto* shared = static_cast<RenderShared*>(ctx);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
shared->updatePending = true;
|
||||
}
|
||||
shared->cv.notify_all();
|
||||
}
|
||||
|
||||
void renderThreadMain(RenderShared* shared) {
|
||||
CGLSetCurrentContext(shared->cgl);
|
||||
|
||||
const int w = shared->width;
|
||||
const int h = shared->height;
|
||||
const GLsizeiptr frameBytes = (GLsizeiptr)w * 4 * h;
|
||||
|
||||
GLuint texture = 0, fbo = 0;
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA,
|
||||
GL_UNSIGNED_BYTE, nullptr);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glGenFramebuffers(1, &fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
||||
texture, 0);
|
||||
|
||||
GLuint pbos[SPIKE_RING_SLOTS] = {0};
|
||||
glGenBuffers(SPIKE_RING_SLOTS, pbos);
|
||||
for (GLuint pbo : pbos) {
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbo);
|
||||
glBufferData(GL_PIXEL_PACK_BUFFER, frameBytes, nullptr, GL_STREAM_READ);
|
||||
}
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
|
||||
const bool fboOk =
|
||||
glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
|
||||
|
||||
mpv_opengl_init_params glInit = {getProcAddress, nullptr};
|
||||
mpv_render_param createParams[] = {
|
||||
{MPV_RENDER_PARAM_API_TYPE,
|
||||
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
||||
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||
};
|
||||
mpv_render_context* rc = nullptr;
|
||||
const int createResult =
|
||||
fboOk ? mpv_render_context_create(&rc, shared->mpv, createParams) : -1;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
shared->ready = true;
|
||||
shared->failed = createResult < 0;
|
||||
}
|
||||
shared->cv.notify_all();
|
||||
if (createResult < 0) {
|
||||
std::fprintf(stderr, "[helper] render context init failed (%s)\n",
|
||||
fboOk ? mpv_error_string(createResult) : "FBO incomplete");
|
||||
return;
|
||||
}
|
||||
std::fprintf(stderr, "[helper] GL renderer: %s\n",
|
||||
(const char*)glGetString(GL_RENDERER));
|
||||
mpv_render_context_set_update_callback(rc, onMpvRenderUpdate, shared);
|
||||
|
||||
SpikeShmHeader* hdr = shared->shm->hdr;
|
||||
uint64_t nextSeq = 1;
|
||||
int pendingPbo = -1; /* PBO with an in-flight readback from last frame */
|
||||
uint64_t pendingPtsUs = SPIKE_PTS_UNKNOWN;
|
||||
int cursor = 0;
|
||||
|
||||
PhaseStats renderStats, copyStats;
|
||||
uint64_t framesThisSecond = 0;
|
||||
uint64_t statsWindowStart = nowNs();
|
||||
|
||||
auto publishPending = [&]() {
|
||||
if (pendingPbo < 0) return;
|
||||
const uint64_t t0 = nowNs();
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbos[pendingPbo]);
|
||||
const void* mapped =
|
||||
glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, frameBytes, GL_MAP_READ_BIT);
|
||||
if (mapped) {
|
||||
const uint64_t seq = nextSeq++;
|
||||
SpikeSlot& slot = hdr->slots[seq % SPIKE_RING_SLOTS];
|
||||
slot.seq.store(0, std::memory_order_release);
|
||||
std::memcpy(shared->shm->slotData(seq), mapped,
|
||||
(size_t)frameBytes);
|
||||
slot.pts_us = pendingPtsUs;
|
||||
slot.produce_time_ns = nowNs();
|
||||
slot.seq.store(seq, std::memory_order_release);
|
||||
hdr->latest_seq.store(seq, std::memory_order_release);
|
||||
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
|
||||
copyStats.add((double)(nowNs() - t0) / 1e6);
|
||||
framesThisSecond++;
|
||||
}
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
pendingPbo = -1;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(shared->mutex);
|
||||
shared->cv.wait_for(lock, std::chrono::milliseconds(100), [&] {
|
||||
return shared->updatePending || shared->stop;
|
||||
});
|
||||
if (shared->stop) break;
|
||||
shared->updatePending = false;
|
||||
}
|
||||
|
||||
const uint64_t flags = mpv_render_context_update(rc);
|
||||
if (flags & MPV_RENDER_UPDATE_FRAME) {
|
||||
const uint64_t t0 = nowNs();
|
||||
mpv_opengl_fbo mpvFbo = {(int)fbo, w, h, 0};
|
||||
int flipY = 1; /* top-down rows so the consumer can upload as-is */
|
||||
mpv_render_param renderParams[] = {
|
||||
{MPV_RENDER_PARAM_OPENGL_FBO, &mpvFbo},
|
||||
{MPV_RENDER_PARAM_FLIP_Y, &flipY},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||
};
|
||||
mpv_render_context_render(rc, renderParams);
|
||||
renderStats.add((double)(nowNs() - t0) / 1e6);
|
||||
|
||||
/* Kick off the async readback for this frame ... */
|
||||
double pts = 0;
|
||||
pendingPtsUs =
|
||||
mpv_get_property(shared->mpv, "time-pos", MPV_FORMAT_DOUBLE,
|
||||
&pts) >= 0
|
||||
? (uint64_t)(pts * 1e6)
|
||||
: SPIKE_PTS_UNKNOWN;
|
||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
|
||||
glReadBuffer(GL_COLOR_ATTACHMENT0);
|
||||
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
|
||||
/* Publish the previous frame first so this readback stays async. */
|
||||
publishPending();
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, pbos[cursor]);
|
||||
glReadPixels(0, 0, w, h, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV,
|
||||
nullptr);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
pendingPbo = cursor;
|
||||
cursor = (cursor + 1) % SPIKE_RING_SLOTS;
|
||||
mpv_render_context_report_swap(rc);
|
||||
} else {
|
||||
/* No new video frame; still flush a leftover readback so pause
|
||||
* doesn't strand the last frame inside a PBO. */
|
||||
publishPending();
|
||||
}
|
||||
|
||||
const uint64_t now = nowNs();
|
||||
hdr->heartbeat_ns.store(now, std::memory_order_relaxed);
|
||||
if (now - statsWindowStart >= 1000000000ull) {
|
||||
const double seconds = (double)(now - statsWindowStart) / 1e9;
|
||||
const double fps = (double)framesThisSecond / seconds;
|
||||
hdr->producer_fps_milli.store((uint64_t)(fps * 1000.0),
|
||||
std::memory_order_relaxed);
|
||||
std::fprintf(stderr,
|
||||
"[helper] fps=%5.1f | render ms avg=%5.2f p95=%5.2f | "
|
||||
"map+copy ms avg=%5.2f p95=%5.2f\n",
|
||||
fps, renderStats.avg(), renderStats.p95(),
|
||||
copyStats.avg(), copyStats.p95());
|
||||
renderStats.reset();
|
||||
copyStats.reset();
|
||||
framesThisSecond = 0;
|
||||
statsWindowStart = now;
|
||||
}
|
||||
}
|
||||
|
||||
mpv_render_context_set_update_callback(rc, nullptr, nullptr);
|
||||
mpv_render_context_free(rc);
|
||||
glDeleteBuffers(SPIKE_RING_SLOTS, pbos);
|
||||
glDeleteFramebuffers(1, &fbo);
|
||||
glDeleteTextures(1, &texture);
|
||||
CGLSetCurrentContext(nullptr);
|
||||
}
|
||||
|
||||
/* ---- main / mpv event loop ---------------------------------------------- */
|
||||
|
||||
std::atomic<bool> g_quit{false};
|
||||
void onSignal(int) { g_quit.store(true); }
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::setlocale(LC_NUMERIC, "C");
|
||||
const Args args = parseArgs(argc, argv);
|
||||
|
||||
g_glDylib = dlopen(
|
||||
"/System/Library/Frameworks/OpenGL.framework/Versions/Current/OpenGL",
|
||||
RTLD_LAZY | RTLD_LOCAL);
|
||||
if (!g_glDylib) {
|
||||
std::fprintf(stderr, "[helper] failed to dlopen OpenGL framework\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Shm shm;
|
||||
if (!shmCreate(shm, args.shmName, args.width, args.height)) return 1;
|
||||
|
||||
CGLPixelFormatAttribute pfAttrs[] = {
|
||||
kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_3_2_Core,
|
||||
kCGLPFAAccelerated,
|
||||
kCGLPFAColorSize, (CGLPixelFormatAttribute)24,
|
||||
kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8,
|
||||
(CGLPixelFormatAttribute)0,
|
||||
};
|
||||
CGLPixelFormatObj pixelFormat = nullptr;
|
||||
GLint pixelFormatCount = 0;
|
||||
CGLContextObj cgl = nullptr;
|
||||
if (CGLChoosePixelFormat(pfAttrs, &pixelFormat, &pixelFormatCount) !=
|
||||
kCGLNoError ||
|
||||
!pixelFormat ||
|
||||
CGLCreateContext(pixelFormat, nullptr, &cgl) != kCGLNoError) {
|
||||
std::fprintf(stderr, "[helper] failed to create headless CGL context\n");
|
||||
return 1;
|
||||
}
|
||||
CGLDestroyPixelFormat(pixelFormat);
|
||||
|
||||
mpv_handle* mpv = mpv_create();
|
||||
if (!mpv) {
|
||||
std::fprintf(stderr, "[helper] mpv_create failed\n");
|
||||
return 1;
|
||||
}
|
||||
mpv_set_option_string(mpv, "vo", "libmpv");
|
||||
mpv_set_option_string(mpv, "hwdec", args.hwdec.c_str());
|
||||
mpv_set_option_string(mpv, "keep-open", "yes");
|
||||
if (!args.audio) mpv_set_option_string(mpv, "aid", "no");
|
||||
if (args.loop) mpv_set_option_string(mpv, "loop-file", "inf");
|
||||
if (mpv_initialize(mpv) < 0) {
|
||||
std::fprintf(stderr, "[helper] mpv_initialize failed\n");
|
||||
return 1;
|
||||
}
|
||||
mpv_request_log_messages(mpv, "warn");
|
||||
|
||||
std::fprintf(stderr,
|
||||
"[helper] media=%s size=%dx%d shm=%s hwdec=%s audio=%s\n",
|
||||
args.media.c_str(), args.width, args.height,
|
||||
args.shmName.c_str(), args.hwdec.c_str(),
|
||||
args.audio ? "on" : "off");
|
||||
|
||||
RenderShared shared;
|
||||
shared.mpv = mpv;
|
||||
shared.shm = &shm;
|
||||
shared.width = args.width;
|
||||
shared.height = args.height;
|
||||
shared.cgl = cgl;
|
||||
|
||||
std::thread renderThread(renderThreadMain, &shared);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(shared.mutex);
|
||||
shared.cv.wait(lock, [&] { return shared.ready; });
|
||||
if (shared.failed) {
|
||||
lock.unlock();
|
||||
renderThread.join();
|
||||
mpv_terminate_destroy(mpv);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
signal(SIGINT, onSignal);
|
||||
signal(SIGTERM, onSignal);
|
||||
|
||||
const char* loadCmd[] = {"loadfile", args.media.c_str(), nullptr};
|
||||
mpv_command(mpv, loadCmd);
|
||||
|
||||
while (!g_quit.load()) {
|
||||
mpv_event* event = mpv_wait_event(mpv, 0.25);
|
||||
if (event->event_id == MPV_EVENT_NONE) continue;
|
||||
if (event->event_id == MPV_EVENT_SHUTDOWN) break;
|
||||
if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
|
||||
auto* msg = static_cast<mpv_event_log_message*>(event->data);
|
||||
std::fprintf(stderr, "[mpv/%s] %s", msg->prefix, msg->text);
|
||||
} else if (event->event_id == MPV_EVENT_END_FILE) {
|
||||
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
||||
std::fprintf(stderr, "[helper] end-file reason=%d error=%s\n",
|
||||
end->reason,
|
||||
end->error < 0 ? mpv_error_string(end->error) : "none");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared.mutex);
|
||||
shared.stop = true;
|
||||
}
|
||||
shared.cv.notify_all();
|
||||
renderThread.join();
|
||||
mpv_terminate_destroy(mpv);
|
||||
CGLDestroyContext(cgl);
|
||||
munmap(shm.base, shm.size);
|
||||
shm_unlink(shm.name.c_str());
|
||||
std::fprintf(stderr, "[helper] clean exit\n");
|
||||
return 0;
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and run the full spike: helper (producer) + Electron viewer (consumer).
|
||||
#
|
||||
# ./run.sh <media-url-or-path> [WxH]
|
||||
# ./run.sh 'av://lavfi:testsrc2=size=1920x1080:rate=60' 1920x1080
|
||||
#
|
||||
# Env overrides: ELECTRON (viewer binary), SPIKE_SHM (ring name),
|
||||
# HELPER_ARGS (extra helper flags, e.g. "--hwdec videotoolbox-copy --loop").
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
MEDIA="${1:?usage: run.sh <media-url-or-path> [WxH]}"
|
||||
SIZE="${2:-1920x1080}"
|
||||
SHM="${SPIKE_SHM:-/mpv-frame-spike}"
|
||||
|
||||
make -C "$DIR"
|
||||
|
||||
if [[ -z "${ELECTRON:-}" ]]; then
|
||||
for candidate in \
|
||||
"$DIR/../../node_modules/.bin/electron" \
|
||||
"$DIR/../../../../../node_modules/.bin/electron"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
ELECTRON="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${ELECTRON:-}" ]]; then
|
||||
echo "electron binary not found; set ELECTRON=/path/to/electron" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
"$DIR/build/mpv_helper" "$MEDIA" --size "$SIZE" --shm "$SHM" ${HELPER_ARGS:-} &
|
||||
HELPER_PID=$!
|
||||
trap 'kill "$HELPER_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
SPIKE_SHM="$SHM" "$ELECTRON" "$DIR/viewer/main.js"
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* mpv frame-copy spike — shared-memory reader addon (plain C N-API).
|
||||
*
|
||||
* Maps the ring created by helper/mpv_helper.cpp and copies the newest
|
||||
* complete frame into a caller-provided ArrayBuffer. A memcpy is mandatory:
|
||||
* Electron's V8 memory cage forbids external ArrayBuffers over foreign
|
||||
* memory (napi_create_external_arraybuffer aborts), so zero-copy into JS is
|
||||
* not possible — this copy is one of the 2-3 copies the architecture doc
|
||||
* budgets for.
|
||||
*
|
||||
* Built directly with clang (-undefined dynamic_lookup); N-API is ABI-stable
|
||||
* so the same .node loads in Node and Electron.
|
||||
*/
|
||||
#define NAPI_VERSION 8
|
||||
#include <node_api.h>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "spike_shm.h"
|
||||
|
||||
static SpikeShmHeader* g_hdr = NULL;
|
||||
static uint8_t* g_base = NULL;
|
||||
static size_t g_size = 0;
|
||||
|
||||
static uint64_t now_ns(void) {
|
||||
return clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
|
||||
}
|
||||
|
||||
static napi_value throw_error(napi_env env, const char* msg) {
|
||||
napi_throw_error(env, NULL, msg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void set_named_double(napi_env env, napi_value obj, const char* key,
|
||||
double value) {
|
||||
napi_value v;
|
||||
napi_create_double(env, value, &v);
|
||||
napi_set_named_property(env, obj, key, v);
|
||||
}
|
||||
|
||||
/* open(name) -> { width, height, stride, frameBytes } */
|
||||
static napi_value Open(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 1;
|
||||
napi_value argv[1];
|
||||
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
|
||||
if (argc < 1) return throw_error(env, "open(name) requires a shm name");
|
||||
|
||||
char name[256];
|
||||
size_t len = 0;
|
||||
if (napi_get_value_string_utf8(env, argv[0], name, sizeof(name), &len) !=
|
||||
napi_ok)
|
||||
return throw_error(env, "shm name must be a string");
|
||||
|
||||
if (g_base) {
|
||||
munmap(g_base, g_size);
|
||||
g_base = NULL;
|
||||
g_hdr = NULL;
|
||||
}
|
||||
|
||||
const int fd = shm_open(name, O_RDONLY, 0);
|
||||
if (fd < 0) return throw_error(env, "shm_open failed (helper not running?)");
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0 || st.st_size < (off_t)sizeof(SpikeShmHeader)) {
|
||||
close(fd);
|
||||
return throw_error(env, "shm segment too small");
|
||||
}
|
||||
void* base = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (base == MAP_FAILED) return throw_error(env, "mmap failed");
|
||||
|
||||
SpikeShmHeader* hdr = (SpikeShmHeader*)base;
|
||||
if (hdr->magic != SPIKE_SHM_MAGIC || hdr->version != SPIKE_SHM_VERSION) {
|
||||
munmap(base, (size_t)st.st_size);
|
||||
return throw_error(env, "shm ring not initialized yet");
|
||||
}
|
||||
|
||||
g_base = (uint8_t*)base;
|
||||
g_size = (size_t)st.st_size;
|
||||
g_hdr = hdr;
|
||||
|
||||
napi_value result;
|
||||
napi_create_object(env, &result);
|
||||
set_named_double(env, result, "width", hdr->width);
|
||||
set_named_double(env, result, "height", hdr->height);
|
||||
set_named_double(env, result, "stride", hdr->stride);
|
||||
set_named_double(env, result, "frameBytes", (double)hdr->frame_bytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* latestSeq() -> number (0 = no frame yet) */
|
||||
static napi_value LatestSeq(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
const uint64_t seq =
|
||||
g_hdr ? atomic_load_explicit(&g_hdr->latest_seq, memory_order_acquire)
|
||||
: 0;
|
||||
napi_create_double(env, (double)seq, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* copyLatest(arrayBuffer) -> { seq, ptsSec, ageMs, copyMs, torn } | null */
|
||||
static napi_value CopyLatest(napi_env env, napi_callback_info info) {
|
||||
size_t argc = 1;
|
||||
napi_value argv[1];
|
||||
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
|
||||
if (!g_hdr) return throw_error(env, "call open() first");
|
||||
if (argc < 1) return throw_error(env, "copyLatest(buffer) needs a buffer");
|
||||
|
||||
void* dst = NULL;
|
||||
size_t dstLen = 0;
|
||||
if (napi_get_arraybuffer_info(env, argv[0], &dst, &dstLen) != napi_ok)
|
||||
return throw_error(env, "argument must be an ArrayBuffer");
|
||||
if (dstLen < g_hdr->frame_bytes)
|
||||
return throw_error(env, "buffer smaller than one frame");
|
||||
|
||||
napi_value null_value;
|
||||
napi_get_null(env, &null_value);
|
||||
|
||||
const uint64_t seq =
|
||||
atomic_load_explicit(&g_hdr->latest_seq, memory_order_acquire);
|
||||
if (seq == 0) return null_value;
|
||||
|
||||
SpikeSlot* slot = &g_hdr->slots[seq % SPIKE_RING_SLOTS];
|
||||
if (atomic_load_explicit(&slot->seq, memory_order_acquire) != seq)
|
||||
return null_value; /* writer racing this slot; try next tick */
|
||||
|
||||
const uint8_t* src =
|
||||
g_base + g_hdr->data_offset + (seq % SPIKE_RING_SLOTS) * g_hdr->frame_bytes;
|
||||
const uint64_t t0 = now_ns();
|
||||
memcpy(dst, src, (size_t)g_hdr->frame_bytes);
|
||||
const uint64_t t1 = now_ns();
|
||||
|
||||
const int torn =
|
||||
atomic_load_explicit(&slot->seq, memory_order_acquire) != seq;
|
||||
const uint64_t pts_us = slot->pts_us;
|
||||
const double age_ms = (double)(t1 - slot->produce_time_ns) / 1e6;
|
||||
|
||||
napi_value result;
|
||||
napi_create_object(env, &result);
|
||||
set_named_double(env, result, "seq", (double)seq);
|
||||
set_named_double(env, result, "ptsSec",
|
||||
pts_us == SPIKE_PTS_UNKNOWN ? -1.0 : (double)pts_us / 1e6);
|
||||
set_named_double(env, result, "ageMs", age_ms);
|
||||
set_named_double(env, result, "copyMs", (double)(t1 - t0) / 1e6);
|
||||
napi_value torn_value;
|
||||
napi_get_boolean(env, torn, &torn_value);
|
||||
napi_set_named_property(env, result, "torn", torn_value);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* producerFps() -> number */
|
||||
static napi_value ProducerFps(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
const double fps =
|
||||
g_hdr ? (double)atomic_load_explicit(&g_hdr->producer_fps_milli,
|
||||
memory_order_relaxed) /
|
||||
1000.0
|
||||
: 0;
|
||||
napi_create_double(env, fps, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* producerAliveMs() -> ms since last producer heartbeat (-1 if unknown) */
|
||||
static napi_value ProducerAliveMs(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
double ms = -1;
|
||||
if (g_hdr) {
|
||||
const uint64_t hb =
|
||||
atomic_load_explicit(&g_hdr->heartbeat_ns, memory_order_relaxed);
|
||||
if (hb) ms = (double)(now_ns() - hb) / 1e6;
|
||||
}
|
||||
napi_create_double(env, ms, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* nowMs() -> CLOCK_MONOTONIC_RAW in ms (same clock as helper timestamps) */
|
||||
static napi_value NowMs(napi_env env, napi_callback_info info) {
|
||||
napi_value out;
|
||||
napi_create_double(env, (double)now_ns() / 1e6, &out);
|
||||
return out;
|
||||
}
|
||||
|
||||
static napi_value Init(napi_env env, napi_value exports) {
|
||||
const struct {
|
||||
const char* name;
|
||||
napi_callback fn;
|
||||
} fns[] = {
|
||||
{"open", Open}, {"latestSeq", LatestSeq},
|
||||
{"copyLatest", CopyLatest}, {"producerFps", ProducerFps},
|
||||
{"producerAliveMs", ProducerAliveMs}, {"nowMs", NowMs},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(fns) / sizeof(fns[0]); i++) {
|
||||
napi_value fn;
|
||||
napi_create_function(env, fns[i].name, NAPI_AUTO_LENGTH, fns[i].fn,
|
||||
NULL, &fn);
|
||||
napi_set_named_property(env, exports, fns[i].name, fn);
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
NAPI_MODULE(shm_reader, Init)
|
||||
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>mpv frame-copy spike viewer</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #101014;
|
||||
color: #e8e8ee;
|
||||
font: 13px/1.5 ui-monospace, monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
#stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
#hud {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(10, 10, 14, 0.78);
|
||||
border: 1px solid #2a2a33;
|
||||
border-radius: 6px;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<div id="stage"><canvas id="video"></canvas></div>
|
||||
<div id="hud">waiting for helper…</div>
|
||||
<script>
|
||||
const path = require('path');
|
||||
const shm = require(path.join(__dirname, '..', 'build', 'shm_reader.node'));
|
||||
const shmName =
|
||||
new URLSearchParams(location.search).get('shm') || '/mpv-frame-spike';
|
||||
const hud = document.getElementById('hud');
|
||||
|
||||
function percentile(samples, p) {
|
||||
if (!samples.length) return 0;
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return sorted[Math.floor((sorted.length - 1) * p)];
|
||||
}
|
||||
const avg = (samples) =>
|
||||
samples.length
|
||||
? samples.reduce((sum, v) => sum + v, 0) / samples.length
|
||||
: 0;
|
||||
|
||||
function start(info) {
|
||||
const width = info.width;
|
||||
const height = info.height;
|
||||
const canvas = document.getElementById('video');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: false,
|
||||
});
|
||||
const vertexSrc = `#version 300 es
|
||||
out vec2 v_uv;
|
||||
void main() {
|
||||
vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
|
||||
v_uv = vec2(corner.x, 1.0 - corner.y); // top-down rows in shm
|
||||
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
|
||||
}`;
|
||||
const fragmentSrc = `#version 300 es
|
||||
precision mediump float;
|
||||
uniform sampler2D u_tex;
|
||||
in vec2 v_uv;
|
||||
out vec4 o_color;
|
||||
void main() {
|
||||
vec4 c = texture(u_tex, v_uv);
|
||||
o_color = vec4(c.b, c.g, c.r, 1.0); // shm frames are BGRA
|
||||
}`;
|
||||
const program = gl.createProgram();
|
||||
for (const [type, src] of [
|
||||
[gl.VERTEX_SHADER, vertexSrc],
|
||||
[gl.FRAGMENT_SHADER, fragmentSrc],
|
||||
]) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, src);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error('shader: ' + gl.getShaderInfoLog(shader));
|
||||
}
|
||||
gl.attachShader(program, shader);
|
||||
}
|
||||
gl.linkProgram(program);
|
||||
gl.useProgram(program);
|
||||
gl.createVertexArray && gl.bindVertexArray(gl.createVertexArray());
|
||||
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, null,
|
||||
);
|
||||
gl.viewport(0, 0, width, height);
|
||||
|
||||
const frame = new Uint8Array(info.frameBytes);
|
||||
let pixelProbeDone = false;
|
||||
function probePixels() {
|
||||
// One-shot sanity check that frames carry a real image: sample a
|
||||
// grid and report channel spread (a black/uniform frame fails).
|
||||
let min = 255;
|
||||
let max = 0;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const offset =
|
||||
Math.floor((i / 64) * (info.frameBytes / 4)) * 4;
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
const v = frame[offset + channel];
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
console.log(`PIXELPROBE min=${min} max=${max} spread=${max - min}`);
|
||||
}
|
||||
let lastSeq = 0;
|
||||
let drawTicks = 0;
|
||||
let newFrames = 0;
|
||||
let dropped = 0;
|
||||
let torn = 0;
|
||||
const copyMs = [];
|
||||
const uploadMs = [];
|
||||
const ageMs = [];
|
||||
let windowStart = performance.now();
|
||||
|
||||
function tick() {
|
||||
requestAnimationFrame(tick);
|
||||
drawTicks++;
|
||||
|
||||
if (shm.latestSeq() > lastSeq) {
|
||||
const result = shm.copyLatest(frame.buffer);
|
||||
if (result) {
|
||||
const t0 = performance.now();
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, width, height,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, frame,
|
||||
);
|
||||
const upload = performance.now() - t0;
|
||||
if (lastSeq > 0 && result.seq - lastSeq > 1) {
|
||||
dropped += result.seq - lastSeq - 1;
|
||||
}
|
||||
lastSeq = result.seq;
|
||||
newFrames++;
|
||||
if (!pixelProbeDone && result.seq > 60) {
|
||||
pixelProbeDone = true;
|
||||
probePixels();
|
||||
}
|
||||
copyMs.push(result.copyMs);
|
||||
uploadMs.push(upload);
|
||||
ageMs.push(result.ageMs + upload);
|
||||
if (result.torn) torn++;
|
||||
}
|
||||
}
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
|
||||
const now = performance.now();
|
||||
if (now - windowStart >= 2000) {
|
||||
const seconds = (now - windowStart) / 1000;
|
||||
const line =
|
||||
`STATS draw=${(drawTicks / seconds).toFixed(1)}fps ` +
|
||||
`new=${(newFrames / seconds).toFixed(1)}fps ` +
|
||||
`producer=${shm.producerFps().toFixed(1)}fps | ` +
|
||||
`copy ms avg=${avg(copyMs).toFixed(2)} p95=${percentile(copyMs, 0.95).toFixed(2)} | ` +
|
||||
`upload ms avg=${avg(uploadMs).toFixed(2)} p95=${percentile(uploadMs, 0.95).toFixed(2)} | ` +
|
||||
`age ms avg=${avg(ageMs).toFixed(1)} p95=${percentile(ageMs, 0.95).toFixed(1)} | ` +
|
||||
`dropped=${dropped} torn=${torn}`;
|
||||
console.log(line);
|
||||
hud.textContent =
|
||||
`shm ${shmName} ${width}x${height}\n` +
|
||||
line.replace('STATS ', '').replaceAll(' | ', '\n');
|
||||
drawTicks = 0;
|
||||
newFrames = 0;
|
||||
copyMs.length = 0;
|
||||
uploadMs.length = 0;
|
||||
ageMs.length = 0;
|
||||
windowStart = now;
|
||||
}
|
||||
}
|
||||
tick();
|
||||
}
|
||||
|
||||
(function waitForHelper() {
|
||||
let info = null;
|
||||
try {
|
||||
info = shm.open(shmName);
|
||||
} catch (error) {
|
||||
hud.textContent = `waiting for helper… (${error.message})`;
|
||||
}
|
||||
if (info) {
|
||||
console.log(
|
||||
`attached to ${shmName}: ${info.width}x${info.height}, ` +
|
||||
`${info.frameBytes} bytes/frame`,
|
||||
);
|
||||
start(info);
|
||||
} else {
|
||||
setTimeout(waitForHelper, 250);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* mpv frame-copy spike — Electron viewer harness.
|
||||
*
|
||||
* Deliberately minimal: nodeIntegration on, no preload, renderer console is
|
||||
* mirrored to stdout so an automated run can scrape the STATS lines.
|
||||
*/
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
const shmName = process.env.SPIKE_SHM || '/mpv-frame-spike';
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const win = new BrowserWindow({
|
||||
width: 1600,
|
||||
height: 980,
|
||||
backgroundColor: '#101014',
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
win.webContents.on('console-message', (_event, _level, message) => {
|
||||
console.log(`[viewer] ${message}`);
|
||||
});
|
||||
win.loadFile(path.join(__dirname, 'index.html'), {
|
||||
query: { shm: shmName },
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => app.quit());
|
||||
Reference in new issue
Block a user