From c74448fd3dfd0833d158495190313c10ea92b00b Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:25:28 +1000 Subject: [PATCH] Correct colour handling during recording Record captured frames with BT.709 conversion and explicit limited-range metadata. Share the Windows conversion code between capture engines and apply matching colour metadata on macOS and FFmpeg fallbacks. --- electron/ipc/recording/ffmpeg.ts | 10 +++ .../native/ScreenCaptureKitRecorder.swift | 5 ++ .../native/ScreenCaptureKitRecorder.test.ts | 9 +++ electron/native/common/bt709_video.h | 66 +++++++++++++++++++ .../native/wgc-capture/src/mf_encoder.cpp | 37 ++--------- .../native/windows-capture/src/mf_encoder.cpp | 37 ++--------- 6 files changed, 104 insertions(+), 60 deletions(-) create mode 100644 electron/native/common/bt709_video.h diff --git a/electron/ipc/recording/ffmpeg.ts b/electron/ipc/recording/ffmpeg.ts index dae48fc0..0c38eb0b 100644 --- a/electron/ipc/recording/ffmpeg.ts +++ b/electron/ipc/recording/ffmpeg.ts @@ -25,12 +25,22 @@ export function getDisplayWorkAreaForSource(source: SelectedSource) { export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string) { const commonOutputArgs = [ "-an", + "-vf", + "scale=in_range=full:out_range=tv:sws_dither=a_dither", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", "-movflags", "+faststart", outputPath, diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index ed3649ec..0d039dfb 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -180,6 +180,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { outputSettings[AVVideoWidthKey] = outputWidth outputSettings[AVVideoHeightKey] = outputHeight + outputSettings[AVVideoColorPropertiesKey] = [ + AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, + AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, + AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2, + ] let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) videoInput.expectsMediaDataInRealTime = true diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index 85264100..b2de7e59 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -40,3 +40,12 @@ describe("ScreenCaptureKitRecorder resume timing", () => { ); }); }); + +describe("ScreenCaptureKitRecorder colour metadata", () => { + it("tags recordings as BT.709", () => { + expect(recorderSource).toContain("AVVideoColorPropertiesKey"); + expect(recorderSource).toContain("AVVideoColorPrimaries_ITU_R_709_2"); + expect(recorderSource).toContain("AVVideoTransferFunction_ITU_R_709_2"); + expect(recorderSource).toContain("AVVideoYCbCrMatrix_ITU_R_709_2"); + }); +}); diff --git a/electron/native/common/bt709_video.h b/electron/native/common/bt709_video.h new file mode 100644 index 00000000..f4758dcc --- /dev/null +++ b/electron/native/common/bt709_video.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +inline int clampVideoSample(int value, int minimum, int maximum) { + return value < minimum ? minimum : (value > maximum ? maximum : value); +} + +inline HRESULT setBt709LimitedVideoAttributes(IMFMediaType* mediaType) { + HRESULT result = mediaType->SetUINT32(MF_MT_VIDEO_PRIMARIES, MFVideoPrimaries_BT709); + if (FAILED(result)) return result; + result = mediaType->SetUINT32(MF_MT_TRANSFER_FUNCTION, MFVideoTransFunc_709); + if (FAILED(result)) return result; + result = mediaType->SetUINT32(MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709); + if (FAILED(result)) return result; + return mediaType->SetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, MFNominalRange_16_235); +} + +inline void convertBgraToBt709LimitedNv12( + const uint8_t* bgra, + int bgraPitch, + int width, + int height, + std::vector& nv12Buffer) { + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const uint8_t* pixel = bgra + y * bgraPitch + x * 4; + const int blue = pixel[0]; + const int green = pixel[1]; + const int red = pixel[2]; + const int luma = ((47 * red + 157 * green + 16 * blue + 128) >> 8) + 16; + nv12Buffer[y * width + x] = static_cast(clampVideoSample(luma, 16, 235)); + } + } + + uint8_t* uvPlane = nv12Buffer.data() + width * height; + for (int y = 0; y < height; y += 2) { + for (int x = 0; x < width; x += 2) { + int red = 0; + int green = 0; + int blue = 0; + for (int offsetY = 0; offsetY < 2; ++offsetY) { + for (int offsetX = 0; offsetX < 2; ++offsetX) { + const uint8_t* pixel = + bgra + (y + offsetY) * bgraPitch + (x + offsetX) * 4; + blue += pixel[0]; + green += pixel[1]; + red += pixel[2]; + } + } + red = (red + 2) / 4; + green = (green + 2) / 4; + blue = (blue + 2) / 4; + + // Coefficients sum to zero so neutral greys remain neutral after quantization. + const int chromaBlue = ((-26 * red - 87 * green + 113 * blue + 128) >> 8) + 128; + const int chromaRed = ((112 * red - 102 * green - 10 * blue + 128) >> 8) + 128; + const int uvIndex = (y / 2) * width + x; + uvPlane[uvIndex] = static_cast(clampVideoSample(chromaBlue, 16, 240)); + uvPlane[uvIndex + 1] = static_cast(clampVideoSample(chromaRed, 16, 240)); + } + } +} diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 4875d095..17b11eff 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -6,16 +6,13 @@ #include #include #include +#include "../../common/bt709_video.h" #pragma comment(lib, "mfplat.lib") #pragma comment(lib, "mfreadwrite.lib") #pragma comment(lib, "mf.lib") #pragma comment(lib, "mfuuid.lib") -static int clampByte(int v) { - return v < 0 ? 0 : (v > 255 ? 255 : v); -} - static UINT32 calculateScreenRecordingBitrate(int width, int height, int fps) { constexpr uint64_t kFourKPixels = 3840ULL * 2160ULL; constexpr uint64_t kQhdPixels = 2560ULL * 1440ULL; @@ -82,6 +79,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(outputType.Get()); + if (FAILED(hr)) return false; std::cerr << "Encoder bitrate: " << videoBitrate << " bps for " << width_ << "x" << height_ << "@" << fps_ << "fps" << std::endl; @@ -96,6 +95,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(inputType.Get()); + if (FAILED(hr)) return false; // Create SinkWriter with MPEG4 container ComPtr writerAttrs; @@ -225,34 +226,10 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); if (FAILED(hr)) return false; - // Convert BGRA → NV12 + // Convert full-range desktop BGRA to explicitly tagged BT.709 video-range NV12. const uint8_t* bgra = static_cast(mapped.pData); const int bgraPitch = static_cast(mapped.RowPitch); - - // Y plane - for (int y = 0; y < height_; y++) { - for (int x = 0; x < width_; x++) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int yVal = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; - nv12Buffer_[y * width_ + x] = static_cast(clampByte(yVal)); - } - } - - // UV plane (interleaved, subsampled 2x2) - const int ySize = width_ * height_; - uint8_t* uvPlane = nv12Buffer_.data() + ySize; - for (int y = 0; y < height_; y += 2) { - for (int x = 0; x < width_; x += 2) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; - int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; - int uvIdx = (y / 2) * width_ + (x / 2) * 2; - uvPlane[uvIdx] = static_cast(clampByte(u)); - uvPlane[uvIdx + 1] = static_cast(clampByte(v)); - } - } + convertBgraToBt709LimitedNv12(bgra, bgraPitch, width_, height_, nv12Buffer_); context_->Unmap(stagingTexture_.Get(), 0); diff --git a/electron/native/windows-capture/src/mf_encoder.cpp b/electron/native/windows-capture/src/mf_encoder.cpp index a1474c20..bfd62f33 100644 --- a/electron/native/windows-capture/src/mf_encoder.cpp +++ b/electron/native/windows-capture/src/mf_encoder.cpp @@ -4,16 +4,13 @@ #include #include #include +#include "../../common/bt709_video.h" #pragma comment(lib, "mfplat.lib") #pragma comment(lib, "mfreadwrite.lib") #pragma comment(lib, "mf.lib") #pragma comment(lib, "mfuuid.lib") -static int clampByte(int v) { - return v < 0 ? 0 : (v > 255 ? 255 : v); -} - MFEncoder::MFEncoder() {} MFEncoder::~MFEncoder() { @@ -53,6 +50,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(outputType.Get()); + if (FAILED(hr)) return false; // Input media type (NV12) ComPtr inputType; @@ -65,6 +64,8 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + hr = setBt709LimitedVideoAttributes(inputType.Get()); + if (FAILED(hr)) return false; // Create SinkWriter with MPEG4 container ComPtr writerAttrs; @@ -132,34 +133,10 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); if (FAILED(hr)) return false; - // Convert BGRA → NV12 + // Convert full-range desktop BGRA to explicitly tagged BT.709 video-range NV12. const uint8_t* bgra = static_cast(mapped.pData); const int bgraPitch = static_cast(mapped.RowPitch); - - // Y plane - for (int y = 0; y < height_; y++) { - for (int x = 0; x < width_; x++) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int yVal = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; - nv12Buffer_[y * width_ + x] = static_cast(clampByte(yVal)); - } - } - - // UV plane (interleaved, subsampled 2x2) - const int ySize = width_ * height_; - uint8_t* uvPlane = nv12Buffer_.data() + ySize; - for (int y = 0; y < height_; y += 2) { - for (int x = 0; x < width_; x += 2) { - const uint8_t* pixel = bgra + y * bgraPitch + x * 4; - uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; - int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; - int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; - int uvIdx = (y / 2) * width_ + (x / 2) * 2; - uvPlane[uvIdx] = static_cast(clampByte(u)); - uvPlane[uvIdx + 1] = static_cast(clampByte(v)); - } - } + convertBgraToBt709LimitedNv12(bgra, bgraPitch, width_, height_, nv12Buffer_); context_->Unmap(stagingTexture_.Get(), 0);