Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ fi
FROM ubuntu:questing AS runtime
WORKDIR /app

# TARGETARCH is set automatically by docker buildx (e.g. amd64, arm64).
# We use it to fetch the right architecture of our patched GStreamer plugins.
ARG TARGETARCH

# Install GStreamer runtime dependencies
# Note: Ubuntu Plucky (25.04) reached EOL before the nvcodec fix (Bug #2109413) was released.
# Ubuntu Questing (25.10) includes the fix in gstreamer1.0-plugins-bad 1.26.3+.
Expand Down Expand Up @@ -206,6 +210,21 @@ RUN apt-get update && apt-get install -y \
avahi-daemon \
&& rm -rf /var/lib/apt/lists/*

# Override the distro-shipped decklink plugin with our patched build, which
# adds the `capture-group` property to decklinkvideosrc for synchronized
# capture group support (see tools/patched-gstreamer-plugins/README.md).
# The patched .so is forward-ABI-compatible with the runtime gstreamer 1.26
# in this image because it was built against the 1.22 ABI.
ARG PATCHED_PLUGINS_TAG=patched-plugins-v1.0-gst1.22.12
ARG PATCHED_PLUGINS_REPO=Eyevinn/strom
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& curl -fsSL \
"https://github.com/${PATCHED_PLUGINS_REPO}/releases/download/${PATCHED_PLUGINS_TAG}/libgstdecklink-linux-${TARGETARCH}.so" \
-o "/usr/lib/$(uname -m)-linux-gnu/gstreamer-1.0/libgstdecklink.so" \
&& apt-get remove -y curl \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*

# Copy the compiled binaries from backend-builder to /app
COPY --from=backend-builder /app/target/release/strom /app/strom
COPY --from=backend-builder /app/target/release/strom-mcp-server /app/strom-mcp-server
Expand Down
46 changes: 46 additions & 0 deletions backend/src/blocks/builtin/decklink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@

use crate::blocks::{BlockBuildContext, BlockBuildError, BlockBuildResult, BlockBuilder};
use gstreamer as gst;
use gstreamer::prelude::*;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use strom_types::{
block::{StreamMode, *},
element::ElementPadRef,
Expand Down Expand Up @@ -138,6 +141,14 @@ impl BlockBuilder for DeckLinkInputBuilder {
})
.unwrap_or(false);

let synchronized_capture = properties
.get("synchronized_capture")
.and_then(|v| match v {
PropertyValue::Bool(b) => Some(*b),
_ => None,
})
.unwrap_or(false);

let videosrc_id = format!("{}:decklinkvideosrc", instance_id);
let videosrc = gst::ElementFactory::make("decklinkvideosrc")
.name(&videosrc_id)
Expand All @@ -149,6 +160,27 @@ impl BlockBuilder for DeckLinkInputBuilder {
.build()
.map_err(|e| BlockBuildError::ElementCreation(format!("decklinkvideosrc: {}", e)))?;

// BMD synced capture groups only work for inputs armed atomically as
// a unit, which means all members must live in the same pipeline. We
// derive the capture group ID from the flow ID so two synchronized_
// capture blocks in the same flow always land in the same group, and
// can never collide with another flow's group on the same card.
// The capture-group property only exists on our patched plugin; if
// the runtime has the stock plugin we just skip silently.
if synchronized_capture && videosrc.has_property("capture-group") {
let flow_id = properties
.get("_flow_id")
.and_then(|v| match v {
PropertyValue::String(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
let mut hasher = DefaultHasher::new();
flow_id.hash(&mut hasher);
let group_id = ((hasher.finish() & 0x7FFF_FFFF) as i32).max(0);
videosrc.set_property("capture-group", group_id);
}

let mut elements: Vec<(String, gst::Element)> = vec![(videosrc_id.clone(), videosrc)];
let mut internal_links: Vec<(ElementPadRef, ElementPadRef)> = Vec::new();

Expand Down Expand Up @@ -202,6 +234,7 @@ impl BlockBuilder for DeckLinkInputBuilder {
.map_err(|e| {
BlockBuildError::ElementCreation(format!("decklinkaudiosrc: {}", e))
})?;

elements.push((audiosrc_id, audiosrc));

info!(
Expand Down Expand Up @@ -660,6 +693,19 @@ fn decklink_input_definition() -> BlockDefinition {
},
live: false,
},
ExposedProperty {
name: "synchronized_capture".to_string(),
label: "Synchronized Capture".to_string(),
description: "Sample-align frames with other DeckLink Input blocks in this flow that also have synchronized capture enabled. The hardware arms all members atomically so their frames share a common time origin — useful for cross-port audio cancellation, mixing, or multi-camera capture from a single card. Requires a card supporting BMDDeckLinkSupportsSynchronizeToCaptureGroup (e.g. Quad 2, 8K Pro) and the patched gst-plugins-bad decklink plugin; silently ignored on stock builds or unsupported cards. Capture groups are flow-scoped: blocks in different flows cannot share a group (BMD's hardware-level sync requires all members to be armed in the same start cycle, which only happens within one pipeline). KNOWN BMD DRIVER QUIRK: stopping a synchronized-capture pipeline blocks for ~12 s before the stop completes; this is a Blackmagic driver-internal timeout reproducible in their own SDK examples, not a strom bug. Subsequent restart works normally.".to_string(),
property_type: PropertyType::Bool,
default_value: Some(PropertyValue::Bool(false)),
mapping: PropertyMapping {
element_id: "_block".to_string(),
property_name: "synchronized_capture".to_string(),
transform: None,
},
live: false,
},
ExposedProperty {
name: "audio_connection".to_string(),
label: "Audio Connection".to_string(),
Expand Down
2 changes: 2 additions & 0 deletions tools/patched-gstreamer-plugins/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
gstreamer-src/
69 changes: 69 additions & 0 deletions tools/patched-gstreamer-plugins/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Reproducible build environment for patched GStreamer plugins.
#
# Debian bookworm has gstreamer 1.22.x dev packages out of the box, which
# is the ABI we pin to (see ../gstreamer-version.txt). Plugins built here
# load in any GStreamer 1.22+ runtime via forward ABI compat.
#
# The image is multi-arch via docker buildx + QEMU. TARGETARCH is set by
# buildx to "amd64" or "arm64" and is consumed by the build steps.

FROM debian:bookworm AS builder

ARG GSTREAMER_VERSION=1.22.12
ARG TARGETARCH

RUN apt-get update && apt-get install --no-install-recommends -y \
build-essential \
ca-certificates \
git \
ninja-build \
pkg-config \
python3 \
python3-pip \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
&& rm -rf /var/lib/apt/lists/*

# Bookworm ships meson 1.0; we need >= 1.4 for the gst-plugins-bad meson.build.
RUN pip3 install --break-system-packages --no-cache-dir 'meson==1.5.2'

WORKDIR /work

# Pinned source tree from upstream GStreamer monorepo. We only need the
# gst-plugins-bad subdirectory plus its meson wraps; cloning the full
# monorepo at the tag is the simplest reproducible path.
RUN git clone --depth 1 --branch ${GSTREAMER_VERSION} \
https://gitlab.freedesktop.org/gstreamer/gstreamer.git src \
&& cd src \
&& git rev-parse HEAD > /work/gstreamer-rev.txt

COPY patches/ /work/patches/

# Apply every patch in lexical order. Patches must be in unified diff
# format relative to the GStreamer monorepo root.
RUN cd src && for p in $(ls /work/patches/*.patch | sort); do \
echo "Applying $p" && \
git apply --check "$p" && git apply "$p" \
; done

# Standalone gst-plugins-bad build (linked against system gstreamer 1.22),
# decklink only. Auto-features=disabled keeps the build small.
RUN cd src/subprojects/gst-plugins-bad \
&& meson setup /work/build . \
--buildtype=release \
--auto-features=disabled \
-Ddecklink=enabled \
-Dtests=disabled \
-Dexamples=disabled \
-Dnls=disabled \
-Dintrospection=disabled \
-Ddoc=disabled \
-Dorc=disabled \
-Dgpl=enabled \
&& meson compile -C /work/build

# Final stage: scratch image whose only role is to expose the built
# artifacts to `docker buildx build --output`.
FROM scratch AS export
COPY --from=builder /work/build/sys/decklink/libgstdecklink.so /libgstdecklink.so
COPY --from=builder /work/gstreamer-rev.txt /gstreamer-rev.txt
152 changes: 152 additions & 0 deletions tools/patched-gstreamer-plugins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Patched GStreamer plugins

This directory holds patches we apply on top of upstream GStreamer plugins
plus a reproducible Docker-based build environment that produces them as
shared libraries (`.so`) for `linux/amd64` and `linux/arm64`.

The output binaries are uploaded as assets on a GitHub release in this
repo. Strom's main Docker image fetches the right one for its target
architecture and drops it into the GStreamer plugin path, overriding the
distro-shipped plugin.

This setup is intended for patches that we don't (yet) plan to upstream
and that change rarely. Right now there is exactly one patch — see
[Current patches](#current-patches).

## Why we do it this way

* **Docker-based build** — pinned base image, pinned GStreamer source
version, pinned build tool versions. Anyone on the team can rebuild and
get a byte-identical (or close-to-identical) artifact.
* **Build once, ship binary** — patches change very rarely. We don't want
every strom CI build to spend the time recompiling GStreamer plugins.
After a manual one-shot build + GitHub release upload, the strom build
pipeline just does `curl ... | install`.
* **GStreamer ABI stability** — GStreamer maintains forward ABI compat
within the 1.x line, so a plugin built against 1.22 loads cleanly in
1.22, 1.24, 1.26 and beyond.

## Layout

```
tools/patched-gstreamer-plugins/
├── Dockerfile # multi-arch builder
├── build.sh # one-shot wrapper around docker buildx
├── gstreamer-version.txt # pinned upstream GStreamer source version
├── patches/ # unified diffs applied to the upstream tree
│ └── 0001-…patch
└── dist/ # build output, .gitignore'd
├── linux_amd64/
└── linux_arm64/
```

## Building

Requires Docker with `buildx` available (modern Docker Desktop / Linux
with `docker-ce` + `docker-buildx-plugin`).

```sh
./build.sh
```

The first run sets up a buildx builder named
`patched-gstreamer-plugins-builder` and installs QEMU emulators for
cross-arch builds. Subsequent runs reuse them.

The arm64 build runs under QEMU emulation on x86_64 hosts, which is slow
(typically 10–20 minutes). On native arm64 hosts both builds are fast.

Output: `dist/linux_amd64/libgstdecklink.so` and
`dist/linux_arm64/libgstdecklink.so` (plus a `gstreamer-rev.txt` per arch
recording the exact upstream commit the plugin was built from).

## Releasing

1. Run `./build.sh`.
2. Verify the output by loading it locally with `gst-inspect-1.0`:

```sh
GST_PLUGIN_PATH=$PWD/dist/linux_amd64 gst-inspect-1.0 decklinkvideosrc \
| grep -A2 capture-group
```

3. Create a GitHub release on this repo. Tag convention:
`patched-plugins-v<X.Y>-gst<gst-version>` — e.g.
`patched-plugins-v1.0-gst1.22.12`. Bump `<X.Y>` whenever any patch in
`patches/` changes; keep the `gst<version>` suffix in sync with
`gstreamer-version.txt`.

4. Upload the four artifacts:

```
libgstdecklink-linux-amd64.so ← dist/linux_amd64/libgstdecklink.so
libgstdecklink-linux-arm64.so ← dist/linux_arm64/libgstdecklink.so
gstreamer-rev-linux-amd64.txt ← dist/linux_amd64/gstreamer-rev.txt
gstreamer-rev-linux-arm64.txt ← dist/linux_arm64/gstreamer-rev.txt
```

Renaming with the platform suffix avoids name collisions in the
release UI.

## Consuming from strom's Dockerfile

Strom's main `Dockerfile` overrides the distro-shipped decklink plugin
with our patched build, picking the right architecture automatically:

```Dockerfile
ARG TARGETARCH
ARG PATCHED_PLUGINS_TAG=patched-plugins-v1.0-gst1.22.12
ARG PATCHED_PLUGINS_REPO=eyevinntechnology/strom

RUN curl -fsSL \
"https://github.com/${PATCHED_PLUGINS_REPO}/releases/download/${PATCHED_PLUGINS_TAG}/libgstdecklink-linux-${TARGETARCH}.so" \
-o "/usr/lib/$(dpkg-architecture -q DEB_HOST_MULTIARCH)/gstreamer-1.0/libgstdecklink.so"
```

`TARGETARCH` is set automatically by `docker buildx` and matches our
release naming (`amd64` / `arm64`).

## Adding a new patch

1. Edit the GStreamer source locally however you like (recommended: a
monorepo checkout at the version pinned in `gstreamer-version.txt`).
2. `git diff > tools/patched-gstreamer-plugins/patches/NNNN-short-description.patch`
from the gstreamer repo root. Patches must be unified diffs relative
to the GStreamer monorepo root and must apply cleanly with
`git apply --check`.
3. Run `./build.sh` and confirm the resulting `.so` loads in
`gst-inspect-1.0`.
4. Bump the release tag and re-release as above.

## Current patches

### `0001-decklink-add-capture-group-property-and-sync-control.patch`

Exposes BMD's *synchronized capture group* feature
(`bmdVideoInputSynchronizeToCaptureGroup` flag,
`bmdDeckLinkConfigCaptureGroup` config ID) as a `capture-group` int
property on `decklinkvideosrc`. When two or more inputs share a
non-negative `capture-group` value, BMD arms them atomically at the same
hardware vblank, so their captured frames have a common time origin and
audio/video can be sample-aligned across SDI ports.

The plugin's element model otherwise calls `StartStreams()` /
`StopStreams()` once per element. With sync enabled, the BMD SDK manual
specifies these should be called exactly once per group, on any one
member. The patch adds a refcounted leader-tracking layer so the same
`IDeckLinkInput` instance that calls `StartStreams` also calls
`StopStreams`, regardless of which element's state-change handler runs
first.

A known BMD driver quirk: `StopStreams()` on an input that was enabled
with `bmdVideoInputSynchronizeToCaptureGroup` blocks for ~12 s before
returning (reproducible in BMD's own `SynchronizedCapture` SDK example;
see [forum thread 190441][forum]). We accept the wait — bypassing
`StopStreams()` would violate the documented teardown order
(`StopStreams` → `DisableVideoInput`) and break subsequent pipeline
restarts with caps not-negotiated errors.

[forum]: https://forum.blackmagicdesign.com/viewtopic.php?f=12&t=190441

The strom-side glue (block property exposure, runtime activation) lives
in `backend/src/blocks/builtin/decklink.rs`.
55 changes: 55 additions & 0 deletions tools/patched-gstreamer-plugins/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
#
# Build patched GStreamer plugins for linux/amd64 and linux/arm64.
#
# Run this once when patches change. Output:
# dist/linux_amd64/libgstdecklink.so
# dist/linux_arm64/libgstdecklink.so
# dist/linux_amd64/gstreamer-rev.txt
# dist/linux_arm64/gstreamer-rev.txt
#
# Then upload the dist/ files to a GitHub release in this repo, tagged
# `patched-plugins-vX.Y-gst<gst-version>`. The strom Dockerfile pulls
# the right .so for its target architecture from that release URL.
#
# Requires: docker with buildx + QEMU (for cross-arch emulation).

set -euo pipefail

cd "$(dirname "$0")"

GSTREAMER_VERSION="$(cat gstreamer-version.txt)"
PLATFORMS="linux/amd64,linux/arm64"
BUILDER_NAME="patched-gstreamer-plugins-builder"

echo "== Patched GStreamer plugins build =="
echo "Pinned GStreamer source: ${GSTREAMER_VERSION}"
echo "Target platforms: ${PLATFORMS}"
echo

# Ensure buildx + QEMU multi-arch emulation are available. Idempotent.
if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
echo "== Creating buildx builder '${BUILDER_NAME}' =="
docker buildx create --name "${BUILDER_NAME}" --use
docker run --privileged --rm tonistiigi/binfmt --install all >/dev/null
else
docker buildx use "${BUILDER_NAME}"
fi

rm -rf dist
mkdir -p dist

echo "== Building =="
docker buildx build \
--platform "${PLATFORMS}" \
--build-arg "GSTREAMER_VERSION=${GSTREAMER_VERSION}" \
--target export \
--output "type=local,dest=dist" \
.

echo
echo "== Done =="
find dist -type f -printf '%p (%s bytes)\n'
echo
echo "Next: upload these files to a GitHub release in this repo,"
echo " e.g. tagged patched-plugins-v1.0-gst${GSTREAMER_VERSION}"
1 change: 1 addition & 0 deletions tools/patched-gstreamer-plugins/gstreamer-version.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.22.12
Loading
Loading