How to write a new MediaIO subclass — picking a strategy class, implementing executeCmd, declaring ports during open, populating cached state, and registering the backend with the factory system.
For an introduction to using the framework as a consumer, see the MediaIO User Guide. The user guide explains the always-async API, port connections, and signal contracts; this guide assumes you know that material and focuses on the implementer's surface.
You inherit from one of the three concrete strategy classes — never from MediaIO or CommandMediaIO directly. The strategy class controls what thread your executeCmd runs on. The CommandMediaIO layer routes commands by type to the right overload; MediaIO owns the cached state and the public API.
| Strategy | Use when |
|---|---|
InlineMediaIO | The backend is fast, deterministic, and does no I/O. Tests, in-memory transforms, generators driven entirely by config. submit runs on the caller's thread. |
SharedThreadMediaIO | The backend does CPU-bound work that needs serialization but does not block on external resources. The compute backends (CSC, SRC, FrameSync, VideoEncoder, VideoDecoder, FrameBridge, NullPacing, Burn) all live here. Strand serializes commands per-instance; the shared pool keeps thread count bounded across the process. |
DedicatedThreadMediaIO | The backend can block on syscalls (file I/O, sockets, capture devices, condvars). Owns its own worker thread so a slow backend cannot starve the shared pool. The I/O backends (ImageFile, AudioFile, QuickTime, V4L2, RTP, MjpegStream) live here. |
Default to SharedThreadMediaIO for compute and DedicatedThreadMediaIO for blocking I/O. InlineMediaIO is mostly for tests; production backends almost always want the strand or the worker thread.
Every backend has a paired MediaIOFactory subclass that the framework consults for identity, config metadata, and bridging. The factory is registered via PROMEKI_REGISTER_MEDIAIO_FACTORY:
The macro registers a singleton instance with the global MediaIOFactory registry at static-init time. From that point on, MediaIOFactory::findByName("Foo") and the convenience helpers on MediaIO::create / createForFileRead / createFromUrl will find the backend.
What the framework expects from each virtual:
name() / description() — identity. name() is the lookup key. displayName() defaults to name(); override it when you want a different label in UI.extensions() — file extensions the backend claims (no leading dot). Empty if it doesn't speak files.schemes() — URL schemes routed to this backend (lowercase, no trailing colon). Empty if it doesn't speak URLs.canBeSource() / canBeSink() / canBeTransform() — role flags consulted by the planner and by the user-facing MediaIO::create* helpers.create(config, parent) — construct a fresh, un-opened instance. Pass parent through to MediaIO's constructor. Do not open here; executeCmd(Open) does that.configSpecs() — the MediaConfig::SpecMap mapping every MediaConfig::ID the backend understands to a VariantSpec that carries defaults, accepted types, ranges, and descriptions. Used by the planner and the validation helpers.urlToConfig(url, outConfig) — translate a parsed URL into a MediaConfig. Default returns Error::NotSupported.bridge(from, to, outConfig, outCost) — only override on transform backends. Returns true and populates the outputs when the transform can convert from to to.Note: defaultConfig, unknownConfigKeys, and validateConfigKeys are static helpers on MediaIOFactory keyed by backend name — not virtuals you override. They derive their answers from configSpecs() (defaults from each spec's default value, "unknown" by diffing against the spec map). You get them for free once you've populated configSpecs().
CommandMediaIO exposes one virtual per command type. Override the ones your backend supports; the defaults return sensible errors for the rest:
The seven-step flow:
io->open(), source->readFrame(), …).MediaIO / MediaIOSource / MediaIOSink / MediaIOPortGroup) builds the typed MediaIOCommand, wraps it in a MediaIORequest, and calls the protected submit().submit() records QueueWaitDuration and either short-circuits to Error::Cancelled (if the request was cancelled before dispatch) or calls dispatch(cmd).CommandMediaIO::dispatch does a type switch and routes to the matching executeCmd(...) overload.executeCmd runs. Read the inputs, do the work, populate the outputs, return an Error.Error into cmd->result and calls MediaIO::completeCommand(cmd).completeCommand applies the output fields to the cached state, emits the relevant signals, and resolves the request's promise. Order is fixed: cache → signals → promise.Hard rule: backends never call completeCommand. Backends populate output fields and return an Error. The strategy class handles the rest.
submit() is not re-entrant from inside executeCmd. Calling it on this from within your own hook would deadlock the strand or serialize behind itself on the dedicated thread. Backends that want to chain operations expose them at the public API level so the caller composes via MediaIORequest::then(...).
Open is the only place a backend may construct ports. Use the CommandMediaIO helpers:
The framework reads the cached mediaDesc / audioDesc / metadata from the first source (or first sink for sink-only backends) post-open, and the frameRate from the first port group.
Open-failure cleanup contract. If your executeCmd(Open) returns non-Ok, CommandMediaIO::dispatch automatically calls executeCmd(Close) on the same instance to give you a chance to release any partially-allocated resources. The same Close handler also runs after a successful open, so write it once defensively (check each handle for validity before releasing).
If your backend detects mid-stream descriptor changes (VFR, segmented streams, format-changing live captures), set cmd.mediaDescChanged = true and fill cmd.updatedMediaDesc before returning. The framework will:
_mediaDesc / _audioDesc / _metadata / _frameRate.Metadata::MediaDescChanged = true on the returned frame.descriptorChanged signal.If a write carries a non-empty Frame::configUpdate delta (e.g. encoder bitrate ramp), dispatch calls your configChanged hook on the same thread, just before executeCmd(Write). The default is a no-op:
Apply the delta to your encoder/transcoder/... state.
Seek and setStep automatically discard any prefetched reads sitting in the read cache (the framework cancels the in-flight queue and drops cached results), so the next read starts from the new position.
Define the param IDs as static const MediaIOParamsID members on the backend's class so callers can reference them by symbol.
MediaIOStats defines well-known cumulative-aggregate keys (FramesDropped, FramesRepeated, FramesLate, QueueDepth, QueueCapacity, BytesPerSecond, AverageLatencyMs, PeakLatencyMs, LastErrorMessage). Cross-backend tooling relies on these; backends are free to add their own keys.
Three states (only state 3 is the backend's responsibility):
Error::Cancelled without calling executeCmd. Free.executeCmd runs to completion. The wrapping request resolves with whatever the operation actually returned.cancelBlockingWork():Common interruption primitives:
read/accept.std::condition_variable to wake a wait loop.The interrupted executeCmd should return a sensible error (Error::Cancelled, Error::Interrupted, or whatever the syscall produced). The framework writes whichever error you returned into cmd->result.
executeCmd calls on the same instance never overlap. The strategy class serializes them.executeCmd without locks.submit() (or any public API on this) from inside your own executeCmd. Doing so would deadlock the strand or serialize behind itself on the dedicated thread. Chain operations at the public API level so the caller composes via MediaIORequest::then(...).executeCmd themselves — typically by buffering into a Queue<Frame> that executeCmd(Read) drains.Capture devices produce frames on their own clock and need to be decoupled from the user's readFrame() calls.
executeCmd(Open), start a callback or thread that feeds a bounded Queue<Frame>.executeCmd(Read) pops from the queue (blocking if empty), stamps per-frame metadata, returns the frame.cmd.defaultPrefetchDepth = 2..4 so MediaIO keeps a small pipeline of in-flight reads for latency hiding.executeCmd(Stats).Backends that block in capture syscalls should derive from DedicatedThreadMediaIO and implement cancelBlockingWork() to wake the syscall on close.
If your backend is a transform that the planner should be allowed to auto-insert (CSC, SRC, FrameSync, VideoEncoder, VideoDecoder, FrameBridge), implement MediaIOFactory::bridge:
The default implementation returns false (i.e. "I'm not a
planner-insertable bridge"). See MediaPipelinePlanner for how the planner uses these.
Two header-only test helpers in tests/unit/mediaio_test_helpers.h:
promeki::tests::InlineTestMediaIO : InlineMediaIO — callback-driven executeCmd overrides fronted by std::function hooks (onOpen, onClose, onRead, onWrite, onSeek, onParams, onStats). Defaults are sensible. Use this for canned-response unit tests.promeki::tests::PausedTestMediaIO : CommandMediaIO — manually pumped helper. submit() queues commands without dispatching; the test calls processOne() / processAll() to drain. Used for cancellation tests so pre-dispatch cancel is deterministic.Most backend tests inherit from InlineMediaIO directly with custom executeCmd overrides — see tests/unit/mediaio_negotiation.cpp for an example. Wrap the inline subclass with a matching MediaIOFactory subclass (also locally defined in the test file) so the planner-side surface (canBeSource, bridge, etc.) can be exercised.
include/promeki/mediaio.h + src/proav/mediaio.cpp — abstract MediaIO + cached state + completeCommandinclude/promeki/commandmediaio.h + src/proav/commandmediaio.cpp — executeCmd virtuals + dispatch + port helpersinclude/promeki/inlinemediaio.h + .cpp — inline strategyinclude/promeki/sharedthreadmediaio.h + .cpp — strand strategyinclude/promeki/dedicatedthreadmediaio.h + .cpp — dedicated-thread strategyinclude/promeki/mediaiofactory.h + .cpp — abstract factory + registryinclude/promeki/mediaiocommand.h + .cpp — command hierarchy + the PROMEKI_MEDIAIO_COMMAND macroinclude/promeki/mediaiorequest.h + .cpp — promise/future-style request handle (wait, wait(ms), then, cancel, commandAs<T>)include/promeki/mediaio{port,source,sink,portgroup,portconnection}.hinclude/promeki/mediaioreadcache.h — per-source read result cacheinclude/promeki/mediaiostats.h — VariantDatabase for per-cmd