feat(mcap): add paced replay tooling

This commit is contained in:
2026-03-11 15:51:38 +08:00
parent bc1b619dee
commit ed3f32ff6e
7 changed files with 605 additions and 13 deletions
+27
View File
@@ -210,3 +210,30 @@ target_link_libraries(mcap_reader_tester PRIVATE cvmmap_streamer_protobuf)
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(mcap_reader_tester PRIVATE PkgConfig::PROTOBUF_PKG)
endif()
add_executable(mcap_replay_tester src/testers/mcap_replay_tester.cpp)
target_include_directories(mcap_replay_tester
PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_LIST_DIR}/lib/CLI11/include"
"${CMAKE_CURRENT_LIST_DIR}/lib/mcap/include"
"${CVMMAP_PROXY_INCLUDE_DIR}"
"${CMAKE_CURRENT_BINARY_DIR}")
target_link_libraries(mcap_replay_tester
PRIVATE
Threads::Threads
cvmmap_streamer_foxglove_proto
PkgConfig::ZSTD
PkgConfig::LZ4)
if (TARGET spdlog::spdlog)
target_link_libraries(mcap_replay_tester PRIVATE spdlog::spdlog)
elseif (TARGET spdlog)
target_link_libraries(mcap_replay_tester PRIVATE spdlog)
endif()
if (TARGET CLI11::CLI11)
target_link_libraries(mcap_replay_tester PRIVATE CLI11::CLI11)
endif()
target_link_libraries(mcap_replay_tester PRIVATE cvmmap_streamer_protobuf)
if (TARGET PkgConfig::PROTOBUF_PKG)
target_link_libraries(mcap_replay_tester PRIVATE PkgConfig::PROTOBUF_PKG)
endif()
@@ -21,7 +21,12 @@ public:
McapRecordSink &operator=(McapRecordSink &&other) noexcept;
[[nodiscard]]
static std::expected<McapRecordSink, std::string> create(const RuntimeConfig &config);
static std::expected<McapRecordSink, std::string> create(
const RuntimeConfig &config,
const encode::EncodedStreamInfo &stream_info);
[[nodiscard]]
std::expected<void, std::string> update_stream_info(const encode::EncodedStreamInfo &stream_info);
[[nodiscard]]
std::expected<void, std::string> write_access_unit(const encode::EncodedAccessUnit &access_unit);
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build}"
REPLAY_BIN="${REPLAY_BIN:-$BUILD_DIR/mcap_replay_tester}"
FFPLAY_BIN="${FFPLAY_BIN:-ffplay}"
FFPLAY_ARGS="${FFPLAY_ARGS:-}"
usage() {
cat <<'EOF'
Usage:
replay_mcap.sh <input.mcap> [h264|h265]
This replays MCAP video using the recorded timestamps and pipes frames to ffplay.
The optional codec argument defaults to h264. Extra ffplay flags may be supplied
via FFPLAY_ARGS, for example: FFPLAY_ARGS='-window_title replay'.
EOF
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
if [[ $# -lt 1 || $# -gt 2 ]]; then
usage >&2
exit 1
fi
INPUT_PATH="$1"
FORMAT="${2:-h264}"
if [[ "$FORMAT" != "h264" && "$FORMAT" != "h265" ]]; then
echo "unsupported format: $FORMAT (expected h264 or h265)" >&2
exit 1
fi
if [[ ! -f "$INPUT_PATH" ]]; then
echo "input MCAP not found: $INPUT_PATH" >&2
exit 1
fi
if [[ ! -x "$REPLAY_BIN" ]]; then
echo "replay binary not found: $REPLAY_BIN" >&2
exit 1
fi
if ! command -v "$FFPLAY_BIN" >/dev/null 2>&1; then
echo "ffplay not found: $FFPLAY_BIN" >&2
exit 1
fi
extra_args=()
if [[ -n "$FFPLAY_ARGS" ]]; then
# shellcheck disable=SC2206
extra_args=($FFPLAY_ARGS)
fi
cmd=(
"$REPLAY_BIN"
"$INPUT_PATH"
--expect-format "$FORMAT"
--ffplay-path "$FFPLAY_BIN"
)
for arg in "${extra_args[@]}"; do
cmd+=(--ffplay-arg "$arg")
done
"${cmd[@]}"
+24 -9
View File
@@ -331,15 +331,6 @@ int run_pipeline(const RuntimeConfig &config) {
rtp_publisher.emplace(std::move(*created));
}
if (config.record.mcap.enabled) {
auto created = record::McapRecordSink::create(config);
if (!created) {
spdlog::error("pipeline MCAP sink init failed: {}", created.error());
return exit_code(PipelineExitCode::InitializationError);
}
mcap_sink.emplace(std::move(*created));
}
PipelineStats stats{};
metrics::IngestEmitLatencyTracker latency_tracker{};
bool producer_offline{false};
@@ -386,6 +377,30 @@ int run_pipeline(const RuntimeConfig &config) {
}
rtmp_output.emplace(std::move(*created));
}
if (config.record.mcap.enabled) {
auto stream_info = (*backend)->stream_info();
if (!stream_info) {
return unexpected_error(
stream_info.error().code,
"pipeline MCAP stream info unavailable: " + format_error(stream_info.error()));
}
if (!mcap_sink) {
auto created = record::McapRecordSink::create(config, *stream_info);
if (!created) {
return unexpected_error(
ERR_INTERNAL,
"pipeline MCAP sink init failed: " + created.error());
}
mcap_sink.emplace(std::move(*created));
} else {
auto updated = mcap_sink->update_stream_info(*stream_info);
if (!updated) {
return unexpected_error(
ERR_INTERNAL,
"pipeline MCAP stream update failed: " + updated.error());
}
}
}
started = true;
restart_pending = false;
restart_target_info.reset();
+161 -3
View File
@@ -12,9 +12,11 @@
#include <cstdint>
#include <expected>
#include <memory>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace cvmmap_streamer::record {
@@ -46,6 +48,134 @@ google::protobuf::Timestamp to_proto_timestamp(std::uint64_t timestamp_ns) {
return timestamp;
}
void append_start_code(std::vector<std::uint8_t> &output) {
output.push_back(0x00);
output.push_back(0x00);
output.push_back(0x00);
output.push_back(0x01);
}
[[nodiscard]]
std::expected<std::uint16_t, std::string> read_be16(std::span<const std::uint8_t> bytes, std::size_t offset) {
if (offset + 2 > bytes.size()) {
return std::unexpected("decoder config truncated");
}
return static_cast<std::uint16_t>((static_cast<std::uint16_t>(bytes[offset]) << 8) | bytes[offset + 1]);
}
[[nodiscard]]
bool looks_like_annexb(std::span<const std::uint8_t> bytes) {
if (bytes.size() >= 4 &&
bytes[0] == 0x00 &&
bytes[1] == 0x00 &&
bytes[2] == 0x00 &&
bytes[3] == 0x01) {
return true;
}
return bytes.size() >= 3 &&
bytes[0] == 0x00 &&
bytes[1] == 0x00 &&
bytes[2] == 0x01;
}
[[nodiscard]]
std::expected<std::vector<std::uint8_t>, std::string> avcc_to_annexb(std::span<const std::uint8_t> decoder_config) {
if (decoder_config.size() < 7 || decoder_config[0] != 1) {
return std::unexpected("invalid AVC decoder config");
}
std::vector<std::uint8_t> annexb{};
std::size_t offset = 5;
const auto sps_count = static_cast<std::size_t>(decoder_config[offset++] & 0x1fu);
for (std::size_t i = 0; i < sps_count; ++i) {
auto size = read_be16(decoder_config, offset);
if (!size) {
return std::unexpected(size.error());
}
offset += 2;
if (offset + *size > decoder_config.size()) {
return std::unexpected("invalid AVC decoder config payload");
}
append_start_code(annexb);
annexb.insert(annexb.end(), decoder_config.begin() + static_cast<std::ptrdiff_t>(offset), decoder_config.begin() + static_cast<std::ptrdiff_t>(offset + *size));
offset += *size;
}
if (offset >= decoder_config.size()) {
return std::unexpected("invalid AVC decoder config: missing PPS count");
}
const auto pps_count = static_cast<std::size_t>(decoder_config[offset++]);
for (std::size_t i = 0; i < pps_count; ++i) {
auto size = read_be16(decoder_config, offset);
if (!size) {
return std::unexpected(size.error());
}
offset += 2;
if (offset + *size > decoder_config.size()) {
return std::unexpected("invalid AVC decoder config payload");
}
append_start_code(annexb);
annexb.insert(annexb.end(), decoder_config.begin() + static_cast<std::ptrdiff_t>(offset), decoder_config.begin() + static_cast<std::ptrdiff_t>(offset + *size));
offset += *size;
}
return annexb;
}
[[nodiscard]]
std::expected<std::vector<std::uint8_t>, std::string> hvcc_to_annexb(std::span<const std::uint8_t> decoder_config) {
if (decoder_config.size() < 23 || decoder_config[0] != 1) {
return std::unexpected("invalid HEVC decoder config");
}
std::vector<std::uint8_t> annexb{};
std::size_t offset = 22;
const auto array_count = static_cast<std::size_t>(decoder_config[offset++]);
for (std::size_t array_index = 0; array_index < array_count; ++array_index) {
if (offset + 3 > decoder_config.size()) {
return std::unexpected("invalid HEVC decoder config arrays");
}
offset += 1;
auto nal_count = read_be16(decoder_config, offset);
if (!nal_count) {
return std::unexpected(nal_count.error());
}
offset += 2;
for (std::size_t nal_index = 0; nal_index < *nal_count; ++nal_index) {
auto nal_size = read_be16(decoder_config, offset);
if (!nal_size) {
return std::unexpected(nal_size.error());
}
offset += 2;
if (offset + *nal_size > decoder_config.size()) {
return std::unexpected("invalid HEVC decoder config payload");
}
append_start_code(annexb);
annexb.insert(annexb.end(), decoder_config.begin() + static_cast<std::ptrdiff_t>(offset), decoder_config.begin() + static_cast<std::ptrdiff_t>(offset + *nal_size));
offset += *nal_size;
}
}
return annexb;
}
[[nodiscard]]
std::expected<std::vector<std::uint8_t>, std::string> decoder_config_to_annexb(
CodecType codec,
std::span<const std::uint8_t> decoder_config) {
if (decoder_config.empty()) {
return std::vector<std::uint8_t>{};
}
if (looks_like_annexb(decoder_config)) {
return std::vector<std::uint8_t>(decoder_config.begin(), decoder_config.end());
}
if (codec == CodecType::H265) {
return hvcc_to_annexb(decoder_config);
}
return avcc_to_annexb(decoder_config);
}
}
struct McapRecordSink::State {
@@ -54,6 +184,8 @@ struct McapRecordSink::State {
std::string frame_id{};
mcap::ChannelId channel_id{0};
std::uint32_t sequence{0};
CodecType codec{CodecType::H264};
std::vector<std::uint8_t> keyframe_preamble{};
};
McapRecordSink::~McapRecordSink() {
@@ -74,7 +206,9 @@ McapRecordSink &McapRecordSink::operator=(McapRecordSink &&other) noexcept {
return *this;
}
std::expected<McapRecordSink, std::string> McapRecordSink::create(const RuntimeConfig &config) {
std::expected<McapRecordSink, std::string> McapRecordSink::create(
const RuntimeConfig &config,
const encode::EncodedStreamInfo &stream_info) {
McapRecordSink sink{};
auto state = std::make_unique<State>();
state->path = config.record.mcap.path;
@@ -101,9 +235,27 @@ std::expected<McapRecordSink, std::string> McapRecordSink::create(const RuntimeC
state->channel_id = channel.id;
sink.state_ = state.release();
auto update = sink.update_stream_info(stream_info);
if (!update) {
sink.close();
return std::unexpected(update.error());
}
return sink;
}
std::expected<void, std::string> McapRecordSink::update_stream_info(const encode::EncodedStreamInfo &stream_info) {
if (state_ == nullptr) {
return std::unexpected("MCAP sink is not open");
}
auto decoder_config_annexb = decoder_config_to_annexb(stream_info.codec, stream_info.decoder_config);
if (!decoder_config_annexb) {
return std::unexpected("failed to prepare MCAP keyframe decoder config: " + decoder_config_annexb.error());
}
state_->codec = stream_info.codec;
state_->keyframe_preamble = std::move(*decoder_config_annexb);
return {};
}
std::expected<void, std::string> McapRecordSink::write_access_unit(const encode::EncodedAccessUnit &access_unit) {
if (state_ == nullptr) {
return std::unexpected("MCAP sink is not open");
@@ -113,9 +265,15 @@ std::expected<void, std::string> McapRecordSink::write_access_unit(const encode:
*message.mutable_timestamp() = to_proto_timestamp(access_unit.source_timestamp_ns);
message.set_frame_id(state_->frame_id);
message.set_format(codec_format(access_unit.codec));
std::vector<std::uint8_t> payload{};
if (access_unit.keyframe && !state_->keyframe_preamble.empty()) {
payload.reserve(state_->keyframe_preamble.size() + access_unit.annexb_bytes.size());
payload.insert(payload.end(), state_->keyframe_preamble.begin(), state_->keyframe_preamble.end());
}
payload.insert(payload.end(), access_unit.annexb_bytes.begin(), access_unit.annexb_bytes.end());
message.set_data(
reinterpret_cast<const char *>(access_unit.annexb_bytes.data()),
static_cast<int>(access_unit.annexb_bytes.size()));
reinterpret_cast<const char *>(payload.data()),
static_cast<int>(payload.size()));
std::string serialized{};
if (!message.SerializeToString(&serialized)) {
+21
View File
@@ -7,6 +7,7 @@
#include <cstdint>
#include <expected>
#include <fstream>
#include <iostream>
#include <optional>
#include <string>
@@ -25,6 +26,7 @@ enum class TesterExitCode : int {
FormatMismatch = 7,
EmptyPayload = 8,
ThresholdError = 9,
DumpError = 10,
};
[[nodiscard]]
@@ -36,6 +38,7 @@ struct Config {
std::string input_path{};
std::optional<std::string> expected_topic{};
std::optional<std::string> expected_format{};
std::optional<std::string> dump_annexb_output{};
std::uint32_t min_messages{1};
};
@@ -46,6 +49,7 @@ std::expected<Config, int> parse_args(int argc, char **argv) {
app.add_option("input", config.input_path, "Input MCAP path")->required();
app.add_option("--expect-topic", config.expected_topic, "Expected MCAP topic");
app.add_option("--expect-format", config.expected_format, "Expected CompressedVideo format");
app.add_option("--dump-annexb-output", config.dump_annexb_output, "Write concatenated CompressedVideo.data payloads to a file");
app.add_option("--min-messages", config.min_messages, "Minimum expected message count")->check(CLI::PositiveNumber);
try {
@@ -74,6 +78,15 @@ int main(int argc, char **argv) {
std::uint64_t message_count{0};
std::uint64_t previous_log_time{0};
bool saw_log_time{false};
std::optional<std::ofstream> dump_stream{};
if (config->dump_annexb_output) {
dump_stream.emplace(*config->dump_annexb_output, std::ios::binary | std::ios::trunc);
if (!dump_stream->is_open()) {
spdlog::error("failed to open dump output '{}'", *config->dump_annexb_output);
reader.close();
return exit_code(TesterExitCode::DumpError);
}
}
auto message_view = reader.readMessages();
for (auto it = message_view.begin(); it != message_view.end(); ++it) {
@@ -117,6 +130,14 @@ int main(int argc, char **argv) {
reader.close();
return exit_code(TesterExitCode::EmptyPayload);
}
if (dump_stream) {
dump_stream->write(message.data().data(), static_cast<std::streamsize>(message.data().size()));
if (!dump_stream->good()) {
spdlog::error("failed to write Annex B dump to '{}'", *config->dump_annexb_output);
reader.close();
return exit_code(TesterExitCode::DumpError);
}
}
previous_log_time = it->message.logTime;
saw_log_time = true;
+298
View File
@@ -0,0 +1,298 @@
#define MCAP_IMPLEMENTATION
#include <mcap/reader.hpp>
#include <foxglove/CompressedVideo.pb.h>
#include <CLI/CLI.hpp>
#include <chrono>
#include <cstdint>
#include <expected>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <system_error>
#include <thread>
#include <vector>
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <spdlog/spdlog.h>
namespace {
enum class TesterExitCode : int {
Success = 0,
OpenError = 2,
SchemaError = 3,
TopicMismatch = 4,
ParseError = 5,
FormatMismatch = 6,
EmptyPayload = 7,
ProcessError = 8,
WriteError = 9,
WaitError = 10,
};
[[nodiscard]]
constexpr int exit_code(TesterExitCode code) {
return static_cast<int>(code);
}
struct Config {
std::string input_path{};
std::optional<std::string> expected_topic{};
std::string expected_format{"h264"};
std::string ffplay_path{"ffplay"};
std::vector<std::string> ffplay_args{};
double speed{1.0};
bool no_pace{false};
};
struct ReplayMessage {
std::uint64_t timestamp_ns{0};
std::vector<std::uint8_t> payload{};
};
[[nodiscard]]
std::expected<Config, int> parse_args(int argc, char **argv) {
Config config{};
CLI::App app{"mcap_replay_tester - replay foxglove.CompressedVideo MCAP with recorded pacing"};
app.add_option("input", config.input_path, "Input MCAP path")->required();
app.add_option("--expect-topic", config.expected_topic, "Expected MCAP topic");
app.add_option("--expect-format", config.expected_format, "Expected CompressedVideo format")
->check(CLI::IsMember({"h264", "h265"}));
app.add_option("--ffplay-path", config.ffplay_path, "ffplay binary path");
app.add_option("--ffplay-arg", config.ffplay_args, "Extra ffplay argument (repeatable)");
app.add_option("--speed", config.speed, "Playback speed multiplier")->check(CLI::PositiveNumber);
app.add_flag("--no-pace", config.no_pace, "Write frames as fast as possible instead of using recorded timestamps");
try {
app.parse(argc, argv);
} catch (const CLI::ParseError &e) {
return std::unexpected(app.exit(e));
}
return config;
}
[[nodiscard]]
std::uint64_t proto_timestamp_ns(const google::protobuf::Timestamp &timestamp) {
return static_cast<std::uint64_t>(timestamp.seconds()) * 1000000000ull + static_cast<std::uint64_t>(timestamp.nanos());
}
[[nodiscard]]
std::expected<std::vector<ReplayMessage>, TesterExitCode> load_messages(const Config &config) {
mcap::McapReader reader{};
const auto open_status = reader.open(config.input_path);
if (!open_status.ok()) {
spdlog::error("failed to open MCAP file '{}': {}", config.input_path, open_status.message);
return std::unexpected(TesterExitCode::OpenError);
}
std::vector<ReplayMessage> messages{};
auto view = reader.readMessages();
for (auto it = view.begin(); it != view.end(); ++it) {
if (it->schema == nullptr || it->channel == nullptr) {
spdlog::error("MCAP message missing schema or channel metadata");
reader.close();
return std::unexpected(TesterExitCode::SchemaError);
}
if (it->schema->encoding != "protobuf" || it->schema->name != "foxglove.CompressedVideo") {
continue;
}
if (it->channel->messageEncoding != "protobuf") {
spdlog::error("unexpected MCAP message encoding: {}", it->channel->messageEncoding);
reader.close();
return std::unexpected(TesterExitCode::SchemaError);
}
if (config.expected_topic && it->channel->topic != *config.expected_topic) {
spdlog::error("unexpected topic: expected '{}' got '{}'", *config.expected_topic, it->channel->topic);
reader.close();
return std::unexpected(TesterExitCode::TopicMismatch);
}
foxglove::CompressedVideo message{};
if (!message.ParseFromArray(it->message.data, static_cast<int>(it->message.dataSize))) {
spdlog::error("failed to parse foxglove.CompressedVideo payload");
reader.close();
return std::unexpected(TesterExitCode::ParseError);
}
if (message.format() != config.expected_format) {
spdlog::error("unexpected format: expected '{}' got '{}'", config.expected_format, message.format());
reader.close();
return std::unexpected(TesterExitCode::FormatMismatch);
}
if (message.data().empty()) {
spdlog::error("compressed video payload is empty");
reader.close();
return std::unexpected(TesterExitCode::EmptyPayload);
}
auto timestamp_ns = proto_timestamp_ns(message.timestamp());
if (timestamp_ns == 0) {
timestamp_ns = it->message.logTime;
}
ReplayMessage replay{};
replay.timestamp_ns = timestamp_ns;
replay.payload.assign(
reinterpret_cast<const std::uint8_t *>(message.data().data()),
reinterpret_cast<const std::uint8_t *>(message.data().data()) + message.data().size());
messages.push_back(std::move(replay));
}
reader.close();
if (messages.empty()) {
spdlog::error("no foxglove.CompressedVideo messages found in '{}'", config.input_path);
return std::unexpected(TesterExitCode::EmptyPayload);
}
return messages;
}
[[nodiscard]]
const char *ffplay_input_format(std::string_view format) {
return format == "h265" ? "hevc" : "h264";
}
[[nodiscard]]
std::expected<pid_t, TesterExitCode> spawn_ffplay(
const Config &config,
int &stdin_write_fd) {
int pipe_fds[2]{-1, -1};
if (::pipe(pipe_fds) != 0) {
spdlog::error("failed to create ffplay stdin pipe: {}", std::strerror(errno));
return std::unexpected(TesterExitCode::ProcessError);
}
const auto pid = ::fork();
if (pid < 0) {
spdlog::error("failed to fork ffplay: {}", std::strerror(errno));
::close(pipe_fds[0]);
::close(pipe_fds[1]);
return std::unexpected(TesterExitCode::ProcessError);
}
if (pid == 0) {
::dup2(pipe_fds[0], STDIN_FILENO);
::close(pipe_fds[0]);
::close(pipe_fds[1]);
std::vector<std::string> owned_args{};
owned_args.push_back(config.ffplay_path);
owned_args.push_back("-hide_banner");
owned_args.push_back("-loglevel");
owned_args.push_back("warning");
owned_args.push_back("-fflags");
owned_args.push_back("nobuffer");
owned_args.push_back("-flags");
owned_args.push_back("low_delay");
owned_args.push_back("-f");
owned_args.push_back(ffplay_input_format(config.expected_format));
owned_args.insert(owned_args.end(), config.ffplay_args.begin(), config.ffplay_args.end());
owned_args.push_back("pipe:0");
std::vector<char *> argv{};
argv.reserve(owned_args.size() + 1);
for (auto &arg : owned_args) {
argv.push_back(arg.data());
}
argv.push_back(nullptr);
::execvp(config.ffplay_path.c_str(), argv.data());
::perror("execvp ffplay");
::_exit(127);
}
::close(pipe_fds[0]);
stdin_write_fd = pipe_fds[1];
return pid;
}
[[nodiscard]]
std::expected<void, TesterExitCode> write_all(int fd, std::span<const std::uint8_t> bytes) {
std::size_t written{0};
while (written < bytes.size()) {
const auto result = ::write(fd, bytes.data() + written, bytes.size() - written);
if (result < 0) {
if (errno == EINTR) {
continue;
}
spdlog::error("failed to write replay data to ffplay: {}", std::strerror(errno));
return std::unexpected(TesterExitCode::WriteError);
}
if (result == 0) {
spdlog::error("short write to ffplay stdin");
return std::unexpected(TesterExitCode::WriteError);
}
written += static_cast<std::size_t>(result);
}
return {};
}
}
int main(int argc, char **argv) {
auto config = parse_args(argc, argv);
if (!config) {
return config.error();
}
auto messages = load_messages(*config);
if (!messages) {
return exit_code(messages.error());
}
int stdin_write_fd{-1};
auto child_pid = spawn_ffplay(*config, stdin_write_fd);
if (!child_pid) {
return exit_code(child_pid.error());
}
const auto start_wall = std::chrono::steady_clock::now();
const auto first_timestamp = messages->front().timestamp_ns;
for (const auto &message : *messages) {
if (!config->no_pace) {
const auto delta_ns = message.timestamp_ns > first_timestamp ? message.timestamp_ns - first_timestamp : 0ull;
const auto scaled_ns = static_cast<std::uint64_t>(static_cast<long double>(delta_ns) / config->speed);
std::this_thread::sleep_until(start_wall + std::chrono::nanoseconds(scaled_ns));
}
auto write = write_all(stdin_write_fd, std::span<const std::uint8_t>(message.payload.data(), message.payload.size()));
if (!write) {
::close(stdin_write_fd);
int status = 0;
::waitpid(*child_pid, &status, 0);
return exit_code(write.error());
}
}
::close(stdin_write_fd);
int status{0};
if (::waitpid(*child_pid, &status, 0) < 0) {
spdlog::error("failed to wait for ffplay: {}", std::strerror(errno));
return exit_code(TesterExitCode::WaitError);
}
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
spdlog::info("replayed {} MCAP video messages", messages->size());
return exit_code(TesterExitCode::Success);
}
if (WIFSIGNALED(status)) {
spdlog::error("ffplay exited on signal {}", WTERMSIG(status));
return exit_code(TesterExitCode::WaitError);
}
if (WIFEXITED(status)) {
spdlog::error("ffplay exited with status {}", WEXITSTATUS(status));
return WEXITSTATUS(status);
}
spdlog::error("ffplay exited unexpectedly");
return exit_code(TesterExitCode::WaitError);
}