Skip to content

Commit ccf901e

Browse files
committed
fix(windows): encode H.264 High with BT.709 colour the compositor expects
Every Windows recording was written in BT.601 and untagged. The helper fed the encoder RGB32, and the colour converter Media Foundation puts in front of it produced BT.601 whatever the media types said: solid red came out at Y 82, Cb 90 from the software and the hardware encoder alike, where BT.709 is 63 and 102. The compositor decodes every recording as BT.709, so colours shifted. The encoders also defaulted to Constrained Baseline. - The helper now converts BGRA to NV12 BT.709 studio range itself (SSE2) and feeds NV12 on every path. A 1080p frame costs 1.6 ms to hand over, against about 2 ms for the old copy plus converter. - Range, matrix, primaries and transfer are tagged BT.709 on both tracks. - H.264 High profile, with B-frames off through the encoding parameters (the software encoder added them in High). - The cpuInputIsNv12 option is gone: every system-memory path is NV12. mf_encoder_color_test drives the real encoder, software and hardware, and reads the file back: High, no B-frames, bt709 tags, red/green/blue exact to the code value, and the converter within one code value of BT.709 on noise. It runs from npm run build:native:win. Fixes #922 Fixes #923
1 parent 46c304e commit ccf901e

7 files changed

Lines changed: 495 additions & 69 deletions

File tree

‎electron/native/wgc-capture/CMakeLists.txt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,30 @@ target_compile_definitions(frame_visibility_test PRIVATE
149149
)
150150

151151
target_compile_options(frame_visibility_test PRIVATE /EHsc /W4 /utf-8)
152+
153+
add_executable(mf_encoder_color_test
154+
src/audio_sample_utils.cpp
155+
src/audio_sample_utils.h
156+
src/mf_encoder.cpp
157+
src/mf_encoder.h
158+
src/mf_encoder_color_test.cpp
159+
)
160+
161+
target_compile_definitions(mf_encoder_color_test PRIVATE
162+
NOMINMAX
163+
WIN32_LEAN_AND_MEAN
164+
_WIN32_WINNT=0x0A00
165+
)
166+
167+
target_compile_options(mf_encoder_color_test PRIVATE /EHsc /W4 /utf-8)
168+
169+
target_link_libraries(mf_encoder_color_test PRIVATE
170+
d3d11
171+
dxgi
172+
mf
173+
mfplat
174+
mfreadwrite
175+
mfuuid
176+
ole32
177+
propsys
178+
)

‎electron/native/wgc-capture/src/main.cpp‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,6 @@ int wmain(int argc, wchar_t* argv[]) {
969969
MFEncoderOptions webcamEncoderOptions = encoderOptions;
970970
webcamEncoderOptions.injectDefaultSinkWriterFailureOnce = false;
971971
webcamEncoderOptions.useDxgiInput = false;
972-
webcamEncoderOptions.cpuInputIsNv12 = webcamCapture.deliversNv12();
973972
// The two-step ladder this replaces topped out at 8 Mbit/s for anything
974973
// 720p or larger. That was sized for a camera nobody had configured
975974
// above 640x480; now that the capture runs at the camera's real

‎electron/native/wgc-capture/src/mf_encoder.cpp‎

Lines changed: 153 additions & 59 deletions
Large diffs are not rendered by default.

‎electron/native/wgc-capture/src/mf_encoder.h‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <cstdint>
1212
#include <mutex>
1313
#include <string>
14+
#include <vector>
1415

1516
struct BgraFrameView {
1617
const BYTE* data = nullptr;
@@ -33,6 +34,10 @@ struct Nv12FrameView {
3334
int height = 0;
3435
};
3536

37+
// BGRA to NV12, BT.709 studio range, for every frame the encoder gets from
38+
// system memory. `width` must be even. Exposed for mf_encoder_color_test.
39+
void convertBgraToNv12Bt709(const BYTE* bgra, int stride, int width, int height, BYTE* nv12);
40+
3641
struct AudioInputFormat {
3742
GUID subtype = MFAudioFormat_PCM;
3843
UINT32 sampleRate = 0;
@@ -62,14 +67,6 @@ struct MFEncoderOptions {
6267
// driver that refuses shared keyed-mutex textures records exactly as it did
6368
// before the path existed. Ask usesDxgiInput() for what actually happened.
6469
bool useDxgiInput = false;
65-
/**
66-
* Feed this encoder NV12 from system memory instead of RGB32.
67-
*
68-
* Only meaningful when `useDxgiInput` is false. The webcam encoder sets it
69-
* when the camera itself delivers NV12; the screen encoder's CPU path
70-
* still produces BGRA and leaves it alone.
71-
*/
72-
bool cpuInputIsNv12 = false;
7370
};
7471

7572
constexpr const char* kVideoEncoderSelectionDefault = "default";
@@ -257,14 +254,16 @@ class MFEncoder {
257254
DWORD videoStreamIndex_ = 0;
258255
DWORD audioStreamIndex_ = 0;
259256
bool hasAudioStream_ = false;
257+
// The BGRA frame when it has to be drawn on or rescaled before the NV12
258+
// conversion; reused so a frame does not allocate one.
259+
std::vector<BYTE> bgraScratch_;
260260
int width_ = 0;
261261
int height_ = 0;
262262
int fps_ = 60;
263263
int64_t firstTimestampHns_ = -1;
264264
int64_t lastTimestampHns_ = -1;
265265
bool finalized_ = false;
266266
bool useDxgiInput_ = false;
267-
bool cpuInputIsNv12_ = false;
268267
const char* videoEncoderSelection_ = kVideoEncoderSelectionDefault;
269268
const char* videoEncoderRuntime_ = kVideoEncoderRuntimeUnknown;
270269
const char* containerFormat_ = kContainerFormatMp4;
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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

Comments
 (0)