Skip to content

Commit b1dd45e

Browse files
committed
Settings > Player > External Monitor > HDR Preview Window
1 parent 243cb5a commit b1dd45e

14 files changed

Lines changed: 633 additions & 27 deletions

src/CMakeLists.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE
146146
transportcontrol.h
147147
util.cpp util.h
148148
videowidget.cpp videowidget.h
149+
hdrpreviewwindow.cpp hdrpreviewwindow.h
149150
widgets/alsawidget.cpp widgets/alsawidget.h
150151
widgets/alsawidget.ui
151152
widgets/audiometerwidget.cpp widgets/audiometerwidget.h
@@ -266,12 +267,29 @@ add_custom_target(OTHER_FILES
266267
../scripts/staple.sh
267268
)
268269

270+
# Compile HDR gain shader (used by HdrPreview.qml ShaderEffect)
271+
find_program(QSB_EXECUTABLE qsb HINTS
272+
"${Qt6_DIR}/../../../bin" "${Qt6Core_DIR}/../../../bin")
273+
if(QSB_EXECUTABLE)
274+
set(HDR_GAIN_FRAG ${CMAKE_CURRENT_SOURCE_DIR}/qml/views/hdr_gain.frag)
275+
set(HDR_GAIN_QSB ${CMAKE_CURRENT_SOURCE_DIR}/qml/views/hdr_gain.frag.qsb)
276+
add_custom_command(
277+
OUTPUT ${HDR_GAIN_QSB}
278+
COMMAND ${QSB_EXECUTABLE} --glsl "100 es,120,150" --hlsl 50 --msl 12 -o ${HDR_GAIN_QSB} ${HDR_GAIN_FRAG}
279+
DEPENDS ${HDR_GAIN_FRAG}
280+
COMMENT "Compiling HDR gain shader"
281+
)
282+
add_custom_target(hdr_shaders ALL DEPENDS ${HDR_GAIN_QSB})
283+
add_dependencies(shotcut hdr_shaders)
284+
endif()
285+
269286
target_link_libraries(shotcut
270287
PRIVATE
271288
CuteLogger
272289
PkgConfig::mlt++
273290
PkgConfig::FFTW
274291
Qt6::Charts
292+
Qt6::GuiPrivate
275293
Qt6::Multimedia
276294
Qt6::Network
277295
Qt6::OpenGL

src/hdrpreviewwindow.cpp

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Copyright (c) 2026 Meltytech, LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
#include "hdrpreviewwindow.h"
19+
20+
#include "qmltypes/qmlutilities.h"
21+
22+
#include <private/qrhi_p.h>
23+
#include <QDebug>
24+
#include <QDir>
25+
#include <QQmlContext>
26+
#include <QQmlEngine>
27+
#include <QQuickItem>
28+
29+
#ifdef Q_OS_MACOS
30+
#include "macos.h"
31+
#endif
32+
33+
// HLG OETF: scene-referred linear to HLG electrical signal.
34+
// See ITU-R BT.2100-2.
35+
static float hlgOetf(float linear)
36+
{
37+
const float a = 0.17883277f;
38+
const float b = 0.28466892f;
39+
const float c = 0.55991073f;
40+
if (linear < 1.0f / 12.0f)
41+
return sqrtf(3.0f * linear);
42+
return a * logf(12.0f * linear - b) + c;
43+
}
44+
45+
HdrPreviewWindow::HdrPreviewWindow(QWindow *parent)
46+
: QQuickView(QmlUtilities::sharedEngine(), parent)
47+
{
48+
setTitle(tr("HDR Preview"));
49+
setResizeMode(QQuickView::SizeRootObjectToView);
50+
setColor(Qt::black);
51+
52+
// Request HDR swapchain via the internal Qt scene graph property.
53+
// Qt's video fragment shaders only select the linear HDR output path
54+
// (nv12_bt2020_hlg_linear.frag) for HDRExtendedSrgbLinear, not for
55+
// HDRExtendedDisplayP3Linear. So we must use "scrgb" on all platforms.
56+
setProperty("_qt_sg_hdr_format", QByteArrayLiteral("scrgb"));
57+
58+
rootContext()->setContextProperty("hdrWindow", this);
59+
60+
QDir qmlDir = QmlUtilities::qmlDir();
61+
setSource(QUrl::fromLocalFile(qmlDir.filePath("views/HdrPreview.qml")));
62+
63+
resize(960, 540);
64+
65+
#ifdef Q_OS_MACOS
66+
// Override NSScreen.maximumExtendedDynamicRangeColorComponentValue so that
67+
// Qt's video shader outputs > 1.0 values (HDR) on the first frame, which
68+
// then causes macOS to allocate real EDR headroom.
69+
macosOverrideEdrHeadroom(true);
70+
71+
// Monitor EDR headroom every second for the first 30 seconds.
72+
connect(&m_edrTimer, &QTimer::timeout, this, &HdrPreviewWindow::checkEdrHeadroom);
73+
m_edrTimer.start(1000);
74+
#endif
75+
}
76+
77+
HdrPreviewWindow::~HdrPreviewWindow()
78+
{
79+
#ifdef Q_OS_MACOS
80+
macosOverrideEdrHeadroom(false);
81+
#endif
82+
}
83+
84+
void HdrPreviewWindow::setVideoSink(QVideoSink *sink)
85+
{
86+
m_videoSink = sink;
87+
}
88+
89+
void HdrPreviewWindow::pushFrame(const QVideoFrame &frame)
90+
{
91+
if (m_videoSink && isVisible()) {
92+
if (!m_loggedSwapChain) {
93+
m_loggedSwapChain = true;
94+
auto *sc = swapChain();
95+
if (sc) {
96+
qDebug() << "HDR Preview: swapChain format =" << sc->format()
97+
<< "hdrInfo =" << sc->hdrInfo();
98+
} else {
99+
qDebug() << "HDR Preview: swapChain() returned nullptr!";
100+
}
101+
qDebug() << "HDR Preview frame: pixelFormat =" << frame.surfaceFormat().pixelFormat()
102+
<< "colorTransfer =" << frame.surfaceFormat().colorTransfer()
103+
<< "maxLuminance =" << frame.surfaceFormat().maxLuminance();
104+
#ifdef Q_OS_MACOS
105+
auto wid = winId();
106+
qDebug() << "HDR Preview EDR:"
107+
<< "current =" << macosCurrentEdrHeadroom(wid)
108+
<< "potential =" << macosPotentialEdrHeadroom(wid)
109+
<< "reference =" << macosReferenceEdrHeadroom(wid);
110+
#endif
111+
}
112+
updateHdrGain();
113+
m_videoSink->setVideoFrame(frame);
114+
}
115+
}
116+
117+
void HdrPreviewWindow::setHlg(bool isHlg)
118+
{
119+
if (m_isHlg != isHlg) {
120+
m_isHlg = isHlg;
121+
if (!m_isHlg && !qFuzzyCompare(m_hdrGain, 1.0f)) {
122+
m_hdrGain = 1.0f;
123+
emit hdrGainChanged();
124+
}
125+
}
126+
}
127+
128+
void HdrPreviewWindow::updateHdrGain()
129+
{
130+
if (!m_isHlg)
131+
return;
132+
133+
auto *sc = swapChain();
134+
if (!sc || sc->format() != QRhiSwapChain::HDRExtendedSrgbLinear)
135+
return;
136+
137+
auto info = sc->hdrInfo();
138+
float headroom = 1.0f;
139+
if (info.limitsType == QRhiSwapChainHdrInfo::ColorComponentValue)
140+
headroom = info.limits.colorComponentValue.maxColorComponentValue;
141+
142+
if (headroom <= 1.0f)
143+
return;
144+
145+
// Qt's HLG shader has a bug: maxLum is HLG-encoded but used as a linear
146+
// multiplier in the OOTF. Compensate by multiplying the rendered output
147+
// by the ratio of the correct linear value to the HLG-encoded one.
148+
float newGain = headroom / hlgOetf(headroom);
149+
if (!qFuzzyCompare(newGain, m_hdrGain)) {
150+
m_hdrGain = newGain;
151+
qDebug() << "HDR Preview: gain =" << m_hdrGain << "(headroom =" << headroom << ")";
152+
emit hdrGainChanged();
153+
}
154+
}
155+
156+
void HdrPreviewWindow::checkEdrHeadroom()
157+
{
158+
#ifdef Q_OS_MACOS
159+
float headroom = macosCurrentEdrHeadroom(winId());
160+
if (headroom != m_lastLoggedHeadroom) {
161+
m_lastLoggedHeadroom = headroom;
162+
auto *sc = swapChain();
163+
qDebug() << "HDR Preview: EDR headroom =" << headroom
164+
<< "(swapChain hdrInfo =" << (sc ? sc->hdrInfo() : QRhiSwapChainHdrInfo()) << ")";
165+
}
166+
if (++m_edrCheckCount >= 30)
167+
m_edrTimer.stop();
168+
#endif
169+
}

src/hdrpreviewwindow.h

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (c) 2026 Meltytech, LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
#ifndef HDRPREVIEWWINDOW_H
19+
#define HDRPREVIEWWINDOW_H
20+
21+
#include <QPointer>
22+
#include <QQuickView>
23+
#include <QTimer>
24+
#include <QVideoFrame>
25+
#include <QVideoSink>
26+
27+
class HdrPreviewWindow : public QQuickView
28+
{
29+
Q_OBJECT
30+
Q_PROPERTY(float hdrGain READ hdrGain NOTIFY hdrGainChanged)
31+
32+
public:
33+
explicit HdrPreviewWindow(QWindow *parent = nullptr);
34+
~HdrPreviewWindow();
35+
36+
Q_INVOKABLE void setVideoSink(QVideoSink *sink);
37+
float hdrGain() const { return m_hdrGain; }
38+
39+
public slots:
40+
void pushFrame(const QVideoFrame &frame);
41+
void setHlg(bool isHlg);
42+
43+
signals:
44+
void hdrGainChanged();
45+
46+
private slots:
47+
void checkEdrHeadroom();
48+
49+
private:
50+
void updateHdrGain();
51+
52+
QPointer<QVideoSink> m_videoSink;
53+
QTimer m_edrTimer;
54+
bool m_loggedSwapChain{false};
55+
bool m_isHlg{false};
56+
float m_lastLoggedHeadroom{0.0f};
57+
int m_edrCheckCount{0};
58+
float m_hdrGain{1.0f};
59+
};
60+
61+
#endif // HDRPREVIEWWINDOW_H

src/macos.h

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,32 @@
1717

1818
#pragma once
1919

20+
#include <cstdint>
21+
2022
void removeMacosTabBar();
2123
void macosSetDockProgress(int percent);
2224
void macosPauseDockProgress(int percent);
2325
void macosResetDockProgress();
2426
void macosFinishDockProgress(bool isSuccess, bool stopped);
27+
28+
/// Override NSScreen.maximumExtendedDynamicRangeColorComponentValue to return
29+
/// the *potential* headroom. This breaks the chicken-and-egg where Qt's shader
30+
/// won't output > 1.0 because headroom=1, and macOS won't allocate headroom
31+
/// because no content > 1.0 is being rendered. Once the shader outputs HDR
32+
/// values, macOS allocates real headroom and the override becomes a no-op.
33+
/// Safe: only affects the HDR preview window's video shader (main window uses
34+
/// SDR swapchain format, so Qt's video node ignores hdrInfo for it).
35+
void macosOverrideEdrHeadroom(bool enable);
36+
37+
/// Query the current EDR headroom for the screen hosting the given window.
38+
/// Returns NSScreen.maximumExtendedDynamicRangeColorComponentValue.
39+
/// @param windowId QWindow::winId()
40+
float macosCurrentEdrHeadroom(uintptr_t windowId);
41+
42+
/// Query the potential (maximum) EDR headroom.
43+
/// Returns NSScreen.maximumPotentialExtendedDynamicRangeColorComponentValue.
44+
float macosPotentialEdrHeadroom(uintptr_t windowId);
45+
46+
/// Query the reference EDR headroom.
47+
/// Returns NSScreen.maximumReferenceExtendedDynamicRangeColorComponentValue.
48+
float macosReferenceEdrHeadroom(uintptr_t windowId);

src/macos.mm

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@
2020
#import <AppKit/NSDockTile.h>
2121
#import <AppKit/NSImageView.h>
2222
#import <AppKit/NSProgressIndicator.h>
23+
#import <AppKit/NSScreen.h>
2324
#import <AppKit/NSView.h>
2425
#import <AppKit/NSWindow.h>
26+
#import <objc/runtime.h>
27+
28+
#include <atomic>
2529

2630
void removeMacosTabBar()
2731
{
@@ -96,3 +100,69 @@ void macosFinishDockProgress(bool isSuccess, bool stopped)
96100
[NSApp requestUserAttention:NSCriticalRequest];
97101
}
98102
}
103+
104+
// ---------------------------------------------------------------------------
105+
// EDR headroom override via method swizzle on NSScreen
106+
// ---------------------------------------------------------------------------
107+
static std::atomic<bool> s_edrOverrideEnabled{false};
108+
static bool s_swizzled = false;
109+
110+
// Category that holds the swizzled implementation.
111+
@interface NSScreen (ShotcutEdrOverride)
112+
- (CGFloat)shotcut_maximumExtendedDynamicRangeColorComponentValue;
113+
@end
114+
115+
@implementation NSScreen (ShotcutEdrOverride)
116+
- (CGFloat)shotcut_maximumExtendedDynamicRangeColorComponentValue
117+
{
118+
// After swizzling, calling the swizzled selector invokes the ORIGINAL impl.
119+
CGFloat real = [self shotcut_maximumExtendedDynamicRangeColorComponentValue];
120+
if (s_edrOverrideEnabled.load(std::memory_order_relaxed) && real < 2.0) {
121+
return self.maximumPotentialExtendedDynamicRangeColorComponentValue;
122+
}
123+
return real;
124+
}
125+
@end
126+
127+
void macosOverrideEdrHeadroom(bool enable)
128+
{
129+
if (!s_swizzled) {
130+
s_swizzled = true;
131+
Method original = class_getInstanceMethod(
132+
[NSScreen class],
133+
@selector(maximumExtendedDynamicRangeColorComponentValue));
134+
Method swizzled = class_getInstanceMethod(
135+
[NSScreen class],
136+
@selector(shotcut_maximumExtendedDynamicRangeColorComponentValue));
137+
method_exchangeImplementations(original, swizzled);
138+
NSLog(@"macosOverrideEdrHeadroom: swizzled NSScreen.maximumExtendedDynamicRangeColorComponentValue");
139+
}
140+
s_edrOverrideEnabled.store(enable, std::memory_order_relaxed);
141+
NSLog(@"macosOverrideEdrHeadroom: %s", enable ? "enabled" : "disabled");
142+
}
143+
144+
float macosCurrentEdrHeadroom(uintptr_t windowId)
145+
{
146+
NSView *view = reinterpret_cast<NSView *>(windowId);
147+
if (!view || !view.window || !view.window.screen)
148+
return 1.0f;
149+
return static_cast<float>(view.window.screen.maximumExtendedDynamicRangeColorComponentValue);
150+
}
151+
152+
float macosPotentialEdrHeadroom(uintptr_t windowId)
153+
{
154+
NSView *view = reinterpret_cast<NSView *>(windowId);
155+
if (!view || !view.window || !view.window.screen)
156+
return 1.0f;
157+
return static_cast<float>(view.window.screen.maximumPotentialExtendedDynamicRangeColorComponentValue);
158+
}
159+
160+
float macosReferenceEdrHeadroom(uintptr_t windowId)
161+
{
162+
NSView *view = reinterpret_cast<NSView *>(windowId);
163+
if (!view || !view.window || !view.window.screen)
164+
return 1.0f;
165+
if (@available(macOS 12.0, *))
166+
return static_cast<float>(view.window.screen.maximumReferenceExtendedDynamicRangeColorComponentValue);
167+
return 1.0f;
168+
}

0 commit comments

Comments
 (0)