OBS Integration
OBS Shared Frame Plugin
Code-driven documentation of VLiva shared memory capture and OBS source integration, focused on behavior and design without full source dump.
Overview
What this page covers
Source files referenced by this page
include/vliva/capture/shared_frame_protocol.hppinclude/vliva/capture/shared_frame_publisher.hppinclude/vliva/capture/shared_frame_consumer.hppsrc/capture/shared_frame_publisher.cppsrc/capture/shared_frame_consumer.cppobs-plugin/vliva_obs_source.cppobs-plugin/CMakeLists.txtobs-plugin/build.sh
Full source: github.com/TamKungZ/VLiva
Why I am keeping this OBS path for now
Originally, I considered sending frames to OBS via PipeWire between apps. I also considered switching to libfunnel and obs-pwvideo after a developer recommendation. For now, I still prefer the current path because I want VLiva to stay lightweight, keep dependencies minimal, and preserve a one-click install experience as much as possible.
- I prefer working in my own environment because it gives tighter control and reduces time spent studying external implementation docs.
- For VLiva at its current stage, libfunnel feels unnecessary unless I hit a real bottleneck.
- If users are not reporting concrete issues yet, I will keep the current implementation and revisit this when bottlenecks become real.
| Current approach | libfunnel |
|---|---|
| GPU -> CPU -> GPU | GPU -> GPU |
| Heavy memcpy | zero-copy / dma-buf |
| Higher latency | Lower latency |
OBS plugin demo
Runtime example for shared-frame ingestion inside the VLiva OBS source.
Official OBS resource: https://obsproject.com/forum/resources/vliva-capture.2434/
This page documents architecture and code behavior at a high level without disclosing full implementation.
Section 1
Shared Memory Header Contract
The transport begins with a strict header contract used by both publisher and consumer.
magic/version/formatare validated by consumer before accepting memory mapping.activeIndexselects which of two frame slots is currently publish-ready.ready,frameId,width, andheightindicate availability and freshness of frame payload.
c
struct SharedFrameHeader {
uint32_t magic;
uint32_t version;
uint32_t maxWidth;
uint32_t maxHeight;
uint32_t stride;
uint32_t format;
uint32_t activeIndex;
uint32_t ready;
uint64_t frameId;
uint64_t timestampNs;
uint32_t width;
uint32_t height;
};
Section 2
Session Name + Region Size Helpers
Protocol helpers keep publisher/consumer mapping logic deterministic.
- Shared region size reserves header + 2 frame buffers (double buffering).
- Session name is normalized with leading slash for POSIX
shm_opencompatibility. - Empty session name resolves to default
/vliva_obs_mainendpoint.
cpp
inline size_t calculateSharedRegionSize(uint32_t stride, uint32_t maxHeight)
{
const size_t singleFrameBytes = static_cast<size_t>(stride) * static_cast<size_t>(maxHeight);
return sizeof(SharedFrameHeader) + (singleFrameBytes * 2u);
}
inline std::string normalizeSharedMemoryName(const std::string& rawName)
{
if (rawName.empty()) {
return std::string(kDefaultSharedSessionName);
}
if (!rawName.empty() && rawName.front() == '/') {
return rawName;
}
return std::string("/") + rawName;
}
Section 3
Publisher Open + Header Initialization
Publisher creates fresh shared memory mapping and initializes protocol metadata.
open()unlinks stale mapping first to prevent crash leftovers.shm_open+ftruncate+mmapallocate the shared region for header and payload.initializeHeader()writes magic/version/format and resets runtime fields.
cpp
bool SharedFramePublisher::open(const std::string& sessionName, uint32_t maxWidth, uint32_t maxHeight)
{
close();
m_sessionName = normalizeSharedMemoryName(sessionName);
m_stride = maxWidth * 4u;
::shm_unlink(m_sessionName.c_str());
m_fd = ::shm_open(m_sessionName.c_str(), O_CREAT | O_RDWR, 0666);
::ftruncate(m_fd, static_cast<off_t>(regionSize));
void* mapped = ::mmap(nullptr, regionSize, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
m_header = static_cast<SharedFrameHeader*>(mapped);
initializeHeader();
}
Section 4
Publisher Frame Commit Path
publishRgbaBottomLeft() converts and commits each frame with atomic ordering.
- Frame writes target the non-active slot:
writeIndex = activeIndex ^ 1. - Rows are vertically flipped because
glReadPixelsorigin is bottom-left. - Frame metadata is written before
activeIndex/readyare released to readers.
cpp
const uint32_t currentIndex = __atomic_load_n(&m_header->activeIndex, __ATOMIC_RELAXED);
const uint32_t writeIndex = (currentIndex ^ 1u) & 1u;
for (uint32_t row = 0; row < height; ++row) {
const uint32_t srcRow = (height - 1u) - row; // GL bottom-left -> top-left
std::memcpy(dstLine, srcLine, srcStride);
}
__atomic_store_n(&m_header->width, width, __ATOMIC_RELAXED);
__atomic_store_n(&m_header->height, height, __ATOMIC_RELAXED);
__atomic_store_n(&m_header->timestampNs, timestampNs, __ATOMIC_RELAXED);
__atomic_thread_fence(__ATOMIC_RELEASE);
__atomic_store_n(&m_header->frameId, ++m_frameId, __ATOMIC_RELAXED);
__atomic_store_n(&m_header->activeIndex, writeIndex, __ATOMIC_RELEASE);
__atomic_store_n(&m_header->ready, 1u, __ATOMIC_RELEASE);
Section 5
Consumer Stable Read Strategy
readLatest() uses a copy-and-verify loop to avoid torn frame reads.
- Consumer exits early when
ready == 0or invalid dimensions are observed. - Frame data is copied, then
activeIndex/frameIdare re-read to verify consistency. - Two attempts are allowed before reporting no stable frame.
cpp
const uint32_t ready = __atomic_load_n(&m_header->ready, __ATOMIC_ACQUIRE);
if (ready == 0) {
return false;
}
for (int attempt = 0; attempt < 2; ++attempt) {
const uint32_t activeA = __atomic_load_n(&m_header->activeIndex, __ATOMIC_ACQUIRE) & 1u;
const uint64_t frameA = __atomic_load_n(&m_header->frameId, __ATOMIC_ACQUIRE);
copyFrameRows(activeA);
const uint32_t activeB = __atomic_load_n(&m_header->activeIndex, __ATOMIC_ACQUIRE) & 1u;
const uint64_t frameB = __atomic_load_n(&m_header->frameId, __ATOMIC_ACQUIRE);
if (activeA == activeB && frameA == frameB) {
return true;
}
}
return false;
Section 6
OBS Source Registration
obs_module_load() wires lifecycle callbacks for vliva_capture_source.
- Source id is
vliva_capture_sourceand output is video input. - Callback table connects create/update/tick/render/size handlers.
- This makes VLiva Capture appear as selectable source type in OBS.
cpp
sourceInfo.id = "vliva_capture_source";
sourceInfo.type = OBS_SOURCE_TYPE_INPUT;
sourceInfo.output_flags = OBS_SOURCE_VIDEO;
sourceInfo.create = vlivaObsCreate;
sourceInfo.update = vlivaObsUpdate;
sourceInfo.video_tick = vlivaObsTick;
sourceInfo.video_render = vlivaObsRender;
obs_register_source(&sourceInfo);
Section 7
OBS Tick Loop + Reconnect Heuristic
The per-frame OBS update path reads latest frame and self-recovers from stale mappings.
- When
readLatestfails for too long, reconnect is triggered every ~1 second. - Unchanged
frameIdis treated as potential stale consumer mapping. - Successful read ensures texture size then uploads RGBA bytes via
gs_texture_set_image.
cpp
if (!ctx->consumer.readLatest(ctx->frameBuffer, width, height, frameId)) {
if (ctx->reconnectTimer >= 1.0f) {
vlivaObsReconnect(ctx);
}
return;
}
if (frameId == ctx->lastFrameId) {
if (ctx->reconnectTimer >= 1.0f) {
vlivaObsReconnect(ctx);
}
return;
}
vlivaObsEnsureTexture(ctx, width, height);
gs_texture_set_image(ctx->texture, ctx->frameBuffer.data(), width * 4u, false);