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

  1. 01Shared Memory Header Contract
  2. 02Session Name + Region Size Helpers
  3. 03Publisher Open + Header Initialization
  4. 04Publisher Frame Commit Path
  5. 05Consumer Stable Read Strategy
  6. 06OBS Source Registration
  7. 07OBS Tick Loop + Reconnect Heuristic
  8. 08Build + Install .so Module
Source files referenced by this page
  • include/vliva/capture/shared_frame_protocol.hpp
  • include/vliva/capture/shared_frame_publisher.hpp
  • include/vliva/capture/shared_frame_consumer.hpp
  • src/capture/shared_frame_publisher.cpp
  • src/capture/shared_frame_consumer.cpp
  • obs-plugin/vliva_obs_source.cpp
  • obs-plugin/CMakeLists.txt
  • obs-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 approachlibfunnel
GPU -> CPU -> GPUGPU -> GPU
Heavy memcpyzero-copy / dma-buf
Higher latencyLower latency

OBS plugin demo

Runtime example for shared-frame ingestion inside the VLiva OBS source.

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 / format are validated by consumer before accepting memory mapping.
  • activeIndex selects which of two frame slots is currently publish-ready.
  • ready, frameId, width, and height indicate 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_open compatibility.
  • Empty session name resolves to default /vliva_obs_main endpoint.

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 + mmap allocate 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 glReadPixels origin is bottom-left.
  • Frame metadata is written before activeIndex / ready are 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 == 0 or invalid dimensions are observed.
  • Frame data is copied, then activeIndex / frameId are 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_source and 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 readLatest fails for too long, reconnect is triggered every ~1 second.
  • Unchanged frameId is 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);

Section 8

Build + Install .so Module

Deployment flow from build.sh/CMake for producing and copying vliva_obs_source.so.

  • build.sh configures CMake with OBS include/lib and VLiva capture include path.
  • Resulting module is installed to OBS plugin path (default ~/.config/obs-studio/plugins/...).
  • Script verifies unresolved SharedFrameConsumer symbol is not left in deployed binary.

text

# helper script defaults
DEFAULT_DEST_PLUGIN_SO="$HOME/.config/obs-studio/plugins/vliva_obs_source/bin/64bit/vliva_obs_source.so"

cmake -S "${ROOT_DIR}" -B "${BUILD_DIR}" \
  -DVLIVA_REQUIRE_OBS=ON \
  -DVLIVA_REQUIRE_CAPTURE=ON \
  -DOBS_INCLUDE_DIR="${OBS_INCLUDE_DIR}" \
  -DOBS_LIBRARY="${OBS_LIBRARY}" \
  -DVLIVA_CAPTURE_INCLUDE_DIR="${VLIVA_CAPTURE_INCLUDE_DIR}"

cmake --build "${BUILD_DIR}" -j "${JOBS}"
install -Dm755 "${BUILD_DIR}/vliva_obs_source.so" "${DEST_PLUGIN_SO}"