initial commit
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
# DMF Studio — Implementation Plan
|
||||
|
||||
## Product Definition
|
||||
|
||||
**DMF Studio** is a node-based visual production platform for the Dynamic Media Facility architecture. It provides a canvas where users create, connect, and control containerized Media Functions that exchange live video via the MXL shared-memory layer.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Choice |
|
||||
|---|---|
|
||||
| Language | C++20 |
|
||||
| Build | CMake 3.24+, vcpkg |
|
||||
| MXL SDK | Git submodule (github.com/dmf-mxl/mxl) |
|
||||
| WebSocket | libwebsockets |
|
||||
| JSON | nlohmann/json |
|
||||
| Logging | spdlog |
|
||||
| GPU compute | Vulkan (headless render + compute shaders) |
|
||||
| SDI I/O | Blackmagic DeckLink SDK |
|
||||
| Color conversion | libyuv |
|
||||
| JPEG encoding | libjpeg-turbo |
|
||||
| Testing | Catch2 |
|
||||
| Frontend (phase 4) | Vue 3 + Vue Flow + Vite + Pinia |
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
1. **Separate processes per node** — crash isolation, true DMF model
|
||||
2. **MXL is the ONLY data path between nodes** — zero-copy shared memory
|
||||
3. **Engine owns the graph, nodes own the media** — clean separation
|
||||
4. **Engine generates flow IDs, nodes create MXL readers/writers** — centralized routing
|
||||
5. **WebSocket control per node** — NMOS IS-12 aligned, bidirectional
|
||||
6. **All nodes use `libdmf-node` skeleton** — consistent lifecycle
|
||||
7. **Node binary = 1 C++ class + 1 main.cpp** — minimal per-node code
|
||||
8. **Vulkan for GPU, CPU fallback available** — GPU-accelerated but not GPU-required
|
||||
9. **Video only for MVP** — audio deferred
|
||||
10. **Backend first, frontend last** — validate with curl + minimal HTML before building UI
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dmf-studio/
|
||||
├── CMakeLists.txt
|
||||
├── vcpkg.json
|
||||
├── vcpkg-configuration.json
|
||||
│
|
||||
├── extern/
|
||||
│ └── mxl/ # git submodule
|
||||
│
|
||||
├── libs/
|
||||
│ ├── dmf-node/ # Node skeleton library (static)
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ ├── include/dmf-node/
|
||||
│ │ │ ├── node.hpp # Abstract Node interface
|
||||
│ │ │ ├── port.hpp # PortDef, PortDirection, MediaType
|
||||
│ │ │ ├── node_runner.hpp # CLI, MXL init, control WS, main loop
|
||||
│ │ │ ├── control_server.hpp # WS server per-node
|
||||
│ │ │ └── types.hpp # NodeId, PortId, FlowId aliases
|
||||
│ │ └── src/
|
||||
│ │ ├── node_runner.cpp
|
||||
│ │ └── control_server.cpp
|
||||
│ │
|
||||
│ └── dmf-engine/ # Engine library (static)
|
||||
│ ├── CMakeLists.txt
|
||||
│ ├── include/dmf-engine/
|
||||
│ │ ├── graph.hpp # Graph model: nodes, edges
|
||||
│ │ ├── flow_manager.hpp # Flow UUID gen, NMOS flow defs
|
||||
│ │ ├── process_manager.hpp # Spawn/monitor/kill node processes
|
||||
│ │ ├── api_server.hpp # REST + WS to browser
|
||||
│ │ └── types.hpp # Graph-level types
|
||||
│ └── src/
|
||||
│ ├── graph.cpp
|
||||
│ ├── flow_manager.cpp
|
||||
│ ├── process_manager.cpp
|
||||
│ └── api_server.cpp
|
||||
│
|
||||
├── nodes/
|
||||
│ ├── passthrough/
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ └── src/
|
||||
│ │ ├── main.cpp
|
||||
│ │ ├── passthrough_node.hpp
|
||||
│ │ └── passthrough_node.cpp
|
||||
│ │
|
||||
│ ├── test-source/
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ └── src/
|
||||
│ │ ├── main.cpp
|
||||
│ │ ├── test_source_node.hpp
|
||||
│ │ ├── test_source_node.cpp
|
||||
│ │ ├── vulkan_context.hpp
|
||||
│ │ ├── vulkan_context.cpp
|
||||
│ │ ├── vulkan_renderer.hpp
|
||||
│ │ ├── vulkan_renderer.cpp
|
||||
│ │ └── shaders/
|
||||
│ │ ├── pattern.vert
|
||||
│ │ ├── pattern_bars.frag
|
||||
│ │ ├── pattern_ramp.frag
|
||||
│ │ ├── pattern_solid.frag
|
||||
│ │ └── rgb_to_v210.comp
|
||||
│ │
|
||||
│ ├── decklink-in/
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ └── src/
|
||||
│ │ ├── main.cpp
|
||||
│ │ ├── decklink_in_node.hpp
|
||||
│ │ └── decklink_in_node.cpp
|
||||
│ │
|
||||
│ ├── decklink-out/
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ └── src/
|
||||
│ │ ├── main.cpp
|
||||
│ │ ├── decklink_out_node.hpp
|
||||
│ │ └── decklink_out_node.cpp
|
||||
│ │
|
||||
│ ├── preview/
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ └── src/
|
||||
│ │ ├── main.cpp
|
||||
│ │ ├── preview_node.hpp
|
||||
│ │ └── preview_node.cpp
|
||||
│ │
|
||||
│ └── mixer/
|
||||
│ ├── CMakeLists.txt
|
||||
│ └── src/
|
||||
│ ├── main.cpp
|
||||
│ ├── mixer_node.hpp
|
||||
│ └── mixer_node.cpp
|
||||
│
|
||||
├── engine/
|
||||
│ ├── CMakeLists.txt
|
||||
│ └── src/
|
||||
│ └── main.cpp
|
||||
│
|
||||
├── web/ # Phase 4 — Vue 3 frontend
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ ├── tsconfig.json
|
||||
│ └── src/
|
||||
│ ├── App.vue
|
||||
│ ├── main.ts
|
||||
│ ├── api/
|
||||
│ │ ├── rest.ts
|
||||
│ │ └── websocket.ts
|
||||
│ ├── stores/
|
||||
│ │ ├── graph.ts
|
||||
│ │ └── connection.ts
|
||||
│ ├── canvas/
|
||||
│ │ ├── StudioCanvas.vue
|
||||
│ │ ├── nodes/
|
||||
│ │ │ ├── SourceNode.vue
|
||||
│ │ │ ├── ProcessorNode.vue
|
||||
│ │ │ ├── OutputNode.vue
|
||||
│ │ │ └── NodeThumbnail.vue
|
||||
│ │ └── edges/
|
||||
│ │ └── FlowEdge.vue
|
||||
│ ├── panels/
|
||||
│ │ ├── NodeProperties.vue
|
||||
│ │ ├── Toolbar.vue
|
||||
│ │ └── StatusBar.vue
|
||||
│ └── templates/
|
||||
│ └── templateLoader.ts
|
||||
│
|
||||
├── templates/
|
||||
│ ├── loopback.json
|
||||
│ ├── sdi_monitor.json
|
||||
│ ├── sdi_pass.json
|
||||
│ └── simple_switcher.json
|
||||
│
|
||||
├── docker/
|
||||
│ ├── Dockerfile.engine
|
||||
│ ├── Dockerfile.node.test-source
|
||||
│ ├── Dockerfile.node.decklink-in
|
||||
│ ├── Dockerfile.node.decklink-out
|
||||
│ ├── Dockerfile.node.preview
|
||||
│ ├── Dockerfile.node.passthrough
|
||||
│ └── docker-compose.yaml
|
||||
│
|
||||
└── tests/
|
||||
├── CMakeLists.txt
|
||||
├── test_graph.cpp
|
||||
├── test_flow_manager.cpp
|
||||
└── test_control_protocol.cpp
|
||||
```
|
||||
|
||||
## Node Interface
|
||||
|
||||
```cpp
|
||||
class Node {
|
||||
public:
|
||||
virtual ~Node() = default;
|
||||
virtual std::string type() const = 0;
|
||||
virtual std::vector<PortDef> input_ports() const = 0;
|
||||
virtual std::vector<PortDef> output_ports() const = 0;
|
||||
virtual void configure(const nlohmann::json& params) = 0;
|
||||
virtual void on_add_writer(const std::string& port_id, mxlFlowWriter writer) = 0;
|
||||
virtual void on_add_reader(const std::string& port_id, mxlFlowReader reader) = 0;
|
||||
virtual void on_remove_writer(const std::string& port_id) = 0;
|
||||
virtual void on_remove_reader(const std::string& port_id) = 0;
|
||||
virtual void process() = 0;
|
||||
virtual nlohmann::json status() const = 0;
|
||||
};
|
||||
```
|
||||
|
||||
## Control Protocol (Engine ↔ Node via WebSocket)
|
||||
|
||||
Engine → Node:
|
||||
|
||||
```json
|
||||
{ "cmd": "add_writer", "flow_id": "uuid", "flow_def": { ... } }
|
||||
{ "cmd": "add_reader", "flow_id": "uuid" }
|
||||
{ "cmd": "remove_writer", "port_id": "out_0" }
|
||||
{ "cmd": "remove_reader", "port_id": "in_0" }
|
||||
{ "cmd": "configure", "params": { ... } }
|
||||
{ "cmd": "shutdown" }
|
||||
```
|
||||
|
||||
Node → Engine:
|
||||
|
||||
```json
|
||||
{ "event": "ready", "type": "test_source", "ports": { ... } }
|
||||
{ "event": "flow_status", "port_id": "out_0", "writing": true, "fps": 50 }
|
||||
{ "event": "error", "message": "..." }
|
||||
{ "event": "grain_stats", "port_id": "out_0", "grains_written": 1234, "latency_us": 120 }
|
||||
```
|
||||
|
||||
## Flow Connection Sequence
|
||||
|
||||
```
|
||||
User draws edge in UI (or REST API call):
|
||||
1. Engine generates flow UUID + NMOS flow definition
|
||||
2. Engine → Node A: { "cmd": "add_writer", "flow_id": ..., "flow_def": ... }
|
||||
3. Node A creates mxlFlowWriter, starts writing grains
|
||||
4. Node A → Engine: { "event": "flow_status", "writing": true }
|
||||
5. Engine → Node B: { "cmd": "add_reader", "flow_id": ... }
|
||||
6. Node B creates mxlFlowReader, starts reading grains
|
||||
7. Node B → Engine: { "event": "flow_status", "reading": true }
|
||||
|
||||
User removes edge:
|
||||
1. Engine → Node B: { "cmd": "remove_reader", "port_id": ... }
|
||||
2. Node B destroys mxlFlowReader
|
||||
3. Engine → Node A: { "cmd": "remove_writer", "port_id": ... }
|
||||
4. Node A destroys mxlFlowWriter
|
||||
```
|
||||
|
||||
## Engine REST API
|
||||
|
||||
```
|
||||
GET /api/graph # Current node graph
|
||||
POST /api/graph/nodes # Add node { type, id? }
|
||||
DELETE /api/graph/nodes/:id # Remove node
|
||||
POST /api/graph/edges # Connect { from_node:from_port, to_node:to_port }
|
||||
DELETE /api/graph/edges/:id # Disconnect
|
||||
PUT /api/graph/nodes/:id # Configure node
|
||||
POST /api/graph/start # Start all node processes + create flows
|
||||
POST /api/graph/stop # Stop all
|
||||
GET /api/templates # List templates
|
||||
POST /api/templates/:name/load # Load template as graph
|
||||
GET /api/nodes/types # List available node types
|
||||
```
|
||||
|
||||
## Node Designs
|
||||
|
||||
### Test Source (Vulkan)
|
||||
|
||||
- Headless Vulkan: render 2 fullscreen triangles with pattern fragment shader → RGBA8 image
|
||||
- Compute shader `rgb_to_v210.comp`: RGBA8 → BT.709 YCbCr → 4:2:2 downsample → V210 packing
|
||||
- vkMapMemory → memcpy to MXL grain → commit
|
||||
- Patterns: SMPTE bars, luma ramp, solid color
|
||||
- Frame pacing to target 50fps
|
||||
- 1 output port: `video_out` (V210)
|
||||
|
||||
### Passthrough
|
||||
|
||||
- Read 1 MXL grain → memcpy → write 1 MXL grain
|
||||
- 1 input: `video_in`, 1 output: `video_out`
|
||||
- Hello-world node for validating the pipeline
|
||||
|
||||
### Preview
|
||||
|
||||
- Read V210 MXL grain → libyuv convert to RGBA → downsample to 480×270 → libjpeg-turbo → JPEG
|
||||
- Push JPEG frames to subscribed WS clients (throttled to ~15fps)
|
||||
- 1 input: `video_in`
|
||||
|
||||
### DeckLink In
|
||||
|
||||
- IDeckLinkInputCallback::VideoInputFrameArrived → GetBytes (V210) → memcpy to MXL grain → commit
|
||||
- V210 native match = straight copy, no conversion
|
||||
- 1 output: `video_out`
|
||||
- Config: device index, connection type
|
||||
|
||||
### DeckLink Out
|
||||
|
||||
- IDeckLinkOutputCallback::ScheduledFrameCompleted → read MXL grain → memcpy to DeckLink buffer → schedule frame
|
||||
- 1 input: `video_in`
|
||||
- Config: device index, connection type
|
||||
|
||||
### Mixer
|
||||
|
||||
- 2 inputs: `video_a`, `video_b` (V210) → 1 output: `program` (V210)
|
||||
- Cut: select active input
|
||||
- Dissolve: blend A×(1-t) + B×t over N frames
|
||||
- V210 blending: unpack 10-bit → linear blend → repack
|
||||
- Config: active_input, transition_type, transition_duration_frames
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Framework + Passthrough (Week 1-2)
|
||||
|
||||
**Goal: Two processes exchanging video via MXL, controlled by engine**
|
||||
|
||||
| Step | Task | Test |
|
||||
|---|---|---|
|
||||
| 1.1 | Repo init, CMake top-level, vcpkg, MXL submodule | Build succeeds |
|
||||
| 1.2 | Verify MXL SDK: `mxl-gst-testsrc` + `mxl-gst-sink` work | Video displays |
|
||||
| 1.3 | Build `libdmf-node`: node.hpp, port.hpp, control_server, node_runner | Unit tests pass |
|
||||
| 1.4 | Build `passthrough` node binary | Starts, connects to MXL |
|
||||
| 1.5 | Build `libdmf-engine`: graph, flow_manager, process_manager, api_server | Unit tests pass |
|
||||
| 1.6 | Build engine binary with minimal REST | `curl` works |
|
||||
| 1.7 | Integration: engine starts 2 passthrough processes, creates flow between them | Frames flow A→B |
|
||||
|
||||
**Testing method**: curl + mxl-gst-testsrc/sink as external I/O
|
||||
|
||||
### Phase 2: Test Source + Preview (Week 3-4)
|
||||
|
||||
**Goal: GPU-generated color bars visible in browser**
|
||||
|
||||
| Step | Task | Test |
|
||||
|---|---|---|
|
||||
| 2.1 | Vulkan headless context (VkDevice, queues, command pool) | Device created |
|
||||
| 2.2 | Pattern fragment shaders (bars, ramp, solid) + fullscreen vert | Render to RGBA8 |
|
||||
| 2.3 | `rgb_to_v210.comp` compute shader | V210 output verified |
|
||||
| 2.4 | `test-source` node: render → compute → MXL write | MXL flow active |
|
||||
| 2.5 | `preview` node: MXL read → libyuv → JPEG → WS | Browser sees frames |
|
||||
| 2.6 | Engine integration: test-source → preview | curl starts graph, browser shows bars |
|
||||
|
||||
**Testing method**: curl to start graph, open browser to preview WS endpoint, see live color bars
|
||||
|
||||
### Phase 3: DeckLink I/O (Week 5-6)
|
||||
|
||||
**Goal: Real SDI signal flowing through DMF**
|
||||
|
||||
| Step | Task | Test |
|
||||
|---|---|---|
|
||||
| 3.1 | `decklink-in` node: DeckLink callback → MXL writer | SDI camera → MXL flow |
|
||||
| 3.2 | `decklink-out` node: MXL reader → DeckLink playback | MXL flow → SDI monitor |
|
||||
| 3.3 | Templates: sdi_monitor, sdi_loopback, test_output | Load and run via curl |
|
||||
| 3.4 | Full pipeline: SDI in → preview → browser | Camera visible in browser |
|
||||
|
||||
**Testing method**: curl to start graph, real SDI camera + monitor
|
||||
|
||||
### Phase 4: Frontend (Week 7-8)
|
||||
|
||||
**Goal: Visual graph editor on DMF TV**
|
||||
|
||||
| Step | Task | Test |
|
||||
|---|---|---|
|
||||
| 4.1 | Vue 3 + Vite project setup, Vue Flow integration | Dev server runs |
|
||||
| 4.2 | Pinia stores: graph model, WS connection | State management works |
|
||||
| 4.3 | StudioCanvas: drag-add nodes, draw edges, delete | Graph editing works |
|
||||
| 4.4 | Custom node components: type icon, name, status, live thumbnail | Nodes render |
|
||||
| 4.5 | FlowEdge: color-coded by flow status | Edges render |
|
||||
| 4.6 | NodeProperties panel: dynamic config from node schema | Config changes work |
|
||||
| 4.7 | Toolbar: New/Load/Save/Run/Stop + template menu | Full workflow works |
|
||||
| 4.8 | StatusBar: node count, flow count, running state | Status updates live |
|
||||
| 4.9 | Test on DMF TV browser | Everything renders and works |
|
||||
|
||||
**Testing method**: Full end-to-end — build graph in UI, run, see live video
|
||||
|
||||
### Phase 5: Mixer Node (Week 9-10)
|
||||
|
||||
**Goal: DMF Studio is a production tool**
|
||||
|
||||
| Step | Task | Test |
|
||||
|---|---|---|
|
||||
| 5.1 | `mixer` node: 2-in, 1-out, cut + dissolve | Cut/dissolve work via curl |
|
||||
| 5.2 | Mixer control panel in Vue: PGM/PVW buses, CUT, AUTO TRANS, T-bar | UI controls work |
|
||||
| 5.3 | Template: Simple Switcher (2 sources → mixer → preview + SDI out) | Full production workflow |
|
||||
| 5.4 | Polish, bug fixes, edge cases | Stable demo |
|
||||
|
||||
## vcpkg.json Dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dmf-studio",
|
||||
"version": "0.1.0",
|
||||
"dependencies": [
|
||||
"libwebsockets",
|
||||
"nlohmann-json",
|
||||
"spdlog",
|
||||
"fmt",
|
||||
"libyuv",
|
||||
"libjpeg-turbo",
|
||||
"catch2"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Vulkan: `find_package(Vulkan)` — system install, not vcpkg.
|
||||
DeckLink SDK: vendor headers linked manually, not in vcpkg.
|
||||
|
||||
## Key Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| MXL SDK API changes | Pin submodule to v1.0.1 tag, test before upgrading |
|
||||
| V210 compute shader correctness | Validate against known reference frames, compare with libyuv output |
|
||||
| DeckLink SDK availability on dev machine | Develop without hardware using test-source; DeckLink nodes only need testing on machines with cards |
|
||||
| Single-process engine becomes bottleneck | Engine never touches media data; it only manages metadata and process lifecycle |
|
||||
| WebSocket control latency | All control is localhost; sub-ms latency. Remote control is future work |
|
||||
| Vulkan driver compatibility | Start with standard Vulkan 1.2; no extensions required for headless compute |
|
||||
Reference in New Issue
Block a user