From 9a4d28ddfcdf1d8e4de8404f09e983914b2c567f Mon Sep 17 00:00:00 2001 From: Adrian Hilber Date: Fri, 16 Oct 2026 09:17:58 +0200 Subject: [PATCH 1/2] 1.0.5: interlaced video follows the shared field-per-grain convention Capture writes frame k (from the frame's start at the frame rate) as two field grains, 2k with the first field's lines and 2k + 1 with the other lines (v210 and 8-bit YUV); before, one grain per frame at the field-rate index held frame lines 0-539 and every odd index stayed invalid. NTSC is interlaced_bff in the flow definition and NMOS (was tff for every mode). ANC of an interlaced input runs at the field rate (2x the frame rate): field 1's packets at grain 2k, field 2's at 2k + 1, the video field's index. Before, packets were written at the field index into a frame-rate data flow. Playout reads an interlaced flow as frames: grains 2k and 2k + 1 woven with line parity (idx & 1) ^ bff into the DeckLink frame; read position, preroll, offsets, catch-up, the sync wait and the audio per frame use the frame rate (1920 samples at 1080i50, was 960). Before, one field per output frame went into the top half and the read advanced at the field rate. The web UI maps interlaced flows to HD1080i50/HD1080i5994/PAL/NTSC. The mock marks the odd lines' counter band grey in interlaced modes and can dump played frames (MOCK_PLAYBACK_FRAME_FILE). Progressive is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01St7QwmonpoG9EEEqaLFXLi --- CHANGELOG.md | 12 ++ CMakeLists.txt | 4 +- README.md | 6 +- SPECIFICATION.md | 15 +- deploy/mxl-decklink.yaml | 2 +- src/channel/input_channel.cpp | 79 ++++++++-- src/channel/output_channel.cpp | 8 +- src/config/videomodes.cpp | 5 +- src/config/videomodes.hpp | 3 + src/decklink/mock/mockbackend.cpp | 47 +++++- src/mxlbridge/flowdef.cpp | 2 +- src/mxlbridge/flowdef.hpp | 5 +- src/mxlbridge/videoreader.cpp | 92 ++++++++++- src/mxlbridge/videoreader.hpp | 40 ++++- src/mxlbridge/videowriter.cpp | 22 +++ src/mxlbridge/videowriter.hpp | 7 + src/nmos/node.cpp | 6 +- src/util/interlace.cpp | 47 ++++++ src/util/interlace.hpp | 57 +++++++ src/version.hpp | 2 +- tests/unit/test_flow_reopen.cpp | 135 ++++++++++++++++ tests/unit/test_flowdef.cpp | 4 + tests/unit/test_interlace.cpp | 251 ++++++++++++++++++++++++++++++ web/src/api.js | 8 +- 24 files changed, 813 insertions(+), 46 deletions(-) create mode 100644 src/util/interlace.cpp create mode 100644 src/util/interlace.hpp create mode 100644 tests/unit/test_interlace.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index badd142..1d81e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.0.5 + +Interlaced video (`PAL`, `NTSC`, `HD1080i50`, `HD1080i5994`) now follows the shared convention of the MXL media functions (SPECIFICATION §3.2.2): one field per grain at twice the frame rate, grain 2k = the first field in time of TAI frame k, grain 2k + 1 = the second; the flow definition keeps `grain_rate` = the frame rate and the real `interlace_mode`. Progressive modes are unchanged. + +- **Input (SDI → MXL):** an interlaced capture wrote one grain per frame at the field-rate index, holding frame lines 0–539 of the woven frame (the top half with both fields, cut to the grain size); every odd index stayed invalid. Every reader that follows the convention (multiviewer, webrtc-monitor, srt and 2110 egress) showed the top half stretched, with combing. Now the frame index k comes from the frame's start (one frame period before its timestamp) at the frame rate, and the bridge writes grain 2k from the first field's lines and 2k + 1 from the other lines, by row (v210, and 8-bit YUV after the expansion; for `video/v210a` the fill lines). `mxl_decklink_grains_committed_total` counts two grains per interlaced frame. +- **Field order:** `NTSC` is lower field first (`bmdLowerFieldFirst`), so its flow definition and NMOS flow and receiver caps say `interlaced_bff`; they said `interlaced_tff` for every interlaced mode. `PAL` and 1080i stay `interlaced_tff`. +- **ANC of an interlaced input** runs at the field rate: the `video/smpte291` flow is declared at twice the frame rate (50/1 for 1080i50, 60000/1001 for 1080i59.94), field 1's packets go to grain 2k and field 2's to grain 2k + 1, the index of the video field (a field without packets gets an empty grain). The field follows from the packet's SMPTE line number. Before, the packets were written at the field-rate index into a data flow declared at the frame rate, about twice the TAI index, so readers asking for the current index found nothing. The output does not read ANC, so there is no reader to change. +- **Output (MXL → SDI):** an interlaced flow was read one field per output frame into the top half of the DeckLink frame (the rest stale), the read position advanced at the field rate (half of what the source writes, so it fell behind and skipped ahead), and the audio per frame was half a frame (960 samples at 1080i50). Now the reader reads frame k as grains 2k and 2k + 1 and weaves them with line parity `(index & 1) ^ bff`; read position, preroll, read offset, catch-up and slips count frames, the sync-group wait waits for the second field's time, and the audio per frame is 48000 / frame rate (1920 at 25 frames/s). A frame with an invalid field repeats the last frame (`source_invalid`). A re-created flow that changes between progressive and interlaced restarts the channel. `output_channel_started` logs `interlaced`. +- **Web UI:** the MXL tab now maps an interlaced flow to its mode: 1080 lines at 25/1 to `HD1080i50`, at 30000/1001 to `HD1080i5994`, 576 lines at 25/1 to `PAL`, 486 lines at 30000/1001 to `NTSC`. It looked for `HD1080i25` and `HD1080i2997`, which do not exist, and offered no mode. +- **Mock card:** in interlaced modes the counter band of the odd lines is mid grey, so each field can be told apart; new `MOCK_PLAYBACK_FRAME_FILE` writes every 25th frame an output is given to a file. +- New unit tests: field split and weave for tff and bff (every line back in place), the frame index from the frame's start and the field timestamps at 1080i50 and 1080i59.94, the newest complete frame of a field head, ANC line → field, an interlaced flow written field by field and read back as woven frames (PAL tff, NTSC bff), and an output playing an interlaced flow at the frame rate with woven frames and a frame's worth of audio. + ## 1.0.4 - An output channel re-opens its MXL readers when the writer re-creates a flow under the same id. MXL then gives the flow a new data file, and a read past the old head returns `MXL_ERR_FLOW_INVALID` (dmf-mxl/mxl@218ddaa `lib/internal/src/PosixDiscreteFlowReader.cpp:96-104`, `244-254`). Before, the channel treated that like any failed read: it repeated the last frame and counted a drop, but every read of the stale reader first waited `CHx_READER_TIMEOUT_MS` twice (sync group, then grain). Only a preroll reset (25 late or dropped DeckLink frames in 10 s) re-opened the reader; when the card did not report late frames, the SDI output stayed frozen until the channel was restarted. Now the callback takes the reader out of the sync group and stops reading it; the supervisor releases it and opens it again for the same flow id at once, then after 100 ms, 200 ms, 500 ms and every 1 s while the flow is missing. Video repeats the last frame and the re-created audio flow plays silence until the new reader is open; these repeats count in `mxl_decklink_frames_repeated_total{reason="flow_reopen"}` and never trigger a preroll reset. A flow missing for more than 1 s marks the channel `degraded` until it is back. A re-created flow with another format, rate, line size or channel count restarts the channel instead. New counter `mxl_decklink_flow_reopens_total` (one per re-created flow) and log events `mxl_flow_recreated` (once per event), `mxl_flow_reopen_failed` (first failed open of an event), `mxl_flow_reopened` (with `attempts` and `after_ms`) and `mxl_flow_recreated_format_changed`. mxl-st2110-gateway, FlowXer, the multiviewer and replay already re-open on this error. diff --git a/CMakeLists.txt b/CMakeLists.txt index 39a3c5d..4ee80df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.24 FATAL_ERROR) project(mxl-decklink - VERSION 1.0.4 + VERSION 1.0.5 DESCRIPTION "MXL MediaFunction container for Blackmagic DeckLink" LANGUAGES CXX ) @@ -112,6 +112,7 @@ add_library(mxl-decklink-core STATIC src/util/logging.cpp src/util/taiclock.cpp src/util/v210.cpp + src/util/interlace.cpp src/util/audioconv.cpp src/util/anc.cpp src/util/uuid.cpp @@ -205,6 +206,7 @@ if(MXL_DECKLINK_BUILD_TESTS) tests/unit/test_route_state.cpp tests/unit/test_flowsync.cpp tests/unit/test_flow_reopen.cpp + tests/unit/test_interlace.cpp ) target_link_libraries(unit-tests PRIVATE mxl-decklink-core) add_test(NAME unit-tests COMMAND unit-tests) diff --git a/README.md b/README.md index 5ea23e9..56ff11f 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,11 @@ The build always contains a deterministic **mock DeckLink backend** counter band, a 1 kHz tone, an ANC test packet, TAI-paced callbacks, and scriptable fault injection (`MOCK_SIGNAL_LOSS_AFTER_FRAMES`, `MOCK_FORMAT_CHANGE_AFTER_FRAMES`, `MOCK_SUBDEVICE_COUNT`). This drives the -identical channel/MXL code paths as real hardware. +identical channel/MXL code paths as real hardware. In interlaced modes the +counter band of the odd lines is mid grey, so the two fields of a frame can be +told apart. `MOCK_PLAYBACK_FRAME_FILE=` makes the mock output +write every 25th frame it is given to that file, to check what an output +plays. ```bash # unit tests diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 3ee39a8..121c9a1 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -73,7 +73,7 @@ Then the MXL sequence runs: `mxlFlowWriterOpenGrain(inst, writer, grainIndex, &g In parallel, `audioPacket->GetBytes(&pcm)` is read. Because DeckLink delivers interleaved PCM (`bmdAudioSampleType32bitInteger`, 48 kHz) and MXL expects `audio/float32` deinterleaved per channel in a `channels` blob, the bridge converts in flight: for each sample, `float32 = int32 / 2147483648.0f`, deinterleaving into an `mxlMutableWrappedMultiBufferSlice`. The slice structure addresses ring-buffer wraparound via two fragments. `mxlFlowWriterOpenSamples` / `CommitSamples` open and finalize the batch. -For ANC data, `IDeckLinkVideoFrameAncillaryPackets` is iterated and written into a `video/smpte291` flow per RFC 8331 §2 (from the length field onward) when the channel has ANC enabled. The callback must complete well under the frame interval (at 1080p50: <20 ms) — therefore no blocking operations inside it. +For ANC data, `IDeckLinkVideoFrameAncillaryPackets` is iterated and written into a `video/smpte291` flow per RFC 8331 §2 (from the length field onward) when the channel has ANC enabled (one grain per field for interlaced modes, §3.2.2). The callback must complete well under the frame interval (at 1080p50: <20 ms) — therefore no blocking operations inside it. ### 2.4 Data Flow, Output Path (MXL → DeckLink) @@ -143,6 +143,15 @@ With an explicit `CHx_VIDEO_MODE` (e.g. `HD1080p50`, `4K2160p50`), the container The container maintains this mapping internally. Essential entries (non-exhaustive): `HD720p50 → bmdModeHD720p50`, `HD720p5994 → bmdModeHD720p5994`, `HD1080i50 → bmdModeHD1080i50`, `HD1080p24 → bmdModeHD1080p24`, `HD1080p25 → bmdModeHD1080p25`, `HD1080p2997 → bmdModeHD1080p2997`, `HD1080p50 → bmdModeHD1080p50`, `HD1080p5994 → bmdModeHD1080p5994`, `4K2160p25 → bmdMode4K2160p25`, `4K2160p50 → bmdMode4K2160p50`, `4K2160p5994 → bmdMode4K2160p5994`, `4K2160p60 → bmdMode4K2160p60`, `8K4320p50 → bmdMode8K4320p50`, `8K4320p60 → bmdMode8K4320p60`. Modes not in the table are rejected. +#### 3.2.2 Interlaced Modes (`PAL`, `NTSC`, `HD1080i50`, `HD1080i5994`) + +The container follows the shared convention of the MXL media functions (MXL `218ddaa`). The flow definition keeps `grain_rate` = the frame rate (25/1 or 30000/1001) and the real `interlace_mode`; MXL then stores **one field per grain** (height / 2 lines) at twice that rate. Grain 2k is the first field in time of TAI frame k (k = `mxlTimestampToIndex` at the frame rate), grain 2k + 1 the second. `interlaced_tff`: the first field is the top field, the even frame lines (0, 2, 4, …); `interlaced_bff`: the bottom field, the odd lines. A reader weaves grain i into the frame lines of parity `(i & 1) ^ bff`. MXL has no per-grain field marker, so a reader cannot detect a writer that puts the first field on an odd index. + +- **Field order** comes from the mode table: `NTSC` is lower field first (`bmdLowerFieldFirst` in `IDeckLinkDisplayMode::GetFieldDominance`), so its flow is `interlaced_bff`; `PAL`, `HD1080i50` and `HD1080i5994` are `interlaced_tff`. The MXL flow definition and the NMOS flow and receiver caps carry it. +- **Input:** k comes from the frame's start, one frame period before its timestamp (§3.5; the frame arrives when it is complete). The bridge writes grain 2k from the first field's lines and grain 2k + 1 from the other lines of the woven DeckLink frame, by row (v210 directly, 8-bit YUV after the expansion of §3.3; for `video/v210a` the fill lines). +- **ANC** of an interlaced input runs at the **field rate**: the `video/smpte291` flow is declared at twice the frame rate (50/1 for 1080i50, 60000/1001 for 1080i59.94). The packets of field 1 go to grain 2k and those of field 2 to grain 2k + 1, the index of the video field; a field without packets gets an empty grain. The field follows from the packet's SMPTE line number: 1080i lines 564–1125, 625-line lines 313–625 and 525-line lines 266–525 and 1–3 are field 2. Progressive ANC stays at the frame rate. +- **Output:** a flow whose `interlace_mode` is `interlaced_tff` or `interlaced_bff` is read as frames: for frame k the channel reads grains 2k and 2k + 1 and weaves them into the DeckLink frame. Every frame count of §2.4 (read position, preroll, read offset, catch-up, slips, the sync-group wait, which waits for the second field's time) is in frames, and the audio per frame is 48000 / frame rate samples (1920 at 25 frames/s). A frame with an invalid field is repeated like an invalid grain. The output does not read ANC. + ### 3.3 Pixel Format Mapping MXL v1.0 supports exactly two video formats: **`video/v210`** (10-bit YUV 4:2:2, Apple/SMPTE v210 layout) and **`video/v210a`** (v210 plus alpha, for fill+key). This is fortunately congruent with the DeckLink standard format `bmdFormat10BitYUV`, which uses the same bit packing (6 pixels in 16 bytes, 128-byte row alignment). The container therefore uses **`bmdFormat10BitYUV` as the default**. `IDeckLinkOutput::RowBytesForPixelFormat` is used to compute row bytes instead of hand-rolled formulas. @@ -165,7 +174,7 @@ In mode **`MXL_TIMESTAMP_SOURCE=hardware`**, the container uses `IDeckLinkVideoI In mode **`MXL_TIMESTAMP_SOURCE=host`**, the container takes `clock_gettime(CLOCK_TAI)` at callback entry as the frame timestamp — simpler, but subject to callback jitter (typically ±0.5 ms). -The grain index follows from `mxlTimestampToIndex(&grainRate, tai_ns)`. For signal-loss frames (`bmdFrameHasNoInputSource` set), no grain is committed; a gap in the ring buffer is accepted so readers detect the outage via `MXL_GRAIN_FLAG_INVALID` or the missing sample index. +The grain index follows from `mxlTimestampToIndex(&grainRate, tai_ns)`; interlaced modes write two field grains from the frame index of the frame's start (§3.2.2). For signal-loss frames (`bmdFrameHasNoInputSource` set), no grain is committed; a gap in the ring buffer is accepted so readers detect the outage via `MXL_GRAIN_FLAG_INVALID` or the missing sample index. ### 3.6 Behavior on Signal Loss @@ -443,7 +452,7 @@ Every setting displays its **provenance** (default / file / environment). Settin #### 7.5.4 Flow-to-output assignment -The MXL tab allows selecting any **video flow** (and matching audio flow) discovered in the attached domain and assigning it to an output channel. The UI derives the required `CHx_VIDEO_MODE` from the flow descriptor (`frame_width`/`frame_height`/`grain_rate`/`interlace_mode`) and warns when no DeckLink mode of the card matches the flow geometry. The assignment is a normal per-channel configuration change (§7.5.3). +The MXL tab allows selecting any **video flow** (and matching audio flow) discovered in the attached domain and assigning it to an output channel. The UI derives the required `CHx_VIDEO_MODE` from the flow descriptor (`frame_width`/`frame_height`/`grain_rate`/`interlace_mode`; an interlaced flow's `grain_rate` is its frame rate, so 1080 lines at 25/1 map to `HD1080i50`, 30000/1001 to `HD1080i5994`, 576 lines at 25/1 to `PAL` and 486 lines at 30000/1001 to `NTSC`) and warns when no DeckLink mode of the card matches the flow geometry. The assignment is a normal per-channel configuration change (§7.5.3). #### 7.5.5 Security diff --git a/deploy/mxl-decklink.yaml b/deploy/mxl-decklink.yaml index 38bbf8b..238b2aa 100644 --- a/deploy/mxl-decklink.yaml +++ b/deploy/mxl-decklink.yaml @@ -30,7 +30,7 @@ spec: # feature.node.kubernetes.io/pci-1edb.present: "true" containers: - name: mxl-decklink - image: ghcr.io/leeo86/mxl-decklink:1.0.4 + image: ghcr.io/leeo86/mxl-decklink:1.0.5 resources: # §6.4: Guaranteed QoS — requests == limits, integer CPUs. # Sizing per §6.3 (Quad 2, 8× HD half duplex). diff --git a/src/channel/input_channel.cpp b/src/channel/input_channel.cpp index 1e3715a..088dccb 100644 --- a/src/channel/input_channel.cpp +++ b/src/channel/input_channel.cpp @@ -5,6 +5,7 @@ #include +#include "util/interlace.hpp" #include "util/logging.hpp" #include "util/threading.hpp" #include "util/v210.hpp" @@ -332,6 +333,7 @@ namespace mxldl::channel vp.rateNumerator = mode.rateNumerator; vp.rateDenominator = mode.rateDenominator; vp.interlaced = mode.interlaced; + vp.bottomFieldFirst = mode.bottomFieldFirst; vp.withAlpha = _cfg.pixelFormat == config::PixelFormat::YUVA10; _videoWriter = std::make_unique(_domain, vp, _cfg.commitBatchHintVideo); @@ -387,7 +389,9 @@ namespace mxldl::channel np.groupHint = _cfg.groupHint + ":ANC"; np.sourceId = _cfg.sourceId; np.deviceId = _cfg.deviceId; - np.rateNumerator = mode.rateNumerator; + // Interlaced: one ANC grain per field, at the field rate (shared + // convention, util/interlace.hpp). + np.rateNumerator = mode.interlaced ? 2 * mode.rateNumerator : mode.rateNumerator; np.rateDenominator = mode.rateDenominator; _ancWriter = std::make_unique(_domain, np); } @@ -456,12 +460,26 @@ namespace mxldl::channel } auto const tai = frameTimestampTai(video); - mxlRational rate{_currentMode.rateNumerator, _currentMode.rateDenominator}; - if (_videoWriter) + // Interlaced (util/interlace.hpp): frame k from the frame's start + // time at the frame rate (the frame arrives one frame period after it + // started); its first field goes to grain 2k, the second to 2k + 1. + // Progressive: the grain of the arrival time, as before. + bool const fields = _currentMode.interlaced; + mxlRational const frameRate{_currentMode.rateNumerator, _currentMode.rateDenominator}; + std::uint64_t grainIndex = 0; // interlaced: the first field's grain + if (fields) { - rate = _videoWriter->grainRate(); + grainIndex = util::firstFieldGrainIndex(frameRate, tai); + } + else + { + mxlRational rate = frameRate; + if (_videoWriter) + { + rate = _videoWriter->grainRate(); + } + grainIndex = ::mxlTimestampToIndex(&rate, tai); } - auto const grainIndex = ::mxlTimestampToIndex(&rate, tai); if (!_videoWriter) { @@ -472,29 +490,44 @@ namespace mxldl::channel else { // Video grain (§2.3): one memcpy for v210; expansion for 8-bit. + // Interlaced: two field grains, each from every second frame line. auto const commitStart = util::taiNowNs(); mxlStatus status; + std::uint8_t const* frame = video.bytes; + std::size_t frameRowBytes = video.rowBytes; if (_cfg.pixelFormat == config::PixelFormat::YUV8) { auto const dstRowBytes = util::v210RowBytes(video.width); _conversionBuffer.resize(static_cast(dstRowBytes) * video.height); util::expandUyvyToV210(video.bytes, video.rowBytes, _conversionBuffer.data(), dstRowBytes, video.width, video.height); - status = _videoWriter->writeFrame(grainIndex, _conversionBuffer.data(), _conversionBuffer.size()); + frame = _conversionBuffer.data(); + frameRowBytes = dstRowBytes; + } + std::uint64_t grains = 1; + if (fields) + { + grains = 2; + auto const first = util::fieldParity(grainIndex, _currentMode.bottomFieldFirst); + status = _videoWriter->writeField(grainIndex, frame, frameRowBytes, video.height, first); + if (status == MXL_STATUS_OK) + { + status = _videoWriter->writeField(grainIndex + 1, frame, frameRowBytes, video.height, first ^ 1U); + } } else { - status = _videoWriter->writeFrame(grainIndex, video.bytes, video.rowBytes * video.height); + status = _videoWriter->writeFrame(grainIndex, frame, frameRowBytes * video.height); } if (status == MXL_STATUS_OK) { auto const commitEnd = util::taiNowNs(); _commitLatency->observe(static_cast(commitEnd - commitStart) / 1e9); - _grainsCommitted->inc(); - _grainsWrittenBytes->inc(static_cast(_videoWriter->grainPayloadSize())); - _status.grainsCommitted.fetch_add(1); + _grainsCommitted->inc(static_cast(grains)); + _grainsWrittenBytes->inc(static_cast(grains * _videoWriter->grainPayloadSize())); + _status.grainsCommitted.fetch_add(grains); _status.lastFrameTaiNs.store(tai); - _headIndexGauge->set(static_cast(grainIndex)); + _headIndexGauge->set(static_cast(grainIndex + grains - 1)); } else { @@ -553,12 +586,30 @@ namespace mxldl::channel } // ANC (§2.3): RFC 8331 grain aligned with the video grain index. + // Interlaced: each field's packets at its field's grain (a field + // without packets gets an empty grain). if (_cfg.ancEnable && _ancWriter && !video.ancPackets.empty()) { - auto const ancStatus = _ancWriter->writePackets(grainIndex, video.ancPackets); - if (ancStatus != MXL_STATUS_OK) + auto writeAnc = [this](std::uint64_t index, std::vector const& packets) { + auto const ancStatus = _ancWriter->writePackets(index, packets); + if (ancStatus != MXL_STATUS_OK) + { + log::debug("anc_write_failed", {{"channel_index", _cfg.index}, {"status", static_cast(ancStatus)}}); + } + }; + if (fields) + { + std::vector perField[2]; + for (auto const& p : video.ancPackets) + { + perField[util::ancLineInSecondField(_currentMode.height, p.lineNumber) ? 1 : 0].push_back(p); + } + writeAnc(grainIndex, perField[0]); + writeAnc(grainIndex + 1, perField[1]); + } + else { - log::debug("anc_write_failed", {{"channel_index", _cfg.index}, {"status", static_cast(ancStatus)}}); + writeAnc(grainIndex, video.ancPackets); } } diff --git a/src/channel/output_channel.cpp b/src/channel/output_channel.cpp index 157aca9..28de4a4 100644 --- a/src/channel/output_channel.cpp +++ b/src/channel/output_channel.cpp @@ -713,6 +713,7 @@ namespace mxldl::channel {"flow_id", _cfg.videoFlowId.toString()}, {"preroll_grains", _cfg.outputPrerollGrains}, {"sync_group", true}, + {"interlaced", _videoReader->interlaced()}, }); return true; } @@ -788,7 +789,8 @@ namespace mxldl::channel } if (_sync) { - auto const timestamp = ::mxlIndexToTimestamp(&_videoRate, grainIndex); + // Interlaced: the time of the frame's second field. + auto const timestamp = _videoReader->syncTimestamp(grainIndex); if (timestamp != std::numeric_limits::max()) { // One wait covers the video grain and the audio sample at @@ -1417,7 +1419,9 @@ namespace mxldl::channel try { newVideo = std::make_unique(readerDomain(_cfg.readerDomainPath), _cfg.videoFlowId.toString()); - formatChanged = formatChanged || !sameVideoFormat(newVideo->configInfo(), _videoConfig); + // The frame rate also tells progressive from interlaced + // (the same grain rate in MXL). + formatChanged = formatChanged || !sameVideoFormat(newVideo->configInfo(), _videoConfig) || !sameRate(newVideo->grainRate(), _videoRate); } catch (std::exception const& e) { diff --git a/src/config/videomodes.cpp b/src/config/videomodes.cpp index f490c83..3076eb5 100644 --- a/src/config/videomodes.cpp +++ b/src/config/videomodes.cpp @@ -16,7 +16,10 @@ namespace mxldl::config return { // SD {"PAL", bmdModePAL, 720, 576, 25, 1, true}, - {"NTSC", bmdModeNTSC, 720, 486, 30000, 1001, true}, + // NTSC is lower field first (IDeckLinkDisplayMode:: + // GetFieldDominance: bmdLowerFieldFirst); the others are upper + // field first. + {"NTSC", bmdModeNTSC, 720, 486, 30000, 1001, true, true}, // HD 720p {"HD720p50", bmdModeHD720p50, 1280, 720, 50, 1, false}, diff --git a/src/config/videomodes.hpp b/src/config/videomodes.hpp index f11575d..9e4dcd0 100644 --- a/src/config/videomodes.hpp +++ b/src/config/videomodes.hpp @@ -19,6 +19,9 @@ namespace mxldl::config std::int64_t rateNumerator = 0; // frame rate (frames per second) std::int64_t rateDenominator = 1; bool interlaced = false; + // Interlaced only: the bottom field (odd frame lines) comes first in + // time (DeckLink bmdLowerFieldFirst: NTSC). Otherwise the top field. + bool bottomFieldFirst = false; [[nodiscard]] double fps() const { diff --git a/src/decklink/mock/mockbackend.cpp b/src/decklink/mock/mockbackend.cpp index 124c655..d550a3d 100644 --- a/src/decklink/mock/mockbackend.cpp +++ b/src/decklink/mock/mockbackend.cpp @@ -10,10 +10,19 @@ // MOCK_SIGNAL_LOSS_DURATION_FRAMES how long the outage lasts (default 50) // MOCK_FORMAT_CHANGE_AFTER_FRAMES frames until a format change to // MOCK_FORMAT_CHANGE_MODE (default HD720p50) +// MOCK_PLAYBACK_FRAME_FILE playback writes every 25th scheduled +// frame's bytes to this file (replaced +// atomically), so a lab run can check +// what an output plays +// +// Interlaced modes: the counter band of the odd frame lines is mid grey, so +// each field can be told apart in the MXL grains. #include #include #include +#include #include +#include #include #include #include @@ -37,6 +46,7 @@ namespace mxldl::dl std::int64_t signalLossDurationFrames = 50; std::int64_t formatChangeAfterFrames = -1; std::string formatChangeMode = "HD720p50"; + std::string playbackFrameFile; }; MockOptions readOptions(config::EnvReader const& env) @@ -58,12 +68,18 @@ namespace mxldl::dl { opt.formatChangeMode = *v; } + if (auto const v = env("MOCK_PLAYBACK_FRAME_FILE"); v && !v->empty()) + { + opt.playbackFrameFile = *v; + } return opt; } /// Fills a v210 frame with 8 vertical color bars plus a moving - /// counter band encoded in the luma of the last 32 rows. - void renderBars(std::vector& frame, std::uint32_t width, std::uint32_t height, std::size_t rowBytes, std::uint64_t counter) + /// counter band encoded in the luma of the last 32 rows. Interlaced: + /// the odd lines' band is mid grey (luma 512). + void renderBars(std::vector& frame, std::uint32_t width, std::uint32_t height, std::size_t rowBytes, std::uint64_t counter, + bool interlaced) { struct YCbCr { @@ -103,7 +119,11 @@ namespace mxldl::dl { // Luma; encode the counter as alternating stripes in // the bottom band so tests can assert frame advance. - if (y >= counterBandStart) + if (y >= counterBandStart && interlaced && (y & 1U) != 0) + { + component = 512; + } + else if (y >= counterBandStart) { bool const bit = ((pixel / 16) + counter) % 2 == 0; component = bit ? 940 : 64; @@ -316,7 +336,7 @@ namespace mxldl::dl } else { - renderBars(videoFrame, mode.width, mode.height, rowBytes, frameCounter); + renderBars(videoFrame, mode.width, mode.height, rowBytes, frameCounter, mode.interlaced); } view.bytes = videoFrame.data(); @@ -402,8 +422,9 @@ namespace mxldl::dl class MockPlaybackSession final : public IPlaybackSession { public: - explicit MockPlaybackSession(std::shared_ptr shared) - : _shared(std::move(shared)) + MockPlaybackSession(std::shared_ptr shared, std::string frameFile) + : _frameFile(std::move(frameFile)) + , _shared(std::move(shared)) {} ~MockPlaybackSession() override @@ -418,10 +439,19 @@ namespace mxldl::dl return std::nullopt; } - std::optional scheduleFrame(std::uint8_t const* /*bytes*/, std::size_t /*size*/, std::int64_t /*displayTime*/, + std::optional scheduleFrame(std::uint8_t const* bytes, std::size_t size, std::int64_t /*displayTime*/, std::int64_t /*displayDuration*/, std::int64_t /*timeScale*/) override { std::uint64_t const id = _nextFrameId.fetch_add(1); + if (!_frameFile.empty() && id % 25 == 0) + { + auto const tmp = _frameFile + ".tmp"; + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + out.write(reinterpret_cast(bytes), static_cast(size)); + } + std::rename(tmp.c_str(), _frameFile.c_str()); + } _bufferedFrames.fetch_add(1); return id; } @@ -540,6 +570,7 @@ namespace mxldl::dl Params _params; PlaybackCallbacks _callbacks; + std::string _frameFile; std::atomic _running{false}; std::atomic _nextFrameId{1}; std::atomic _bufferedFrames{0}; @@ -630,7 +661,7 @@ namespace mxldl::dl std::unique_ptr openPlayback() override { - return std::make_unique(_shared); + return std::make_unique(_shared, _options.playbackFrameFile); } private: diff --git a/src/mxlbridge/flowdef.cpp b/src/mxlbridge/flowdef.cpp index 934988a..033c8e4 100644 --- a/src/mxlbridge/flowdef.cpp +++ b/src/mxlbridge/flowdef.cpp @@ -52,7 +52,7 @@ namespace mxldl::mxlbridge out += "\"frame_width\":" + std::to_string(p.width) + ","; out += "\"frame_height\":" + std::to_string(p.height) + ","; out += "\"interlace_mode\":\""; - out += p.interlaced ? "interlaced_tff" : "progressive"; + out += !p.interlaced ? "progressive" : p.bottomFieldFirst ? "interlaced_bff" : "interlaced_tff"; out += "\","; out += "\"colorspace\":\"BT709\","; out += "\"components\":["; diff --git a/src/mxlbridge/flowdef.hpp b/src/mxlbridge/flowdef.hpp index 9ecb586..6605f0c 100644 --- a/src/mxlbridge/flowdef.hpp +++ b/src/mxlbridge/flowdef.hpp @@ -24,6 +24,7 @@ namespace mxldl::mxlbridge std::int64_t rateNumerator = 0; std::int64_t rateDenominator = 1; bool interlaced = false; + bool bottomFieldFirst = false; // interlaced_bff instead of interlaced_tff bool withAlpha = false; // video/v210a }; @@ -46,7 +47,9 @@ namespace mxldl::mxlbridge std::string groupHint; std::optional sourceId; std::optional deviceId; - std::int64_t rateNumerator = 0; // grain rate follows the video channel + // Grain rate: the video's frame rate, or twice it (the field rate) + // for an interlaced video (one grain per field). + std::int64_t rateNumerator = 0; std::int64_t rateDenominator = 1; }; diff --git a/src/mxlbridge/videoreader.cpp b/src/mxlbridge/videoreader.cpp index f0e5c5b..10a005e 100644 --- a/src/mxlbridge/videoreader.cpp +++ b/src/mxlbridge/videoreader.cpp @@ -1,9 +1,15 @@ // SPDX-License-Identifier: MIT #include "videoreader.hpp" +#include #include #include +#include +#include + +#include "util/interlace.hpp" + namespace mxldl::mxlbridge { VideoReader::VideoReader(Domain& domain, std::string flowId) @@ -21,6 +27,36 @@ namespace mxldl::mxlbridge _reader = nullptr; throw std::runtime_error("mxlFlowReaderGetConfigInfo (video " + _flowId + ") failed"); } + _frameRate = _configInfo.common.grainRate; + + // interlace_mode and frame_height are in the flow definition only. + std::vector def(16384); + auto size = def.size(); + auto status2 = ::mxlGetFlowDef(domain.instance(), _flowId.c_str(), def.data(), &size); + if (status2 == MXL_ERR_INVALID_ARG && size > def.size()) + { + def.resize(size); + status2 = ::mxlGetFlowDef(domain.instance(), _flowId.c_str(), def.data(), &size); + } + picojson::value root; + if (status2 == MXL_STATUS_OK && picojson::parse(root, std::string(def.data())).empty() && root.is()) + { + auto const& obj = root.get(); + auto const mode = obj.find("interlace_mode"); + auto const height = obj.find("frame_height"); + if (mode != obj.end() && mode->second.is() && height != obj.end() && height->second.is()) + { + auto const& m = mode->second.get(); + _interlaced = m == "interlaced_tff" || m == "interlaced_bff"; + _bottomFieldFirst = m == "interlaced_bff"; + _frameHeight = static_cast(height->second.get()); + } + } + if (_interlaced) + { + // MXL reports the field rate (twice the flow definition's). + _frameRate.numerator /= 2; + } } VideoReader::~VideoReader() @@ -33,10 +69,47 @@ namespace mxldl::mxlbridge mxlStatus VideoReader::getGrain(std::uint64_t index, std::uint64_t timeoutNs, Grain& out) { - std::uint8_t* payload = nullptr; - auto const status = ::mxlFlowReaderGetGrain(_reader, index, timeoutNs, &out.info, &payload); - out.payload = payload; - return status; + if (!_interlaced) + { + std::uint8_t* payload = nullptr; + auto const status = ::mxlFlowReaderGetGrain(_reader, index, timeoutNs, &out.info, &payload); + out.payload = payload; + return status; + } + + // Frame `index`: its fields are grains 2k and 2k + 1, both read + // within the timeout. + auto const start = std::chrono::steady_clock::now(); + Grain field[2]; + for (std::uint64_t i = 0; i < 2; ++i) + { + auto const elapsed = static_cast(std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count()); + std::uint8_t* payload = nullptr; + auto const status = ::mxlFlowReaderGetGrain(_reader, 2 * index + i, timeoutNs > elapsed ? timeoutNs - elapsed : 0, &field[i].info, &payload); + field[i].payload = payload; + if (status != MXL_STATUS_OK) + { + return status; + } + } + out.info = field[0].info; + if (field[0].payload == nullptr || field[1].payload == nullptr || ((field[0].info.flags | field[1].info.flags) & MXL_GRAIN_FLAG_INVALID) != 0) + { + out.info.flags |= MXL_GRAIN_FLAG_INVALID; + out.payload = nullptr; + return MXL_STATUS_OK; + } + // Each field's v210 fill rows into the frame lines of its parity + // (for v210a the key rows are left out: the output plays the fill). + std::size_t const lineBytes = _configInfo.discrete.sliceSizes[0]; + _woven.resize(lineBytes * _frameHeight); + for (std::uint64_t i = 0; i < 2; ++i) + { + util::weaveField(field[i].payload, lineBytes, _frameHeight, util::fieldParity(2 * index + i, _bottomFieldFirst), _woven.data(), lineBytes); + } + out.payload = _woven.data(); + out.info.grainSize = static_cast(_woven.size()); + return MXL_STATUS_OK; } std::uint64_t VideoReader::headIndex() const @@ -46,7 +119,16 @@ namespace mxldl::mxlbridge { return 0; } - return info.headIndex; + return _interlaced ? util::frameHeadOfFieldHead(info.headIndex) : info.headIndex; + } + + std::uint64_t VideoReader::syncTimestamp(std::uint64_t index) const + { + if (_interlaced) + { + return ::mxlIndexToTimestamp(&_configInfo.common.grainRate, 2 * index + 1); + } + return ::mxlIndexToTimestamp(&_frameRate, index); } bool VideoReader::flowInvalid() const diff --git a/src/mxlbridge/videoreader.hpp b/src/mxlbridge/videoreader.hpp index be4bcab..4ed1471 100644 --- a/src/mxlbridge/videoreader.hpp +++ b/src/mxlbridge/videoreader.hpp @@ -1,9 +1,15 @@ // SPDX-License-Identifier: MIT // Video grain reading for the output path (SPECIFICATION.md §2.4). +// +// An interlaced flow (one field per grain at twice the frame rate, +// util/interlace.hpp) is read as frames: every index, rate, head and count of +// this class is then a frame (k), and getGrain(k) weaves the field grains 2k +// and 2k + 1 into one frame. A progressive flow is read grain by grain. #pragma once #include #include +#include #include #include @@ -31,22 +37,43 @@ namespace mxldl::mxlbridge std::uint8_t const* payload = nullptr; }; - /// Blocking fetch with timeout (CHx_READER_TIMEOUT_MS). + /// Blocking fetch with timeout (CHx_READER_TIMEOUT_MS). Interlaced: + /// frame `index`, both fields within the timeout, woven into a buffer + /// of this reader (valid until the next call); a frame with an + /// invalid field is returned invalid (flag set, no payload). mxlStatus getGrain(std::uint64_t index, std::uint64_t timeoutNs, Grain& out); + /// Frames per second (interlaced: half MXL's field grain rate). [[nodiscard]] mxlRational grainRate() const { - return _configInfo.common.grainRate; + return _frameRate; } + /// Frames the ring holds. [[nodiscard]] std::uint32_t grainCount() const { - return _configInfo.discrete.grainCount; + return _interlaced ? _configInfo.discrete.grainCount / 2 : _configInfo.discrete.grainCount; } - /// Current head index of the flow ring (for lag metrics §7.3). + /// Newest frame of the flow ring (interlaced: with both fields), for + /// lag metrics §7.3 and the read position. [[nodiscard]] std::uint64_t headIndex() const; + /// TAI time at which frame `index` is complete for a sync group wait: + /// the time of its last grain (interlaced: the second field). + [[nodiscard]] std::uint64_t syncTimestamp(std::uint64_t index) const; + + /// interlace_mode of the flow definition is interlaced_tff or _bff. + [[nodiscard]] bool interlaced() const + { + return _interlaced; + } + + [[nodiscard]] bool bottomFieldFirst() const + { + return _bottomFieldFirst; + } + /// True when MXL reports that the writer re-created the flow (its /// data file changed): a non-blocking read past the head returns /// MXL_ERR_FLOW_INVALID. Does not wait. @@ -72,5 +99,10 @@ namespace mxldl::mxlbridge std::string _flowId; mxlFlowReader _reader = nullptr; mxlFlowConfigInfo _configInfo{}; + mxlRational _frameRate{0, 1}; + bool _interlaced = false; + bool _bottomFieldFirst = false; + std::uint32_t _frameHeight = 0; + std::vector _woven; // interlaced: the frame getGrain returns }; } diff --git a/src/mxlbridge/videowriter.cpp b/src/mxlbridge/videowriter.cpp index 6af7aec..65ca250 100644 --- a/src/mxlbridge/videowriter.cpp +++ b/src/mxlbridge/videowriter.cpp @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT #include "videowriter.hpp" +#include #include #include +#include "util/interlace.hpp" #include "util/logging.hpp" namespace mxldl::mxlbridge @@ -67,6 +69,26 @@ namespace mxldl::mxlbridge return status; } + mxlStatus VideoWriter::writeField(std::uint64_t grainIndex, std::uint8_t const* frameData, std::size_t frameRowBytes, std::uint32_t frameHeight, + unsigned parity) + { + mxlGrainInfo info{}; + std::uint8_t* payload = nullptr; + auto status = ::mxlFlowWriterOpenGrain(_writer, grainIndex, &info, &payload); + if (status != MXL_STATUS_OK) + { + return status; + } + // The grain's v210 line length (slice 0); the fill rows come first. + // Never more rows than the grain holds (like writeFrame's cut). + std::size_t const lineBytes = _configInfo.discrete.sliceSizes[0]; + auto const fieldRows = lineBytes > 0 ? std::min(frameHeight / 2, info.grainSize / lineBytes) : 0; + util::extractField(frameData, frameRowBytes, static_cast(fieldRows * 2), parity, payload, lineBytes); + info.flags = 0; + info.validSlices = info.totalSlices; + return ::mxlFlowWriterCommitGrain(_writer, &info); + } + mxlStatus VideoWriter::writeFrameWithKey(std::uint64_t grainIndex, std::uint8_t const* fill, std::size_t fillBytes, std::uint8_t const* key, std::size_t keyBytes) { diff --git a/src/mxlbridge/videowriter.hpp b/src/mxlbridge/videowriter.hpp index bb398b1..650241a 100644 --- a/src/mxlbridge/videowriter.hpp +++ b/src/mxlbridge/videowriter.hpp @@ -29,6 +29,13 @@ namespace mxldl::mxlbridge /// or the fill-plane size for v210a. Returns MXL status. mxlStatus writeFrame(std::uint64_t grainIndex, std::uint8_t const* frameData, std::size_t frameBytes); + /// Interlaced flow: copies the frame lines of `parity` (parity, + /// parity + 2, ...) of a woven v210 frame into the field grain at + /// `grainIndex` (height / 2 rows) and commits it. For v210a only the + /// fill rows are written. + mxlStatus writeField(std::uint64_t grainIndex, std::uint8_t const* frameData, std::size_t frameRowBytes, std::uint32_t frameHeight, + unsigned parity); + /// Writes fill+key planes for video/v210a flows. mxlStatus writeFrameWithKey(std::uint64_t grainIndex, std::uint8_t const* fill, std::size_t fillBytes, std::uint8_t const* key, std::size_t keyBytes); diff --git a/src/nmos/node.cpp b/src/nmos/node.cpp index d9c5a4a..8940262 100644 --- a/src/nmos/node.cpp +++ b/src/nmos/node.cpp @@ -73,7 +73,11 @@ namespace mxldl::nmosnode nmos::interlace_mode interlaceOf(config::VideoMode const& mode) { - return mode.interlaced ? nmos::interlace_modes::interlaced_tff : nmos::interlace_modes::progressive; + if (!mode.interlaced) + { + return nmos::interlace_modes::progressive; + } + return mode.bottomFieldFirst ? nmos::interlace_modes::interlaced_bff : nmos::interlace_modes::interlaced_tff; } nmos::media_type videoMedia(config::ChannelConfig const& ch) diff --git a/src/util/interlace.cpp b/src/util/interlace.cpp new file mode 100644 index 0000000..a323e21 --- /dev/null +++ b/src/util/interlace.cpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +#include "interlace.hpp" + +#include +#include + +#include + +namespace mxldl::util +{ + std::uint64_t firstFieldGrainIndex(mxlRational const& frameRate, std::uint64_t arrivalTai) + { + auto const periodNs = 1'000'000'000ULL * static_cast(frameRate.denominator) / static_cast(frameRate.numerator); + return 2 * ::mxlTimestampToIndex(&frameRate, arrivalTai > periodNs ? arrivalTai - periodNs : 0); + } + + void extractField(std::uint8_t const* frame, std::size_t frameRowBytes, std::uint32_t frameHeight, unsigned parity, std::uint8_t* field, + std::size_t fieldRowBytes) + { + auto const n = std::min(frameRowBytes, fieldRowBytes); + for (std::uint32_t r = 0; r < frameHeight / 2; ++r) + { + std::memcpy(field + r * fieldRowBytes, frame + (2 * static_cast(r) + parity) * frameRowBytes, n); + } + } + + void weaveField(std::uint8_t const* field, std::size_t fieldRowBytes, std::uint32_t frameHeight, unsigned parity, std::uint8_t* frame, + std::size_t frameRowBytes) + { + auto const n = std::min(frameRowBytes, fieldRowBytes); + for (std::uint32_t r = 0; r < frameHeight / 2; ++r) + { + std::memcpy(frame + (2 * static_cast(r) + parity) * frameRowBytes, field + r * fieldRowBytes, n); + } + } + + bool ancLineInSecondField(std::uint32_t frameHeight, std::uint32_t line) + { + switch (frameHeight) + { + case 1080: return line >= 564; + case 576: return line >= 313; + case 486: return line >= 266 || line <= 3; + default: return false; + } + } +} diff --git a/src/util/interlace.hpp b/src/util/interlace.hpp new file mode 100644 index 0000000..d63371c --- /dev/null +++ b/src/util/interlace.hpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// Interlaced video in MXL (SPECIFICATION.md §2.3, §2.4). Shared convention of +// the media functions: an interlaced flow (interlace_mode interlaced_tff or +// interlaced_bff, grain_rate = the frame rate in the flow definition) holds +// one field per grain (height / 2 lines) at twice the frame rate. Grain 2k is +// the first field in time of TAI frame k, grain 2k + 1 the second. tff: the +// first field is the top field (even frame lines 0, 2, 4, ...); bff: the +// bottom field (odd lines). A reader weaves grain i into the frame lines of +// parity (i & 1) ^ bff. ANC of an interlaced video runs at the field rate, +// with the same index as the video field. +#pragma once + +#include +#include + +#include + +namespace mxldl::util +{ + /// Frame line parity (0 = even lines, 1 = odd lines) of the field in grain + /// `grainIndex` of an interlaced flow. + constexpr unsigned fieldParity(std::uint64_t grainIndex, bool bottomFieldFirst) + { + return static_cast(grainIndex & 1U) ^ (bottomFieldFirst ? 1U : 0U); + } + + /// Frame k of an interlaced flow whose newest grain (field) is + /// `fieldHead`: the newest frame with both fields written. 0 stays 0 (no + /// grain yet). + constexpr std::uint64_t frameHeadOfFieldHead(std::uint64_t fieldHead) + { + return fieldHead == 0 ? 0 : (fieldHead - 1) / 2; + } + + /// Capture: grain index 2k of the first field of the frame that arrived + /// at TAI `arrivalTai`. k is the TAI frame index (at the frame rate) of + /// the frame's start, one frame period before it arrived. + std::uint64_t firstFieldGrainIndex(mxlRational const& frameRate, std::uint64_t arrivalTai); + + /// Copies the frame lines of `parity` (parity, parity + 2, ...) into + /// consecutive rows of `field` (frameHeight / 2 rows). Each row copies + /// min(frameRowBytes, fieldRowBytes) bytes. + void extractField(std::uint8_t const* frame, std::size_t frameRowBytes, std::uint32_t frameHeight, unsigned parity, std::uint8_t* field, + std::size_t fieldRowBytes); + + /// The inverse: the frameHeight / 2 rows of `field` into the frame lines + /// of `parity`. + void weaveField(std::uint8_t const* field, std::size_t fieldRowBytes, std::uint32_t frameHeight, unsigned parity, std::uint8_t* frame, + std::size_t frameRowBytes); + + /// True when SDI line `line` (SMPTE line number, as IDeckLinkAncillary + /// Packet::GetLineNumber reports it) of an interlaced mode with + /// `frameHeight` active lines lies in the second field in time: 1080i + /// (SMPTE ST 274) lines 564-1125, 625-line PAL (ITU-R BT.656) lines + /// 313-625, 525-line NTSC lines 266-525 and 1-3. Other heights: false. + bool ancLineInSecondField(std::uint32_t frameHeight, std::uint32_t line); +} diff --git a/src/version.hpp b/src/version.hpp index a883132..9830f33 100644 --- a/src/version.hpp +++ b/src/version.hpp @@ -3,5 +3,5 @@ namespace mxldl { - inline constexpr char const* kVersion = "1.0.4"; + inline constexpr char const* kVersion = "1.0.5"; } diff --git a/tests/unit/test_flow_reopen.cpp b/tests/unit/test_flow_reopen.cpp index 3d38eeb..848acb6 100644 --- a/tests/unit/test_flow_reopen.cpp +++ b/tests/unit/test_flow_reopen.cpp @@ -27,7 +27,9 @@ #include "mxlbridge/domain.hpp" #include "mxlbridge/videowriter.hpp" #include "ops/metrics.hpp" +#include "util/interlace.hpp" #include "util/logging.hpp" +#include "util/v210.hpp" using namespace mxldl; namespace fs = std::filesystem; @@ -105,6 +107,9 @@ namespace std::atomic deliveryLatencyUs{0}; // every completion call comes this late (a real card's latency) std::atomic reportStreamTime{true}; // the card reports its playback position std::atomic streamWholeFrames{false}; // ... in whole frames only + std::atomic keepBytes{false}; // keep the last scheduled frame's bytes + std::vector lastBytes; + std::uint32_t audioSampleFrames = 0; // of the last audio block /// Median of audio block - video grain over the pairs from `from`: /// constant while audio and video stay in sync. @@ -212,6 +217,10 @@ namespace { std::lock_guard const lock{_played->mutex}; _played->frames.push_back(size > 0 ? bytes[0] : 0); + if (_played->keepBytes) + { + _played->lastBytes.assign(bytes, bytes + size); + } if (size >= kStampAt + sizeof(std::uint64_t)) { std::uint64_t index = 0; @@ -239,6 +248,7 @@ namespace } std::lock_guard const lock{_played->mutex}; ++_played->audioBlocks; + _played->audioSampleFrames = sampleFrames; _played->audioBlocksWithSound += sound ? 1 : 0; if (sound && !_played->grains.empty()) { @@ -565,6 +575,62 @@ namespace Pump _pump; // last: stops before the writer goes }; + /// An interlaced video writer (util/interlace.hpp): frame k of the + /// current TAI time as field grains 2k and 2k + 1. Byte c of frame line r + /// is the low byte of r * 7 + c; line 0 carries k at kStampAt. + class InterlacedSource + { + public: + InterlacedSource(mxlbridge::Domain& domain, mxlbridge::VideoFlowParams const& p) + : _writer(domain, p, 1) + , _rate{p.rateNumerator, p.rateDenominator} + , _bff(p.bottomFieldFirst) + , _height(p.height) + , _lineBytes(util::v210RowBytes(p.width)) + , _pump([this] { + auto const k = ::mxlGetCurrentIndex(&_rate); + if (k <= _last) + { + return; + } + std::memcpy(_frame.data() + kStampAt, &k, sizeof k); + auto const first = util::fieldParity(2 * k, _bff); + (void)_writer.writeField(2 * k, _frame.data(), _lineBytes, _height, first); + (void)_writer.writeField(2 * k + 1, _frame.data(), _lineBytes, _height, first ^ 1U); + _last = k; + }) + {} + + /// The frame without its stamp. + static std::uint8_t expected(std::uint32_t row, std::size_t column) + { + return static_cast(row * 7 + column); + } + + private: + static std::vector pattern(std::size_t lineBytes, std::uint32_t height) + { + std::vector frame(lineBytes * height); + for (std::uint32_t r = 0; r < height; ++r) + { + for (std::size_t c = 0; c < lineBytes; ++c) + { + frame[r * lineBytes + c] = expected(r, c); + } + } + return frame; + } + + mxlbridge::VideoWriter _writer; + mxlRational _rate; + bool _bff; + std::uint32_t _height; + std::size_t _lineBytes; + std::vector _frame = pattern(_lineBytes, _height); + std::uint64_t _last = 0; + Pump _pump; // last: stops before the writer goes + }; + /// AudioSource value that stamps each sample with its block number. constexpr std::int32_t kStamped = -1; @@ -1514,3 +1580,72 @@ TEST_CASE("an idle mirror whose first grain needs 2.5 s: the output waits in ini video.reset(); audio.reset(); } + +TEST_CASE("an interlaced flow plays as woven frames at the frame rate, with a frame's worth of audio per frame (tff PAL, bff NTSC)") +{ + for (char const* name : {"PAL", "NTSC"}) + { + CAPTURE(name); + Rig rig(name); + auto const mode = *config::lookupVideoMode(name); + rig.video.width = mode.width; + rig.video.height = mode.height; + rig.video.rateNumerator = mode.rateNumerator; + rig.video.rateDenominator = mode.rateDenominator; + rig.video.interlaced = true; + rig.video.bottomFieldFirst = mode.bottomFieldFirst; + rig.ch.videoModeName = mode.name; + rig.ch.videoMode = mode; + rig.withAudio(); + rig.played->keepBytes = true; + InterlacedSource video(*rig.domain, rig.video); + AudioSource audio(*rig.domain, rig.audio, kStamped); + std::this_thread::sleep_for(300ms); + + channel::OutputChannel out(rig.global, rig.ch, *rig.sub, *rig.domain, rig.metrics, "test"); + out.start(); + REQUIRE(waitUntil([&] { return out.status().state.load() == channel::State::Healthy; }, 3s)); + std::this_thread::sleep_for(500ms); + auto const from = rig.played->grainCount(); + auto const t0 = std::chrono::steady_clock::now(); + std::this_thread::sleep_for(2s); + auto const seconds = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + auto const scheduled = static_cast(rig.played->grainCount() - from); + out.stop(); + + // One frame (two field grains) per output frame: the frame index + // goes up by one per frame, at the frame rate. + CHECK(scheduled / seconds == doctest::Approx(mode.fps()).epsilon(0.15)); + CHECK(rig.played->largestGrainStep(from) == 1); + CHECK(rig.counter("mxl_decklink_frames_repeated_total", "reason", "source_late") == 0.0); + // 48 kHz / frame rate: 1920 samples at 25 frames/s, 1601 or 1602 at + // 29.97 (the integer part, as for progressive 59.94). + CHECK(rig.played->audioSampleFrames == static_cast(48000 * mode.rateDenominator / mode.rateNumerator)); + if (mode.rateDenominator == 1) + { + // Not at 29.97: the audio read position is frame index x + // samples per frame, which is off the TAI sample grid when the + // samples per frame are not whole (also for progressive 59.94). + CHECK(rig.played->soundBlocks() > 0); + } + + // The last frame shown: every line back in its place. + std::lock_guard const lock{rig.played->mutex}; + auto const& frame = rig.played->lastBytes; + std::size_t const lineBytes = util::v210RowBytes(mode.width); + REQUIRE(frame.size() == lineBytes * mode.height); + std::size_t wrong = 0; + for (std::uint32_t r = 0; r < mode.height; ++r) + { + for (std::size_t c = 0; c < lineBytes; ++c) + { + if (r == 0 && c >= kStampAt && c < kStampAt + sizeof(std::uint64_t)) + { + continue; + } + wrong += frame[r * lineBytes + c] != InterlacedSource::expected(r, c) ? 1 : 0; + } + } + CHECK(wrong == 0); + } +} diff --git a/tests/unit/test_flowdef.cpp b/tests/unit/test_flowdef.cpp index 4c40439..22d503c 100644 --- a/tests/unit/test_flowdef.cpp +++ b/tests/unit/test_flowdef.cpp @@ -38,6 +38,10 @@ TEST_CASE("video flow definition JSON carries the MXL v1.0 required fields") p.interlaced = true; auto const jsonI = buildVideoFlowDef(p); CHECK(jsonI.find("\"interlace_mode\":\"interlaced_tff\"") != std::string::npos); + + p.bottomFieldFirst = true; + auto const jsonB = buildVideoFlowDef(p); + CHECK(jsonB.find("\"interlace_mode\":\"interlaced_bff\"") != std::string::npos); } TEST_CASE("audio flow definition JSON") diff --git a/tests/unit/test_interlace.cpp b/tests/unit/test_interlace.cpp new file mode 100644 index 0000000..f30b6fe --- /dev/null +++ b/tests/unit/test_interlace.cpp @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +// Interlaced video in MXL (util/interlace.hpp): field split and weave, the +// index and rate math, and an interlaced flow written field by field and read +// back as woven frames. +#include +#include +#include + +#include + +#include +#include +#include + +#include "config/videomodes.hpp" +#include "mxlbridge/domain.hpp" +#include "mxlbridge/flowdef.hpp" +#include "mxlbridge/videoreader.hpp" +#include "mxlbridge/videowriter.hpp" +#include "util/interlace.hpp" +#include "util/v210.hpp" + +using namespace mxldl; +namespace fs = std::filesystem; + +namespace +{ + /// A frame whose every byte of line r is the low byte of r * 7 + column. + std::vector makeFrame(std::size_t rowBytes, std::uint32_t height) + { + std::vector frame(rowBytes * height); + for (std::uint32_t r = 0; r < height; ++r) + { + for (std::size_t c = 0; c < rowBytes; ++c) + { + frame[r * rowBytes + c] = static_cast(r * 7 + c); + } + } + return frame; + } + + bool rowEquals(std::uint8_t const* a, std::uint8_t const* b, std::size_t n) + { + return std::memcmp(a, b, n) == 0; + } +} + +TEST_CASE("field parity per grain index: tff even lines first, bff odd lines first") +{ + CHECK(util::fieldParity(0, false) == 0); + CHECK(util::fieldParity(1, false) == 1); + CHECK(util::fieldParity(100, false) == 0); + CHECK(util::fieldParity(101, false) == 1); + CHECK(util::fieldParity(0, true) == 1); + CHECK(util::fieldParity(1, true) == 0); + CHECK(util::fieldParity(100, true) == 1); + CHECK(util::fieldParity(101, true) == 0); +} + +TEST_CASE("split a frame into its fields and weave them back: every line back in place (tff and bff)") +{ + std::uint32_t const height = 1080; + std::size_t const rowBytes = util::v210RowBytes(1920); + auto const frame = makeFrame(rowBytes, height); + + for (bool const bff : {false, true}) + { + CAPTURE(bff); + // Grains 2k and 2k + 1 of one frame. + std::vector field[2]; + for (unsigned i = 0; i < 2; ++i) + { + field[i].assign(rowBytes * height / 2, 0); + util::extractField(frame.data(), rowBytes, height, util::fieldParity(2 * 21 + i, bff), field[i].data(), rowBytes); + } + // The first field (grain 2k) holds the even lines for tff, the odd + // lines for bff. + unsigned const firstParity = bff ? 1 : 0; + for (std::uint32_t r = 0; r < height / 2; ++r) + { + REQUIRE(rowEquals(field[0].data() + r * rowBytes, frame.data() + (2 * r + firstParity) * rowBytes, rowBytes)); + REQUIRE(rowEquals(field[1].data() + r * rowBytes, frame.data() + (2 * r + (firstParity ^ 1)) * rowBytes, rowBytes)); + } + + std::vector woven(frame.size(), 0); + for (unsigned i = 0; i < 2; ++i) + { + util::weaveField(field[i].data(), rowBytes, height, util::fieldParity(2 * 21 + i, bff), woven.data(), rowBytes); + } + CHECK(woven == frame); + } +} + +TEST_CASE("field split with a wider source stride (an 8-bit row or a padded card buffer) copies the grain's line length") +{ + std::uint32_t const height = 486; // NTSC + std::size_t const srcRow = 2048; + std::size_t const lineBytes = util::v210RowBytes(720); + auto const frame = makeFrame(srcRow, height); + std::vector field(lineBytes * height / 2, 0); + util::extractField(frame.data(), srcRow, height, 1, field.data(), lineBytes); + for (std::uint32_t r = 0; r < height / 2; ++r) + { + REQUIRE(rowEquals(field.data() + r * lineBytes, frame.data() + (2 * r + 1) * srcRow, lineBytes)); + } +} + +TEST_CASE("frame index k from the frame's start time: grain 2k for the first field, the field timestamps on the TAI grid") +{ + mxlRational const i50{25, 1}; + mxlRational const field50{50, 1}; + std::uint64_t const k = 44'803'311'586ULL; + auto const frameStart = ::mxlIndexToTimestamp(&i50, k); + // The frame arrives one period after its start, a few ms late or early. + for (std::int64_t const lateMs : {0, 3, 15, -15}) + { + CAPTURE(lateMs); + auto const arrival = static_cast(static_cast(frameStart + 40'000'000ULL) + lateMs * 1'000'000LL); + CHECK(util::firstFieldGrainIndex(i50, arrival) == 2 * k); + } + // Grain 2k of the field-rate flow is the frame's start, 2k + 1 half a + // frame later. + CHECK(::mxlIndexToTimestamp(&field50, 2 * k) == frameStart); + CHECK(::mxlIndexToTimestamp(&field50, 2 * k + 1) == frameStart + 20'000'000ULL); + + mxlRational const i5994{30000, 1001}; + mxlRational const field5994{60000, 1001}; + std::uint64_t const k2 = 53'709'000'000ULL; + auto const start2 = ::mxlIndexToTimestamp(&i5994, k2); + CHECK(util::firstFieldGrainIndex(i5994, start2 + 33'366'667ULL + 2'000'000ULL) == 2 * k2); + CHECK(::mxlIndexToTimestamp(&field5994, 2 * k2) == start2); +} + +TEST_CASE("newest complete frame of a field head") +{ + CHECK(util::frameHeadOfFieldHead(0) == 0); + CHECK(util::frameHeadOfFieldHead(1) == 0); + CHECK(util::frameHeadOfFieldHead(2) == 0); // frame 1 has its first field only + CHECK(util::frameHeadOfFieldHead(3) == 1); + CHECK(util::frameHeadOfFieldHead(2 * 1000 + 1) == 1000); + CHECK(util::frameHeadOfFieldHead(2 * 1000) == 999); +} + +TEST_CASE("ANC packets by field: SMPTE line numbers of the second field") +{ + // 1080i: field 1 lines 1-563, field 2 lines 564-1125. + CHECK_FALSE(util::ancLineInSecondField(1080, 9)); + CHECK_FALSE(util::ancLineInSecondField(1080, 563)); + CHECK(util::ancLineInSecondField(1080, 564)); + CHECK(util::ancLineInSecondField(1080, 572)); + // 625: field 1 lines 1-312, field 2 lines 313-625. + CHECK_FALSE(util::ancLineInSecondField(576, 23)); + CHECK(util::ancLineInSecondField(576, 336)); + // 525: field 1 lines 4-265, field 2 lines 266-525 and 1-3. + CHECK_FALSE(util::ancLineInSecondField(486, 21)); + CHECK(util::ancLineInSecondField(486, 284)); + CHECK(util::ancLineInSecondField(486, 2)); + // Progressive heights: no second field. + CHECK_FALSE(util::ancLineInSecondField(720, 600)); +} + +TEST_CASE("field order and ANC rate of the interlaced modes") +{ + CHECK(config::lookupVideoMode("NTSC")->bottomFieldFirst); + CHECK_FALSE(config::lookupVideoMode("PAL")->bottomFieldFirst); + CHECK_FALSE(config::lookupVideoMode("HD1080i50")->bottomFieldFirst); + CHECK_FALSE(config::lookupVideoMode("HD1080i5994")->bottomFieldFirst); + CHECK_FALSE(config::lookupVideoMode("HD1080p50")->bottomFieldFirst); +} + +TEST_CASE("an interlaced flow written field by field reads back as woven frames (tff PAL, bff NTSC)") +{ + auto const path = fs::temp_directory_path() / ("mxldl-interlace-" + std::to_string(::getpid())); + fs::remove_all(path); + { + mxlbridge::Domain domain(path.string()); + struct Case + { + char const* mode; + char const* id; + char const* interlaceMode; + }; + for (auto const& c : {Case{"PAL", "7a0c2e11-1c1d-4a55-9a6e-0b1c2d3e4f01", "interlaced_tff"}, + Case{"NTSC", "7a0c2e11-1c1d-4a55-9a6e-0b1c2d3e4f02", "interlaced_bff"}}) + { + CAPTURE(c.mode); + auto const mode = *config::lookupVideoMode(c.mode); + mxlbridge::VideoFlowParams p; + p.id = *util::parseUuid(c.id); + p.label = c.mode; + p.groupHint = "interlace:Video"; + p.width = mode.width; + p.height = mode.height; + p.rateNumerator = mode.rateNumerator; + p.rateDenominator = mode.rateDenominator; + p.interlaced = mode.interlaced; + p.bottomFieldFirst = mode.bottomFieldFirst; + mxlbridge::VideoWriter writer(domain, p, 1); + + std::size_t const lineBytes = util::v210RowBytes(mode.width); + // One field per grain at twice the frame rate. + CHECK(writer.grainPayloadSize() == lineBytes * mode.height / 2); + CHECK(writer.grainRate().numerator == 2 * mode.rateNumerator); + CHECK(std::string(mxlbridge::buildVideoFlowDef(p)).find(std::string("\"interlace_mode\":\"") + c.interlaceMode + "\"") != std::string::npos); + + auto const frame = makeFrame(lineBytes, mode.height); + mxlRational const frameRate{mode.rateNumerator, mode.rateDenominator}; + auto const k = ::mxlGetCurrentIndex(&frameRate); + auto const first = util::fieldParity(2 * k, mode.bottomFieldFirst); + REQUIRE(writer.writeField(2 * k, frame.data(), lineBytes, mode.height, first) == MXL_STATUS_OK); + REQUIRE(writer.writeField(2 * k + 1, frame.data(), lineBytes, mode.height, first ^ 1U) == MXL_STATUS_OK); + + mxlbridge::VideoReader reader(domain, c.id); + CHECK(reader.interlaced()); + CHECK(reader.bottomFieldFirst() == mode.bottomFieldFirst); + CHECK(reader.grainRate().numerator == mode.rateNumerator); + CHECK(reader.grainRate().denominator == mode.rateDenominator); + CHECK(reader.headIndex() == k); + // The sync wait waits for the second field: half a frame later. + mxlRational const fieldRate{2 * mode.rateNumerator, mode.rateDenominator}; + CHECK(reader.syncTimestamp(k) == ::mxlIndexToTimestamp(&fieldRate, 2 * k + 1)); + CHECK(reader.syncTimestamp(k) - ::mxlIndexToTimestamp(&frameRate, k) == doctest::Approx(500'000'000.0 * mode.rateDenominator / mode.rateNumerator).epsilon(1e-6)); + + // The raw grains: 2k is the first field in time (even lines for + // tff, odd lines for bff), each height / 2 lines. + mxlGrainInfo info{}; + std::uint8_t* payload = nullptr; + REQUIRE(::mxlFlowReaderGetGrain(reader.handle(), 2 * k, 0, &info, &payload) == MXL_STATUS_OK); + CHECK(info.grainSize == lineBytes * mode.height / 2); + for (std::uint32_t r = 0; r < mode.height / 2; ++r) + { + REQUIRE(rowEquals(payload + r * lineBytes, frame.data() + (2 * r + (mode.bottomFieldFirst ? 1 : 0)) * lineBytes, lineBytes)); + } + + // Frame k woven from both fields: every line back in place. + mxlbridge::VideoReader::Grain grain; + REQUIRE(reader.getGrain(k, 0, grain) == MXL_STATUS_OK); + REQUIRE(grain.payload != nullptr); + CHECK((grain.info.flags & MXL_GRAIN_FLAG_INVALID) == 0); + REQUIRE(grain.info.grainSize == frame.size()); + CHECK(std::memcmp(grain.payload, frame.data(), frame.size()) == 0); + + // Frame k + 1 with only its first field written is not there yet. + REQUIRE(writer.writeField(2 * k + 2, frame.data(), lineBytes, mode.height, first) == MXL_STATUS_OK); + CHECK(reader.headIndex() == k); + CHECK(reader.getGrain(k + 1, 0, grain) == MXL_ERR_OUT_OF_RANGE_TOO_EARLY); + } + } + std::error_code ec; + fs::remove_all(path, ec); +} diff --git a/web/src/api.js b/web/src/api.js index 8e380e6..10fde3f 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -106,8 +106,12 @@ export function modeNameForFlow(flow, options) { : String(flow.rate_numerator); if (!fps) return null; let name = null; - if (flow.height === 720) name = `HD720p${fps}`; - else if (flow.height === 1080) name = flow.interlaced ? `HD1080i${fps}` : `HD1080p${fps}`; + // An interlaced flow's grain_rate is the frame rate; the mode names the + // field rate (HD1080i50 = 25 frames/s). + const interlacedName = { 25: { 576: "PAL", 1080: "HD1080i50" }, 2997: { 486: "NTSC", 1080: "HD1080i5994" } }[fps]; + if (flow.interlaced) name = interlacedName ? interlacedName[flow.height] ?? null : null; + else if (flow.height === 720) name = `HD720p${fps}`; + else if (flow.height === 1080) name = `HD1080p${fps}`; else if (flow.height === 2160) name = `4K2160p${fps}`; else if (flow.height === 4320) name = `8K4320p${fps}`; return options.includes(name) ? name : null; From 4786562ec3b94c26b36d3ee3e70df642db5d5a6d Mon Sep 17 00:00:00 2001 From: Adrian Hilber Date: Fri, 16 Oct 2026 09:43:08 +0200 Subject: [PATCH 2/2] Interlaced review round 2: ANC F bits, NMOS ANC field rate, reader warnings - RFC 8331 F bits: interlaced ANC grain 2k gets 0b10 (field 1), 2k + 1 gets 0b11 (field 2); progressive keeps 0b00 (as mxl-test-player 1.2.3). - ANC line -> field: NTSC line 0 (unknown) is field 1, not field 2. - NMOS ANC flow grain_rate and the ANC receiver caps follow the MXL flow definition: the field rate for interlaced modes; video keeps the frame rate. - VideoReader logs mxl_flow_def_unreadable when the flow definition cannot be read or parsed (the flow is then read as progressive), and returns a frame with an invalid first field without waiting for the second. - Docs: ANC empty grains only when the other field has packets; grain counters count two grains per interlaced frame. - Tests: F bits, line 0, invalid first field, and an InputChannel capturing PAL from a fake card (field grains, counters, ANC per field with F bits). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01St7QwmonpoG9EEEqaLFXLi --- CHANGELOG.md | 8 +- SPECIFICATION.md | 7 +- src/channel/input_channel.cpp | 15 +-- src/mxlbridge/ancwriter.cpp | 4 +- src/mxlbridge/ancwriter.hpp | 5 +- src/mxlbridge/videoreader.cpp | 27 +++- src/nmos/node.cpp | 14 ++- src/util/anc.cpp | 4 +- src/util/anc.hpp | 10 +- src/util/interlace.cpp | 2 +- src/util/interlace.hpp | 3 +- tests/unit/test_anc.cpp | 23 ++++ tests/unit/test_interlace.cpp | 224 ++++++++++++++++++++++++++++++++++ 13 files changed, 313 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d81e50..419b9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,13 @@ Interlaced video (`PAL`, `NTSC`, `HD1080i50`, `HD1080i5994`) now follows the shared convention of the MXL media functions (SPECIFICATION §3.2.2): one field per grain at twice the frame rate, grain 2k = the first field in time of TAI frame k, grain 2k + 1 = the second; the flow definition keeps `grain_rate` = the frame rate and the real `interlace_mode`. Progressive modes are unchanged. -- **Input (SDI → MXL):** an interlaced capture wrote one grain per frame at the field-rate index, holding frame lines 0–539 of the woven frame (the top half with both fields, cut to the grain size); every odd index stayed invalid. Every reader that follows the convention (multiviewer, webrtc-monitor, srt and 2110 egress) showed the top half stretched, with combing. Now the frame index k comes from the frame's start (one frame period before its timestamp) at the frame rate, and the bridge writes grain 2k from the first field's lines and 2k + 1 from the other lines, by row (v210, and 8-bit YUV after the expansion; for `video/v210a` the fill lines). `mxl_decklink_grains_committed_total` counts two grains per interlaced frame. +- **Input (SDI → MXL):** an interlaced capture wrote one grain per frame at the field-rate index, holding frame lines 0–539 of the woven frame (the top half with both fields, cut to the grain size); every odd index stayed invalid. Every reader that follows the convention (multiviewer, webrtc-monitor, srt and 2110 egress) showed the top half stretched, with combing. Now the frame index k comes from the frame's start (one frame period before its timestamp) at the frame rate, and the bridge writes grain 2k from the first field's lines and 2k + 1 from the other lines, by row (v210, and 8-bit YUV after the expansion; for `video/v210a` the fill lines). `mxl_decklink_grains_committed_total` and `grains_committed` count grains: two per interlaced frame, one per progressive frame; `frames_total` counts frames. - **Field order:** `NTSC` is lower field first (`bmdLowerFieldFirst`), so its flow definition and NMOS flow and receiver caps say `interlaced_bff`; they said `interlaced_tff` for every interlaced mode. `PAL` and 1080i stay `interlaced_tff`. -- **ANC of an interlaced input** runs at the field rate: the `video/smpte291` flow is declared at twice the frame rate (50/1 for 1080i50, 60000/1001 for 1080i59.94), field 1's packets go to grain 2k and field 2's to grain 2k + 1, the index of the video field (a field without packets gets an empty grain). The field follows from the packet's SMPTE line number. Before, the packets were written at the field-rate index into a data flow declared at the frame rate, about twice the TAI index, so readers asking for the current index found nothing. The output does not read ANC, so there is no reader to change. -- **Output (MXL → SDI):** an interlaced flow was read one field per output frame into the top half of the DeckLink frame (the rest stale), the read position advanced at the field rate (half of what the source writes, so it fell behind and skipped ahead), and the audio per frame was half a frame (960 samples at 1080i50). Now the reader reads frame k as grains 2k and 2k + 1 and weaves them with line parity `(index & 1) ^ bff`; read position, preroll, read offset, catch-up and slips count frames, the sync-group wait waits for the second field's time, and the audio per frame is 48000 / frame rate (1920 at 25 frames/s). A frame with an invalid field repeats the last frame (`source_invalid`). A re-created flow that changes between progressive and interlaced restarts the channel. `output_channel_started` logs `interlaced`. +- **ANC of an interlaced input** runs at the field rate: the `video/smpte291` flow is declared at twice the frame rate (50/1 for 1080i50, 60000/1001 for 1080i59.94), field 1's packets go to grain 2k and field 2's to grain 2k + 1, the index of the video field, with the RFC 8331 `F` bits 0b10 (field 1) and 0b11 (field 2), as mxl-test-player 1.2.3 does; progressive grains keep 0b00. When only one field of a frame has packets, the other gets an empty grain; a frame without ANC packets writes no ANC grain. The field follows from the packet's SMPTE line number (line 0, unknown, counts as field 1). The NMOS ANC flow (`grain_rate`) and the ANC receiver caps follow the MXL flow definition: the field rate for interlaced modes (video keeps the frame rate). Before, the packets were written at the field-rate index into a data flow declared at the frame rate, about twice the TAI index, so readers asking for the current index found nothing. The output does not read ANC, so there is no reader to change. +- **Output (MXL → SDI):** an interlaced flow was read one field per output frame into the top half of the DeckLink frame (the rest stale), the read position advanced at the field rate (half of what the source writes, so it fell behind and skipped ahead), and the audio per frame was half a frame (960 samples at 1080i50). Now the reader reads frame k as grains 2k and 2k + 1 and weaves them with line parity `(index & 1) ^ bff`; read position, preroll, read offset, catch-up and slips count frames, the sync-group wait waits for the second field's time, and the audio per frame is 48000 / frame rate (1920 at 25 frames/s). A frame with an invalid field repeats the last frame (`source_invalid`), without waiting for the second field when the first is invalid. A flow definition that cannot be read is read as progressive and logged (`mxl_flow_def_unreadable`, warning). A re-created flow that changes between progressive and interlaced restarts the channel. `output_channel_started` logs `interlaced`. - **Web UI:** the MXL tab now maps an interlaced flow to its mode: 1080 lines at 25/1 to `HD1080i50`, at 30000/1001 to `HD1080i5994`, 576 lines at 25/1 to `PAL`, 486 lines at 30000/1001 to `NTSC`. It looked for `HD1080i25` and `HD1080i2997`, which do not exist, and offered no mode. - **Mock card:** in interlaced modes the counter band of the odd lines is mid grey, so each field can be told apart; new `MOCK_PLAYBACK_FRAME_FILE` writes every 25th frame an output is given to a file. -- New unit tests: field split and weave for tff and bff (every line back in place), the frame index from the frame's start and the field timestamps at 1080i50 and 1080i59.94, the newest complete frame of a field head, ANC line → field, an interlaced flow written field by field and read back as woven frames (PAL tff, NTSC bff), and an output playing an interlaced flow at the frame rate with woven frames and a frame's worth of audio. +- New unit tests: field split and weave for tff and bff (every line back in place), the frame index from the frame's start and the field timestamps at 1080i50 and 1080i59.94, the newest complete frame of a field head, ANC line → field, an interlaced flow written field by field and read back as woven frames (PAL tff, NTSC bff), an output playing an interlaced flow at the frame rate with woven frames and a frame's worth of audio, an input channel capturing PAL from a fake card (field grains at 2k and 2k + 1, two grains per frame, ANC per field at 50/1 with F bits), the RFC 8331 F bits, and a frame with an invalid first field returned at once. ## 1.0.4 diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 121c9a1..b01ce93 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -148,9 +148,10 @@ The container maintains this mapping internally. Essential entries (non-exhausti The container follows the shared convention of the MXL media functions (MXL `218ddaa`). The flow definition keeps `grain_rate` = the frame rate (25/1 or 30000/1001) and the real `interlace_mode`; MXL then stores **one field per grain** (height / 2 lines) at twice that rate. Grain 2k is the first field in time of TAI frame k (k = `mxlTimestampToIndex` at the frame rate), grain 2k + 1 the second. `interlaced_tff`: the first field is the top field, the even frame lines (0, 2, 4, …); `interlaced_bff`: the bottom field, the odd lines. A reader weaves grain i into the frame lines of parity `(i & 1) ^ bff`. MXL has no per-grain field marker, so a reader cannot detect a writer that puts the first field on an odd index. - **Field order** comes from the mode table: `NTSC` is lower field first (`bmdLowerFieldFirst` in `IDeckLinkDisplayMode::GetFieldDominance`), so its flow is `interlaced_bff`; `PAL`, `HD1080i50` and `HD1080i5994` are `interlaced_tff`. The MXL flow definition and the NMOS flow and receiver caps carry it. +- **Counters:** `mxl_decklink_grains_committed_total` (and `grains_committed` in `/statusz`) counts MXL grains: two per frame for an interlaced input, one for a progressive input. `mxl_decklink_frames_total` counts frames in both. - **Input:** k comes from the frame's start, one frame period before its timestamp (§3.5; the frame arrives when it is complete). The bridge writes grain 2k from the first field's lines and grain 2k + 1 from the other lines of the woven DeckLink frame, by row (v210 directly, 8-bit YUV after the expansion of §3.3; for `video/v210a` the fill lines). -- **ANC** of an interlaced input runs at the **field rate**: the `video/smpte291` flow is declared at twice the frame rate (50/1 for 1080i50, 60000/1001 for 1080i59.94). The packets of field 1 go to grain 2k and those of field 2 to grain 2k + 1, the index of the video field; a field without packets gets an empty grain. The field follows from the packet's SMPTE line number: 1080i lines 564–1125, 625-line lines 313–625 and 525-line lines 266–525 and 1–3 are field 2. Progressive ANC stays at the frame rate. -- **Output:** a flow whose `interlace_mode` is `interlaced_tff` or `interlaced_bff` is read as frames: for frame k the channel reads grains 2k and 2k + 1 and weaves them into the DeckLink frame. Every frame count of §2.4 (read position, preroll, read offset, catch-up, slips, the sync-group wait, which waits for the second field's time) is in frames, and the audio per frame is 48000 / frame rate samples (1920 at 25 frames/s). A frame with an invalid field is repeated like an invalid grain. The output does not read ANC. +- **ANC** of an interlaced input runs at the **field rate**: the `video/smpte291` flow is declared at twice the frame rate (50/1 for 1080i50, 60000/1001 for 1080i59.94). The packets of field 1 go to grain 2k and those of field 2 to grain 2k + 1, the index of the video field, with the RFC 8331 `F` field 0b10 (field 1) or 0b11 (field 2); progressive grains keep `F` = 0b00. When only one field of a frame has packets, the other field gets an empty grain; a frame without ANC packets writes no ANC grain. The field follows from the packet's SMPTE line number: 1080i lines 564–1125, 625-line lines 313–625 and 525-line lines 266–525 and 1–3 are field 2; line 0 (unknown) counts as field 1. The NMOS ANC flow and the ANC receiver caps carry the same field rate as the MXL flow definition (video keeps the frame rate). Progressive ANC stays at the frame rate. +- **Output:** a flow whose `interlace_mode` is `interlaced_tff` or `interlaced_bff` is read as frames: for frame k the channel reads grains 2k and 2k + 1 and weaves them into the DeckLink frame. Every frame count of §2.4 (read position, preroll, read offset, catch-up, slips, the sync-group wait, which waits for the second field's time) is in frames, and the audio per frame is 48000 / frame rate samples (1920 at 25 frames/s). A frame with an invalid field is repeated like an invalid grain (the second field is not waited for when the first is invalid). A flow definition that cannot be read or parsed is read as progressive with the warning `mxl_flow_def_unreadable`. The output does not read ANC. ### 3.3 Pixel Format Mapping @@ -418,7 +419,7 @@ Each channel is in one of: `init` (0), `healthy` (1), `degraded` (2, e.g. signal All channel-scoped Prometheus metrics carry the labels `card_id`, `channel_index`, `channel_label`, `direction`, `subdevice_index` (plus `flow_id` where applicable). Core set: -- Counters: `mxl_decklink_frames_total`, `mxl_decklink_frames_dropped_total` (inputs: frames lost; outputs: frames the card dropped or that could not be scheduled, output-side trouble only), `mxl_decklink_frames_late_total` (outputs: frames the card displayed late), `mxl_decklink_frames_repeated_total` (outputs, label `reason`: `source_stall` the source stopped writing, `source_late` its grain came after the read deadline and the read position slipped one frame, `flow_reopen` the flow is being re-opened, `read_error` an invalid grain or another read error; §2.4), `mxl_decklink_source_stalls_total` (outputs, label `essence` `video`/`audio`: a reader's source stopped writing, §2.4), `mxl_decklink_signal_lost_total`, `mxl_decklink_format_changes_total`, `mxl_decklink_reconnect_total`, `mxl_decklink_flow_reopens_total` (output: flows re-created by their writer whose reader was re-opened, §2.4), `mxl_grains_committed_total`, `mxl_grains_written_bytes_total` +- Counters: `mxl_decklink_frames_total`, `mxl_decklink_frames_dropped_total` (inputs: frames lost; outputs: frames the card dropped or that could not be scheduled, output-side trouble only), `mxl_decklink_frames_late_total` (outputs: frames the card displayed late), `mxl_decklink_frames_repeated_total` (outputs, label `reason`: `source_stall` the source stopped writing, `source_late` its grain came after the read deadline and the read position slipped one frame, `flow_reopen` the flow is being re-opened, `read_error` an invalid grain or another read error; §2.4), `mxl_decklink_source_stalls_total` (outputs, label `essence` `video`/`audio`: a reader's source stopped writing, §2.4), `mxl_decklink_signal_lost_total`, `mxl_decklink_format_changes_total`, `mxl_decklink_reconnect_total`, `mxl_decklink_flow_reopens_total` (output: flows re-created by their writer whose reader was re-opened, §2.4), `mxl_grains_committed_total`, `mxl_grains_written_bytes_total` (inputs: per MXL grain, so two per frame for an interlaced mode, one per frame for a progressive mode; `mxl_decklink_frames_total` counts frames in both cases) - Gauges: `mxl_decklink_channel_state`, `mxl_decklink_signal_lock` (0/1), `mxl_ptp_locked` (IP cards), `mxl_ringbuffer_headindex`, `mxl_ringbuffer_reader_lag_grains`, `mxl_buffered_video_frames`, `mxl_buffered_audio_samples`, `mxl_flow_writer_active`, `mxl_active_video_flow_id` (info label); `mxl_decklink_read_offset_grains` (outputs: `CHx_READ_OFFSET_GRAINS` in effect after the cap, §2.4) - Histograms: `mxl_flow_grain_commit_latency_seconds` (buckets 100 µs … 100 ms), `mxl_callback_duration_seconds` diff --git a/src/channel/input_channel.cpp b/src/channel/input_channel.cpp index 088dccb..72da8a9 100644 --- a/src/channel/input_channel.cpp +++ b/src/channel/input_channel.cpp @@ -586,12 +586,13 @@ namespace mxldl::channel } // ANC (§2.3): RFC 8331 grain aligned with the video grain index. - // Interlaced: each field's packets at its field's grain (a field - // without packets gets an empty grain). + // Interlaced: each field's packets at its field's grain, RFC 8331 F + // = field 1 / field 2 (when only one field has packets, the other + // gets an empty grain; a frame without packets writes none). if (_cfg.ancEnable && _ancWriter && !video.ancPackets.empty()) { - auto writeAnc = [this](std::uint64_t index, std::vector const& packets) { - auto const ancStatus = _ancWriter->writePackets(index, packets); + auto writeAnc = [this](std::uint64_t index, std::vector const& packets, std::uint8_t field) { + auto const ancStatus = _ancWriter->writePackets(index, packets, field); if (ancStatus != MXL_STATUS_OK) { log::debug("anc_write_failed", {{"channel_index", _cfg.index}, {"status", static_cast(ancStatus)}}); @@ -604,12 +605,12 @@ namespace mxldl::channel { perField[util::ancLineInSecondField(_currentMode.height, p.lineNumber) ? 1 : 0].push_back(p); } - writeAnc(grainIndex, perField[0]); - writeAnc(grainIndex + 1, perField[1]); + writeAnc(grainIndex, perField[0], util::kAncField1); + writeAnc(grainIndex + 1, perField[1], util::kAncField2); } else { - writeAnc(grainIndex, video.ancPackets); + writeAnc(grainIndex, video.ancPackets, util::kAncFieldProgressive); } } diff --git a/src/mxlbridge/ancwriter.cpp b/src/mxlbridge/ancwriter.cpp index fd96f16..ac2f1c5 100644 --- a/src/mxlbridge/ancwriter.cpp +++ b/src/mxlbridge/ancwriter.cpp @@ -34,7 +34,7 @@ namespace mxldl::mxlbridge } } - mxlStatus AncWriter::writePackets(std::uint64_t grainIndex, std::vector const& packets) + mxlStatus AncWriter::writePackets(std::uint64_t grainIndex, std::vector const& packets, std::uint8_t field) { mxlGrainInfo info{}; std::uint8_t* payload = nullptr; @@ -44,7 +44,7 @@ namespace mxldl::mxlbridge return status; } - auto const encoded = util::encodeRfc8331(packets, info.grainSize); + auto const encoded = util::encodeRfc8331(packets, info.grainSize, field); if (encoded.packetsDropped > 0) { log::warn("anc_packets_dropped", diff --git a/src/mxlbridge/ancwriter.hpp b/src/mxlbridge/ancwriter.hpp index 7e58ccf..e637149 100644 --- a/src/mxlbridge/ancwriter.hpp +++ b/src/mxlbridge/ancwriter.hpp @@ -27,8 +27,9 @@ namespace mxldl::mxlbridge /// Serializes the frame's ANC packets per RFC 8331 §2 and commits the /// grain. Empty packet lists commit an empty (but valid) grain so the - /// data flow stays continuous with video. - mxlStatus writePackets(std::uint64_t grainIndex, std::vector const& packets); + /// data flow stays continuous with video. `field`: the RFC 8331 F + /// value (util::kAncField1 / kAncField2 for an interlaced field). + mxlStatus writePackets(std::uint64_t grainIndex, std::vector const& packets, std::uint8_t field = util::kAncFieldProgressive); [[nodiscard]] mxlRational grainRate() const { diff --git a/src/mxlbridge/videoreader.cpp b/src/mxlbridge/videoreader.cpp index 10a005e..9b0bcf4 100644 --- a/src/mxlbridge/videoreader.cpp +++ b/src/mxlbridge/videoreader.cpp @@ -9,6 +9,7 @@ #include #include "util/interlace.hpp" +#include "util/logging.hpp" namespace mxldl::mxlbridge { @@ -39,6 +40,7 @@ namespace mxldl::mxlbridge status2 = ::mxlGetFlowDef(domain.instance(), _flowId.c_str(), def.data(), &size); } picojson::value root; + bool parsed = false; if (status2 == MXL_STATUS_OK && picojson::parse(root, std::string(def.data())).empty() && root.is()) { auto const& obj = root.get(); @@ -50,8 +52,18 @@ namespace mxldl::mxlbridge _interlaced = m == "interlaced_tff" || m == "interlaced_bff"; _bottomFieldFirst = m == "interlaced_bff"; _frameHeight = static_cast(height->second.get()); + parsed = true; } } + if (!parsed) + { + log::warn("mxl_flow_def_unreadable", + { + {"flow_id", _flowId}, + {"status", static_cast(status2)}, + {"details", "no interlace_mode/frame_height from the flow definition; the flow is read as progressive"}, + }); + } if (_interlaced) { // MXL reports the field rate (twice the flow definition's). @@ -91,14 +103,17 @@ namespace mxldl::mxlbridge { return status; } + if (payload == nullptr || (field[i].info.flags & MXL_GRAIN_FLAG_INVALID) != 0) + { + // An invalid field makes the frame invalid: no need to + // wait for the other one. + out.info = field[i].info; + out.info.flags |= MXL_GRAIN_FLAG_INVALID; + out.payload = nullptr; + return MXL_STATUS_OK; + } } out.info = field[0].info; - if (field[0].payload == nullptr || field[1].payload == nullptr || ((field[0].info.flags | field[1].info.flags) & MXL_GRAIN_FLAG_INVALID) != 0) - { - out.info.flags |= MXL_GRAIN_FLAG_INVALID; - out.payload = nullptr; - return MXL_STATUS_OK; - } // Each field's v210 fill rows into the frame lines of its parity // (for v210a the key rows are left out: the output plays the fill). std::size_t const lineBytes = _configInfo.discrete.sliceSizes[0]; diff --git a/src/nmos/node.cpp b/src/nmos/node.cpp index 8940262..daa5cad 100644 --- a/src/nmos/node.cpp +++ b/src/nmos/node.cpp @@ -71,6 +71,14 @@ namespace mxldl::nmosnode return nmos::rational{mode.rateNumerator, mode.rateDenominator}; } + /// ANC data flow grain rate, as in the MXL flow definition: the + /// field rate (twice the frame rate) for interlaced modes (one grain + /// per field). Video keeps the frame rate. + nmos::rational ancRateOf(config::VideoMode const& mode) + { + return nmos::rational{mode.interlaced ? 2 * mode.rateNumerator : mode.rateNumerator, mode.rateDenominator}; + } + nmos::interlace_mode interlaceOf(config::VideoMode const& mode) { if (!mode.interlaced) @@ -467,7 +475,7 @@ namespace mxldl::nmosnode { source = nmos::make_data_source(us(leg.sourceId), us(deviceId), nmos::clock_names::clk0, rateOf(mode), model.settings); flow = nmos::make_sdianc_data_flow(us(flowId), us(leg.sourceId), us(deviceId), model.settings); - flow.data[U("grain_rate")] = nmos::make_rational(rateOf(mode)); + flow.data[U("grain_rate")] = nmos::make_rational(ancRateOf(mode)); flow.data[U("media_type")] = value::string(nmos::media_types::video_smpte291.name); } auto const role = leg.kind == nmosroute::LegKind::Video ? "Video" : leg.kind == nmosroute::LegKind::Audio ? "Audio" : "Data"; @@ -548,7 +556,7 @@ namespace mxldl::nmosnode receiver = nmos::make_sdianc_data_receiver(us(leg.id), us(deviceId), nmos::transports::mxl, {}, model.settings); web::json::value set = web::json::value::object(); set[U("urn:x-nmos:cap:format:media_type")] = nmos::make_caps_string_constraint({nmos::media_types::video_smpte291.name}); - set[U("urn:x-nmos:cap:format:grain_rate")] = nmos::make_caps_rational_constraint({rateOf(mode)}); + set[U("urn:x-nmos:cap:format:grain_rate")] = nmos::make_caps_rational_constraint({ancRateOf(mode)}); receiver.data[U("caps")] = capsWith(std::move(set), nmos::media_types::video_smpte291.name); } auto const role = leg.kind == nmosroute::LegKind::Video ? "Video" : leg.kind == nmosroute::LegKind::Audio ? "Audio" : "Data"; @@ -670,7 +678,7 @@ namespace mxldl::nmosnode else { flow = nmos::make_sdianc_data_flow(us(flowId), us(leg.sourceId), us(deviceId), model->settings); - flow.data[U("grain_rate")] = nmos::make_rational(rateOf(mode)); + flow.data[U("grain_rate")] = nmos::make_rational(ancRateOf(mode)); setLabel(flow, ch->label + " Data", "mxl-decklink flow"); } insert(*model, model->node_resources, std::move(flow)); diff --git a/src/util/anc.cpp b/src/util/anc.cpp index a40a473..c78d03f 100644 --- a/src/util/anc.cpp +++ b/src/util/anc.cpp @@ -110,7 +110,7 @@ namespace mxldl::util return static_cast((b9 << 9) | (b8 << 8) | value); } - AncEncodeResult encodeRfc8331(std::vector const& packets, std::size_t maxBytes) + AncEncodeResult encodeRfc8331(std::vector const& packets, std::size_t maxBytes, std::uint8_t field) { AncEncodeResult result; if (maxBytes < 8) @@ -146,7 +146,7 @@ namespace mxldl::util // Length is back-patched once the data section size is known. w.put(0, 16); w.put(static_cast(selected.size()), 8); - w.put(0, 2); // F: progressive / unspecified + w.put(field & 0x3U, 2); // F: 00 progressive / unspecified, 10 field 1, 11 field 2 w.put(0, 22); // reserved w.alignTo32(); // header padded to 8 bytes so packets stay word-aligned diff --git a/src/util/anc.hpp b/src/util/anc.hpp index f1ea843..fee215d 100644 --- a/src/util/anc.hpp +++ b/src/util/anc.hpp @@ -22,7 +22,7 @@ namespace mxldl::util /// Serializes packets into the RFC 8331 §2 payload from the Length field /// onward (the container stores no RTP header): - /// Length(16) | ANC_Count(8) | F(2) | reserved(22) | + /// Length(16) | ANC_Count(8) | F(2) | reserved(22) | (F = `field`) /// per packet: C(1) LineNumber(11) HorizOffset(12) S(1) StreamNum(7) /// DID(10) SDID(10) DataCount(10) UDW(10 × n) Checksum(10) /// word-aligned padding @@ -37,7 +37,13 @@ namespace mxldl::util std::size_t packetsDropped = 0; }; - AncEncodeResult encodeRfc8331(std::vector const& packets, std::size_t maxBytes); + /// RFC 8331 F field: progressive (or unspecified), or the field of an + /// interlaced picture the packets belong to. + constexpr std::uint8_t kAncFieldProgressive = 0b00; + constexpr std::uint8_t kAncField1 = 0b10; + constexpr std::uint8_t kAncField2 = 0b11; + + AncEncodeResult encodeRfc8331(std::vector const& packets, std::size_t maxBytes, std::uint8_t field = kAncFieldProgressive); /// Adds the SMPTE 291 parity bits to an 8-bit word (returns the 10-bit word). std::uint16_t addParity(std::uint8_t value); diff --git a/src/util/interlace.cpp b/src/util/interlace.cpp index a323e21..d07ec7f 100644 --- a/src/util/interlace.cpp +++ b/src/util/interlace.cpp @@ -40,7 +40,7 @@ namespace mxldl::util { case 1080: return line >= 564; case 576: return line >= 313; - case 486: return line >= 266 || line <= 3; + case 486: return line >= 266 || (line >= 1 && line <= 3); // 0 = line unknown: field 1 default: return false; } } diff --git a/src/util/interlace.hpp b/src/util/interlace.hpp index d63371c..dc9e2e1 100644 --- a/src/util/interlace.hpp +++ b/src/util/interlace.hpp @@ -52,6 +52,7 @@ namespace mxldl::util /// Packet::GetLineNumber reports it) of an interlaced mode with /// `frameHeight` active lines lies in the second field in time: 1080i /// (SMPTE ST 274) lines 564-1125, 625-line PAL (ITU-R BT.656) lines - /// 313-625, 525-line NTSC lines 266-525 and 1-3. Other heights: false. + /// 313-625, 525-line NTSC lines 266-525 and 1-3. Other heights, and + /// line 0 (unknown), are false (field 1). bool ancLineInSecondField(std::uint32_t frameHeight, std::uint32_t line); } diff --git a/tests/unit/test_anc.cpp b/tests/unit/test_anc.cpp index 7877195..3a392cf 100644 --- a/tests/unit/test_anc.cpp +++ b/tests/unit/test_anc.cpp @@ -116,3 +116,26 @@ TEST_CASE("checksum word follows ST 291-1 (9-bit sum, b9 = !b8)") CHECK((cs10 & 0x1ff) == expected9); CHECK(((cs10 >> 9) & 1U) == (((cs10 >> 8) & 1U) ^ 1U)); } + +TEST_CASE("RFC 8331 F bits: 00 progressive (default), 10 field 1, 11 field 2") +{ + AncPacket p; + p.did = 0x61; + p.sdid = 0x02; + p.lineNumber = 9; + p.userData = {0x01}; + // F is the top two bits of byte 3 (after Length and ANC_Count). + CHECK((encodeRfc8331({p}, 4096).payload[3] & 0xc0) == 0x00); + CHECK((encodeRfc8331({p}, 4096, kAncFieldProgressive).payload[3] & 0xc0) == 0x00); + CHECK((encodeRfc8331({p}, 4096, kAncField1).payload[3] & 0xc0) == 0x80); + CHECK((encodeRfc8331({p}, 4096, kAncField2).payload[3] & 0xc0) == 0xc0); + CHECK((encodeRfc8331({}, 4096, kAncField2).payload[3] & 0xc0) == 0xc0); + // The rest of the header and the packets do not change. + auto const a = encodeRfc8331({p}, 4096); + auto b = encodeRfc8331({p}, 4096, kAncField2); + b.payload[3] &= 0x3f; + CHECK(a.payload == b.payload); + auto const decoded = decodeRfc8331(b.payload.data(), b.payload.size()); + REQUIRE(decoded.size() == 1); + CHECK(decoded[0].lineNumber == 9); +} diff --git a/tests/unit/test_interlace.cpp b/tests/unit/test_interlace.cpp index f30b6fe..1cf29d0 100644 --- a/tests/unit/test_interlace.cpp +++ b/tests/unit/test_interlace.cpp @@ -2,7 +2,10 @@ // Interlaced video in MXL (util/interlace.hpp): field split and weave, the // index and rate math, and an interlaced flow written field by field and read // back as woven frames. +#include +#include #include +#include #include #include @@ -12,11 +15,15 @@ #include #include +#include "channel/input_channel.hpp" #include "config/videomodes.hpp" +#include "decklink/device.hpp" #include "mxlbridge/domain.hpp" #include "mxlbridge/flowdef.hpp" #include "mxlbridge/videoreader.hpp" #include "mxlbridge/videowriter.hpp" +#include "ops/metrics.hpp" +#include "util/anc.hpp" #include "util/interlace.hpp" #include "util/v210.hpp" @@ -155,6 +162,8 @@ TEST_CASE("ANC packets by field: SMPTE line numbers of the second field") CHECK_FALSE(util::ancLineInSecondField(486, 21)); CHECK(util::ancLineInSecondField(486, 284)); CHECK(util::ancLineInSecondField(486, 2)); + CHECK_FALSE(util::ancLineInSecondField(486, 0)); // line unknown: field 1 + CHECK_FALSE(util::ancLineInSecondField(1080, 0)); // Progressive heights: no second field. CHECK_FALSE(util::ancLineInSecondField(720, 600)); } @@ -244,7 +253,222 @@ TEST_CASE("an interlaced flow written field by field reads back as woven frames REQUIRE(writer.writeField(2 * k + 2, frame.data(), lineBytes, mode.height, first) == MXL_STATUS_OK); CHECK(reader.headIndex() == k); CHECK(reader.getGrain(k + 1, 0, grain) == MXL_ERR_OUT_OF_RANGE_TOO_EARLY); + + // A frame whose first field is invalid is invalid at once, without + // waiting for its second field (not written here). + REQUIRE(writer.writeInvalid(2 * k + 4) == MXL_STATUS_OK); + auto const t0 = std::chrono::steady_clock::now(); + REQUIRE(reader.getGrain(k + 2, 500'000'000ULL, grain) == MXL_STATUS_OK); + CHECK(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(100)); + CHECK(grain.payload == nullptr); + CHECK((grain.info.flags & MXL_GRAIN_FLAG_INVALID) != 0); + } + } + std::error_code ec; + fs::remove_all(path, ec); +} + +namespace +{ + /// A capture card that delivers one PAL frame at every TAI frame edge + /// (when the frame is complete, as a card does), with an ANC packet in + /// each field: line 9 (field 1) and line 322 (field 2). + class FakeCapture final : public dl::ICaptureSession + { + public: + explicit FakeCapture(std::vector const& frame) + : _frame(frame) + {} + + ~FakeCapture() override + { + stop(); + } + + std::optional enable(Params const& params, dl::CaptureCallbacks callbacks) override + { + _params = params; + _callbacks = std::move(callbacks); + return std::nullopt; + } + + std::optional start() override + { + _running = true; + _thread = std::thread([this] { + mxlRational const rate{_params.mode.rateNumerator, _params.mode.rateDenominator}; + auto next = ::mxlGetCurrentIndex(&rate) + 1; + while (_running) + { + auto const edge = ::mxlIndexToTimestamp(&rate, next); + std::this_thread::sleep_for(std::chrono::nanoseconds(::mxlGetNsUntilIndex(next, &rate))); + ++next; + dl::VideoFrameView v; + v.bytes = _frame.data(); + v.width = _params.mode.width; + v.height = _params.mode.height; + v.rowBytes = util::v210RowBytes(v.width); + v.hasHardwareTimestamp = true; + v.hardwareTimestampNs = edge; // the hardware clock is TAI here + for (std::uint32_t const line : {9U, 322U}) + { + util::AncPacket p; + p.did = 0x61; + p.sdid = 0x02; + p.lineNumber = line; + p.userData = {0x01, 0x02}; + v.ancPackets.push_back(p); + } + _callbacks.onFrame(v, nullptr); + } + }); + return std::nullopt; + } + + void stop() override + { + _running = false; + if (_thread.joinable()) + { + _thread.join(); + } + } + + void resetStreams() override {} + std::optional changeMode(config::VideoMode const&) override + { + return std::nullopt; + } + void disable() override + { + stop(); + } + bool signalLocked() override + { + return true; + } + std::optional hardwareClockNowNs() override + { + return ::mxlGetTime(); + } + + private: + std::vector const& _frame; + Params _params; + dl::CaptureCallbacks _callbacks; + std::atomic _running{false}; + std::thread _thread; + }; + + class FakeCaptureDevice final : public dl::ISubDevice + { + public: + explicit FakeCaptureDevice(std::vector const& frame) + : _frame(frame) + {} + [[nodiscard]] dl::SubDeviceInfo const& info() const override + { + return _info; + } + bool supportsMode(config::VideoMode const&, config::Direction) override + { + return true; + } + dl::SubDeviceStatus status() override + { + return {}; + } + std::unique_ptr openCapture() override + { + return std::make_unique(_frame); + } + std::unique_ptr openPlayback() override + { + return nullptr; + } + + private: + std::vector const& _frame; + dl::SubDeviceInfo _info; + }; +} + +TEST_CASE("input channel, interlaced capture: two field grains per frame at 2k, 2k + 1, ANC per field at the field rate") +{ + auto const path = fs::temp_directory_path() / ("mxldl-interlace-in-" + std::to_string(::getpid())); + fs::remove_all(path); + { + mxlbridge::Domain domain(path.string()); + auto const mode = *config::lookupVideoMode("PAL"); + std::size_t const lineBytes = util::v210RowBytes(mode.width); + auto const frame = makeFrame(lineBytes, mode.height); + FakeCaptureDevice device(frame); + + config::Config global; + config::ChannelConfig ch; + ch.index = 0; + ch.label = "in"; + ch.groupHint = "interlace-in"; + ch.direction = config::Direction::Input; + ch.videoModeName = mode.name; + ch.videoMode = mode; + ch.videoFlowId = *util::parseUuid("7a0c2e11-1c1d-4a55-9a6e-0b1c2d3e4f11"); + ch.audioEnable = false; + ch.ancEnable = true; + ch.ancFlowId = *util::parseUuid("7a0c2e11-1c1d-4a55-9a6e-0b1c2d3e4f12"); + ops::Registry metrics; + channel::InputChannel in(global, ch, device, domain, metrics, "test"); + in.start(); + std::this_thread::sleep_for(std::chrono::milliseconds(400)); + + // Checked while the channel runs (its writers own the flows). + mxlbridge::VideoReader video(domain, ch.videoFlowId.toString()); + REQUIRE(video.interlaced()); + // The newest complete frame k: delivered at the end of the frame, so + // its first field is grain 2k of the frame's start. + mxlRational const frameRate{mode.rateNumerator, mode.rateDenominator}; + auto const k = video.headIndex(); + CHECK(::mxlGetCurrentIndex(&frameRate) - k <= 3); + for (std::uint64_t i = 0; i < 2; ++i) + { + mxlGrainInfo info{}; + std::uint8_t* payload = nullptr; + REQUIRE(::mxlFlowReaderGetGrain(video.handle(), 2 * k + i, 0, &info, &payload) == MXL_STATUS_OK); + CHECK((info.flags & MXL_GRAIN_FLAG_INVALID) == 0); + CHECK(info.grainSize == lineBytes * mode.height / 2); + std::size_t wrong = 0; + for (std::uint32_t r = 0; r < mode.height / 2; ++r) + { + wrong += rowEquals(payload + r * lineBytes, frame.data() + (2 * r + i) * lineBytes, lineBytes) ? 0 : 1; + } + CHECK(wrong == 0); // tff: 2k = even lines, 2k + 1 = odd lines + } + + // ANC: declared at the field rate, field 1's packet at 2k (F = 10), + // field 2's at 2k + 1 (F = 11). + mxlFlowReader anc = nullptr; + REQUIRE(::mxlCreateFlowReader(domain.instance(), ch.ancFlowId->toString().c_str(), nullptr, &anc) == MXL_STATUS_OK); + mxlFlowConfigInfo aci{}; + REQUIRE(::mxlFlowReaderGetConfigInfo(anc, &aci) == MXL_STATUS_OK); + CHECK(aci.common.grainRate.numerator == 50); + CHECK(aci.common.grainRate.denominator == 1); + for (std::uint64_t i = 0; i < 2; ++i) + { + mxlGrainInfo info{}; + std::uint8_t* payload = nullptr; + REQUIRE(::mxlFlowReaderGetGrain(anc, 2 * k + i, 0, &info, &payload) == MXL_STATUS_OK); + CHECK((payload[3] & 0xc0) == (i == 0 ? 0x80 : 0xc0)); + auto const packets = util::decodeRfc8331(payload, info.grainSize); + REQUIRE(packets.size() == 1); + CHECK(packets[0].lineNumber == (i == 0 ? 9U : 322U)); } + ::mxlReleaseFlowReader(domain.instance(), anc); + + in.stop(); + auto const& st = in.status(); + CHECK(st.framesTotal.load() >= 5); + CHECK(st.grainsCommitted.load() == 2 * st.framesTotal.load()); // two field grains per frame + CHECK(st.framesDropped.load() == 0); } std::error_code ec; fs::remove_all(path, ec);