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:
4grayandClaude Fable 5 committed 2026-07-15 18:20:45 +02:00
1 parent eb1bfaa474
commit 10cf157f79
9 files changed
+1146

No files matched your search

+1
View File
@@ -0,0 +1 @@
build/
+31
View File
@@ -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
+87
View File
@@ -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).
+59
View File
@@ -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;
+480
View File
@@ -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;
}
+38
View File
@@ -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)
+213
View File
@@ -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>
+30
View File
@@ -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());