From 10cf157f79adfd0725be46a73a8022f8932ae13e Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 10 Jul 2026 21:29:20 +0200 Subject: [PATCH] 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 --- spikes/mpv-frame-copy/.gitignore | 1 + spikes/mpv-frame-copy/Makefile | 31 ++ spikes/mpv-frame-copy/README.md | 87 ++++ spikes/mpv-frame-copy/common/spike_shm.h | 59 +++ spikes/mpv-frame-copy/helper/mpv_helper.cpp | 480 ++++++++++++++++++ spikes/mpv-frame-copy/run.sh | 38 ++ .../mpv-frame-copy/viewer/addon/shm_reader.c | 207 ++++++++ spikes/mpv-frame-copy/viewer/index.html | 213 ++++++++ spikes/mpv-frame-copy/viewer/main.js | 30 ++ 9 files changed, 1146 insertions(+) create mode 100644 spikes/mpv-frame-copy/.gitignore create mode 100644 spikes/mpv-frame-copy/Makefile create mode 100644 spikes/mpv-frame-copy/README.md create mode 100644 spikes/mpv-frame-copy/common/spike_shm.h create mode 100644 spikes/mpv-frame-copy/helper/mpv_helper.cpp create mode 100755 spikes/mpv-frame-copy/run.sh create mode 100644 spikes/mpv-frame-copy/viewer/addon/shm_reader.c create mode 100644 spikes/mpv-frame-copy/viewer/index.html create mode 100644 spikes/mpv-frame-copy/viewer/main.js diff --git a/spikes/mpv-frame-copy/.gitignore b/spikes/mpv-frame-copy/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/spikes/mpv-frame-copy/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/spikes/mpv-frame-copy/Makefile b/spikes/mpv-frame-copy/Makefile new file mode 100644 index 000000000..f90c66cfb --- /dev/null +++ b/spikes/mpv-frame-copy/Makefile @@ -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 diff --git a/spikes/mpv-frame-copy/README.md b/spikes/mpv-frame-copy/README.md new file mode 100644 index 000000000..cebb07afd --- /dev/null +++ b/spikes/mpv-frame-copy/README.md @@ -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). diff --git a/spikes/mpv-frame-copy/common/spike_shm.h b/spikes/mpv-frame-copy/common/spike_shm.h new file mode 100644 index 000000000..26ece01fc --- /dev/null +++ b/spikes/mpv-frame-copy/common/spike_shm.h @@ -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 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 + +#ifdef __cplusplus +#include +typedef std::atomic spike_atomic_u64; +#else +#include +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; diff --git a/spikes/mpv-frame-copy/helper/mpv_helper.cpp b/spikes/mpv-frame-copy/helper/mpv_helper.cpp new file mode 100644 index 000000000..b1806c181 --- /dev/null +++ b/spikes/mpv-frame-copy/helper/mpv_helper.cpp @@ -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 +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 [--size WxH] [--shm /name] " + "[--hwdec ] [--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(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(base); + shm.size = total; + shm.name = name; + return true; +} + +/* ---- stats -------------------------------------------------------------- */ + +struct PhaseStats { + std::vector 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(ctx); + { + std::lock_guard 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(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 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 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 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 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(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(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 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; +} diff --git a/spikes/mpv-frame-copy/run.sh b/spikes/mpv-frame-copy/run.sh new file mode 100755 index 000000000..22404a7a8 --- /dev/null +++ b/spikes/mpv-frame-copy/run.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Build and run the full spike: helper (producer) + Electron viewer (consumer). +# +# ./run.sh [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 [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" diff --git a/spikes/mpv-frame-copy/viewer/addon/shm_reader.c b/spikes/mpv-frame-copy/viewer/addon/shm_reader.c new file mode 100644 index 000000000..9d5b3d12a --- /dev/null +++ b/spikes/mpv-frame-copy/viewer/addon/shm_reader.c @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include + +#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) diff --git a/spikes/mpv-frame-copy/viewer/index.html b/spikes/mpv-frame-copy/viewer/index.html new file mode 100644 index 000000000..6d3874757 --- /dev/null +++ b/spikes/mpv-frame-copy/viewer/index.html @@ -0,0 +1,213 @@ + + +mpv frame-copy spike viewer + +
+
waiting for helper…
+ diff --git a/spikes/mpv-frame-copy/viewer/main.js b/spikes/mpv-frame-copy/viewer/main.js new file mode 100644 index 000000000..28f61d0bc --- /dev/null +++ b/spikes/mpv-frame-copy/viewer/main.js @@ -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());