#include "video_params.h" #include #include namespace { int failures = 0; void Expect(bool condition, const char* expression, int line) { if (condition) return; std::cerr << "line " << line << ": check failed: " << expression << '\n'; ++failures; } #define EXPECT(condition) Expect(static_cast(condition), #condition, __LINE__) mpv_node Text(const char* value) { mpv_node node{}; node.format = MPV_FORMAT_STRING; node.u.string = const_cast(value); return node; } mpv_node Number(double value) { mpv_node node{}; node.format = MPV_FORMAT_DOUBLE; node.u.double_ = value; return node; } mpv_node Whole(int64_t value) { mpv_node node{}; node.format = MPV_FORMAT_INT64; node.u.int64 = value; return node; } // Builds the node shape mpv delivers for an observed `video-params`: a map whose // absent fields are missing keys rather than zeroed ones. Node() borrows the // builder's storage, so nothing may be added after it is called. class Params { public: Params& Add(const char* key, mpv_node value) { keys_.push_back(const_cast(key)); values_.push_back(value); return *this; } mpv_node Node() { list_.num = static_cast(values_.size()); list_.keys = keys_.empty() ? nullptr : keys_.data(); list_.values = values_.empty() ? nullptr : values_.data(); mpv_node node{}; node.format = MPV_FORMAT_NODE_MAP; node.u.list = &list_; return node; } private: std::vector keys_; std::vector values_; mpv_node_list list_{}; }; // A fully described HDR10 master: both names and all four luminances, mixed in // among the fields the HDR decision has no use for, since mpv sends the whole // map every time and the keys are in mpv's order, not ours. void TestEveryFieldPresentIsRead() { Params params; params.Add("pixelformat", Text("yuv420p10")) .Add("w", Whole(3840)) .Add("gamma", Text("pq")) .Add("primaries", Text("bt.2020")) .Add("min-luma", Number(0.0001)) .Add("max-luma", Number(1000.0)) .Add("max-cll", Number(999.0)) .Add("max-fall", Number(400.0)) .Add("chroma-location", Text("mpeg2/4/h264")); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.transfer == "pq"); EXPECT(metadata.primaries == "bt.2020"); EXPECT(metadata.max_cll == 999.0); EXPECT(metadata.max_fall == 400.0); EXPECT(metadata.max_luminance == 1000.0); EXPECT(metadata.min_luminance == 0.0001); } // The common HDR10 case: a PQ / BT.2020 stream that states no static metadata at // all. mpv omits the keys entirely, and every luminance has to stay at zero - // which is how the rest of the pipeline spells "not stated" - while both names // still come through, because they are what decides whether the plane may be // described as HDR in the first place. void TestNamesSurviveWithNoLuminances() { Params params; params.Add("gamma", Text("hlg")).Add("primaries", Text("bt.2020")); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.transfer == "hlg"); EXPECT(metadata.primaries == "bt.2020"); EXPECT(metadata.max_cll == 0.0); EXPECT(metadata.max_fall == 0.0); EXPECT(metadata.max_luminance == 0.0); EXPECT(metadata.min_luminance == 0.0); } // Each luminance is independent: a source may state MaxCLL and nothing else, or // a mastering range and no light levels. An absent field must never pick up its // neighbour's value or a default. void TestEachLuminanceIsAbsentOnItsOwn() { { Params params; params.Add("max-cll", Number(1200.0)); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.max_cll == 1200.0); EXPECT(metadata.max_fall == 0.0); EXPECT(metadata.max_luminance == 0.0); EXPECT(metadata.min_luminance == 0.0); } { Params params; params.Add("max-fall", Number(250.0)); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.max_cll == 0.0); EXPECT(metadata.max_fall == 250.0); EXPECT(metadata.max_luminance == 0.0); } { Params params; params.Add("min-luma", Number(0.005)); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.max_luminance == 0.0); EXPECT(metadata.min_luminance == 0.005); } { Params params; params.Add("max-luma", Number(4000.0)); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.max_luminance == 4000.0); EXPECT(metadata.min_luminance == 0.0); } } // The mastering floor is the one luminance a source may legitimately state as // zero, so a present zero has to be taken rather than dropped. The others treat // zero as no statement, which is the same thing they do with an absent key - // and since the field's absent value *is* zero, only a negative can tell the // two rules apart from the outside. void TestMinLumaAcceptsAZeroTheOthersRefuse() { Params params; params.Add("gamma", Text("pq")) .Add("min-luma", Number(0.0)) .Add("max-luma", Number(0.0)) .Add("max-cll", Number(0.0)) .Add("max-fall", Number(0.0)); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.min_luminance == 0.0); EXPECT(metadata.max_luminance == 0.0); EXPECT(metadata.max_cll == 0.0); EXPECT(metadata.max_fall == 0.0); // No luminance may be negative. The floor would scale into a mastering // minimum the protocol rejects, and a negative peak would sail through the // containment checks that only ever compare upwards. Params negative; negative.Add("min-luma", Number(-1.0)) .Add("max-luma", Number(-4000.0)) .Add("max-cll", Number(-1000.0)) .Add("max-fall", Number(-400.0)); const mpv_node negative_node = negative.Node(); const mpv::SourceHdrMetadata refused = mpv::ParseSourceHdrMetadata(&negative_node); EXPECT(refused.min_luminance == 0.0); EXPECT(refused.max_luminance == 0.0); EXPECT(refused.max_cll == 0.0); EXPECT(refused.max_fall == 0.0); } // The names are mpv's own and this parse does not judge them: SDR curves, gamuts // with no protocol counterpart, and anything a future mpv adds all pass through // verbatim for the caller to classify. Copying them into an enum here would put // the same table in two places. void TestUnknownNamesPassThroughVerbatim() { Params params; params.Add("gamma", Text("bt.1886")).Add("primaries", Text("display-p3")).Add("max-cll", Number(120.0)); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.transfer == "bt.1886"); EXPECT(metadata.primaries == "display-p3"); EXPECT(metadata.max_cll == 120.0); } // Everything that is not the map mpv documents has to read as "no stream": the // property is unavailable between files and while an audio-only core runs, and // the event carries MPV_FORMAT_NONE then. Deciding from a half-read map would // describe the plane in a colour space nothing is emitting. void TestNonMapNodesYieldNothing() { EXPECT(mpv::ParseSourceHdrMetadata(nullptr).transfer.empty()); mpv_node none{}; none.format = MPV_FORMAT_NONE; EXPECT(mpv::ParseSourceHdrMetadata(&none).transfer.empty()); // The shape an observation of the same property in another format delivers. const mpv_node string_node = Text("pq"); const mpv::SourceHdrMetadata from_string = mpv::ParseSourceHdrMetadata(&string_node); EXPECT(from_string.transfer.empty()); EXPECT(from_string.primaries.empty()); // An array is a map's near neighbour and has no keys to walk. mpv_node_list list{}; list.num = 1; mpv_node array_node{}; array_node.format = MPV_FORMAT_NODE_ARRAY; array_node.u.list = &list; EXPECT(mpv::ParseSourceHdrMetadata(&array_node).transfer.empty()); // A map claiming entries it does not carry, which is what a truncated or // hostile payload looks like. mpv_node empty_map{}; empty_map.format = MPV_FORMAT_NODE_MAP; empty_map.u.list = &list; EXPECT(mpv::ParseSourceHdrMetadata(&empty_map).transfer.empty()); mpv_node null_map{}; null_map.format = MPV_FORMAT_NODE_MAP; EXPECT(mpv::ParseSourceHdrMetadata(&null_map).transfer.empty()); } // A key whose value is not the format mpv documents for it is ignored rather // than reinterpreted, and it must not stop the rest of the map being read. An // integer luminance is the one exception: the blocking sub-property read this // replaced asked for a double and libmpv would have converted it. void TestMistypedValuesAreSkippedButIntegersAreNot() { Params params; params.Add("gamma", Number(2.2)) .Add("primaries", Text("bt.2020")) .Add("max-cll", Text("1000")) .Add("max-luma", Whole(4000)) .Add("min-luma", Text("0.0001")); const mpv_node node = params.Node(); const mpv::SourceHdrMetadata metadata = mpv::ParseSourceHdrMetadata(&node); EXPECT(metadata.transfer.empty()); EXPECT(metadata.primaries == "bt.2020"); EXPECT(metadata.max_cll == 0.0); EXPECT(metadata.max_luminance == 4000.0); EXPECT(metadata.min_luminance == 0.0); // mpv owns the strings, and a null one is not a name. Params null_name; null_name.Add("gamma", Text(nullptr)).Add("primaries", Text("bt.2020")); const mpv_node null_name_node = null_name.Node(); const mpv::SourceHdrMetadata from_null = mpv::ParseSourceHdrMetadata(&null_name_node); EXPECT(from_null.transfer.empty()); EXPECT(from_null.primaries == "bt.2020"); } } // namespace int main() { TestEveryFieldPresentIsRead(); TestNamesSurviveWithNoLuminances(); TestEachLuminanceIsAbsentOnItsOwn(); TestMinLumaAcceptsAZeroTheOthersRefuse(); TestUnknownNamesPassThroughVerbatim(); TestNonMapNodesYieldNothing(); TestMistypedValuesAreSkippedButIntegersAreNot(); return failures == 0 ? 0 : 1; }