Files
plezy/linux/runner/mpv/mpv_player_hdr_output_test.cc
T

475 lines
22 KiB
C++
Raw Normal View History

// Focused test for MpvPlayer's output-colour-space transaction: the four
// properties that together say what mpv is emitting, applied as a unit, unwound
// as a unit, and escalated to SDR when they cannot be unwound.
//
// Nothing else covers it, and nothing about it is visible on screen. A renamed
// property, a reversed write order or a rollback that reports success without
// restoring all leave working-looking video behind while the plane's committed
// surface description says something the pixels do not — the single state the
// whole transaction exists to prevent. So the assertions here are on the
// recorded (name, value) stream, not only on the returned result.
#include <mpv/client.h>
#include <cstddef>
#include <iostream>
#include <set>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "mpv_player.h"
namespace mpv {
namespace {
void Check(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
struct PropertyWrite {
std::string name;
std::string value;
};
// Stands in for the core: records every property write in order and answers
// each with a scripted mpv status.
//
// Replies synchronously, which real mpv does not. That is sound here because
// the ladder awaits every reply before issuing the next write — the property
// stream a synchronous core sees is the one an asynchronous core would see, and
// no main loop has to be pumped to observe it.
class ScriptedCore {
public:
void Install(MpvPlayer& player) {
player.ConfigurePropertyWritesForTesting(
[this](const std::string& name, const std::string& value, MpvPlayer::StatusCallback callback) {
const size_t position = writes_.size();
writes_.push_back(PropertyWrite{name, value});
const bool refused = refusals_.count(position) != 0;
if (callback) callback(refused ? MPV_ERROR_PROPERTY_ERROR : MPV_ERROR_SUCCESS);
});
}
// Drops the substituted writer, which is what makes the player look like a
// core that can no longer be commanded: with no writer, the real
// disposed_/mpv_ check underneath decides, and a player that never opened a
// handle fails it.
void Uninstall(MpvPlayer& player) { player.ConfigurePropertyWritesForTesting(nullptr); }
// Refuses the write that lands at |position| in the recorded stream, counting
// from the last Forget().
void RefuseWrite(size_t position) { refusals_.insert(position); }
void Forget() {
writes_.clear();
refusals_.clear();
}
const std::vector<PropertyWrite>& writes() const { return writes_; }
private:
std::vector<PropertyWrite> writes_;
std::set<size_t> refusals_;
};
// Both sides of the comparison are printed on a mismatch: the failure mode this
// test guards against is a stream that is *almost* right, and "expected 5 writes,
// got 5 writes" would say nothing about which one moved.
void CheckWrites(const ScriptedCore& core, const std::vector<PropertyWrite>& expected, const char* message) {
const std::vector<PropertyWrite>& actual = core.writes();
bool matched = actual.size() == expected.size();
for (size_t index = 0; matched && index < expected.size(); ++index) {
matched = actual[index].name == expected[index].name && actual[index].value == expected[index].value;
}
if (matched) return;
std::cerr << " expected writes:\n";
for (const PropertyWrite& write : expected) std::cerr << " " << write.name << '=' << write.value << '\n';
std::cerr << " recorded writes:\n";
for (const PropertyWrite& write : actual) std::cerr << " " << write.name << '=' << write.value << '\n';
Check(false, message);
}
void CheckApplied(
const MpvPlayer& player, const char* target_trc, const char* target_prim, const char* tone_mapping,
const char* target_peak, const char* message) {
const MpvPlayer::AppliedOutputColourSpace applied = player.AppliedOutputColourSpaceForTesting();
if (applied.target_trc == target_trc && applied.target_prim == target_prim && applied.tone_mapping == tone_mapping &&
applied.target_peak == target_peak) {
return;
}
std::cerr << " expected applied: trc=" << target_trc << " prim=" << target_prim << " tone-mapping=" << tone_mapping
<< " peak=" << target_peak << '\n';
std::cerr << " recorded applied: trc=" << applied.target_trc << " prim=" << applied.target_prim
<< " tone-mapping=" << applied.tone_mapping << " peak=" << applied.target_peak << '\n';
Check(false, message);
}
// One request, run to completion, with the outcome its caller would act on.
struct Outcome {
int callbacks = 0;
MpvPlayer::HdrOutputResult result = MpvPlayer::HdrOutputResult::kUnknown;
int error = MPV_ERROR_SUCCESS;
};
Outcome Request(MpvPlayer& player, SourceTransfer transfer, uint32_t peak_nits) {
Outcome outcome;
player.SetHdrOutput(transfer, peak_nits, [&outcome](MpvPlayer::HdrOutputResult result, int error) {
++outcome.callbacks;
outcome.result = result;
outcome.error = error;
});
Check(outcome.callbacks == 1, "an output colour space request must complete exactly once");
return outcome;
}
// Puts the player in a real HDR output state before the failure cases run, so
// the values a rollback restores are ones mpv genuinely held. From the default
// state every rollback target is "auto", which a rollback that wrote nothing at
// all would also produce.
void ApplyPqBaseline(MpvPlayer& player, ScriptedCore& core) {
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "the PQ baseline for the failure cases must apply");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "the PQ baseline must be the state later rollbacks restore");
core.Forget();
}
// The happy path, and the exact strings that are the contract with mpv. The
// order is a dependency order: target-peak is what decides whether a tone-map
// pass runs at all, so everything that pass reads is in place before it is
// named. Nothing else in the tree checks either the names or the ordering.
void TestHdrApplyWritesTheFourPropertiesPeakLast() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
CheckWrites(
core, {{"target-trc", "pq"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "auto"}},
"an HDR request must write all four properties in dependency order with the peak last");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied sequence must report kApplied");
Check(outcome.error == MPV_ERROR_SUCCESS, "a fully applied sequence must report no error");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "the applied cache must hold exactly what was written");
}
// A display peak inside mpv's 10..10000 range moves the tone map into mpv, and
// the peak is the property that says so. The operator stays on mpv's own choice
// while the target is HDR — see the tone-mapping comment in RunPendingHdrOutput.
void TestHdrApplyWithADisplayPeakNamesThatPeak() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
const Outcome outcome = Request(player, SourceTransfer::kHlg, 1000);
CheckWrites(
core, {{"target-trc", "hlg"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "1000"}},
"an HLG request with a display peak must encode HLG and name that peak");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied sequence must report kApplied");
CheckApplied(player, "hlg", "bt.2020", "auto", "1000", "the applied cache must hold exactly what was written");
}
// The other half of the contract: an SDR decision withdraws the HDR request
// rather than leaving any part of it standing. A plane on BT.2020 primaries with
// an SDR curve is neither, and is exactly what a partial withdrawal produces.
void TestSdrRequestWithdrawsEveryHdrProperty() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
const Outcome outcome = Request(player, SourceTransfer::kSdr, 0);
CheckWrites(
core, {{"target-trc", "auto"}, {"target-prim", "auto"}, {"tone-mapping", "auto"}, {"target-peak", "auto"}},
"an SDR request must return every output property to auto");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied SDR sequence must report kApplied");
CheckApplied(player, "auto", "auto", "auto", "auto", "the applied cache must follow the SDR withdrawal");
}
// The measured SDR fallback: mpv tone-maps to the compositor's own reference
// white, in the surface's real terms rather than assumed ones. mobius is the
// operator that measurement selected, and it is applied here and nowhere else.
void TestSdrRequestWithAReferenceWhiteNamesTheMeasuredTerms() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core, {{"target-trc", "srgb"}, {"target-prim", "auto"}, {"tone-mapping", "mobius"}, {"target-peak", "203"}},
"an SDR request with a reference white must name sRGB, mobius and that white");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "a fully applied SDR sequence must report kApplied");
CheckApplied(player, "srgb", "auto", "mobius", "203", "the applied cache must hold exactly what was written");
}
// playback-restart re-applies on every seek, so most requests ask for the state
// mpv already holds; skipping those is what stops a seek costing four round
// trips. The skip has to be exact, though: it reports kApplied, and a caller
// commits a surface description against that word. A quad that differs anywhere
// and is skipped anyway leaves the description describing the previous one.
void TestIdenticalRequestIsSkippedButANarrowlyDifferentOneIsNot() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
const Outcome repeat = Request(player, SourceTransfer::kPq, 0);
CheckWrites(core, {}, "a request for the state already in force must write nothing");
Check(repeat.result == MpvPlayer::HdrOutputResult::kApplied, "a skipped request must still report kApplied");
Check(repeat.error == MPV_ERROR_SUCCESS, "a skipped request must report no error");
// Differs from the state in force in target-peak alone.
core.Forget();
const Outcome peak_only = Request(player, SourceTransfer::kPq, 1000);
CheckWrites(
core, {{"target-trc", "pq"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "1000"}},
"a request differing only in the peak must not be skipped");
Check(peak_only.result == MpvPlayer::HdrOutputResult::kApplied, "a re-applied sequence must report kApplied");
CheckApplied(player, "pq", "bt.2020", "auto", "1000", "the applied cache must follow a peak-only change");
// Differs from the state now in force in target-trc alone. The other two
// cannot vary on their own: primaries move with the HDR decision and the
// operator with the tone-map decision, and either decision also moves the
// curve. So the comparison is exercised on the two that can.
core.Forget();
const Outcome curve_only = Request(player, SourceTransfer::kHlg, 1000);
CheckWrites(
core, {{"target-trc", "hlg"}, {"target-prim", "bt.2020"}, {"tone-mapping", "auto"}, {"target-peak", "1000"}},
"a request differing only in the transfer curve must not be skipped");
Check(curve_only.result == MpvPlayer::HdrOutputResult::kApplied, "a re-applied sequence must report kApplied");
CheckApplied(player, "hlg", "bt.2020", "auto", "1000", "the applied cache must follow a curve-only change");
}
// A refused step unwinds what already landed, newest first, and reports
// kRestored — the one result that tells the caller its previously committed
// description is still true and must be left alone. Both halves matter: the
// order, because an unwind in apply order would restore a property the refused
// step's dependencies still contradict, and the values, because "restored" is a
// claim about what mpv now holds, not about how many writes were issued.
void TestRefusedStepUnwindsNewestFirstAndReportsRestored() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping, two properties into the sequence
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core,
{{"target-trc", "srgb"},
{"target-prim", "auto"},
{"tone-mapping", "mobius"},
{"target-prim", "bt.2020"},
{"target-trc", "pq"}},
"a refused step must restore each earlier property to its previous value, newest first");
Check(outcome.result == MpvPlayer::HdrOutputResult::kRestored, "a cleanly unwound sequence must report kRestored");
Check(outcome.error == MPV_ERROR_PROPERTY_ERROR, "the reported error must be the original refusal");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "a refused sequence must leave the applied cache untouched");
}
// An unwinding step that is itself refused leaves mpv in a state that is neither
// the old colour space nor the new, and no surface description is correct for a
// signal nobody can name. The escalation drives every property to auto — always
// valid, always describable — and says so with kForcedSdr, which is the caller's
// cue that any HDR description it committed is now a lie about the pixels.
void TestRefusedRollbackForcesSdr() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping
core.RefuseWrite(3); // the first unwinding write
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core,
{{"target-trc", "srgb"},
{"target-prim", "auto"},
{"tone-mapping", "mobius"},
{"target-prim", "bt.2020"},
{"target-trc", "auto"},
{"target-prim", "auto"},
{"tone-mapping", "auto"},
{"target-peak", "auto"}},
"a refused rollback must drive every output property to auto");
Check(outcome.result == MpvPlayer::HdrOutputResult::kForcedSdr, "a forced SDR unwind must report kForcedSdr");
Check(outcome.error == MPV_ERROR_PROPERTY_ERROR, "the reported error must still be the original refusal");
CheckApplied(player, "auto", "auto", "auto", "auto", "forcing SDR must leave the applied cache describing SDR");
}
// `auto` is valid for all four, so a core that refuses even that is no longer
// taking orders — usually because it is being disposed. What the plane emits is
// then unknowable, and kUnknown is the result the plugin treats as "nothing can
// be said about these pixels", stopping the presentation rather than guessing.
// The applied cache must stay where it was: claiming SDR here would be a guess.
void TestRefusedForcedSdrReportsUnknown() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping
core.RefuseWrite(3); // the first unwinding write
core.RefuseWrite(5); // the second write of the forced-SDR reset
const Outcome outcome = Request(player, SourceTransfer::kSdr, 203);
CheckWrites(
core,
{{"target-trc", "srgb"},
{"target-prim", "auto"},
{"tone-mapping", "mobius"},
{"target-prim", "bt.2020"},
{"target-trc", "auto"},
{"target-prim", "auto"}},
"a refused forced-SDR reset must stop rather than carry on down the list");
Check(outcome.result == MpvPlayer::HdrOutputResult::kUnknown, "an uncommandable core must report kUnknown");
Check(outcome.error == MPV_ERROR_PROPERTY_ERROR, "the reported error must still be the original refusal");
CheckApplied(
player, "auto", "bt.2020", "auto", "auto",
"a reset step that landed must be recorded, even though a later one was refused");
}
// Two mechanisms that are each correct alone and wrong together. A forced-SDR
// reset refused on its *first* step records nothing, so the cache still names
// the pre-request state - while mpv holds neither that nor SDR: the sequence's
// own first write landed before it failed. The no-op short-circuit would then
// find that stale cache matching the next identical request, report kApplied
// without writing anything, and the plugin would commit a PQ image description
// over pixels mpv is emitting as sRGB. That is a wrong picture with no error
// anywhere, and it needs the two together, so neither one's tests can catch it.
void TestAnUnknownOutputStateIsNeverAnsweredFromTheCache() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2); // tone-mapping, the request's own third write
core.RefuseWrite(3); // the rollback of target-prim
core.RefuseWrite(4); // the forced-SDR reset's first step, target-trc
Check(
Request(player, SourceTransfer::kSdr, 203).result == MpvPlayer::HdrOutputResult::kUnknown,
"the setup for this case is a reset refused before it recorded anything");
CheckApplied(
player, "pq", "bt.2020", "auto", "auto",
"nothing landed after the refusal, so the cache still names the pre-request state");
// Exactly the request the baseline made, so the cache above matches it in all
// four values. mpv does not: target-trc is srgb, from the write that landed
// before the refusal. Refusing nothing this time isolates the gate - a skip
// here could only come from trusting the cache.
const size_t before = core.writes().size();
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(core.writes().size() > before, "a request after an unknown output state must write, not skip");
Check(outcome.result == MpvPlayer::HdrOutputResult::kApplied, "the re-applied sequence must succeed");
CheckApplied(player, "pq", "bt.2020", "auto", "auto", "a clean apply must leave the cache naming what it wrote");
}
// A sequence refused on its very first write unwinds nothing - RollbackPropertySequence
// returns straight back out with nothing to undo - so it reports the untouched
// kRestored: "mpv is exactly where it was". True, and worthless once where it was
// is itself unknown. The caller reads kRestored as "the description you committed
// still holds" and puts the plane back on screen; after an unknown state there is
// no description, and mpv is in the half-reset colour space the last refusal left.
// The two mechanisms are each right alone: unwinding nothing really is a clean
// unwind, and kRestored really does mean the description stands - when the
// starting point was nameable.
void TestARefusalAfterAnUnknownStateIsNotReportedAsRestored() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2);
core.RefuseWrite(3);
core.RefuseWrite(4);
Check(
Request(player, SourceTransfer::kSdr, 203).result == MpvPlayer::HdrOutputResult::kUnknown,
"the setup for this case is an output state that can no longer be named");
// The next request's first write, refused. Nothing to unwind, so the unwind
// result stays at its clean default - which is the trap.
core.RefuseWrite(5);
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(
outcome.result != MpvPlayer::HdrOutputResult::kRestored,
"a refusal on top of an unknown output state must not claim mpv was restored");
Check(
outcome.result == MpvPlayer::HdrOutputResult::kUnknown,
"it must stay unknown, so the caller keeps the plane off screen rather than showing it undescribed");
}
// The third place a result is named: the early return taken when the core can no
// longer be commanded at all. It owes the same answer as the other two, or a
// request already queued when the core went away is answered kUnknown by the
// drain while an identical one arriving a moment later hears kRestored - two
// different claims about one output, decided by timing.
void TestAnUncommandableCoreInheritsTheUnknownState() {
ScriptedCore core;
MpvPlayer player;
core.Install(player);
ApplyPqBaseline(player, core);
core.RefuseWrite(2);
core.RefuseWrite(3);
core.RefuseWrite(4);
Check(
Request(player, SourceTransfer::kSdr, 203).result == MpvPlayer::HdrOutputResult::kUnknown,
"the setup for this case is an output state that can no longer be named");
core.Uninstall(player);
const Outcome outcome = Request(player, SourceTransfer::kPq, 0);
Check(
outcome.result == MpvPlayer::HdrOutputResult::kUnknown,
"a core that cannot be commanded must not claim the unknown state was restored");
Check(outcome.error == MPV_ERROR_UNINITIALIZED, "the early return still reports why it could not run");
}
} // namespace
} // namespace mpv
int main() {
GMainContext* context = g_main_context_new();
g_main_context_push_thread_default(context);
// The escalation cases provoke the ladder's own g_warning on purpose, and
// every apply logs its decision. Swallow both: a passing run that prints
// "could not restore target-prim" reads like a failing one, and the next
// person to see it will try to fix the wrong thing. Real failures come back
// through Check, not through the log.
g_log_set_default_handler([](const gchar*, GLogLevelFlags, const gchar*, gpointer) {}, nullptr);
try {
mpv::TestHdrApplyWritesTheFourPropertiesPeakLast();
mpv::TestHdrApplyWithADisplayPeakNamesThatPeak();
mpv::TestSdrRequestWithdrawsEveryHdrProperty();
mpv::TestSdrRequestWithAReferenceWhiteNamesTheMeasuredTerms();
mpv::TestIdenticalRequestIsSkippedButANarrowlyDifferentOneIsNot();
mpv::TestRefusedStepUnwindsNewestFirstAndReportsRestored();
mpv::TestRefusedRollbackForcesSdr();
mpv::TestRefusedForcedSdrReportsUnknown();
mpv::TestAnUnknownOutputStateIsNeverAnsweredFromTheCache();
mpv::TestARefusalAfterAnUnknownStateIsNotReportedAsRestored();
mpv::TestAnUncommandableCoreInheritsTheUnknownState();
} catch (const std::exception& error) {
g_main_context_pop_thread_default(context);
g_main_context_unref(context);
std::cerr << "mpv_player_hdr_output_test: " << error.what() << '\n';
return 1;
}
g_main_context_pop_thread_default(context);
g_main_context_unref(context);
std::cout << "mpv_player_hdr_output_test: PASS\n";
return 0;
}