Files

568 lines
24 KiB
C++

// Explicit synthetic experiment. Not part of App, Server, unit tests or CI.
#include "pool.h"
#include "h264_encoder_adapter.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <exception>
#include <fstream>
#include <future>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <thread>
#include "api/environment/environment_factory.h"
#include "api/field_trials_view.h"
#include "api/make_ref_counted.h"
#include "api/task_queue/task_queue_base.h"
#include "api/task_queue/task_queue_factory.h"
#include "api/video/builtin_video_bitrate_allocator_factory.h"
#include "api/video/i420_buffer.h"
#include "api/video/video_adapter.h"
#include "api/video/video_frame.h"
#include "api/video/video_stream_encoder_settings.h"
#include "api/video_codecs/builtin_video_decoder_factory.h"
#include "api/video_codecs/video_decoder.h"
#include "api/video_codecs/video_decoder_factory.h"
#include "call/video_send_stream.h"
#include "system_wrappers/include/clock.h"
#include "video/adaptation/overuse_frame_detector.h"
#include "video/frame_cadence_adapter.h"
#include "video/send_statistics_proxy.h"
#include "video/video_stream_encoder.h"
#include "vpx/vpx_decoder.h"
#include "vpx/vp8dx.h"
namespace {
using namespace webrtc;
constexpr int kWidth = 640;
constexpr int kHeight = 360;
constexpr int kFps = 30;
class Trials final : public FieldTrialsView {
public:
explicit Trials(bool disable) : disable_(disable) {}
std::string Lookup(absl::string_view key) const override {
return disable_ && key == "WebRTC-VideoRateControl" ? "bitrate_adjuster:false" : "";
}
private:
bool disable_;
};
template <typename F>
void Sync(TaskQueueBase* queue, F fn) {
auto completion = std::make_shared<std::promise<void>>();
auto future = completion->get_future();
queue->PostTask([completion, fn = std::move(fn)]() mutable {
try {
fn();
completion->set_value();
} catch (...) {
completion->set_exception(std::current_exception());
}
});
if (future.wait_for(std::chrono::seconds(10)) != std::future_status::ready)
throw std::runtime_error("task queue deadline exceeded");
future.get();
}
uint64_t HashBytes(uint64_t hash, const uint8_t* bytes, size_t count) {
for (size_t i = 0; i < count; ++i) hash = (hash ^ bytes[i]) * 1099511628211ULL;
return hash;
}
struct FrameProof {
uint64_t payload = 0;
uint64_t pixels = 0;
int width = 0;
int height = 0;
};
class SharedProof {
public:
void Observe(int consumer, uint32_t timestamp, FrameProof proof) {
std::lock_guard<std::mutex> lock(mutex_);
auto& row = pending_[timestamp];
row[consumer] = proof;
if (row[0].payload && row[1].payload) {
if (row[0].payload == row[1].payload) {
++compared;
if (row[0].pixels && row[1].pixels) {
++decodedCompared;
if (row[0].pixels != row[1].pixels || row[0].width != row[1].width ||
row[0].height != row[1].height) ++mismatches;
}
}
pending_.erase(timestamp);
}
while (pending_.size() > 64) pending_.erase(pending_.begin());
}
std::atomic<int> compared{0};
std::atomic<int> decodedCompared{0};
std::atomic<int> mismatches{0};
private:
std::mutex mutex_;
std::map<uint32_t, std::array<FrameProof, 2>> pending_;
};
struct Counts {
int encoded = 0;
int decoded = 0;
int errors = 0;
int keyframes = 0;
int width = 0;
int height = 0;
int min_width = kWidth;
int min_height = kHeight;
uint64_t bytes = 0;
double first_full_ms = -1;
double first_reduced_ms = -1;
};
class Sink final : public VideoStreamEncoderInterface::EncoderSink,
public DecodedImageCallback {
public:
Sink(const Environment& env, int consumer, SharedProof& proof, bool h264 = false)
: consumer_(consumer), proof_(proof) {
if (h264) return; // The pinned SDK has no H264 decoder; export to Browser WebCodecs.
auto factory = CreateBuiltinVideoDecoderFactory();
decoder_ = factory->Create(env, SdpVideoFormat("VP8"));
VideoDecoder::Settings settings;
settings.set_codec_type(kVideoCodecVP8);
settings.set_number_of_cores(1);
if (!decoder_ || !decoder_->Configure(settings) ||
decoder_->RegisterDecodeCompleteCallback(this) != 0)
throw std::runtime_error("VP8 decoder initialization failed");
vpx_codec_dec_cfg_t raw_config{1, 0, 0};
if (vpx_codec_dec_init(&raw_, vpx_codec_vp8_dx(), &raw_config, 0) != VPX_CODEC_OK)
throw std::runtime_error("raw VP8 decoder initialization failed");
}
~Sink() override {
if (decoder_) { decoder_->Release(); vpx_codec_destroy(&raw_); }
}
void OnEncoderConfigurationChanged(std::vector<VideoStream>, bool,
VideoEncoderConfig::ContentType, int) override {}
void OnBitrateAllocationUpdated(const VideoBitrateAllocation&) override {}
void OnVideoLayersAllocationUpdated(VideoLayersAllocation) override {}
void OnFrameDropped(uint32_t, int, bool) override {}
Result OnEncodedImage(const EncodedImage& image,
const CodecSpecificInfo*) override {
std::lock_guard<std::mutex> lock(mutex_);
++counts_.encoded;
counts_.bytes += image.size();
counts_.keyframes += image.IsKey();
if (export_.is_open() && counts_.encoded <= 180) {
constexpr char hex[] = "0123456789abcdef";
std::string data;
data.reserve(image.size() * 2);
for (uint8_t byte : image) {
data.push_back(hex[byte >> 4]);
data.push_back(hex[byte & 15]);
}
export_ << "{\"index\":" << counts_.encoded - 1
<< ",\"width\":" << image._encodedWidth
<< ",\"height\":" << image._encodedHeight
<< ",\"recovery\":" << (image.IsKey() ? "true" : "false")
<< ",\"dataHex\":\"" << data << "\"}\n";
if (!export_) ++counts_.errors;
}
current_timestamp_ = image.RtpTimestamp();
current_payload_ = HashBytes(1469598103934665603ULL, image.data(), image.size());
if (!decoder_) {
RecordSize(image._encodedWidth, image._encodedHeight);
proof_.Observe(consumer_, current_timestamp_,
{current_payload_, 0, static_cast<int>(image._encodedWidth), static_cast<int>(image._encodedHeight)});
} else if (decoder_->Decode(image, 0) < 0) ++counts_.errors;
if (decoder_ && vpx_codec_decode(&raw_, image.data(), static_cast<unsigned>(image.size()), nullptr, 0) != VPX_CODEC_OK) {
++counts_.errors;
} else if (decoder_) {
vpx_codec_iter_t iterator = nullptr;
if (const auto* raw = vpx_codec_get_frame(&raw_, &iterator)) {
uint64_t hash = 1469598103934665603ULL;
for (int plane = 0; plane < 3; ++plane) {
const unsigned width = plane ? (raw->d_w + 1) / 2 : raw->d_w;
const unsigned height = plane ? (raw->d_h + 1) / 2 : raw->d_h;
for (unsigned y = 0; y < height; ++y)
hash = HashBytes(hash, raw->planes[plane] + y * raw->stride[plane], width);
}
proof_.Observe(consumer_, current_timestamp_,
{current_payload_, hash, static_cast<int>(raw->d_w), static_cast<int>(raw->d_h)});
} else ++counts_.errors;
}
if (duplicate_) duplicate_->OnEncodedImage(image, nullptr);
return Result(Result::OK);
}
// The selected built-in VP8 decoder completes synchronously inside Decode.
int32_t Decoded(VideoFrame& frame) override {
++counts_.decoded;
RecordSize(frame.width(), frame.height());
return 0;
}
void RecordSize(int width, int height) {
counts_.width = width;
counts_.height = height;
counts_.min_width = std::min(counts_.min_width, width);
counts_.min_height = std::min(counts_.min_height, height);
const double elapsed = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - phase_started_).count();
if (width == kWidth && height == kHeight && counts_.first_full_ms < 0)
counts_.first_full_ms = elapsed;
if (width < kWidth && counts_.first_reduced_ms < 0)
counts_.first_reduced_ms = elapsed;
}
Counts Snapshot() {
std::lock_guard<std::mutex> lock(mutex_);
return counts_;
}
void Export(const std::string& path) {
export_.open(path, std::ios::binary | std::ios::trunc);
if (!export_) throw std::runtime_error("fixture export unavailable");
}
void SetDuplicate(Sink* sink) {
std::lock_guard<std::mutex> lock(mutex_);
duplicate_ = sink;
}
void FinishExport() {
if (!export_.is_open()) return;
export_.flush();
export_.close();
if (!export_ || counts_.encoded < 180)
throw std::runtime_error("fixture export incomplete");
}
void ResetMinimum() {
std::lock_guard<std::mutex> lock(mutex_);
counts_.min_width = kWidth;
counts_.min_height = kHeight;
counts_.first_full_ms = -1;
counts_.first_reduced_ms = -1;
phase_started_ = std::chrono::steady_clock::now();
}
private:
const int consumer_;
SharedProof& proof_;
std::mutex mutex_;
std::unique_ptr<VideoDecoder> decoder_;
vpx_codec_ctx_t raw_{};
std::ofstream export_;
Sink* duplicate_ = nullptr;
Counts counts_;
uint32_t current_timestamp_ = 0;
uint64_t current_payload_ = 0;
std::chrono::steady_clock::time_point phase_started_ = std::chrono::steady_clock::now();
};
// Each sender applies stock VideoAdapter wants to the same original input.
class Source final : public VideoSourceInterface<VideoFrame> {
public:
void AddOrUpdateSink(VideoSinkInterface<VideoFrame>* sink,
const VideoSinkWants& wants) override {
sink_ = sink;
adapter_.OnSinkWants(wants);
++wants_updates;
min_pixels = std::min(min_pixels, wants.max_pixel_count);
}
void RemoveSink(VideoSinkInterface<VideoFrame>* sink) override {
if (sink_ == sink) sink_ = nullptr;
}
void Push(const VideoFrame& original) {
if (!sink_) return;
int crop_width, crop_height, width, height;
if (!adapter_.AdaptFrameResolution(original.width(), original.height(),
original.timestamp_us() * 1000,
&crop_width, &crop_height, &width, &height)) {
++dropped;
sink_->OnDiscardedFrame();
return;
}
VideoFrame frame = original;
if (width != original.width() || height != original.height() ||
crop_width != original.width() || crop_height != original.height()) {
auto scaled = I420Buffer::Create(width, height);
scaled->CropAndScaleFrom(*original.video_frame_buffer()->ToI420(),
(original.width() - crop_width) / 2,
(original.height() - crop_height) / 2,
crop_width, crop_height);
frame.set_video_frame_buffer(scaled);
}
sink_->OnFrame(frame);
}
int dropped = 0;
int wants_updates = 0;
int min_pixels = kWidth * kHeight;
private:
VideoSinkInterface<VideoFrame>* sink_ = nullptr;
VideoAdapter adapter_{2};
};
struct Pipeline {
Source source;
std::unique_ptr<SendStatisticsProxy> stats;
std::unique_ptr<Sink> sink;
std::unique_ptr<VideoStreamEncoder> encoder;
TaskQueueBase* encode_queue = nullptr;
};
VideoEncoderConfig Config(const SdpVideoFormat& format) {
VideoEncoderConfig config;
config.codec_type = format.name == "H264" ? kVideoCodecH264 : kVideoCodecVP8;
config.video_format = format;
config.content_type = VideoEncoderConfig::ContentType::kRealtimeVideo;
config.number_of_streams = 1;
config.max_bitrate_bps = 2'000'000;
config.frame_drop_enabled = true;
config.is_quality_scaling_allowed = true;
config.simulcast_layers.resize(1);
config.simulcast_layers[0].active = true;
config.simulcast_layers[0].max_framerate = kFps;
config.simulcast_layers[0].max_bitrate_bps = 2'000'000;
config.simulcast_layers[0].num_temporal_layers = 1;
if (config.codec_type == kVideoCodecVP8) {
auto vp8 = VideoEncoder::GetDefaultVp8Settings();
vp8.automaticResizeOn = true;
config.encoder_specific_settings =
make_ref_counted<VideoEncoderConfig::Vp8EncoderSpecificSettings>(vp8);
}
return config;
}
void Start(Pipeline& pipeline, const Environment& env, PoolFactory& factory,
VideoBitrateAllocatorFactory* allocator, int consumer, SharedProof& proof) {
VideoSendStream::Config send_config(nullptr);
const auto format = factory.GetSupportedFormats().front();
send_config.rtp.ssrcs = {static_cast<uint32_t>(100 + consumer)};
send_config.rtp.payload_name = format.name;
send_config.rtp.payload_type = 96;
pipeline.stats = std::make_unique<SendStatisticsProxy>(
&env.clock(), send_config, VideoEncoderConfig::ContentType::kRealtimeVideo,
env.field_trials());
pipeline.sink = std::make_unique<Sink>(env, consumer, proof, format.name == "H264");
VideoStreamEncoderSettings settings(VideoEncoder::Capabilities(false));
settings.encoder_factory = &factory;
settings.bitrate_allocator_factory = allocator;
auto queue = env.task_queue_factory().CreateTaskQueue(
"PoolProbeEncoder", TaskQueueFactory::Priority::kNormal);
pipeline.encode_queue = queue.get();
auto cadence = FrameCadenceAdapterInterface::Create(
&env.clock(), queue.get(), nullptr, TaskQueueBase::Current(), env.field_trials());
pipeline.encoder = std::make_unique<VideoStreamEncoder>(
env, 1, pipeline.stats.get(), settings,
std::make_unique<OveruseFrameDetector>(env, pipeline.stats.get()),
std::move(cadence), std::move(queue),
VideoStreamEncoder::BitrateAllocationCallbackType::kVideoBitrateAllocation);
pipeline.encoder->SetSink(pipeline.sink.get(), false);
pipeline.encoder->SetSource(&pipeline.source, DegradationPreference::MAINTAIN_FRAMERATE);
pipeline.encoder->SetStartBitrate(2'000'000);
pipeline.encoder->ConfigureEncoder(Config(format), 1200);
pipeline.encoder->OnBitrateUpdated(DataRate::BitsPerSec(2'000'000),
DataRate::BitsPerSec(2'000'000), 0, 20, 0);
}
VideoFrame MakeFrame(const Environment& env, int index) {
auto pixels = I420Buffer::Create(kWidth, kHeight);
for (int y = 0; y < kHeight; ++y) {
for (int x = 0; x < kWidth; ++x) {
const int shifted = x + index * 5;
pixels->MutableDataY()[y * pixels->StrideY() + x] =
static_cast<uint8_t>(32 + ((shifted * 3 + y * 2 +
(((shifted / 23) ^ (y / 17)) & 7) * 19) % 192));
}
}
for (int y = 0; y < kHeight / 2; ++y) {
for (int x = 0; x < kWidth / 2; ++x) {
pixels->MutableDataU()[y * pixels->StrideU() + x] =
static_cast<uint8_t>(80 + (x + index * 2) % 96);
pixels->MutableDataV()[y * pixels->StrideV() + x] =
static_cast<uint8_t>(80 + (y + index) % 96);
}
}
return VideoFrame::Builder().set_video_frame_buffer(pixels)
.set_timestamp_us(env.clock().TimeInMicroseconds())
.set_ntp_time_ms(env.clock().CurrentNtpInMilliseconds())
.set_id(static_cast<uint16_t>(index)).build();
}
void PrintCounts(const Counts& before, const Counts& after, double seconds) {
std::cout << "{\"encoded\":" << after.encoded - before.encoded
<< ",\"decoded\":" << after.decoded - before.decoded
<< ",\"fps\":" << (after.decoded - before.decoded) / seconds
<< ",\"kbps\":" << (after.bytes - before.bytes) * 0.008 / seconds
<< ",\"errors\":" << after.errors - before.errors
<< ",\"keyframes\":" << after.keyframes - before.keyframes
<< ",\"width\":" << after.width << ",\"height\":" << after.height
<< ",\"minWidth\":" << after.min_width
<< ",\"minHeight\":" << after.min_height
<< ",\"firstFullMs\":" << after.first_full_ms
<< ",\"firstReducedMs\":" << after.first_reduced_ms << '}';
}
} // namespace
int main(int argc, char** argv) {
try {
const std::vector<std::string> args(argv + 1, argv + argc);
const auto has = [&](const char* flag) { return std::find(args.begin(), args.end(), flag) != args.end(); };
const bool pooled = has("--pooled");
const bool group_adjuster = has("--group-adjuster");
const bool no_adjuster = has("--no-adjuster") || group_adjuster;
const bool healthy_only = has("--healthy-only");
const bool realtime = has("--realtime");
const bool latency_probe = has("--latency-probe");
if (latency_probe && !realtime)
throw std::runtime_error("--latency-probe requires --realtime");
const bool shared_pipeline = has("--shared-pipeline");
const bool h264 = has("--h264");
if (h264 && (!shared_pipeline || !healthy_only || !has("--export")))
throw std::runtime_error("--h264 requires --shared-pipeline --healthy-only --export PATH");
if (shared_pipeline && (pooled || group_adjuster || no_adjuster || has("--unsafe-skip") ||
(!healthy_only && !latency_probe)))
throw std::runtime_error("shared pipeline control requires a healthy/latency trace with stock VSE settings");
const auto env = CreateEnvironment(std::make_unique<Trials>(no_adjuster));
const bool unsafe_skip = has("--unsafe-skip");
PoolFactory factory(pooled, group_adjuster, unsafe_skip,
h264 ? piik::probe::CreateHardwareEncoderFactory() : nullptr);
auto allocator = CreateBuiltinVideoBitrateAllocatorFactory();
auto worker = env.task_queue_factory().CreateTaskQueue(
"PoolProbeWorker", TaskQueueFactory::Priority::kNormal);
SharedProof proof;
std::array<Pipeline, 2> pipelines;
Sync(worker.get(), [&] {
Start(pipelines[0], env, factory, allocator.get(), 0, proof);
if (shared_pipeline) {
pipelines[1].sink = std::make_unique<Sink>(env, 1, proof, h264);
pipelines[0].sink->SetDuplicate(pipelines[1].sink.get());
} else Start(pipelines[1], env, factory, allocator.get(), 1, proof);
});
const auto export_arg = std::find(args.begin(), args.end(), "--export");
if (export_arg != args.end()) {
if (export_arg + 1 == args.end()) throw std::runtime_error("--export needs a path");
pipelines[0].sink->Export(*(export_arg + 1));
}
int frame_index = 0;
auto run = [&](const char* name, int seconds, int weak_bitrate, bool skip, bool retire) {
Sync(worker.get(), [&] {
if (retire) {
if (shared_pipeline) pipelines[0].sink->SetDuplicate(nullptr);
else {
pipelines[1].encoder->Stop();
pipelines[1].encoder.reset();
}
}
});
const auto a = pipelines[0].sink->Snapshot();
const auto b = pipelines[1].sink->Snapshot();
pipelines[0].sink->ResetMinimum();
pipelines[1].sink->ResetMinimum();
const int encodes = factory.encodes.load(), hits = factory.hits.load();
const auto started = std::chrono::steady_clock::now();
for (int frame = 0; frame < seconds * kFps; ++frame) {
// Equal feedback cadence for both controllers; only B's budget differs.
if (frame % 6 == 0) Sync(worker.get(), [&] {
for (int i = 0; i < 2; ++i) {
if (!pipelines[i].encoder) continue;
const auto rate = DataRate::BitsPerSec(i == 0 ? 2'000'000 : weak_bitrate);
pipelines[i].encoder->OnBitrateUpdated(rate, rate, 0, 20, 0);
}
});
auto original = MakeFrame(env, ++frame_index);
for (int consumer = 0; consumer < 2; ++consumer) {
if (!pipelines[consumer].encoder) continue;
if (consumer == 1 && (retire || (skip && frame == kFps))) continue;
Sync(worker.get(), [&, consumer] { pipelines[consumer].source.Push(original); });
if (!realtime) Sync(pipelines[consumer].encode_queue, [] {});
}
std::this_thread::sleep_until(started +
std::chrono::microseconds((frame + 1) * 1'000'000LL / kFps));
}
for (auto& pipeline : pipelines) {
if (pipeline.encoder) Sync(pipeline.encode_queue, [] {});
}
Sync(worker.get(), [] {});
const double elapsed = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started).count();
std::cout << "{\"phase\":\"" << name << "\",\"seconds\":" << elapsed
<< ",\"encodes\":" << factory.encodes.load() - encodes
<< ",\"hits\":" << factory.hits.load() - hits << ",\"a\":";
PrintCounts(a, pipelines[0].sink->Snapshot(), elapsed);
std::cout << ",\"b\":";
PrintCounts(b, pipelines[1].sink->Snapshot(), elapsed);
const auto stats_a = pipelines[0].stats->GetStats();
const auto stats_b = shared_pipeline ? stats_a : pipelines[1].stats->GetStats();
std::cout << ",\"aEncodeMs\":" << stats_a.avg_encode_time_ms
<< ",\"bEncodeMs\":" << stats_b.avg_encode_time_ms
<< ",\"aEncodeUsage\":" << stats_a.encode_usage_percent
<< ",\"bEncodeUsage\":" << stats_b.encode_usage_percent
<< ",\"aCpuLimitedResolution\":" << (stats_a.cpu_limited_resolution ? "true" : "false")
<< ",\"bCpuLimitedResolution\":" << (stats_b.cpu_limited_resolution ? "true" : "false")
<< ",\"aCpuLimitedFramerate\":" << (stats_a.cpu_limited_framerate ? "true" : "false")
<< ",\"bCpuLimitedFramerate\":" << (stats_b.cpu_limited_framerate ? "true" : "false")
<< "}\n" << std::flush;
};
run("healthy", 6, 2'000'000, false, false);
if (latency_probe) {
factory.simulatedLatencyMs = 45;
run("encoder-delayed", 30, 2'000'000, false, false);
factory.simulatedLatencyMs = 0;
run("encoder-released", 30, 2'000'000, false, false);
} else if (!healthy_only) {
run("weak", 15, 100'000, false, false);
run("released", 25, 2'000'000, false, false);
}
if (!shared_pipeline) run("skipped-input", 3, 2'000'000, true, false);
run("retired-b", 2, 2'000'000, false, true);
Sync(worker.get(), [&] {
pipelines[0].encoder->Stop();
pipelines[0].encoder.reset();
});
pipelines[0].sink->FinishExport();
const auto a = pipelines[0].sink->Snapshot(), b = pipelines[1].sink->Snapshot();
const bool integrity = (h264 ? a.encoded > 0 && b.encoded > 0 : a.decoded > 0 && b.decoded > 0) && factory.live.load() == 0 &&
a.errors + b.errors + proof.mismatches.load() == 0 &&
(!pooled || factory.hits.load() > 0 && proof.compared.load() > 0) &&
(!shared_pipeline || proof.compared.load() > 0);
std::cout << "{\"integrity\":\"" << (integrity ? "pass" : "fail")
<< "\",\"codec\":\"" << (h264 ? "h264" : "vp8")
<< "\",\"pooled\":" << (pooled ? "true" : "false")
<< ",\"adjuster\":" << (no_adjuster ? "false" : "true")
<< ",\"groupAdjuster\":" << (group_adjuster ? "true" : "false")
<< ",\"unsafeSkip\":" << (unsafe_skip ? "true" : "false")
<< ",\"realtime\":" << (realtime ? "true" : "false")
<< ",\"latencyProbe\":" << (latency_probe ? "true" : "false")
<< ",\"sharedPipeline\":" << (shared_pipeline ? "true" : "false")
<< ",\"encodes\":" << factory.encodes.load()
<< ",\"hits\":" << factory.hits.load()
<< ",\"splits\":" << factory.splits.load()
<< ",\"rateSplits\":" << factory.rateSplits.load()
<< ",\"dependencySplits\":" << factory.dependencySplits.load()
<< ",\"created\":" << factory.created.load()
<< ",\"joins\":" << factory.joins.load()
<< ",\"live\":" << factory.live.load()
<< ",\"maxLive\":" << factory.maxLive.load()
<< ",\"callbacks\":" << factory.callbacks.load()
<< ",\"samePayloadCompared\":" << proof.compared.load()
<< ",\"decodedHashComparisons\":" << proof.decodedCompared.load()
<< ",\"decodedHashMismatches\":" << proof.mismatches.load()
<< ",\"aWantsUpdates\":" << pipelines[0].source.wants_updates
<< ",\"bWantsUpdates\":" << pipelines[1].source.wants_updates
<< ",\"aMinPixels\":" << pipelines[0].source.min_pixels
<< ",\"bMinPixels\":" << pipelines[1].source.min_pixels << "}\n";
return integrity ? 0 : 1;
} catch (const std::exception& error) {
std::cerr << "pool harness failed: " << error.what() << '\n';
return 2;
}
}