|
| 1 | +// What the H.264 track actually says about itself (getopenscreen/openscreen#922, |
| 2 | +// #923). Drives the real MFEncoder with synthetic BGRA frames -- three solid |
| 3 | +// bands, red, green and blue -- into a temporary MP4, then reads the file back |
| 4 | +// with ffprobe and ffmpeg: the profile, the colour tags, and the YUV values the |
| 5 | +// encoder really produced for each band. The compositor decodes every |
| 6 | +// recording as BT.709 limited range, so that is what the file has to be and |
| 7 | +// has to say. |
| 8 | +// |
| 9 | +// Needs ffprobe and ffmpeg on PATH; without them the checks are skipped, not |
| 10 | +// failed. The hardware encoder is covered when the host has one. |
| 11 | + |
| 12 | +#include "mf_encoder.h" |
| 13 | + |
| 14 | +#include <d3d11.h> |
| 15 | +#include <wrl/client.h> |
| 16 | + |
| 17 | +#include <algorithm> |
| 18 | +#include <chrono> |
| 19 | +#include <cmath> |
| 20 | +#include <cstdio> |
| 21 | +#include <cstdlib> |
| 22 | +#include <iostream> |
| 23 | +#include <string> |
| 24 | +#include <vector> |
| 25 | + |
| 26 | +namespace { |
| 27 | + |
| 28 | +int g_ran = 0; |
| 29 | +int g_failed = 0; |
| 30 | + |
| 31 | +void expect(const std::string& name, bool ok, const std::string& detail) { |
| 32 | + g_ran += 1; |
| 33 | + std::cout << (ok ? "PASS " : "FAIL ") << name << (ok ? "" : " " + detail) << "\n"; |
| 34 | + if (!ok) { |
| 35 | + g_failed += 1; |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +void skip(const std::string& name, const std::string& reason) { |
| 40 | + std::cout << "SKIP " << name << " " << reason << "\n"; |
| 41 | +} |
| 42 | + |
| 43 | +std::string run(const std::string& command) { |
| 44 | + std::string output; |
| 45 | + FILE* pipe = _popen(command.c_str(), "rb"); |
| 46 | + if (!pipe) { |
| 47 | + return output; |
| 48 | + } |
| 49 | + char buffer[65536]; |
| 50 | + size_t read = 0; |
| 51 | + while ((read = fread(buffer, 1, sizeof(buffer), pipe)) > 0) { |
| 52 | + output.append(buffer, read); |
| 53 | + } |
| 54 | + _pclose(pipe); |
| 55 | + return output; |
| 56 | +} |
| 57 | + |
| 58 | +bool toolsAvailable() { |
| 59 | + return run("ffprobe -version 2>NUL").find("ffprobe") != std::string::npos && |
| 60 | + run("ffmpeg -version 2>NUL").find("ffmpeg") != std::string::npos; |
| 61 | +} |
| 62 | + |
| 63 | +std::string field(const std::string& probe, const std::string& key) { |
| 64 | + const auto at = probe.find(key + "="); |
| 65 | + if (at == std::string::npos) { |
| 66 | + return ""; |
| 67 | + } |
| 68 | + const auto start = at + key.size() + 1; |
| 69 | + const auto end = probe.find_first_of("\r\n", start); |
| 70 | + return probe.substr(start, end - start); |
| 71 | +} |
| 72 | + |
| 73 | +constexpr int kWidth = 480; |
| 74 | +constexpr int kHeight = 272; |
| 75 | +constexpr int kFrames = 30; |
| 76 | + |
| 77 | +struct Expected { |
| 78 | + const char* name; |
| 79 | + int y, cb, cr; |
| 80 | +}; |
| 81 | +// BT.709, studio range. BT.601 would put red at Y 81, Cb 90. |
| 82 | +constexpr Expected kBands[] = {{"red", 63, 102, 240}, {"green", 173, 42, 26}, {"blue", 32, 240, 118}}; |
| 83 | + |
| 84 | +void checkEncoder(ID3D11Device* device, ID3D11DeviceContext* context, bool software) { |
| 85 | + const std::string label = software ? "software" : "default"; |
| 86 | + char tempDir[MAX_PATH]{}; |
| 87 | + GetTempPathA(MAX_PATH, tempDir); |
| 88 | + const std::string path = std::string(tempDir) + "openscreen-mf-encoder-color-" + label + ".mp4"; |
| 89 | + const std::wstring widePath(path.begin(), path.end()); |
| 90 | + DeleteFileA(path.c_str()); |
| 91 | + |
| 92 | + std::vector<BYTE> bgra(static_cast<size_t>(kWidth) * kHeight * 4); |
| 93 | + for (int y = 0; y < kHeight; y += 1) { |
| 94 | + for (int x = 0; x < kWidth; x += 1) { |
| 95 | + BYTE* pixel = &bgra[(static_cast<size_t>(y) * kWidth + x) * 4]; |
| 96 | + const int band = x * 3 / kWidth; |
| 97 | + pixel[0] = band == 2 ? 255 : 0; // B |
| 98 | + pixel[1] = band == 1 ? 255 : 0; // G |
| 99 | + pixel[2] = band == 0 ? 255 : 0; // R |
| 100 | + pixel[3] = 255; |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + { |
| 105 | + MFEncoder encoder; |
| 106 | + MFEncoderOptions options; |
| 107 | + options.preferSoftwareEncoder = software; |
| 108 | + if (!encoder.initialize(widePath, kWidth, kHeight, 30, 2'000'000, device, context, nullptr, options)) { |
| 109 | + expect("encoder-initialize-" + label, false, "initialize failed"); |
| 110 | + return; |
| 111 | + } |
| 112 | + const std::string runtime = encoder.videoEncoderRuntime(); |
| 113 | + std::cout << "COLOR_RAW " << label << " runtime=" << runtime << "\n"; |
| 114 | + if (!software && runtime != kVideoEncoderRuntimeHardware) { |
| 115 | + skip("encoder-" + label, "no hardware H.264 encoder on this host (" + runtime + ")"); |
| 116 | + encoder.finalize(); |
| 117 | + return; |
| 118 | + } |
| 119 | + const BgraFrameView frame{bgra.data(), kWidth, kHeight}; |
| 120 | + bool wrote = true; |
| 121 | + for (int i = 0; i < kFrames && wrote; i += 1) { |
| 122 | + Microsoft::WRL::ComPtr<IMFSample> sample; |
| 123 | + wrote = encoder.captureBgraSample(frame, static_cast<int64_t>(i) * 333'333, sample) && |
| 124 | + encoder.submitVideoSample(sample.Get()); |
| 125 | + } |
| 126 | + expect("encoder-writes-" + label, wrote && encoder.finalize(), "write or finalize failed"); |
| 127 | + } |
| 128 | + |
| 129 | + const std::string probe = run( |
| 130 | + "ffprobe -v error -select_streams v:0 -show_entries " |
| 131 | + "stream=profile,has_b_frames,color_range,color_space,color_primaries,color_transfer -of default=nw=1 \"" + |
| 132 | + path + "\""); |
| 133 | + std::cout << "COLOR_RAW " << label << " profile=" << field(probe, "profile") |
| 134 | + << " range=" << field(probe, "color_range") << " space=" << field(probe, "color_space") |
| 135 | + << " primaries=" << field(probe, "color_primaries") |
| 136 | + << " transfer=" << field(probe, "color_transfer") << "\n"; |
| 137 | + expect("profile-high-" + label, field(probe, "profile") == "High", probe); |
| 138 | + // B-frames broke the fragmented writer on macOS; the profile must not bring them. |
| 139 | + expect("no-b-frames-" + label, field(probe, "has_b_frames") == "0", probe); |
| 140 | + expect( |
| 141 | + "colour-tags-bt709-limited-" + label, |
| 142 | + field(probe, "color_range") == "tv" && field(probe, "color_space") == "bt709" && |
| 143 | + field(probe, "color_primaries") == "bt709" && field(probe, "color_transfer") == "bt709", |
| 144 | + probe); |
| 145 | + |
| 146 | + // The last frame, as the encoder wrote its samples: yuv420p out of a yuv420p |
| 147 | + // stream is a straight copy, no range or matrix conversion on the way. |
| 148 | + const std::string yuv = run( |
| 149 | + "ffmpeg -v error -sseof -0.2 -i \"" + path + "\" -frames:v 1 -f rawvideo -pix_fmt yuv420p -"); |
| 150 | + const size_t lumaSize = static_cast<size_t>(kWidth) * kHeight; |
| 151 | + if (yuv.size() < lumaSize * 3 / 2) { |
| 152 | + expect("yuv-readback-" + label, false, "ffmpeg returned " + std::to_string(yuv.size()) + " bytes"); |
| 153 | + return; |
| 154 | + } |
| 155 | + for (int band = 0; band < 3; band += 1) { |
| 156 | + const int x = kWidth * (2 * band + 1) / 6; |
| 157 | + const int y = kHeight / 2; |
| 158 | + const int luma = static_cast<unsigned char>(yuv[static_cast<size_t>(y) * kWidth + x]); |
| 159 | + const size_t chroma = static_cast<size_t>(y / 2) * (kWidth / 2) + x / 2; |
| 160 | + const int cb = static_cast<unsigned char>(yuv[lumaSize + chroma]); |
| 161 | + const int cr = static_cast<unsigned char>(yuv[lumaSize + lumaSize / 4 + chroma]); |
| 162 | + const Expected& want = kBands[band]; |
| 163 | + char detail[128]{}; |
| 164 | + sprintf_s( |
| 165 | + detail, "%s Y=%d Cb=%d Cr=%d, BT.709 limited wants %d/%d/%d", want.name, luma, cb, cr, want.y, |
| 166 | + want.cb, want.cr); |
| 167 | + std::cout << "COLOR_RAW " << label << " " << detail << "\n"; |
| 168 | + expect( |
| 169 | + std::string("yuv-bt709-") + want.name + "-" + label, |
| 170 | + std::abs(luma - want.y) <= 3 && std::abs(cb - want.cb) <= 3 && std::abs(cr - want.cr) <= 3, |
| 171 | + detail); |
| 172 | + } |
| 173 | + DeleteFileA(path.c_str()); |
| 174 | +} |
| 175 | + |
| 176 | +// The converter against BT.709 in double precision, on noise: solid bands |
| 177 | +// cannot tell a 2x2 average from a wrong pairing of pixels, and 38 wide with a |
| 178 | +// padded stride runs the SIMD body, its scalar tail and the row pitch. |
| 179 | +void checkConverterAgainstReference() { |
| 180 | + constexpr int width = 38; |
| 181 | + constexpr int height = 22; |
| 182 | + constexpr int stride = width * 4 + 24; |
| 183 | + std::vector<BYTE> bgra(static_cast<size_t>(stride) * height); |
| 184 | + uint32_t seed = 12345; |
| 185 | + for (BYTE& value : bgra) { |
| 186 | + seed = seed * 1664525u + 1013904223u; |
| 187 | + value = static_cast<BYTE>(seed >> 24); |
| 188 | + } |
| 189 | + std::vector<BYTE> nv12(static_cast<size_t>(width) * height * 3 / 2); |
| 190 | + convertBgraToNv12Bt709(bgra.data(), stride, width, height, nv12.data()); |
| 191 | + |
| 192 | + const double kr = 0.2126; |
| 193 | + const double kb = 0.0722; |
| 194 | + const auto channel = [&](int x, int y, int c) { return bgra[static_cast<size_t>(y) * stride + x * 4 + c] / 255.0; }; |
| 195 | + int worst = 0; |
| 196 | + for (int y = 0; y < height; y += 1) { |
| 197 | + for (int x = 0; x < width; x += 1) { |
| 198 | + const double luma = kr * channel(x, y, 2) + (1 - kr - kb) * channel(x, y, 1) + kb * channel(x, y, 0); |
| 199 | + const int want = static_cast<int>(std::lround(16 + 219 * luma)); |
| 200 | + worst = std::max(worst, std::abs(want - nv12[static_cast<size_t>(y) * width + x])); |
| 201 | + } |
| 202 | + } |
| 203 | + for (int y = 0; y < height; y += 2) { |
| 204 | + for (int x = 0; x < width; x += 2) { |
| 205 | + double r = 0, g = 0, b = 0; |
| 206 | + for (int dy = 0; dy < 2; dy += 1) { |
| 207 | + for (int dx = 0; dx < 2; dx += 1) { |
| 208 | + r += channel(x + dx, y + dy, 2) / 4; |
| 209 | + g += channel(x + dx, y + dy, 1) / 4; |
| 210 | + b += channel(x + dx, y + dy, 0) / 4; |
| 211 | + } |
| 212 | + } |
| 213 | + const double luma = kr * r + (1 - kr - kb) * g + kb * b; |
| 214 | + const int cb = static_cast<int>(std::lround(128 + 224 * (b - luma) / (2 * (1 - kb)))); |
| 215 | + const int cr = static_cast<int>(std::lround(128 + 224 * (r - luma) / (2 * (1 - kr)))); |
| 216 | + const size_t at = static_cast<size_t>(width) * height + static_cast<size_t>(y / 2) * width + x; |
| 217 | + worst = std::max({worst, std::abs(cb - nv12[at]), std::abs(cr - nv12[at + 1])}); |
| 218 | + } |
| 219 | + } |
| 220 | + std::cout << "COLOR_RAW converter worst deviation from BT.709 = " << worst << " code values" << std::endl; |
| 221 | + expect("converter-matches-bt709-reference", worst <= 1, "worst=" + std::to_string(worst)); |
| 222 | +} |
| 223 | + |
| 224 | +// Not a pass/fail: what one 1080p frame costs to hand over and encode, for a |
| 225 | +// before/after comparison of the conversion's cost. |
| 226 | +void timeFullHd(ID3D11Device* device, ID3D11DeviceContext* context) { |
| 227 | + constexpr int width = 1920; |
| 228 | + constexpr int height = 1080; |
| 229 | + constexpr int frames = 120; |
| 230 | + char tempDir[MAX_PATH]{}; |
| 231 | + GetTempPathA(MAX_PATH, tempDir); |
| 232 | + const std::string path = std::string(tempDir) + "openscreen-mf-encoder-timing.mp4"; |
| 233 | + const std::wstring widePath(path.begin(), path.end()); |
| 234 | + std::vector<BYTE> bgra(static_cast<size_t>(width) * height * 4); |
| 235 | + for (size_t i = 0; i < bgra.size(); i += 1) { |
| 236 | + bgra[i] = static_cast<BYTE>((i * 2654435761u) >> 24); |
| 237 | + } |
| 238 | + MFEncoder encoder; |
| 239 | + if (!encoder.initialize(widePath, width, height, 60, 18'000'000, device, context, nullptr, {})) { |
| 240 | + return; |
| 241 | + } |
| 242 | + const BgraFrameView frame{bgra.data(), width, height}; |
| 243 | + double captureMs = 0.0; |
| 244 | + double submitMs = 0.0; |
| 245 | + for (int i = 0; i < frames; i += 1) { |
| 246 | + Microsoft::WRL::ComPtr<IMFSample> sample; |
| 247 | + const auto start = std::chrono::steady_clock::now(); |
| 248 | + encoder.captureBgraSample(frame, static_cast<int64_t>(i) * 166'667, sample); |
| 249 | + const auto captured = std::chrono::steady_clock::now(); |
| 250 | + encoder.submitVideoSample(sample.Get()); |
| 251 | + const auto submitted = std::chrono::steady_clock::now(); |
| 252 | + captureMs += std::chrono::duration<double, std::milli>(captured - start).count(); |
| 253 | + submitMs += std::chrono::duration<double, std::milli>(submitted - captured).count(); |
| 254 | + } |
| 255 | + encoder.finalize(); |
| 256 | + DeleteFileA(path.c_str()); |
| 257 | + std::cout << "COLOR_RAW timing 1080p " << encoder.videoEncoderRuntime() << ": capture " |
| 258 | + << captureMs / frames << " ms, submit " << submitMs / frames << " ms per frame" << std::endl; |
| 259 | +} |
| 260 | + |
| 261 | +} // namespace |
| 262 | + |
| 263 | +int main() { |
| 264 | + checkConverterAgainstReference(); |
| 265 | + if (!toolsAvailable()) { |
| 266 | + skip("mf-encoder-color", "ffprobe/ffmpeg not on PATH"); |
| 267 | + return g_failed == 0 ? 0 : 1; |
| 268 | + } |
| 269 | + if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) { |
| 270 | + skip("mf-encoder-color", "CoInitializeEx failed"); |
| 271 | + return 0; |
| 272 | + } |
| 273 | + Microsoft::WRL::ComPtr<ID3D11Device> device; |
| 274 | + Microsoft::WRL::ComPtr<ID3D11DeviceContext> context; |
| 275 | + const UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT; |
| 276 | + if (FAILED(D3D11CreateDevice( |
| 277 | + nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, nullptr, 0, D3D11_SDK_VERSION, &device, |
| 278 | + nullptr, &context)) && |
| 279 | + FAILED(D3D11CreateDevice( |
| 280 | + nullptr, D3D_DRIVER_TYPE_WARP, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, |
| 281 | + D3D11_SDK_VERSION, &device, nullptr, &context))) { |
| 282 | + skip("mf-encoder-color", "no D3D11 device"); |
| 283 | + return 0; |
| 284 | + } |
| 285 | + checkEncoder(device.Get(), context.Get(), true); |
| 286 | + checkEncoder(device.Get(), context.Get(), false); |
| 287 | + timeFullHd(device.Get(), context.Get()); |
| 288 | + |
| 289 | + std::cout << "ran " << g_ran << " tests\n"; |
| 290 | + if (g_failed != 0) { |
| 291 | + std::cout << g_failed << " failed\n"; |
| 292 | + return 1; |
| 293 | + } |
| 294 | + return 0; |
| 295 | +} |
0 commit comments