Skip to content

Save and Restore a Media Blocks Pipeline as JSON in C

Media Blocks SDK .Net

Table of Contents

Overview

A Media Blocks pipeline is normally built in code: you construct blocks, connect their pads, and start the pipeline. The persistence layer lets you do the same thing with a document — an ordinary JSON file that names the blocks, their settings and their connections. That document can be written by your application, edited by hand, shipped as a preset, or produced by a visual editor.

Three types do the work, and each has a single responsibility:

Type What it does
MediaBlocksPipelineJsonSerializer JSON text ⇄ MediaBlocksPipelineDocument
MediaBlocksPipelineValidator checks a document before anything is built
MediaBlocksPipelineMaterializer document → live MediaBlocksPipeline

The reverse direction — live pipeline → document — is MediaBlocksPipelineSnapshot, reached through MediaBlocksPipeline.ToDocument().

flowchart LR
    JSON[JSON file] -->|Load| Document[MediaBlocksPipelineDocument]
    Document -->|Validate| Diagnostics[Diagnostics]
    Document -->|Materialize| Pipeline[MediaBlocksPipeline]
    Pipeline -->|ToDocument| Document
    Document -->|Serialize| JSON

The document model

MediaBlocksPipelineDocument is a plain DTO tree:

  • SchemaVersion — the document format version. Older versions are migrated on load.
  • PipelineId, Name and optional pipeline-level settings.
  • Blocks — a MediaBlockDocument each: Id (a Guid), Type (the MediaBlockType value), TypeName, Name, and a Settings payload.
  • Connections — a MediaBlockConnectionDocument each, with a From and a To MediaBlockPadReference.

A pad reference is a block id plus a pad id, and the pad id follows one convention:

Pad id Meaning
input / output the block's main pad
input:N / output:N element N of the block's Inputs / Outputs list

The indexed form is the one to prefer when you generate documents: it addresses any block, while the unindexed form needs a main pad that not every block has. For a muxing sink such as MP4SinkBlock, input:0 and input:1 are the video and audio streams.

A minimal document:

{
  "schemaVersion": 1,
  "pipeline": { "id": "6f0d1b6e-1f7a-4f1e-9a1a-0a0f3d5f7a11", "name": "Player" },
  "blocks": [
    {
      "id": "10000000-0000-4000-8000-000000000001",
      "type": 3,
      "typeName": "UniversalSource",
      "name": "Media File",
      "settings": { "uri": "clips/sample.mp4", "renderVideo": true, "renderAudio": true }
    },
    {
      "id": "10000000-0000-4000-8000-000000000002",
      "type": 21,
      "typeName": "VideoRenderer",
      "name": "Preview"
    }
  ],
  "connections": [
    {
      "from": { "blockId": "10000000-0000-4000-8000-000000000001", "padId": "output:0" },
      "to":   { "blockId": "10000000-0000-4000-8000-000000000002", "padId": "input" }
    }
  ]
}

Building a pipeline from JSON

The shortest path replaces an existing pipeline's contents:

var pipeline = new MediaBlocksPipeline();

var result = await pipeline.LoadJsonAsync(
    File.ReadAllText("player.json"),
    resolver: null,
    baseDirectory: Path.GetDirectoryName(Path.GetFullPath("player.json")));

if (!result.Success)
{
    foreach (var diagnostic in result.Diagnostics)
    {
        Console.WriteLine(diagnostic);
    }

    return;
}

await pipeline.StartAsync();

LoadJsonAsync requires the pipeline to be stopped, and it never leaves a half-built graph behind: if materialization fails partway, the pipeline is cleared.

To build a pipeline without owning one first, use the materializer directly. It returns the pipeline and the mapping from document ids to live blocks, which is what you need to reach a block afterwards:

var loaded = MediaBlocksPipelineJsonSerializer.Load(json);
var result = MediaBlocksPipelineMaterializer.Materialize(loaded.Document, resolver: null, baseDirectory: folder);

if (result.Success)
{
    var overlay = (TextOverlayBlock)result.Blocks[captionId];
    overlay.Settings.Text = "Live";
}

Network sources are probed when the pipeline starts

A document carries a source's endpoint, never its media information: restoring one contacts nothing. Two sources need that information before they can be built — RTSPRAWSourceBlock picks its depayloader and parser from the stream layout, and the desktop NDISourceBlock creates its converters from the stream counts — so StartAsync probes them for you, once, before the graph is built. The camera or sender has to be reachable at that moment; if it is not, StartAsync returns false and logs which source could not be read.

The synchronous Start has nowhere to await a probe and does not do this, and neither do the VideoCaptureCoreX and MediaPlayerCoreX engines, which build their graph through it. Restore a document whose pipeline you intend to start that way by replacing those settings with ones built through RTSPRAWSourceSettings.CreateAsync / NDISourceSettings.CreateAsync after materialization.

Audio devices are matched back to this machine

A document stores what identifies an audio device — its name, the API it belongs to, the endpoint path where the platform publishes one, and, on macOS, the stable CoreAudio unique-id — and never the enumerator's live handle, which means nothing outside the process that made it. Materialization matches that identity against the devices this machine has now and hands the block a live device again. That is also why a restored pipeline opens the right endpoint: the numeric CoreAudio id and the WASAPI device path are reassigned between runs, and the match re-reads the current ones.

If the device is gone, the pipeline still builds — on another device of the same API, reported as an MBS063 warning naming both. If the machine has no device of that API at all, the block keeps the document's identity and materialization reports MBS064; the block will not open anything.

Matching enumerates devices, which on a cold process starts a GStreamer device monitor and can take seconds, so call Materialize off the UI thread — or enumerate once beforehand. Inputs and outputs are cached separately: AudioSourcesAsync warms capture devices, AudioOutputsAsync warms renderers, and on Apple platforms the asynchronous capture call is also the one that asks for microphone permission. The synchronous match inside Materialize does neither.

Validating before you build

Validate answers the same questions materialization would, without constructing anything — so a UI can show a document's problems while the user edits it:

var diagnostics = MediaBlocksPipelineValidator.Validate(
    document,
    resolver: null,
    baseDirectory: folder);

var errors = diagnostics.Where(d => d.Severity == MediaBlocksDiagnosticSeverity.Error).ToList();

It checks the document's structure, that every block type is known to this build, that each block has a construction path, that pad references parse and point at pads the block actually has, that no feedback cycle exists, and — when you pass a baseDirectory — that the files a source reads are present.

Pass baseDirectory: null to skip the file-system checks entirely; that is the default, and it keeps the behaviour of a document you validate before its assets are in place.

Capturing a live pipeline

ToDocument() walks the live graph and produces a document. It is safe while the pipeline is running — the block list is taken under the pipeline's own lock and nothing is changed — which makes it the save-during-Run path:

var snapshot = pipeline.ToDocument();

if (!snapshot.Complete)
{
    foreach (var diagnostic in snapshot.Diagnostics)
    {
        Console.WriteLine(diagnostic);
    }
}

await MediaBlocksPipelineJsonSerializer.SaveFileAsync(snapshot.Document, "captured.json");

ToJson() and SaveJsonAsync(path) are the one-line forms; they send the diagnostics to the SDK log instead of returning them.

The graph is captured exactly; the settings are not always

A block's identity, type and connections can be read from any block, so the shape of a pipeline always round-trips. Its configuration can only be read from blocks that expose their settings — 153 of this SDK's 404 block types do. For the rest there is nothing to read, and rather than write a document that looks complete and rebuilds the block on its defaults, the snapshot reports an MBS060 diagnostic and leaves that block's settings empty.

Check MediaBlocksPipelineSnapshotResult.Complete before treating a snapshot as a faithful copy. A pipeline that was itself built from a document round-trips fully, because materialization stamps the document's block ids onto the blocks it creates.

Relative asset paths

Settings that name a file may be relative to the document. Pass the document's folder as baseDirectory, and both validation and materialization resolve them against it:

var folder = Path.GetDirectoryName(Path.GetFullPath(documentPath));
var result = MediaBlocksPipelineMaterializer.Materialize(document, resolver: null, baseDirectory: folder);

The rewriting covers Uri-typed settings and string settings named *Path, *File, Filename or *Location. Values that name an endpoint rather than a file are left alone — anything with a scheme (rtsp://, srt://:8888/), an IPv4 literal, or a first segment that reads as a host name (cam.local/stream.m3u8). A folder whose name reads like a host is ambiguous, and the validator says so with an MBS057 warning; prefix such a value with ./ to force the folder reading.

Diagnostics

Every entry point returns MediaBlocksDiagnostic values rather than throwing, so a host can show the whole list at once. Each carries a Severity, a Code, a Message and the BlockId it belongs to.

Code Meaning
MBS031 unknown block type — this build's catalog has no such block
MBS032 the block has no construction path
MBS040 the block could not be constructed
MBS048 / MBS054 a pad reference could not be resolved, or a pad is used twice
MBS049 the settings have no default constructor and the document carries no usable payload
MBS050 the settings payload could not be deserialized
MBS055 a file a source reads is not there
MBS056 a resources entry could not be applied
MBS057 a value that reads as a network endpoint was left as written
MBS060 a block did not expose its settings to a snapshot
MBS061 a block could not be named in a snapshot
MBS062 a connection could not be expressed in a snapshot
MBS063 the audio device a block names is gone, and another device of the same API is used instead
MBS064 the audio device a block names could not be attached at all — nothing of that API is present, or the lookup failed

Discovering block types

MediaBlockCatalog is the index the persistence layer builds on, and it is useful on its own: it lists every block this build carries, with its settings type, its editable properties and an availability probe.

foreach (var descriptor in MediaBlockCatalog.All)
{
    Console.WriteLine($"{descriptor.TypeName} - {descriptor.DisplayName} ({descriptor.Category})");
}

var mp4 = MediaBlockCatalog.Get(MediaBlockType.MP4Sink);
Console.WriteLine(mp4.IsSink);                 // true - it ends a branch
Console.WriteLine(mp4.AcceptsDynamicInputs);   // true - one input pad per stream

Demos

A runnable console sample - build a pipeline in code, save it as JSON, rebuild it from that JSON and compare the two - ships with the SDK samples under Media Blocks SDK / Console.