diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 7a609823a7..7b4aa41f1a 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -18,7 +18,7 @@ cmake --build build/cc-debug
build/cc-debug/src/shotcut
```
-The default preset `cc-debug` (defined in `CMakePresets.json`) uses Ninja, gcc or clang from `$PATH` and Qt 6.8.3 from `~/Qt/6.8.3`, and produces a Debug build.
+The default preset `cc-debug` (defined in `CMakePresets.json`) uses Ninja, gcc or clang from `$PATH` and Qt 6.10.3 from `~/Qt/6.10.3`, and produces a Debug build.
To properly debug on Windows you must set the environment variable `QSG_RHI_BACKEND=d3d11` to prevent running as a child process detached from the debugger.
diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml
index 12b0911c34..56e9e96721 100644
--- a/.github/workflows/build-linux.yml
+++ b/.github/workflows/build-linux.yml
@@ -64,7 +64,7 @@ jobs:
appimage:
runs-on: ubuntu-latest
needs: build
- if: ${{ github.repository_owner == 'mltframework' }}
+ if: ${{ github.repository_owner == 'mltframework' && github.ref_name == 'master' }}
steps:
- uses: actions/checkout@v6
@@ -107,7 +107,7 @@ jobs:
snap:
runs-on: ubuntu-latest
needs: build
- if: ${{ github.repository_owner == 'mltframework' }}
+ if: ${{ github.repository_owner == 'mltframework' && github.ref_name == 'master' }}
steps:
- uses: actions/checkout@v6
diff --git a/.github/workflows/build-sdk-windows.yml b/.github/workflows/build-sdk-windows.yml
index 84fb2d64ac..52e2ebedc5 100644
--- a/.github/workflows/build-sdk-windows.yml
+++ b/.github/workflows/build-sdk-windows.yml
@@ -70,10 +70,10 @@ jobs:
echo Downloading Qt
mkdir Qt
cd Qt
- curl -kLO --no-progress-meter https://s3.amazonaws.com/misc.meltymedia/shotcut-build/qt-6.8.3-x64-mingw.txz
+ curl -kLO --no-progress-meter https://s3.amazonaws.com/misc.meltymedia/shotcut-build/qt-6.10.3-x64-mingw.txz
echo Extracting Qt
- tar -xJf qt-6.8.3-x64-mingw.txz
- rm qt-6.8.3-x64-mingw.txz
+ tar -xJf qt-6.10.3-x64-mingw.txz
+ rm qt-6.10.3-x64-mingw.txz
cd ..
echo Downloading a few prebuilt dependencies
curl -LO --no-progress-meter https://s3.amazonaws.com/misc.meltymedia/shotcut-build/mlt-prebuilt-mingw64-v6.txz
diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml
index 7dd9d1c3cd..161ebb6fa3 100644
--- a/.github/workflows/build-windows.yml
+++ b/.github/workflows/build-windows.yml
@@ -83,10 +83,10 @@ jobs:
mkdir Qt
cd Qt
echo Downloading Qt
- curl -kLO --no-progress-meter https://s3.amazonaws.com/misc.meltymedia/shotcut-build/qt-6.8.3-x64-mingw.txz
+ curl -kLO --no-progress-meter https://s3.amazonaws.com/misc.meltymedia/shotcut-build/qt-6.10.3-x64-mingw.txz
echo Extracting Qt
- tar -xJf qt-6.8.3-x64-mingw.txz
- rm qt-6.8.3-x64-mingw.txz
+ tar -xJf qt-6.10.3-x64-mingw.txz
+ rm qt-6.10.3-x64-mingw.txz
cd ..
echo Downloading a few prebuilt dependencies
curl -kLO --no-progress-meter https://s3.amazonaws.com/misc.meltymedia/shotcut-build/mlt-prebuilt-mingw64-v6.txz
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 750e143441..2991c5b762 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -23,6 +23,9 @@ endif()
option(CLANG_FORMAT "Enable Clang Format" ON)
option(EXTERNAL_LAUNCHERS "Whether include features to launch external programs; for example, this should be off for Flatpak due to sandbox." ON)
option(USE_VULKAN "Whether to use Vulkan for hardware video decoding" OFF)
+if (UNIX AND NOT APPLE)
+ option(BUILD_MINIMAL_MEDIA_BACKEND "Whether to build the `minimal` Qt media backend for Linux/BSD" OFF)
+endif()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
@@ -50,6 +53,9 @@ find_package(Qt6 6.4 REQUIRED
Widgets
Xml
)
+if(Qt6_VERSION VERSION_GREATER_EQUAL "6.9.0")
+ find_package(Qt6 6.4 REQUIRED GuiPrivate)
+endif()
if(UNIX AND NOT APPLE)
find_package(Qt6 6.4 REQUIRED COMPONENTS DBus)
# X11 for WindowPicker (Linux/X11)
@@ -66,6 +72,9 @@ endif()
add_subdirectory(CuteLogger)
add_subdirectory(src)
add_subdirectory(translations)
+if(BUILD_MINIMAL_MEDIA_BACKEND)
+ add_subdirectory(MinimalMediaBackend)
+endif()
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
@@ -81,7 +90,7 @@ if(CLANG_FORMAT)
# Test new versions before changing the allowed version here to avoid
# accidental broad changes to formatting.
find_package(ClangFormat 14 EXACT)
- if(CLANGFORMAT_FOUND)
+ if(ClangFormat_FOUND)
file(GLOB_RECURSE FORMAT_FILES "src/*.h" "src/*.c" "src/*.cpp")
# exclude 3rd party & generated source from format checking
list(FILTER FORMAT_FILES EXCLUDE REGEX "/.*/spatialmedia/")
diff --git a/CMakePresets.json b/CMakePresets.json
index d2d4fd58d7..86e868740a 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -8,7 +8,7 @@
"binaryDir": "${sourceDir}/build/${presetName}",
"generator": "Ninja",
"environment": {
- "QT_VERSION": "6.8.3",
+ "QT_VERSION": "6.10.3",
"PKG_CONFIG_PATH": "$env{HOME}/.local/lib/pkgconfig:$env{HOME}/.local/lib64/pkgconfig:$env{HOME}/opt/lib/pkgconfig:$env{HOME}/lib/pkgconfig:/usr/local/lib/pkgconfig:/opt/local/lib/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:$penv{PKG_CONFIG_PATH}",
"PATH": "$env{HOME}/Qt/$env{QT_VERSION}/macos/bin:$env{HOME}/opt/bin:/usr/local/bin:/opt/local/bin:$penv{PATH}"
},
@@ -16,7 +16,8 @@
"CMAKE_INSTALL_PREFIX": "${sourceDir}/install/${presetName}",
"CMAKE_BUILD_TYPE": "Debug",
"CMAKE_PREFIX_PATH": "$env{HOME}/Qt/$env{QT_VERSION}/gcc_64;$env{HOME}/Qt/$env{QT_VERSION}/macos;$env{HOME}/.local;$env{HOME}/opt;/usr/local;/opt/local",
- "CLANG_FORMAT": "ON"
+ "CLANG_FORMAT": "ON",
+ "BUILD_MINIMAL_MEDIA_BACKEND": "ON"
}
}
],
diff --git a/MinimalMediaBackend/CMakeLists.txt b/MinimalMediaBackend/CMakeLists.txt
new file mode 100644
index 0000000000..f9498d5376
--- /dev/null
+++ b/MinimalMediaBackend/CMakeLists.txt
@@ -0,0 +1,19 @@
+cmake_minimum_required(VERSION 3.12...3.31)
+
+project(MinimalMediaBackend)
+
+find_package(Qt6 REQUIRED COMPONENTS Core Multimedia)
+find_package(Qt6MultimediaPrivate REQUIRED)
+
+qt_add_plugin(minimalmediaplugin
+ CLASS_NAME MinimalMediaPlugin
+ PLUGIN_TYPE multimedia
+ OUTPUT_TARGETS minimalmediaplugin_targets
+ minimalmediaplugin.cpp
+)
+
+target_link_libraries(minimalmediaplugin PRIVATE
+ Qt6::Core
+ Qt6::Multimedia
+ Qt6::MultimediaPrivate
+)
diff --git a/MinimalMediaBackend/minimalmediaplugin.cpp b/MinimalMediaBackend/minimalmediaplugin.cpp
new file mode 100644
index 0000000000..a0bdfc559d
--- /dev/null
+++ b/MinimalMediaBackend/minimalmediaplugin.cpp
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2026 Meltytech, LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include
+#include
+#include
+
+QT_BEGIN_NAMESPACE
+
+// Minimal QPlatformVideoSink — the base class does all the work
+class MinimalVideoSink : public QPlatformVideoSink
+{
+ Q_OBJECT
+public:
+ explicit MinimalVideoSink(QVideoSink *sink)
+ : QPlatformVideoSink(sink)
+ {}
+};
+
+// Minimal integration — only creates video sinks
+class MinimalMediaIntegration : public QPlatformMediaIntegration
+{
+public:
+ MinimalMediaIntegration()
+ : QPlatformMediaIntegration(QLatin1String("minimal"))
+ {}
+
+#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
+ q23::expected createVideoSink(QVideoSink *sink) override
+ {
+ return new MinimalVideoSink(sink);
+ }
+#else
+ QMaybe createVideoSink(QVideoSink *sink) override
+ {
+ return new MinimalVideoSink(sink);
+ }
+#endif
+};
+
+// Plugin entry point
+class MinimalMediaPlugin : public QPlatformMediaPlugin
+{
+ Q_OBJECT
+ Q_PLUGIN_METADATA(IID QPlatformMediaPlugin_iid FILE "minimalmediaplugin.json")
+
+public:
+ MinimalMediaPlugin(QObject *parent = nullptr)
+ : QPlatformMediaPlugin(parent)
+ {}
+
+ QPlatformMediaIntegration *create(const QString &key) override
+ {
+ if (key == QLatin1String("minimal"))
+ return new MinimalMediaIntegration;
+ return nullptr;
+ }
+};
+
+QT_END_NAMESPACE
+
+#include "minimalmediaplugin.moc"
diff --git a/MinimalMediaBackend/minimalmediaplugin.json b/MinimalMediaBackend/minimalmediaplugin.json
new file mode 100644
index 0000000000..71ec6ad265
--- /dev/null
+++ b/MinimalMediaBackend/minimalmediaplugin.json
@@ -0,0 +1,3 @@
+{
+ "Keys": ["minimal"]
+}
diff --git a/scripts/build-shotcut-msys2.sh b/scripts/build-shotcut-msys2.sh
index 74a15d9410..ee3c0baa52 100755
--- a/scripts/build-shotcut-msys2.sh
+++ b/scripts/build-shotcut-msys2.sh
@@ -74,7 +74,7 @@ NV_CODEC_REVISION="sdk/12.0"
PYTHON_VERSION=$(python3 --version | awk '{split($2, parts, "."); print parts[1] "." parts[2]}')
PYTHON_VERSION_DLL=$(python3 --version | awk '{split($2, parts, "."); print parts[1]parts[2]}')
-QT_VERSION_X64="6.8.3"
+QT_VERSION_X64="6.10.3"
################################################################################
# Location of config file - if not overridden on command line
@@ -1240,7 +1240,7 @@ function deploy
log Copying some libs from Qt
if [ "$DEBUG_BUILD" != "1" -o "$SDK" = "1" ]; then
- cmd cp -p "$QTDIR"/bin/Qt6{Charts,Concurrent,Core,Core5Compat,Gui,Multimedia,Network,OpenGL,OpenGLWidgets,Qml,QmlMeta,QmlModels,QmlWorkerScript,Quick,QuickControls2*,QuickDialogs2,QuickDialogs2QuickImpl,QuickDialogs2Utils,QuickLayouts,QuickTemplates2,QuickWidgets,Sql,Svg,SvgWidgets,UiTools,WebSockets,Widgets,Xml}.dll .
+ cmd cp -p "$QTDIR"/bin/Qt6{Charts,Concurrent,Core,Core5Compat,Gui,Multimedia,MultimediaQuick,Network,OpenGL,OpenGLWidgets,Qml,QmlMeta,QmlModels,QmlWorkerScript,Quick,QuickControls2*,QuickDialogs2,QuickDialogs2QuickImpl,QuickDialogs2Utils,QuickLayouts,QuickShapes,QuickTemplates2,QuickWidgets,Sql,Svg,SvgWidgets,UiTools,WebSockets,Widgets,Xml}.dll .
fi
if [ "$ENABLE_GLAXNIMATE" = "1" ]; then
@@ -1293,7 +1293,7 @@ function deploy
done
done
cmd mkdir -p lib/qml
- cmd cp -pr "$QT_SHARE_DIR"/qml/{Qt,QtCore,QtQml,QtQuick} lib/qml
+ cmd cp -pr "$QT_SHARE_DIR"/qml/{Qt,QtCore,QtMultimedia,QtQml,QtQuick} lib/qml
cmd cp -pr "$QT_SHARE_DIR"/translations/qt_*.qm share/translations
cmd cp -pr "$QT_SHARE_DIR"/translations/qtbase_*.qm share/translations
diff --git a/scripts/build-shotcut.sh b/scripts/build-shotcut.sh
index 8876e8d92d..9177702fb9 100755
--- a/scripts/build-shotcut.sh
+++ b/scripts/build-shotcut.sh
@@ -923,7 +923,7 @@ function set_globals {
CONFIG[7]="${CONFIG[7]} -D CMAKE_INSTALL_PREFIX=."
CONFIG[7]="${CONFIG[7]} -D CMAKE_OSX_ARCHITECTURES='arm64;x86_64'"
else
- CONFIG[7]="${CONFIG[7]} -D CMAKE_INSTALL_PREFIX=$FINAL_INSTALL_DIR"
+ CONFIG[7]="${CONFIG[7]} -D CMAKE_INSTALL_PREFIX=$FINAL_INSTALL_DIR -D BUILD_MINIMAL_MEDIA_BACKEND=ON"
fi
CFLAGS_[7]="$ASAN_CFLAGS $CFLAGS"
LDFLAGS_[7]="$ASAN_LDFLAGS $LDFLAGS"
@@ -1202,13 +1202,15 @@ function install_shotcut_linux {
cmd install -p -c COPYING "$FINAL_INSTALL_DIR"
cmd install -p -c "$QTDIR"/translations/qt_*.qm "$FINAL_INSTALL_DIR"/share/shotcut/translations
cmd install -p -c "$QTDIR"/translations/qtbase_*.qm "$FINAL_INSTALL_DIR"/share/shotcut/translations
- cmd install -p -c "$QTDIR"/lib/libQt6{Charts,Concurrent,Core,Core5Compat,DBus,Gui,LabsFolderListModel,Multimedia,Network,OpenGL,OpenGLWidgets,Qml,QmlMeta,QmlModels,QmlWorkerScript,Quick,QuickControls2*,QuickDialogs2,QuickDialogs2QuickImpl,QuickDialogs2Utils,QuickLayouts,QuickTemplates2,QuickWidgets,Sql,Svg,SvgWidgets,UiTools,WaylandClient,WaylandEglClientHwIntegration,WebSockets,Widgets,Xml,X11Extras,XcbQpa}.so.6 "$FINAL_INSTALL_DIR"/lib
+ cmd install -p -c "$QTDIR"/lib/libQt6{Charts,Concurrent,Core,Core5Compat,DBus,Gui,LabsFolderListModel,Multimedia,MultimediaQuick,Network,OpenGL,OpenGLWidgets,Qml,QmlMeta,QmlModels,QmlWorkerScript,Quick,QuickControls2*,QuickDialogs2,QuickDialogs2QuickImpl,QuickDialogs2Utils,QuickLayouts,QuickShapes,QuickTemplates2,QuickWidgets,Sql,Svg,SvgWidgets,UiTools,WaylandClient,WaylandEglClientHwIntegration,WebSockets,Widgets,Xml,X11Extras,XcbQpa}.so.6 "$FINAL_INSTALL_DIR"/lib
cmd install -p -c "$QTDIR"/lib/lib{icudata,icui18n,icuuc}.so* "$FINAL_INSTALL_DIR"/lib
cmd install -d "$FINAL_INSTALL_DIR"/lib/qt6/sqldrivers
cmd cp -a "$QTDIR"/plugins/{egldeviceintegrations,generic,iconengines,imageformats,multimedia,platforminputcontexts,platforms,platformthemes,tls,wayland-decoration-client,wayland-graphics-integration-client,wayland-shell-integration,xcbglintegrations} "$FINAL_INSTALL_DIR"/lib/qt6
cmd cp -p "$QTDIR"/plugins/sqldrivers/libqsqlite.so "$FINAL_INSTALL_DIR"/lib/qt6/sqldrivers
cmd install -d "$FINAL_INSTALL_DIR"/lib/qml
- cmd cp -a "$QTDIR"/qml/{Qt,QtCore,QtQml,QtQuick} "$FINAL_INSTALL_DIR"/lib/qml
+ cmd cp -a "$QTDIR"/qml/{Qt,QtCore,QtMultimedia,QtQml,QtQuick} "$FINAL_INSTALL_DIR"/lib/qml
+ cmd install -d "$FINAL_INSTALL_DIR"/lib/qt6/multimedia
+ cmd cp -p MinimalMediaBackend/libminimalmediaplugin.so "$FINAL_INSTALL_DIR"/lib/qt6/multimedia
}
function build_vmaf_darwin {
@@ -1952,7 +1954,7 @@ function deploy_mac
# Qt QML modules
log Copying Qt QML modules
cmd mkdir -p Resources/qml 2>/dev/null
- cmd cp -a "$QTDIR"/qml/{Qt,QtCore,QtQml,QtQuick} Resources/qml
+ cmd cp -a "$QTDIR"/qml/{Qt,QtCore,QtMultimedia,QtQml,QtQuick} Resources/qml
for lib in $(find Resources -name '*.dylib'); do
fixlibs "$lib"
done
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 1662ae669f..c4a6f6812f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -146,6 +146,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE
transportcontrol.h
util.cpp util.h
videowidget.cpp videowidget.h
+ hdrpreviewwindow.cpp hdrpreviewwindow.h
widgets/alsawidget.cpp widgets/alsawidget.h
widgets/alsawidget.ui
widgets/audiometerwidget.cpp widgets/audiometerwidget.h
@@ -266,12 +267,29 @@ add_custom_target(OTHER_FILES
../scripts/staple.sh
)
+# Compile HDR gain shader (used by HdrPreview.qml ShaderEffect)
+find_program(QSB_EXECUTABLE qsb HINTS
+ "${Qt6_DIR}/../../../bin" "${Qt6Core_DIR}/../../../bin")
+if(QSB_EXECUTABLE)
+ set(HDR_GAIN_FRAG ${CMAKE_CURRENT_SOURCE_DIR}/qml/views/hdr_gain.frag)
+ set(HDR_GAIN_QSB ${CMAKE_CURRENT_SOURCE_DIR}/qml/views/hdr_gain.frag.qsb)
+ add_custom_command(
+ OUTPUT ${HDR_GAIN_QSB}
+ COMMAND ${QSB_EXECUTABLE} --glsl "100 es,120,150" --hlsl 50 --msl 12 -o ${HDR_GAIN_QSB} ${HDR_GAIN_FRAG}
+ DEPENDS ${HDR_GAIN_FRAG}
+ COMMENT "Compiling HDR gain shader"
+ )
+ add_custom_target(hdr_shaders ALL DEPENDS ${HDR_GAIN_QSB})
+ add_dependencies(shotcut hdr_shaders)
+endif()
+
target_link_libraries(shotcut
PRIVATE
CuteLogger
PkgConfig::mlt++
PkgConfig::FFTW
Qt6::Charts
+ Qt6::GuiPrivate
Qt6::Multimedia
Qt6::Network
Qt6::OpenGL
@@ -301,6 +319,9 @@ endif()
if(USE_VULKAN)
target_compile_definitions(shotcut PRIVATE USE_VULKAN)
endif()
+if(BUILD_MINIMAL_MEDIA_BACKEND)
+ target_compile_definitions(shotcut PRIVATE BUILD_MINIMAL_MEDIA_BACKEND)
+endif()
if(WIN32)
# Windows resource
@@ -310,9 +331,7 @@ if(WIN32)
# Windows integration features
target_sources(shotcut PRIVATE windowstools.cpp windowstools.h)
- target_sources(shotcut PRIVATE widgets/d3dvideowidget.h widgets/d3dvideowidget.cpp)
- target_sources(shotcut PRIVATE widgets/openglvideowidget.h widgets/openglvideowidget.cpp)
- target_link_libraries(shotcut PRIVATE d3d11 d3dcompiler ole32)
+ target_link_libraries(shotcut PRIVATE ole32)
# Runtime exception handler for debug only
if(CMAKE_SYSTEM_PROCESSOR STREQUAL "AMD64")
@@ -331,13 +350,10 @@ if(WIN32)
install(DIRECTORY qml DESTINATION ${CMAKE_INSTALL_PREFIX}/share/shotcut/)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/filter-sets DESTINATION ${CMAKE_INSTALL_PREFIX}/share/shotcut/)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/voices DESTINATION ${CMAKE_INSTALL_PREFIX}/share/shotcut/)
-else()
- target_sources(shotcut PRIVATE widgets/openglvideowidget.h widgets/openglvideowidget.cpp)
endif()
if(APPLE)
- target_sources(shotcut PRIVATE macos.mm macos.h
- widgets/metalvideowidget.h widgets/metalvideowidget.mm)
+ target_sources(shotcut PRIVATE macos.mm macos.h)
set_target_properties(shotcut PROPERTIES
OUTPUT_NAME "Shotcut"
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/packaging/macos/Info.plist.in)
diff --git a/src/dialogs/customprofiledialog.cpp b/src/dialogs/customprofiledialog.cpp
index d884c80313..40feba0813 100644
--- a/src/dialogs/customprofiledialog.cpp
+++ b/src/dialogs/customprofiledialog.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2013-2025 Meltytech, LLC
+ * Copyright (c) 2013-2026 Meltytech, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -49,6 +49,18 @@ CustomProfileDialog::CustomProfileDialog(QWidget *parent)
ui->colorspaceCombo->setCurrentIndex(1);
break;
}
+ // Initialize Dynamic range: enabled only for BT.2020, default from profile/producer
+ if (MLT.profile().colorspace() != 2020) {
+ ui->dynamicRangeCombo->setEnabled(false);
+ } else {
+ const QString trc = MLT.colorTrc();
+ if (trc == QLatin1String("arib-std-b67"))
+ ui->dynamicRangeCombo->setCurrentIndex(1);
+ else if (trc == QLatin1String("smpte2084"))
+ ui->dynamicRangeCombo->setCurrentIndex(2);
+ else
+ ui->dynamicRangeCombo->setCurrentIndex(0);
+ }
}
CustomProfileDialog::~CustomProfileDialog()
@@ -92,6 +104,18 @@ void CustomProfileDialog::on_buttonBox_accepted()
}
MLT.updatePreviewProfile();
MLT.setPreviewScale(Settings.playerPreviewScale());
+ // Set color_trc based on dynamic range selection
+ switch (ui->dynamicRangeCombo->currentIndex()) {
+ case 1: // HLG HDR
+ MLT.setColorTrc(QStringLiteral("arib-std-b67"));
+ break;
+ case 2: // PQ HDR
+ MLT.setColorTrc(QStringLiteral("smpte2084"));
+ break;
+ default: // SDR
+ MLT.setColorTrc(QString());
+ break;
+ }
// Save it to a file
if (!ui->nameEdit->text().isEmpty()) {
@@ -114,6 +138,8 @@ void CustomProfileDialog::on_buttonBox_accepted()
p.set("colorspace", MLT.profile().colorspace());
p.set("frame_rate_num", MLT.profile().frame_rate_num());
p.set("frame_rate_den", MLT.profile().frame_rate_den());
+ if (!MLT.colorTrc().isEmpty())
+ p.set("color_trc", MLT.colorTrc().toLatin1().constData());
p.save(dir.filePath(profileName()).toUtf8().constData());
}
}
@@ -169,3 +195,11 @@ void CustomProfileDialog::on_aspectRatioComboBox_textActivated(const QString &ar
ui->aspectNumSpinner->setValue(parts[0].toInt());
ui->aspectDenSpinner->setValue(parts[1].toInt());
}
+
+void CustomProfileDialog::on_colorspaceCombo_currentIndexChanged(int index)
+{
+ const bool isBt2020 = (index == 2);
+ ui->dynamicRangeCombo->setEnabled(isBt2020);
+ if (!isBt2020)
+ ui->dynamicRangeCombo->setCurrentIndex(0); // reset to SDR
+}
diff --git a/src/dialogs/customprofiledialog.h b/src/dialogs/customprofiledialog.h
index ee3a60c1cb..00b2dd4435 100644
--- a/src/dialogs/customprofiledialog.h
+++ b/src/dialogs/customprofiledialog.h
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2013-2023 Meltytech, LLC
+ * Copyright (c) 2013-2026 Meltytech, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -48,6 +48,8 @@ private slots:
void on_aspectRatioComboBox_textActivated(const QString &arg1);
+ void on_colorspaceCombo_currentIndexChanged(int index);
+
private:
Ui::CustomProfileDialog *ui;
double m_fps;
diff --git a/src/dialogs/customprofiledialog.ui b/src/dialogs/customprofiledialog.ui
index aa64592ba1..ab61caabdb 100644
--- a/src/dialogs/customprofiledialog.ui
+++ b/src/dialogs/customprofiledialog.ui
@@ -7,7 +7,7 @@
0
0
496
- 376
+ 410
@@ -459,6 +459,55 @@
+ -
+
+
+ Dynamic range
+
+
+ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
+
+
+ dynamicRangeCombo
+
+
+
+ -
+
+
-
+
+
-
+
+ SDR
+
+
+ -
+
+ HLG HDR
+
+
+ -
+
+ PQ HDR
+
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
-
diff --git a/src/docks/encodedock.cpp b/src/docks/encodedock.cpp
index 66a5e02962..256d02878e 100644
--- a/src/docks/encodedock.cpp
+++ b/src/docks/encodedock.cpp
@@ -544,6 +544,7 @@ void EncodeDock::onProducerOpened()
}
ui->otherTipLabel->setText(tr("You must enter numeric values using '%1' as the decimal point.")
.arg(MLT.decimalPoint()));
+ updateHdrMetaButton();
}
void EncodeDock::loadPresets()
@@ -835,6 +836,32 @@ Mlt::Properties *EncodeDock::collectProperties(int realtime, bool includeProfile
// Also set some properties so that custom presets can be interpreted properly.
setIfNotSet(p, "g", ui->gopSpinner->value());
setIfNotSet(p, "bf", ui->bFramesSpinner->value());
+ // Inject HDR10 metadata when the source is PQ.
+ if (MLT.colorTrc() == QLatin1String("smpte2084")) {
+ if ((m_hdrMaxCll > 0 || m_hdrMaxFall > 0)
+ && !x265params.contains(QLatin1String("max-cll="))) {
+ x265params = QStringLiteral("max-cll=%1,%2:%3")
+ .arg(m_hdrMaxCll)
+ .arg(m_hdrMaxFall)
+ .arg(x265params);
+ }
+ if ((m_hdrMaxLuminance > 0.0 || m_hdrMinLuminance > 0.0)
+ && !x265params.contains(QLatin1String("master-display="))) {
+ const QString primaries
+ = (m_hdrMasterPreset == 1)
+ // Display P3 (D65)
+ ? QStringLiteral(
+ "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)")
+ // BT.2020 (default)
+ : QStringLiteral(
+ "G(8500,39850)B(6550,2300)R(35400,14600)WP(15635,16450)");
+ x265params = QStringLiteral("master-display=%1L(%2,%3):%4")
+ .arg(primaries)
+ .arg(qRound(m_hdrMaxLuminance * 10000.0))
+ .arg(qRound(m_hdrMinLuminance * 10000.0))
+ .arg(x265params);
+ }
+ }
p->set("x265-params", x265params.toUtf8().constData());
} else if (vcodec == "libsvtav1") {
QString bitrate_text = ui->videoBitrateCombo->currentText();
@@ -900,6 +927,31 @@ Mlt::Properties *EncodeDock::collectProperties(int realtime, bool includeProfile
QString origParams = QString::fromUtf8(p->get("svtav1-params"));
if (!origParams.isEmpty())
encParams << origParams;
+ // Inject HDR10 mastering metadata into svtav1-params when the source is PQ.
+ if ((m_hdrMaxCll > 0 || m_hdrMaxFall > 0)
+ && MLT.colorTrc() == QLatin1String("smpte2084")) {
+ const bool hasCll = encParams.filter(QLatin1String("max-cll=")).isEmpty()
+ && !origParams.contains(QLatin1String("max-cll="));
+ if (hasCll)
+ encParams << QStringLiteral("max-cll=%1,%2")
+ .arg(m_hdrMaxCll)
+ .arg(m_hdrMaxFall);
+ const bool hasMd
+ = encParams.filter(QLatin1String("mastering-display=")).isEmpty()
+ && !origParams.contains(QLatin1String("mastering-display="));
+ if (hasMd) {
+ const QString primaries
+ = (m_hdrMasterPreset == 1)
+ ? QStringLiteral(
+ "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)")
+ : QStringLiteral(
+ "G(8500,39850)B(6550,2300)R(35400,14600)WP(15635,16450)");
+ encParams << QStringLiteral("mastering-display=%1L(%2,%3)")
+ .arg(primaries)
+ .arg(qRound(m_hdrMaxLuminance * 10000.0))
+ .arg(qRound(m_hdrMinLuminance * 10000.0));
+ }
+ }
p->set("svtav1-params", encParams.join(':').toUtf8().constData());
} else if (vcodec.contains("nvenc")) {
@@ -943,6 +995,32 @@ Mlt::Properties *EncodeDock::collectProperties(int realtime, bool includeProfile
// Also set some properties so that custom presets can be interpreted properly.
setIfNotSet(p, "g", ui->gopSpinner->value());
setIfNotSet(p, "bf", ui->bFramesSpinner->value());
+ // Inject HDR10 mastering metadata for hevc_nvenc when the source is PQ.
+ if (vcodec == "hevc_nvenc" && (m_hdrMaxCll > 0 || m_hdrMaxFall > 0)
+ && MLT.colorTrc() == QLatin1String("smpte2084")) {
+ if (!p->get("max_cll"))
+ p->set("max_cll",
+ QStringLiteral("%1,%2")
+ .arg(m_hdrMaxCll)
+ .arg(m_hdrMaxFall)
+ .toUtf8()
+ .constData());
+ if (!p->get("master_display")) {
+ const QString primaries
+ = (m_hdrMasterPreset == 1)
+ ? QStringLiteral(
+ "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)")
+ : QStringLiteral(
+ "G(8500,39850)B(6550,2300)R(35400,14600)WP(15635,16450)");
+ p->set("master_display",
+ QStringLiteral("%1L(%2,%3)")
+ .arg(primaries)
+ .arg(qRound(m_hdrMaxLuminance * 10000.0))
+ .arg(qRound(m_hdrMinLuminance * 10000.0))
+ .toUtf8()
+ .constData());
+ }
+ }
} else if (vcodec.endsWith("_amf")) {
switch (ui->videoRateControlCombo->currentIndex()) {
case RateControlAverage:
@@ -1185,6 +1263,11 @@ Mlt::Properties *EncodeDock::collectProperties(int realtime, bool includeProfile
if (ui->rangeComboBox->currentIndex()) {
setIfNotSet(p, "color_range", "pc");
}
+ // Propagate HDR transfer characteristics to the encoder when the
+ // source (or profile) carries them.
+ const QString &sourceTrc = MLT.colorTrc();
+ if (!sourceTrc.isEmpty())
+ setIfNotSet(p, "color_trc", sourceTrc.toLatin1().constData());
if (ui->formatCombo->currentText() == "image2")
setIfNotSet(p, "threads", 1);
else if (ui->videoCodecThreadsSpinner->value() == 0
@@ -1225,15 +1308,19 @@ void EncodeDock::collectProperties(QDomElement &node, int realtime)
|| processingMode == ShotcutSettings::Linear10Cpu
|| processingMode == ShotcutSettings::Linear10GpuCpu) {
if (!p->property_exists("mlt_image_format")) {
- if (::qstrcmp(p->get("color_trc"), "arib-std-b67"))
- node.setAttribute("mlt_image_format", "rgba64");
+ const char *trc = p->get("color_trc");
+ const bool isHlg = !::qstrcmp(trc, "arib-std-b67");
+ const bool isPq = !::qstrcmp(trc, "smpte2084");
+ if (isHlg || isPq)
+ node.setAttribute("mlt_image_format", "yuv420p10");
else
- node.setAttribute("mlt_image_format", "yuv444p10");
+ node.setAttribute("mlt_image_format", "rgba64");
}
}
if ((processingMode == ShotcutSettings::Linear10Cpu
|| processingMode == ShotcutSettings::Linear10GpuCpu)
- && ::qstrcmp(p->get("color_trc"), "arib-std-b67")) {
+ && ::qstrcmp(p->get("color_trc"), "arib-std-b67")
+ && ::qstrcmp(p->get("color_trc"), "smpte2084")) {
if (!p->property_exists("mlt_color_trc"))
node.setAttribute("mlt_color_trc", "linear");
@@ -1872,6 +1959,17 @@ void EncodeDock::onVideoCodecComboChanged(int index, bool ignorePreset, bool res
ui->dualPassCheckbox->setEnabled(true);
}
on_videoQualitySpinner_valueChanged(ui->videoQualitySpinner->value());
+ updateHdrMetaButton();
+}
+
+void EncodeDock::updateHdrMetaButton()
+{
+ const QString &vcodec = ui->videoCodecCombo->currentText();
+ const bool codecSupportsHdrMeta = (vcodec == QLatin1String("libx265")
+ || vcodec == QLatin1String("libsvtav1")
+ || vcodec == QLatin1String("hevc_nvenc"));
+ const bool sourceIsPq = (MLT.colorTrc() == QLatin1String("smpte2084"));
+ ui->hdrMetaButton->setVisible(codecSupportsHdrMeta && sourceIsPq);
}
static double getBufferSize(Mlt::Properties &preset, const char *property)
@@ -3059,6 +3157,72 @@ void EncodeDock::on_coverArtButton_clicked()
}
}
+void EncodeDock::on_hdrMetaButton_clicked()
+{
+ QDialog dialog(this);
+ dialog.setWindowTitle(tr("HDR Metadata"));
+
+ auto *form = new QFormLayout;
+ form->setLabelAlignment(Qt::AlignRight);
+
+ auto *maxCllSpin = new QSpinBox;
+ maxCllSpin->setRange(0, 10000);
+ maxCllSpin->setValue(m_hdrMaxCll);
+ maxCllSpin->setSpecialValueText(tr("Not set"));
+ maxCllSpin->setSuffix(tr(" nits", "a measure of brightness"));
+ maxCllSpin->setToolTip(
+ tr("Maximum Content Light Level (MaxCLL): the brightest single pixel in the entire clip"));
+ form->addRow(tr("MaxCLL"), maxCllSpin);
+
+ auto *maxFallSpin = new QSpinBox;
+ maxFallSpin->setRange(0, 10000);
+ maxFallSpin->setValue(m_hdrMaxFall);
+ maxFallSpin->setSpecialValueText(tr("Not set"));
+ maxFallSpin->setSuffix(tr(" nits", "a measure of brightness"));
+ maxFallSpin->setToolTip(tr("Maximum Frame-Average Light Level (MaxFALL): the highest average "
+ "brightness of any single frame"));
+ form->addRow(tr("MaxFALL"), maxFallSpin);
+
+ auto *primaryCombo = new QComboBox;
+ primaryCombo->addItem(tr("BT.2020 / Rec.2020"));
+ primaryCombo->addItem(tr("Display P3 (D65)"));
+ primaryCombo->setCurrentIndex(m_hdrMasterPreset);
+ form->addRow(tr("Color primaries"), primaryCombo);
+
+ auto *maxLumSpin = new QSpinBox;
+ maxLumSpin->setRange(1, 10000);
+ maxLumSpin->setValue(m_hdrMaxLuminance);
+ maxLumSpin->setSuffix(tr(" nits", "a measure of brightness"));
+ maxLumSpin->setToolTip(tr("Display mastering maximum luminance"));
+ form->addRow(tr("Display max luminance"), maxLumSpin);
+
+ auto *minLumSpin = new QDoubleSpinBox;
+ minLumSpin->setRange(0.0, 10.0);
+ minLumSpin->setDecimals(4);
+ minLumSpin->setSingleStep(0.001);
+ minLumSpin->setValue(m_hdrMinLuminance);
+ minLumSpin->setSuffix(tr(" nits", "a measure of brightness"));
+ minLumSpin->setToolTip(tr("Display mastering minimum luminance"));
+ form->addRow(tr("Display min luminance"), minLumSpin);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+ connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
+
+ auto *vbox = new QVBoxLayout;
+ vbox->addLayout(form);
+ vbox->addWidget(buttons);
+ dialog.setLayout(vbox);
+
+ if (dialog.exec() == QDialog::Accepted) {
+ m_hdrMaxCll = maxCllSpin->value();
+ m_hdrMaxFall = maxFallSpin->value();
+ m_hdrMasterPreset = primaryCombo->currentIndex();
+ m_hdrMaxLuminance = maxLumSpin->value();
+ m_hdrMinLuminance = minLumSpin->value();
+ }
+}
+
void EncodeDock::setReframeEnabled(bool enabled)
{
ui->widthSpinner->setDisabled(enabled);
diff --git a/src/docks/encodedock.h b/src/docks/encodedock.h
index 04326ca074..447b35c83d 100644
--- a/src/docks/encodedock.h
+++ b/src/docks/encodedock.h
@@ -143,6 +143,8 @@ private slots:
void on_coverArtButton_clicked();
+ void on_hdrMetaButton_clicked();
+
private:
enum {
RateControlAverage = 0,
@@ -168,6 +170,11 @@ private slots:
QStringList m_intraOnlyCodecs;
QStringList m_losslessVideoCodecs;
QStringList m_losslessAudioCodecs;
+ int m_hdrMaxCll{1000};
+ int m_hdrMaxFall{400};
+ int m_hdrMasterPreset{0}; // 0=BT.2020, 1=P3-D65
+ int m_hdrMaxLuminance{1000};
+ double m_hdrMinLuminance{0.01};
void loadPresets();
Mlt::Properties *collectProperties(int realtime, bool includeProfile = false);
@@ -194,6 +201,7 @@ private slots:
Mlt::Producer *fromProducer(bool usePlaylistBin = false) const;
static void filterCodecParams(const QString &vcodec, QStringList &other);
void onVideoCodecComboChanged(int index, bool ignorePreset = false, bool resetBframes = true);
+ void updateHdrMetaButton();
bool checkForMissingFiles();
QString &defaultFormatExtension();
void initSpecialCodecLists();
diff --git a/src/docks/encodedock.ui b/src/docks/encodedock.ui
index 550ff84b6c..df34bd1149 100644
--- a/src/docks/encodedock.ui
+++ b/src/docks/encodedock.ui
@@ -967,9 +967,31 @@ with parallel processing enabled.
-
+
+ 4
+
-
+ -
+
+
+
+ 40
+ 16777215
+
+
+
+ false
+
+
+ Set HDR mastering display and content light level metadata
+
+
+ HDR...
+
+
+
-
diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp
new file mode 100644
index 0000000000..a920f7b876
--- /dev/null
+++ b/src/hdrpreviewwindow.cpp
@@ -0,0 +1,586 @@
+/*
+ * Copyright (c) 2026 Meltytech, LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "hdrpreviewwindow.h"
+
+#include "actions.h"
+#include "mainwindow.h"
+#include "mltcontroller.h"
+#include "player.h"
+#include "qmltypes/qmlutilities.h"
+#include "settings.h"
+
+#ifdef Q_OS_WIN
+#include
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
+#include
+#include
+#endif
+
+#ifdef Q_OS_MACOS
+#include "macos.h"
+#endif
+
+// HLG OETF: scene-referred linear to HLG electrical signal.
+// See ITU-R BT.2100-2.
+static float hlgOetf(float linear)
+{
+ const float a = 0.17883277f;
+ const float b = 0.28466892f;
+ const float c = 0.55991073f;
+ if (linear < 1.0f / 12.0f)
+ return sqrtf(3.0f * linear);
+ return a * logf(12.0f * linear - b) + c;
+}
+
+static QString formatTimecode(int frames, double fps)
+{
+ if (frames < 0 || fps <= 0.0)
+ return QStringLiteral("00:00");
+ const int totalSec = static_cast(frames / fps);
+ const int h = totalSec / 3600;
+ const int m = (totalSec % 3600) / 60;
+ const int s = totalSec % 60;
+ if (h > 0)
+ return QString("%1:%2:%3").arg(h).arg(m, 2, 10, QChar('0')).arg(s, 2, 10, QChar('0'));
+ return QString("%1:%2").arg(m, 2, 10, QChar('0')).arg(s, 2, 10, QChar('0'));
+}
+
+HdrPreviewWindow::HdrPreviewWindow(QWindow *parent)
+ : QQuickView(QmlUtilities::sharedEngine(), parent)
+{
+ setTitle(tr("Shotcut Preview"));
+ setResizeMode(QQuickView::SizeRootObjectToView);
+ setColor(Qt::black);
+
+ // Request HDR swapchain via the internal Qt scene graph property.
+ // On macOS/Windows the NVIDIA driver exposes R16G16B16A16_SFLOAT paired
+ // with EXTENDED_SRGB_LINEAR_EXT, so "scrgb" works and Qt's video shaders
+ // select the linear HDR output path (nv12_bt2020_hlg_linear.frag).
+ // On Linux/Wayland the NVIDIA driver (as of 580.x) only offers
+ // R16G16B16A16_UNORM (not SFLOAT) for that color space, making scRGB
+ // impossible. Fall back to HDR10 there since A2B10G10R10_UNORM_PACK32 +
+ // HDR10_ST2084_EXT IS available.
+#if defined(Q_OS_LINUX)
+ setProperty("_qt_sg_hdr_format", QByteArrayLiteral("hdr10"));
+#else
+ setProperty("_qt_sg_hdr_format", QByteArrayLiteral("scrgb"));
+#endif
+
+ rootContext()->setContextProperty("hdrWindow", this);
+
+ // Load persisted HDR display settings
+ m_displayPeakNits = Settings.playerHdrDisplayPeakNits();
+ m_contentPeakNits = Settings.playerHdrContentPeakNits();
+ m_toneMapping = Settings.playerHdrToneMapping();
+
+ QDir qmlDir = QmlUtilities::qmlDir();
+ setSource(QUrl::fromLocalFile(qmlDir.filePath("views/HdrPreview.qml")));
+
+ resize(960, 540);
+
+ connect(this, &QWindow::windowStateChanged, this, [this]() { emit fullScreenChanged(); });
+
+#ifdef Q_OS_MACOS
+ // Override NSScreen.maximumExtendedDynamicRangeColorComponentValue so that
+ // Qt's video shader outputs > 1.0 values (HDR) on the first frame, which
+ // then causes macOS to allocate real EDR headroom.
+ macosOverrideEdrHeadroom(true);
+
+ // Monitor EDR headroom every second for the first 30 seconds.
+ connect(&m_edrTimer, &QTimer::timeout, this, &HdrPreviewWindow::checkEdrHeadroom);
+ m_edrTimer.start(1000);
+#endif
+}
+
+HdrPreviewWindow::~HdrPreviewWindow()
+{
+#ifdef Q_OS_MACOS
+ macosOverrideEdrHeadroom(false);
+#endif
+}
+
+void HdrPreviewWindow::setVideoSink(QVideoSink *sink)
+{
+ m_videoSink = sink;
+ if (m_videoSink) {
+ // Force the VideoOutput's layer FBO (RGBA16F) to be cleared to a known
+ // state before the first real video frame arrives. Without this, the
+ // Metal/Vulkan texture backing the ShaderEffectSource may contain
+ // undefined (garbage) GPU memory on its first use, producing a single
+ // distorted frame on session open.
+ m_videoSink->setVideoFrame(QVideoFrame());
+ }
+}
+
+void HdrPreviewWindow::pushFrame(const QVideoFrame &frame)
+{
+ if (m_videoSink && isVisible()) {
+ // Qt 6 caches the video format in QSGVideoMaterialRhiShader and
+ // never updates masteringWhite when maxLuminance changes. To
+ // work around this, invalidateVideoNode() pushes an empty frame
+ // (triggering node deletion), and we skip the very next valid
+ // frame so the render thread has time to process the deletion.
+ // The frame after the skip creates a fresh node whose shader
+ // picks up the new maxLuminance.
+ if (m_skipNextFrame) {
+ m_skipNextFrame = false;
+ // Request another frame so the node is recreated even when
+ // playback is paused. Use a short delay to ensure the render
+ // thread has time to process the empty frame and delete the
+ // old QSGVideoNode before the new frame arrives.
+ QTimer::singleShot(100, this, []() { MLT.refreshConsumer(); });
+ return;
+ }
+
+ if (!m_loggedSwapChain) {
+ m_loggedSwapChain = true;
+ auto *sc = swapChain();
+ if (sc) {
+ qDebug() << "HDR Preview: swapChain format =" << sc->format()
+ << "hdrInfo =" << sc->hdrInfo() << "scRGB supported ="
+ << sc->isFormatSupported(QRhiSwapChain::HDRExtendedSrgbLinear)
+ << "HDR10 supported =" << sc->isFormatSupported(QRhiSwapChain::HDR10)
+ << "P3 supported ="
+ << sc->isFormatSupported(QRhiSwapChain::HDRExtendedDisplayP3Linear);
+ } else {
+ qDebug() << "HDR Preview: swapChain() returned nullptr!";
+ }
+#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
+ // Log Vulkan surface formats to diagnose HDR format availability
+ if (auto *vi = vulkanInstance()) {
+ VkSurfaceKHR surf = QVulkanInstance::surfaceForWindow(this);
+ if (surf) {
+ auto *f = vi->functions();
+ VkPhysicalDevice physDev = VK_NULL_HANDLE;
+ uint32_t pdCount = 0;
+ f->vkEnumeratePhysicalDevices(vi->vkInstance(), &pdCount, nullptr);
+ if (pdCount > 0) {
+ QVarLengthArray devs(pdCount);
+ f->vkEnumeratePhysicalDevices(vi->vkInstance(), &pdCount, devs.data());
+ physDev = devs[0];
+ }
+ if (physDev) {
+ auto vkGetPhysicalDeviceSurfaceFormatsKHR
+ = reinterpret_cast(
+ vi->getInstanceProcAddr("vkGetPhysicalDeviceSurfaceFormatsKHR"));
+ if (vkGetPhysicalDeviceSurfaceFormatsKHR) {
+ uint32_t fmtCount = 0;
+ vkGetPhysicalDeviceSurfaceFormatsKHR(physDev, surf, &fmtCount, nullptr);
+ QVarLengthArray fmts(fmtCount);
+ vkGetPhysicalDeviceSurfaceFormatsKHR(physDev,
+ surf,
+ &fmtCount,
+ fmts.data());
+ qDebug() << "HDR Preview: Vulkan surface has" << fmtCount << "formats:";
+ for (uint32_t i = 0; i < fmtCount; ++i) {
+ qDebug() << " format" << fmts[i].format << "colorSpace"
+ << fmts[i].colorSpace;
+ }
+ }
+ }
+ } else {
+ qDebug() << "HDR Preview: VkSurfaceKHR is null — surface not yet created";
+ }
+ }
+#endif
+ qDebug() << "HDR Preview frame: pixelFormat =" << frame.surfaceFormat().pixelFormat()
+ << "colorTransfer =" << frame.surfaceFormat().colorTransfer()
+ << "maxLuminance =" << frame.surfaceFormat().maxLuminance();
+ if (auto *scr = screen()) {
+ qDebug() << "HDR Preview: screen =" << scr->name() << "size =" << scr->size()
+ << "dpr =" << scr->devicePixelRatio();
+ }
+#ifdef Q_OS_MACOS
+ auto wid = winId();
+ qDebug() << "HDR Preview EDR:"
+ << "current =" << macosCurrentEdrHeadroom(wid)
+ << "potential =" << macosPotentialEdrHeadroom(wid)
+ << "reference =" << macosReferenceEdrHeadroom(wid);
+#endif
+ }
+ updateHdrGain();
+ // Log when the frame's stamped maxLuminance changes — confirms the
+ // modified QVideoFrame is reaching Qt's video-sink render path.
+ static float s_lastPushMaxLum = -1.0f;
+ const float pushMaxLum = frame.surfaceFormat().maxLuminance();
+ if (!qFuzzyCompare(s_lastPushMaxLum, pushMaxLum)) {
+ s_lastPushMaxLum = pushMaxLum;
+ qDebug() << "HDR pushFrame → videoSink: maxLuminance =" << pushMaxLum
+ << "pixelFormat =" << frame.surfaceFormat().pixelFormat()
+ << "colorTransfer =" << frame.surfaceFormat().colorTransfer();
+ }
+ m_videoSink->setVideoFrame(frame);
+
+ // Track playback position from frame timestamp
+ const qint64 pts = frame.startTime(); // microseconds
+ const double fps = MLT.profile().fps();
+ if (pts >= 0 && fps > 0.0) {
+ const int frameNum = qRound(pts * fps / 1000000.0);
+ if (m_videoPosition != frameNum) {
+ m_videoPosition = frameNum;
+ emit videoPositionChanged();
+ }
+ }
+ // Track duration from the active producer
+ if (auto *prod = MLT.producer()) {
+ const int len = qMax(0, prod->get_length() - 1);
+ if (m_videoDuration != len) {
+ m_videoDuration = len;
+ emit videoDurationChanged();
+ }
+ }
+ }
+}
+
+void HdrPreviewWindow::triggerPlayPause()
+{
+ Actions["playerPlayPauseAction"]->trigger();
+}
+
+void HdrPreviewWindow::triggerRewind()
+{
+ Actions["playerRewindAction"]->trigger();
+}
+
+void HdrPreviewWindow::triggerFastForward()
+{
+ Actions["playerFastForwardAction"]->trigger();
+}
+
+void HdrPreviewWindow::restoreGeometry(const QRect &r)
+{
+ // Suppress the DAR-snap in resizeEvent so that programmatically restoring
+ // the saved window geometry does not trigger a floating-point-rounded
+ // resize that would make the window grow by 1-2 px on every launch.
+ m_skipDarSnap = true;
+ setGeometry(r);
+ // On macOS the resizeEvent may fire during show() rather than during
+ // setGeometry(), so keep the guard active for a short while.
+ QTimer::singleShot(300, this, [this]() { m_skipDarSnap = false; });
+}
+
+void HdrPreviewWindow::toggleFullScreen()
+{
+ if (windowStates() & Qt::WindowFullScreen) {
+ setWindowStates(Qt::WindowNoState);
+ if (m_normalGeometry.isValid())
+ setGeometry(m_normalGeometry);
+ } else {
+ m_normalGeometry = geometry();
+ showFullScreen();
+ }
+}
+
+QString HdrPreviewWindow::positionText() const
+{
+ return formatTimecode(m_videoPosition, MLT.profile().fps());
+}
+
+QString HdrPreviewWindow::durationText() const
+{
+ return formatTimecode(m_videoDuration, MLT.profile().fps());
+}
+
+void HdrPreviewWindow::seekToFrame(int frame)
+{
+ MAIN.player()->seek(frame);
+}
+
+void HdrPreviewWindow::setPlaying(bool playing)
+{
+ if (m_isPlaying != playing) {
+ m_isPlaying = playing;
+ emit playingChanged();
+ }
+}
+
+bool HdrPreviewWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result)
+{
+#ifdef Q_OS_WIN
+ if (eventType == "windows_generic_MSG") {
+ MSG *msg = static_cast(message);
+ if (msg->message == WM_SIZING && !(windowStates() & Qt::WindowFullScreen)) {
+ const int darNum = MLT.profile().display_aspect_num();
+ const int darDen = MLT.profile().display_aspect_den();
+ if (darNum > 0 && darDen > 0) {
+ RECT *r = reinterpret_cast(msg->lParam);
+ // Measure the actual frame overhead from the live window state.
+ // AdjustWindowRectEx under-reports the DWM extended frame on
+ // Windows 10/11, leading to a wrong client-area AR calculation.
+ RECT curWin, curClient;
+ GetWindowRect(msg->hwnd, &curWin);
+ GetClientRect(msg->hwnd, &curClient);
+ const int fw = (curWin.right - curWin.left) - (curClient.right - curClient.left);
+ const int fh = (curWin.bottom - curWin.top) - (curClient.bottom - curClient.top);
+ const int clientW = (r->right - r->left) - fw;
+ const int clientH = (r->bottom - r->top) - fh;
+ const bool heightPrimary = (msg->wParam == WMSZ_TOP || msg->wParam == WMSZ_BOTTOM);
+ if (heightPrimary) {
+ // Height drives: adjust width from the right
+ r->right = r->left + qRound((double) clientH * darNum / darDen) + fw;
+ } else {
+ // Width drives: adjust height
+ const int newH = qRound((double) clientW * darDen / darNum) + fh;
+ const bool fromTop = (msg->wParam == WMSZ_TOPLEFT
+ || msg->wParam == WMSZ_TOPRIGHT);
+ if (fromTop)
+ r->top = r->bottom - newH;
+ else
+ r->bottom = r->top + newH;
+ }
+ *result = TRUE;
+ return true;
+ }
+ }
+ }
+#endif
+ return QQuickView::nativeEvent(eventType, message, result);
+}
+
+void HdrPreviewWindow::resizeEvent(QResizeEvent *event)
+{
+ QQuickView::resizeEvent(event);
+#ifndef Q_OS_WIN
+ // On Windows, WM_SIZING handles AR constraining smoothly.
+ // On other platforms, snap to the correct AR after each resize.
+ if (windowStates() & Qt::WindowFullScreen)
+ return;
+ if (m_skipDarSnap)
+ return;
+ const QSize newSize = event->size();
+ const QSize oldSize = event->oldSize();
+ if (!oldSize.isValid())
+ return;
+ const int darNum = MLT.profile().display_aspect_num();
+ const int darDen = MLT.profile().display_aspect_den();
+ if (darNum <= 0 || darDen <= 0)
+ return;
+ // Infer which axis the user is dragging by which changed proportionally more
+ const double wChange = qAbs((double) (newSize.width() - oldSize.width())
+ / qMax(oldSize.width(), 1));
+ const double hChange = qAbs((double) (newSize.height() - oldSize.height())
+ / qMax(oldSize.height(), 1));
+ int targetW, targetH;
+ if (wChange >= hChange) {
+ targetW = newSize.width();
+ targetH = qRound((double) targetW * darDen / darNum);
+ } else {
+ targetH = newSize.height();
+ targetW = qRound((double) targetH * darNum / darDen);
+ }
+ if (targetW != newSize.width() || targetH != newSize.height()) {
+ // Defer to avoid recursion
+ QTimer::singleShot(0, this, [this, targetW, targetH]() {
+ if (!(windowStates() & Qt::WindowFullScreen))
+ resize(targetW, targetH);
+ });
+ }
+#endif
+}
+
+void HdrPreviewWindow::keyPressEvent(QKeyEvent *event)
+{
+ // Forward to MainWindow for J/K/L transport handling
+ event->setAccepted(false);
+ MAIN.keyPressEvent(event);
+ if (event->isAccepted())
+ return;
+
+ // Match QAction shortcuts since this window is outside MainWindow's hierarchy
+ QKeySequence keySeq(event->keyCombination());
+ for (const auto &key : Actions.keys()) {
+ QAction *action = Actions[key];
+ if (action && action->isEnabled()) {
+ for (const auto &shortcut : action->shortcuts()) {
+ if (shortcut.matches(keySeq) == QKeySequence::ExactMatch) {
+ action->trigger();
+ event->accept();
+ return;
+ }
+ }
+ }
+ }
+}
+
+void HdrPreviewWindow::keyReleaseEvent(QKeyEvent *event)
+{
+ event->setAccepted(false);
+ MAIN.keyReleaseEvent(event);
+ if (!event->isAccepted())
+ QQuickView::keyReleaseEvent(event);
+}
+
+void HdrPreviewWindow::setHdrTransfer(HdrTransfer transfer)
+{
+ if (m_hdrTransfer != transfer) {
+ m_hdrTransfer = transfer;
+ emit hdrTransferModeChanged();
+ if (m_hdrTransfer == HdrTransfer::SDR && !qFuzzyCompare(m_hdrGain, 1.0f)) {
+ m_hdrGain = 1.0f;
+ emit hdrGainChanged();
+ }
+ }
+}
+
+void HdrPreviewWindow::setDisplayPeakNits(int nits)
+{
+ qDebug() << "HDR Settings: setDisplayPeakNits" << nits;
+ if (m_displayPeakNits != nits) {
+ m_displayPeakNits = nits;
+ Settings.setPlayerHdrDisplayPeakNits(nits);
+ emit displayPeakNitsChanged();
+ updateHdrGain();
+ if (m_hdrTransfer != HdrTransfer::SDR)
+ invalidateVideoNode();
+ }
+}
+
+void HdrPreviewWindow::setContentPeakNits(int nits)
+{
+ qDebug() << "HDR Settings: setContentPeakNits" << nits;
+ if (m_contentPeakNits != nits) {
+ m_contentPeakNits = nits;
+ Settings.setPlayerHdrContentPeakNits(nits);
+ emit contentPeakNitsChanged();
+ updateHdrGain();
+ if (m_hdrTransfer != HdrTransfer::SDR)
+ invalidateVideoNode();
+ }
+}
+
+void HdrPreviewWindow::setToneMapping(bool enabled)
+{
+ qDebug() << "HDR Settings: setToneMapping" << enabled;
+ if (m_toneMapping != enabled) {
+ m_toneMapping = enabled;
+ Settings.setPlayerHdrToneMapping(enabled);
+ emit toneMappingChanged();
+ updateHdrGain();
+ if (m_hdrTransfer != HdrTransfer::SDR)
+ invalidateVideoNode();
+ }
+}
+
+void HdrPreviewWindow::invalidateVideoNode()
+{
+ // Push an invalid frame so QQuickVideoOutput::updatePaintNode()
+ // deletes the existing QSGVideoNode. The next valid frame will
+ // then create a fresh node whose shader picks up the updated
+ // maxLuminance for the BT.2390 EETF.
+ if (m_videoSink) {
+ m_videoSink->setVideoFrame(QVideoFrame());
+ m_skipNextFrame = true;
+ MLT.refreshConsumer();
+ }
+}
+
+void HdrPreviewWindow::updateHdrGain()
+{
+ if (m_hdrTransfer == HdrTransfer::SDR)
+ return;
+
+ auto *sc = swapChain();
+ if (!sc
+ || (sc->format() != QRhiSwapChain::HDRExtendedSrgbLinear
+ && sc->format() != QRhiSwapChain::HDR10)) {
+ if (!m_loggedGainSkip) {
+ m_loggedGainSkip = true;
+ if (!sc)
+ qDebug() << "HDR Preview: gain skipped — no swapChain";
+ else
+ qDebug() << "HDR Preview: gain skipped — swapChain format" << sc->format()
+ << "is not HDR. Try QSG_RHI_BACKEND=vulkan on Linux.";
+ }
+ return;
+ }
+
+ auto info = sc->hdrInfo();
+ // Determine actual display peak from swap chain hdrInfo.
+ float actualMaxNits = 100.0f;
+ if (info.limitsType == QRhiSwapChainHdrInfo::ColorComponentValue)
+ actualMaxNits = 100.0f * info.limits.colorComponentValue.maxColorComponentValue;
+ else if (info.limitsType == QRhiSwapChainHdrInfo::LuminanceInNits)
+ actualMaxNits = info.limits.luminanceInNits.maxLuminance;
+
+ // User-overridden display peak, or actual.
+ float effectiveMaxNits = (m_displayPeakNits > 0) ? static_cast(m_displayPeakNits)
+ : actualMaxNits;
+ float displayMaxLinear = effectiveMaxNits / 100.0f;
+ if (displayMaxLinear <= 1.0f)
+ return;
+
+ float newGain;
+ if (sc->format() == QRhiSwapChain::HDR10 || m_hdrTransfer == HdrTransfer::PQ) {
+ // PQ (ST.2084) — Qt's EETF maps content to the actual display
+ // range. When the user overrides the display peak, scale the
+ // linear output proportionally so the image appears as it would
+ // on a display with that peak brightness.
+ newGain = effectiveMaxNits / actualMaxNits;
+ // On scRGB, Qt's PQ shader decodes to linear without applying
+ // the BT.2390 EETF, so content peak and tone mapping have no
+ // effect through maxLuminance alone. When tone mapping is
+ // enabled, apply a linear scale so that the declared content
+ // peak maps to the effective display peak.
+ if (sc->format() != QRhiSwapChain::HDR10 && m_toneMapping) {
+ float contentPeak = (m_contentPeakNits > 0) ? static_cast(m_contentPeakNits)
+ : 1000.0f;
+ if (contentPeak > effectiveMaxNits) {
+ newGain *= effectiveMaxNits / contentPeak;
+ }
+ }
+ } else {
+ // scRGB (HDRExtendedSrgbLinear) path with HLG content.
+ // Qt's HLG shader has a bug: maxLum is HLG-encoded (via hlgOetf) but used
+ // as a linear multiplier in the OOTF. The shader uniform is set as:
+ // maxLum = hlgOetf(maxNits / 100)
+ // but it should be the linear value (maxNits / 100). Compensate by
+ // multiplying the rendered output by the ratio of the correct linear
+ // value to the HLG-encoded one.
+ newGain = displayMaxLinear / hlgOetf(displayMaxLinear);
+ }
+ if (!qFuzzyCompare(newGain, m_hdrGain)) {
+ m_hdrGain = newGain;
+ qDebug() << "HDR Preview: gain =" << m_hdrGain << "(effectiveMaxNits =" << effectiveMaxNits
+ << "actualMaxNits =" << actualMaxNits << ")";
+ emit hdrGainChanged();
+ }
+}
+
+void HdrPreviewWindow::checkEdrHeadroom()
+{
+#ifdef Q_OS_MACOS
+ float headroom = macosCurrentEdrHeadroom(winId());
+ if (headroom != m_lastLoggedHeadroom) {
+ m_lastLoggedHeadroom = headroom;
+ auto *sc = swapChain();
+ qDebug() << "HDR Preview: EDR headroom =" << headroom
+ << "(swapChain hdrInfo =" << (sc ? sc->hdrInfo() : QRhiSwapChainHdrInfo()) << ")";
+ }
+ if (++m_edrCheckCount >= 30)
+ m_edrTimer.stop();
+#endif
+}
diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h
new file mode 100644
index 0000000000..4ade527404
--- /dev/null
+++ b/src/hdrpreviewwindow.h
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2026 Meltytech, LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef HDRPREVIEWWINDOW_H
+#define HDRPREVIEWWINDOW_H
+
+#include "videowidget.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class HdrPreviewWindow : public QQuickView
+{
+ Q_OBJECT
+ Q_PROPERTY(float hdrGain READ hdrGain NOTIFY hdrGainChanged)
+ Q_PROPERTY(int hdrTransferMode READ hdrTransferMode NOTIFY hdrTransferModeChanged)
+ Q_PROPERTY(int displayPeakNits READ displayPeakNits WRITE setDisplayPeakNits NOTIFY
+ displayPeakNitsChanged)
+ Q_PROPERTY(int contentPeakNits READ contentPeakNits WRITE setContentPeakNits NOTIFY
+ contentPeakNitsChanged)
+ Q_PROPERTY(bool toneMapping READ toneMapping WRITE setToneMapping NOTIFY toneMappingChanged)
+ Q_PROPERTY(bool playing READ isPlaying NOTIFY playingChanged)
+ Q_PROPERTY(bool fullScreen READ isFullScreen NOTIFY fullScreenChanged)
+ Q_PROPERTY(int videoPosition READ videoPosition NOTIFY videoPositionChanged)
+ Q_PROPERTY(int videoDuration READ videoDuration NOTIFY videoDurationChanged)
+ Q_PROPERTY(QString positionText READ positionText NOTIFY videoPositionChanged)
+ Q_PROPERTY(QString durationText READ durationText NOTIFY videoDurationChanged)
+
+public:
+ explicit HdrPreviewWindow(QWindow *parent = nullptr);
+ ~HdrPreviewWindow();
+
+ Q_INVOKABLE void setVideoSink(QVideoSink *sink);
+ float hdrGain() const { return m_hdrGain; }
+ int hdrTransferMode() const { return static_cast(m_hdrTransfer); }
+ int displayPeakNits() const { return m_displayPeakNits; }
+ void setDisplayPeakNits(int nits);
+ int contentPeakNits() const { return m_contentPeakNits; }
+ void setContentPeakNits(int nits);
+ bool toneMapping() const { return m_toneMapping; }
+ void setToneMapping(bool enabled);
+ bool isPlaying() const { return m_isPlaying; }
+ /// Restore a previously saved window geometry without triggering the
+ /// DAR-snap in resizeEvent (which would grow the window on each launch).
+ void restoreGeometry(const QRect &r);
+ bool isFullScreen() const { return windowStates() & Qt::WindowFullScreen; }
+ int videoPosition() const { return m_videoPosition; }
+ int videoDuration() const { return m_videoDuration; }
+ QString positionText() const;
+ QString durationText() const;
+
+ Q_INVOKABLE void triggerPlayPause();
+ Q_INVOKABLE void triggerRewind();
+ Q_INVOKABLE void triggerFastForward();
+ Q_INVOKABLE void toggleFullScreen();
+ Q_INVOKABLE void seekToFrame(int frame);
+
+public slots:
+ void pushFrame(const QVideoFrame &frame);
+ void setHdrTransfer(HdrTransfer transfer);
+ void setPlaying(bool playing);
+
+signals:
+ void hdrGainChanged();
+ void hdrTransferModeChanged();
+ void displayPeakNitsChanged();
+ void contentPeakNitsChanged();
+ void toneMappingChanged();
+ void playingChanged();
+ void fullScreenChanged();
+ void videoPositionChanged();
+ void videoDurationChanged();
+
+protected:
+ void keyPressEvent(QKeyEvent *event) override;
+ void keyReleaseEvent(QKeyEvent *event) override;
+ bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override;
+ void resizeEvent(QResizeEvent *event) override;
+
+private slots:
+ void checkEdrHeadroom();
+
+private:
+ void updateHdrGain();
+ void invalidateVideoNode();
+
+ QPointer m_videoSink;
+ QTimer m_edrTimer;
+ bool m_loggedSwapChain{false};
+ bool m_loggedGainSkip{false};
+ bool m_skipNextFrame{false};
+ HdrTransfer m_hdrTransfer{HdrTransfer::SDR};
+ bool m_isPlaying{false};
+ float m_lastLoggedHeadroom{0.0f};
+ int m_edrCheckCount{0};
+ float m_hdrGain{1.0f};
+ int m_displayPeakNits{0};
+ int m_contentPeakNits{0};
+ bool m_toneMapping{true};
+ bool m_skipDarSnap{false};
+ QRect m_normalGeometry;
+ int m_videoPosition{0};
+ int m_videoDuration{0};
+};
+
+#endif // HDRPREVIEWWINDOW_H
diff --git a/src/macos.h b/src/macos.h
index 8bd2052d39..83a4f5a343 100644
--- a/src/macos.h
+++ b/src/macos.h
@@ -17,8 +17,32 @@
#pragma once
+#include
+
void removeMacosTabBar();
void macosSetDockProgress(int percent);
void macosPauseDockProgress(int percent);
void macosResetDockProgress();
void macosFinishDockProgress(bool isSuccess, bool stopped);
+
+/// Override NSScreen.maximumExtendedDynamicRangeColorComponentValue to return
+/// the *potential* headroom. This breaks the chicken-and-egg where Qt's shader
+/// won't output > 1.0 because headroom=1, and macOS won't allocate headroom
+/// because no content > 1.0 is being rendered. Once the shader outputs HDR
+/// values, macOS allocates real headroom and the override becomes a no-op.
+/// Safe: only affects the HDR preview window's video shader (main window uses
+/// SDR swapchain format, so Qt's video node ignores hdrInfo for it).
+void macosOverrideEdrHeadroom(bool enable);
+
+/// Query the current EDR headroom for the screen hosting the given window.
+/// Returns NSScreen.maximumExtendedDynamicRangeColorComponentValue.
+/// @param windowId QWindow::winId()
+float macosCurrentEdrHeadroom(uintptr_t windowId);
+
+/// Query the potential (maximum) EDR headroom.
+/// Returns NSScreen.maximumPotentialExtendedDynamicRangeColorComponentValue.
+float macosPotentialEdrHeadroom(uintptr_t windowId);
+
+/// Query the reference EDR headroom.
+/// Returns NSScreen.maximumReferenceExtendedDynamicRangeColorComponentValue.
+float macosReferenceEdrHeadroom(uintptr_t windowId);
diff --git a/src/macos.mm b/src/macos.mm
index 73e6298f3a..5bc2869b9f 100644
--- a/src/macos.mm
+++ b/src/macos.mm
@@ -20,8 +20,12 @@
#import
#import
#import
+#import
#import
#import
+#import
+
+#include
void removeMacosTabBar()
{
@@ -96,3 +100,69 @@ void macosFinishDockProgress(bool isSuccess, bool stopped)
[NSApp requestUserAttention:NSCriticalRequest];
}
}
+
+// ---------------------------------------------------------------------------
+// EDR headroom override via method swizzle on NSScreen
+// ---------------------------------------------------------------------------
+static std::atomic s_edrOverrideEnabled{false};
+static bool s_swizzled = false;
+
+// Category that holds the swizzled implementation.
+@interface NSScreen (ShotcutEdrOverride)
+- (CGFloat)shotcut_maximumExtendedDynamicRangeColorComponentValue;
+@end
+
+@implementation NSScreen (ShotcutEdrOverride)
+- (CGFloat)shotcut_maximumExtendedDynamicRangeColorComponentValue
+{
+ // After swizzling, calling the swizzled selector invokes the ORIGINAL impl.
+ CGFloat real = [self shotcut_maximumExtendedDynamicRangeColorComponentValue];
+ if (s_edrOverrideEnabled.load(std::memory_order_relaxed) && real < 2.0) {
+ return self.maximumPotentialExtendedDynamicRangeColorComponentValue;
+ }
+ return real;
+}
+@end
+
+void macosOverrideEdrHeadroom(bool enable)
+{
+ if (!s_swizzled) {
+ s_swizzled = true;
+ Method original = class_getInstanceMethod(
+ [NSScreen class],
+ @selector(maximumExtendedDynamicRangeColorComponentValue));
+ Method swizzled = class_getInstanceMethod(
+ [NSScreen class],
+ @selector(shotcut_maximumExtendedDynamicRangeColorComponentValue));
+ method_exchangeImplementations(original, swizzled);
+ NSLog(@"macosOverrideEdrHeadroom: swizzled NSScreen.maximumExtendedDynamicRangeColorComponentValue");
+ }
+ s_edrOverrideEnabled.store(enable, std::memory_order_relaxed);
+ NSLog(@"macosOverrideEdrHeadroom: %s", enable ? "enabled" : "disabled");
+}
+
+float macosCurrentEdrHeadroom(uintptr_t windowId)
+{
+ NSView *view = reinterpret_cast(windowId);
+ if (!view || !view.window || !view.window.screen)
+ return 1.0f;
+ return static_cast(view.window.screen.maximumExtendedDynamicRangeColorComponentValue);
+}
+
+float macosPotentialEdrHeadroom(uintptr_t windowId)
+{
+ NSView *view = reinterpret_cast(windowId);
+ if (!view || !view.window || !view.window.screen)
+ return 1.0f;
+ return static_cast(view.window.screen.maximumPotentialExtendedDynamicRangeColorComponentValue);
+}
+
+float macosReferenceEdrHeadroom(uintptr_t windowId)
+{
+ NSView *view = reinterpret_cast(windowId);
+ if (!view || !view.window || !view.window.screen)
+ return 1.0f;
+ if (@available(macOS 12.0, *))
+ return static_cast(view.window.screen.maximumReferenceExtendedDynamicRangeColorComponentValue);
+ return 1.0f;
+}
diff --git a/src/main.cpp b/src/main.cpp
index afaac5a630..a075035f9b 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -271,10 +271,6 @@ class Application : public QApplication
#if defined(Q_OS_WIN)
dir.setPath(appPath);
-#elif !defined(Q_OS_MAC)
- if (Settings.drawMethod() == Qt::AA_UseSoftwareOpenGL && !Settings.playerGPU()) {
- ::qputenv("LIBGL_ALWAYS_SOFTWARE", "1");
- }
#endif
// Load translations
QString locale = Settings.language();
@@ -370,6 +366,8 @@ int main(int argc, char **argv)
qputenv("QT_MEDIA_BACKEND", "windows");
if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM"))
qputenv("QT_QPA_PLATFORM", "windows:altgr");
+#elif defined(BUILD_MINIMAL_MEDIA_BACKEND)
+ qputenv("QT_MEDIA_BACKEND", "minimal");
#else
;
#endif
@@ -392,17 +390,7 @@ int main(int argc, char **argv)
}
}
removeMacosTabBar();
-#endif
-
-#if defined(Q_OS_WIN)
- // Windows can use Direct3D or OpenGL
-#elif defined(Q_OS_MAC)
- QQuickWindow::setGraphicsApi(QSGRendererInterface::Metal);
QCoreApplication::setAttribute(Qt::AA_DontShowIconsInMenus);
-#else
- QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL);
- QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL);
- QCoreApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings);
#endif
Application a(argc, argv);
@@ -419,12 +407,35 @@ int main(int argc, char **argv)
#elif defined(Q_OS_MAC)
LOG_INFO() << "macOS version" << QSysInfo::productVersion();
#else
+ if (Settings.drawMethod() == QSGRendererInterface::Vulkan)
+ QQuickWindow::setGraphicsApi(QSGRendererInterface::Vulkan);
+ else if (::qgetenv("QSG_RHI_BACKEND").toLower() != QByteArrayLiteral("vulkan"))
+ QCoreApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings);
LOG_INFO() << "Linux version" << QSysInfo::productVersion();
- ;
#endif
LOG_INFO() << "number of logical cores =" << QThread::idealThreadCount();
LOG_INFO() << "locale =" << QLocale();
LOG_INFO() << "install dir =" << a.applicationDirPath();
+ switch (QQuickWindow::graphicsApi()) {
+ case QSGRendererInterface::Direct3D11:
+ LOG_INFO() << "graphics backend = Direct3D 11";
+ break;
+ case QSGRendererInterface::Direct3D12:
+ LOG_INFO() << "graphics backend = Direct3D 12";
+ break;
+ case QSGRendererInterface::Metal:
+ LOG_INFO() << "graphics backend = Metal";
+ break;
+ case QSGRendererInterface::OpenGL:
+ LOG_INFO() << "graphics backend = OpenGL";
+ break;
+ case QSGRendererInterface::Vulkan:
+ LOG_INFO() << "graphics backend = Vulkan";
+ break;
+ default:
+ LOG_INFO() << "graphics backend = " << QQuickWindow::graphicsApi();
+ break;
+ }
Settings.log();
// Expire old items from the qmlcache
@@ -462,9 +473,7 @@ int main(int argc, char **argv)
a.mainWindow = &MAIN;
if (!a.appDirArg.isEmpty())
a.mainWindow->hideSetDataDirectory();
-#if defined(Q_OS_WIN) || defined(Q_OS_MAC)
a.mainWindow->setProperty("windowOpacity", 0.0);
-#endif
a.mainWindow->show();
a.processEvents();
a.mainWindow->setFullScreen(a.isFullScreen);
@@ -484,12 +493,6 @@ int main(int argc, char **argv)
if (EXIT_RESTART == result || EXIT_RESET == result) {
LOG_DEBUG() << "restarting app";
::qunsetenv("QT_QUICK_CONTROLS_CONF"); // See MainWindow::changeTheme()
-#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
- ::qputenv("LIBGL_ALWAYS_SOFTWARE",
- Settings.drawMethod() == Qt::AA_UseSoftwareOpenGL && !Settings.playerGPU()
- ? "1"
- : "0");
-#endif
QProcess *restart = new QProcess;
QStringList args = a.arguments();
if (!args.isEmpty())
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index b93889ad47..52e71bd04a 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -47,6 +47,7 @@
#include "docks/recentdock.h"
#include "docks/subtitlesdock.h"
#include "docks/timelinedock.h"
+#include "hdrpreviewwindow.h"
#include "jobqueue.h"
#include "jobs/screencapturejob.h"
#include "models/audiolevelstask.h"
@@ -116,6 +117,7 @@
#include
#define SHOTCUT_THEME
+#define USE_SCREENS_FOR_EXTERNAL_MONITORING
static bool eventDebugCallback(void **data)
{
@@ -976,16 +978,6 @@ void MainWindow::connectVideoWidgetSignals()
videoWidget,
&Mlt::VideoWidget::initialize,
Qt::DirectConnection);
- connect(videoWidget->quickWindow(),
- &QQuickWindow::beforeRendering,
- videoWidget,
- &Mlt::VideoWidget::beforeRendering,
- Qt::DirectConnection);
- connect(videoWidget->quickWindow(),
- &QQuickWindow::beforeRenderPassRecording,
- videoWidget,
- &Mlt::VideoWidget::renderVideo,
- Qt::DirectConnection);
connect(videoWidget->quickWindow(),
&QQuickWindow::sceneGraphInitialized,
this,
@@ -1271,8 +1263,9 @@ void MainWindow::setupSettingsMenu()
int n = screens.size();
for (int i = 0; n > 1 && i < n; i++) {
QAction *action
- = new QAction(tr("Screen %1 (%2 x %3 @ %4 Hz)")
+ = new QAction(tr("Screen %1 %2 (%3x%4 @ %5Hz)")
.arg(i)
+ .arg(screens[i]->name())
.arg(screens[i]->size().width() * screens[i]->devicePixelRatio())
.arg(screens[i]->size().height() * screens[i]->devicePixelRatio())
.arg(screens[i]->refreshRate()),
@@ -1281,6 +1274,23 @@ void MainWindow::setupSettingsMenu()
action->setData(i);
m_externalGroup->addAction(action);
}
+
+ auto hdrAction = m_externalGroup->addAction(tr("Preview Window (HDR)"));
+ hdrAction->setCheckable(true);
+#ifdef Q_OS_MAC
+ hdrAction->setShortcut(QKeySequence(Qt::META | Qt::Key_QuoteLeft));
+#else
+ hdrAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_QuoteLeft));
+#endif
+ Actions.add("hdrPreviewAction", hdrAction, tr("Player"));
+ connect(hdrAction, &QAction::toggled, this, &MainWindow::onHdrPreviewToggled);
+ connect(hdrAction, &QAction::triggered, this, [this, hdrAction]() {
+ if (hdrAction->isChecked() && m_hdrPreviewWindow) {
+ m_hdrPreviewWindow->show();
+ m_hdrPreviewWindow->raise();
+ m_hdrPreviewWindow->requestActivate();
+ }
+ });
#endif
Mlt::Profile profile;
@@ -1298,10 +1308,10 @@ void MainWindow::setupSettingsMenu()
if (!m_decklinkGammaGroup) {
m_decklinkGammaGroup = new QActionGroup(this);
- action = new QAction(tr("SDR"), m_decklinkGammaGroup);
+ action = new QAction("SDR", m_decklinkGammaGroup);
action->setData(QVariant(0));
action->setCheckable(true);
- action = new QAction(tr("HLG HDR"), m_decklinkGammaGroup);
+ action = new QAction("HLG HDR", m_decklinkGammaGroup);
action->setData(QVariant(1));
action->setCheckable(true);
}
@@ -1530,33 +1540,24 @@ void MainWindow::setupSettingsMenu()
#endif
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
// Setup the display method actions.
- if (!Settings.playerGPU()) {
- group = new QActionGroup(this);
- delete ui->actionDrawingAutomatic;
- delete ui->actionDrawingDirectX;
- ui->actionDrawingOpenGL->setData(Qt::AA_UseDesktopOpenGL);
- group->addAction(ui->actionDrawingOpenGL);
- ui->actionDrawingSoftware->setData(Qt::AA_UseSoftwareOpenGL);
- group->addAction(ui->actionDrawingSoftware);
- connect(group,
- SIGNAL(triggered(QAction *)),
- this,
- SLOT(onDrawingMethodTriggered(QAction *)));
- switch (Settings.drawMethod()) {
- case Qt::AA_UseDesktopOpenGL:
- ui->actionDrawingOpenGL->setChecked(true);
- break;
- case Qt::AA_UseSoftwareOpenGL:
- ui->actionDrawingSoftware->setChecked(true);
- break;
- default:
- ui->actionDrawingOpenGL->setChecked(true);
- break;
- }
- } else {
- // GPU mode only works with OpenGL.
- delete ui->menuDrawingMethod;
- ui->menuDrawingMethod = 0;
+ group = new QActionGroup(this);
+ delete ui->actionDrawingAutomatic;
+ delete ui->actionDrawingDirectX;
+ ui->actionDrawingOpenGL->setData(Qt::AA_UseDesktopOpenGL);
+ group->addAction(ui->actionDrawingOpenGL);
+ ui->actionDrawingVulkan->setData(QSGRendererInterface::Vulkan);
+ group->addAction(ui->actionDrawingVulkan);
+ connect(group, SIGNAL(triggered(QAction *)), this, SLOT(onDrawingMethodTriggered(QAction *)));
+ switch (Settings.drawMethod()) {
+ case Qt::AA_UseDesktopOpenGL:
+ ui->actionDrawingOpenGL->setChecked(true);
+ break;
+ case QSGRendererInterface::Vulkan:
+ ui->actionDrawingVulkan->setChecked(true);
+ break;
+ default:
+ ui->actionDrawingOpenGL->setChecked(true);
+ break;
}
#else
delete ui->menuDrawingMethod;
@@ -2583,6 +2584,12 @@ void MainWindow::readPlayerSettings()
}
}
+ if (Settings.playerHdrPreview()) {
+ auto hdr = Actions["hdrPreviewAction"];
+ if (hdr)
+ hdr->setChecked(true);
+ }
+
QString profile = Settings.playerProfile();
// Automatic not permitted for SDI/HDMI
if (isExternalPeripheral && profile.isEmpty())
@@ -2862,6 +2869,12 @@ void MainWindow::writeSettings()
#endif
Settings.setWindowGeometry(saveGeometry());
Settings.setWindowState(saveState());
+ if (m_hdrPreviewWindow) {
+ Settings.setPlayerHdrPreviewFullScreen(m_hdrPreviewWindow->windowStates()
+ & Qt::WindowFullScreen);
+ if (!(m_hdrPreviewWindow->windowStates() & Qt::WindowFullScreen))
+ Settings.setPlayerHdrPreviewGeometry(m_hdrPreviewWindow->geometry());
+ }
Settings.sync();
}
@@ -4573,9 +4586,72 @@ void MainWindow::on_actionJack_triggered(bool checked)
}
}
+void MainWindow::onHdrPreviewToggled(bool checked)
+{
+ if (checked) {
+ if (!m_hdrPreviewWindow) {
+ m_hdrPreviewWindow = new HdrPreviewWindow();
+ auto videoWidget = static_cast(&MLT);
+ connect(videoWidget,
+ &Mlt::VideoWidget::videoFrameReady,
+ m_hdrPreviewWindow,
+ &HdrPreviewWindow::pushFrame);
+ connect(videoWidget,
+ &Mlt::VideoWidget::hdrTransferChanged,
+ m_hdrPreviewWindow,
+ &HdrPreviewWindow::setHdrTransfer);
+ m_hdrPreviewWindow->setHdrTransfer(hdrTransferFromTrc(MLT.colorTrc()));
+ auto *win = m_hdrPreviewWindow;
+ connect(m_player, &Player::played, win, [win](double) { win->setPlaying(true); });
+ connect(m_player, &Player::paused, win, [win](int) { win->setPlaying(false); });
+ connect(m_player, &Player::stopped, win, [win]() { win->setPlaying(false); });
+ win->setPlaying(!MLT.isPaused());
+ connect(m_hdrPreviewWindow, &QWindow::visibleChanged, this, [this](bool visible) {
+ if (!visible && m_hdrPreviewWindow) {
+ Settings.setPlayerHdrPreviewFullScreen(m_hdrPreviewWindow->windowStates()
+ & Qt::WindowFullScreen);
+ Settings.setPlayerHdrPreviewGeometry(m_hdrPreviewWindow->geometry());
+ Actions["hdrPreviewAction"]->setChecked(false);
+ }
+ });
+ auto savedGeometry = Settings.playerHdrPreviewGeometry();
+ if (savedGeometry.isValid())
+ m_hdrPreviewWindow->restoreGeometry(savedGeometry);
+ }
+ m_hdrPreviewWindow->show();
+ if (Settings.playerHdrPreviewFullScreen())
+ m_hdrPreviewWindow->showFullScreen();
+ } else {
+ if (m_hdrPreviewWindow) {
+ Settings.setPlayerHdrPreviewFullScreen(m_hdrPreviewWindow->windowStates()
+ & Qt::WindowFullScreen);
+ Settings.setPlayerHdrPreviewGeometry(m_hdrPreviewWindow->geometry());
+ m_hdrPreviewWindow->deleteLater();
+ m_hdrPreviewWindow = nullptr;
+ }
+ }
+ Settings.setPlayerHdrPreview(checked);
+ if (checked && Settings.playerGPU() && MLT.producer()) {
+ if (confirmRestartExternalMonitor()) {
+ m_exitCode = EXIT_RESTART;
+ QApplication::closeAllWindows();
+ }
+ }
+}
+
void MainWindow::onExternalTriggered(QAction *action)
{
LOG_DEBUG() << action->data().toString();
+ // The HDR preview action lives in m_externalGroup so that it appears in
+ // the External menu and participates in the mutual-exclusion toggle, but
+ // it has its own toggled handler — skip the normal external-monitor path.
+ // Also skip when switching from HDR preview back to None since the HDR
+ // preview doesn't use an external MLT consumer.
+ if (action == Actions["hdrPreviewAction"]
+ || (action->data().toString().isEmpty() && Settings.playerExternal().isEmpty())) {
+ Settings.setPlayerExternal(action->data().toString());
+ return;
+ }
bool isExternal = !action->data().toString().isEmpty();
QString profile = Settings.playerProfile();
if (Settings.playerGPU() && MLT.producer() && Settings.playerExternal() != action->data()) {
diff --git a/src/mainwindow.h b/src/mainwindow.h
index fac0c7bf15..d637e4c35d 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -55,6 +55,7 @@ class MarkersDock;
class NotesDock;
class SubtitlesDock;
class ScreenCapture;
+class HdrPreviewWindow;
class MainWindow : public QMainWindow
{
@@ -85,6 +86,7 @@ class MainWindow : public QMainWindow
QString fileName() const { return m_currentFile; }
bool isSourceClipMyProject(QString resource = MLT.resource(), bool withDialog = true);
bool keyframesDockIsVisible() const;
+ Player *player() const { return m_player; }
void keyPressEvent(QKeyEvent *);
void keyReleaseEvent(QKeyEvent *);
@@ -224,6 +226,7 @@ class MainWindow : public QMainWindow
std::unique_ptr m_producerWidget;
FilesDock *m_filesDock;
ScreenCapture *m_screenCapture;
+ HdrPreviewWindow *m_hdrPreviewWindow{nullptr};
public slots:
bool isCompatibleWithProcessingMode(MltXmlChecker &checker, QString &fileName, bool &converted);
@@ -308,6 +311,7 @@ private slots:
void on_actionBicubic_triggered(bool checked);
void on_actionHyper_triggered(bool checked);
void on_actionJack_triggered(bool checked);
+ void onHdrPreviewToggled(bool checked);
void onExternalTriggered(QAction *);
void onDecklinkGammaTriggered(QAction *);
void onKeyerTriggered(QAction *);
diff --git a/src/mainwindow.ui b/src/mainwindow.ui
index bb4c67c94a..c6a72eb802 100644
--- a/src/mainwindow.ui
+++ b/src/mainwindow.ui
@@ -121,6 +121,14 @@
&Player
+
+
+
-
@@ -286,7 +288,6 @@
-
-
-
- true
-
-
- Software (Mesa)
-
-
true
@@ -1560,6 +1553,20 @@
Shift+F1
+
+
+ true
+
+
+ Vulkan
+
+
+ Vulkan
+
+
+ Vulkan
+
+
diff --git a/src/mltcontroller.cpp b/src/mltcontroller.cpp
index 66953e2647..ed097b13ca 100644
--- a/src/mltcontroller.cpp
+++ b/src/mltcontroller.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2011-2025 Meltytech, LLC
+ * Copyright (c) 2011-2026 Meltytech, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -25,14 +25,7 @@
#include "settings.h"
#include "shotcut_mlt_properties.h"
#include "util.h"
-#if defined(Q_OS_WIN)
-#include "widgets/d3dvideowidget.h"
-#include "widgets/openglvideowidget.h"
-#elif defined(Q_OS_MAC)
-#include "widgets/metalvideowidget.h"
-#else
-#include "widgets/openglvideowidget.h"
-#endif
+#include "videowidget.h"
#include
#include
@@ -79,16 +72,7 @@ Controller &Controller::singleton(QObject *parent)
if (!instance) {
qRegisterMetaType("Mlt::Frame");
qRegisterMetaType("SharedFrame");
-#if defined(Q_OS_WIN)
- if (QSGRendererInterface::Direct3D11 == QQuickWindow::graphicsApi())
- instance = new D3DVideoWidget(parent);
- else
- instance = new OpenGLVideoWidget(parent);
-#elif defined(Q_OS_MAC)
- instance = new MetalVideoWidget(parent);
-#else
- instance = new OpenGLVideoWidget(parent);
-#endif
+ instance = new VideoWidget(parent);
}
return *instance;
}
@@ -610,7 +594,15 @@ void Controller::setProfile(const QString &profile_name)
m_profile.set_display_aspect(tmp.display_aspect_num(), tmp.display_aspect_den());
m_profile.set_width(Util::coerceMultiple(tmp.width()));
m_profile.set_explicit(true);
+ // Load color_trc from the profile file (custom profiles store it as an extra property).
+ // For built-in profile names (not file paths), load() will find no such file and
+ // color_trc will remain empty, which is correct for SDR built-in profiles.
+ Mlt::Properties profileProps;
+ profileProps.load(profile_name.toUtf8().constData());
+ const char *trc = profileProps.get("color_trc");
+ m_colorTrc = (trc && *trc) ? QString::fromLatin1(trc) : QString();
} else {
+ m_colorTrc.clear();
m_profile.set_explicit(false);
if (isClosedClip()) {
// Use a default profile with the dummy hidden color producer.
@@ -647,6 +639,42 @@ void Controller::setProcessingMode(ShotcutSettings::ProcessingMode mode)
}
}
+QString Controller::colorTrc() const
+{
+ if (!m_colorTrc.isEmpty())
+ return m_colorTrc;
+ // Automatic mode: read the numeric transfer characteristics from the producer's selected
+ // video stream metadata and map to the string values VideoWidget supports.
+ // Numeric values are FFmpeg's AVColorTransferCharacteristic enum (same as H.273):
+ // 16 = SMPTE ST2084 (PQ), 18 = ARIB B67 (HLG).
+ if (m_producer && m_producer->is_valid()) {
+ const int n = m_producer->get_int("meta.media.nb_streams");
+ const int videoStreamIndex = m_producer->get_int(kVideoIndexProperty);
+ int videoCount = 0;
+ for (int i = 0; i < n; ++i) {
+ QString typeKey = QStringLiteral("meta.media.%1.stream.type").arg(i);
+ if (!::qstrcmp(m_producer->get(typeKey.toLatin1().constData()), "video")) {
+ if (videoCount == videoStreamIndex) {
+ QString trcKey = QStringLiteral("meta.media.%1.codec.color_trc").arg(i);
+ const int trc = m_producer->get_int(trcKey.toLatin1().constData());
+ if (trc == 16)
+ return QStringLiteral("smpte2084"); // PQ
+ if (trc == 18)
+ return QStringLiteral("arib-std-b67"); // HLG
+ return QString(); // SDR or unsupported TRC
+ }
+ ++videoCount;
+ }
+ }
+ }
+ return QString();
+}
+
+void Controller::setColorTrc(const QString &trc)
+{
+ m_colorTrc = trc;
+}
+
QString Controller::resource() const
{
QString resource;
diff --git a/src/mltcontroller.h b/src/mltcontroller.h
index 73ec361327..de46f13994 100644
--- a/src/mltcontroller.h
+++ b/src/mltcontroller.h
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2011-2025 Meltytech, LLC
+ * Copyright (c) 2011-2026 Meltytech, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -155,6 +155,8 @@ class Controller
int audioChannels() const { return m_audioChannels; }
ShotcutSettings::ProcessingMode processingMode() const { return m_processingMode; }
+ QString colorTrc() const;
+ void setColorTrc(const QString &trc);
Mlt::Repository *repository() const { return m_repo; }
Mlt::Profile &profile() { return m_profile; }
Mlt::Profile &previewProfile() { return m_previewProfile; }
@@ -199,6 +201,7 @@ class Controller
Mlt::Profile m_previewProfile;
int m_audioChannels{2};
ShotcutSettings::ProcessingMode m_processingMode{ShotcutSettings::Native8Cpu};
+ QString m_colorTrc;
QScopedPointer m_jackFilter;
QString m_url;
double m_volume{1.0};
diff --git a/src/qml/modules/Shotcut/Controls/VuiBase.qml b/src/qml/modules/Shotcut/Controls/VuiBase.qml
index 192d720e19..299795634b 100644
--- a/src/qml/modules/Shotcut/Controls/VuiBase.qml
+++ b/src/qml/modules/Shotcut/Controls/VuiBase.qml
@@ -16,8 +16,25 @@
*/
import QtQuick
+import QtMultimedia
DropArea {
+ clip: true
+
+ property real _zoom: (video.zoom > 0) ? video.zoom : 1
+
+ Component.onCompleted: video.setVideoSink(videoOutput.videoSink)
+
+ VideoOutput {
+ id: videoOutput
+
+ width: video.rect.width * _zoom
+ height: video.rect.height * _zoom
+ x: video.rect.x + (video.rect.width - width) / 2 - video.offset.x
+ y: video.rect.y + (video.rect.height - height) / 2 - video.offset.y
+ fillMode: VideoOutput.Stretch
+ }
+
Canvas {
id: grid
diff --git a/src/qml/views/HdrPreview.qml b/src/qml/views/HdrPreview.qml
new file mode 100644
index 0000000000..1a7158942c
--- /dev/null
+++ b/src/qml/views/HdrPreview.qml
@@ -0,0 +1,667 @@
+/*
+ * Copyright (c) 2026 Meltytech, LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import QtQuick
+import QtQuick.Shapes
+import QtMultimedia
+
+Rectangle {
+ color: "black"
+
+ component ControlButton: Rectangle {
+ id: _btn
+
+ signal clicked()
+
+ property bool active: false
+
+ width: 44
+ height: 44
+ radius: 8
+ color: _btnMouse.containsPress ? Qt.rgba(1, 1, 1, 0.3) : _btnMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.15) : active ? Qt.rgba(1, 1, 1, 0.12) : "transparent"
+
+ MouseArea {
+ id: _btnMouse
+
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: _btn.clicked()
+ }
+ }
+
+ Component.onCompleted: hdrWindow.setVideoSink(videoOutput.videoSink)
+
+ VideoOutput {
+ id: videoOutput
+
+ anchors.fill: parent
+ fillMode: VideoOutput.PreserveAspectFit
+ layer.enabled: true
+ layer.format: ShaderEffectSource.RGBA16F
+ layer.effect: ShaderEffect {
+ property real gain: hdrWindow.hdrGain
+
+ fragmentShader: "hdr_gain.frag.qsb"
+ }
+ }
+
+ // Auto-hide overlay
+ Item {
+ id: overlay
+
+ property bool _visible: true
+
+ anchors.fill: parent
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ cursorShape: overlay._visible ? Qt.ArrowCursor : Qt.BlankCursor
+ onPositionChanged: {
+ overlay._visible = true;
+ hideTimer.restart();
+ }
+ onEntered: {
+ overlay._visible = true;
+ hideTimer.restart();
+ }
+ }
+
+ Timer {
+ id: hideTimer
+
+ interval: 3000
+ running: true
+ onTriggered: overlay._visible = false
+ }
+
+ // Floating rounded control bar
+ Rectangle {
+ id: controlBar
+
+ anchors {
+ horizontalCenter: parent.horizontalCenter
+ bottom: parent.bottom
+ bottomMargin: parent.height * 0.20
+ }
+ width: Math.max(buttonRow.implicitWidth + 24, 380)
+ height: 88
+ radius: 12
+ visible: overlay._visible
+ color: Qt.rgba(0, 0, 0, 0.72)
+
+ // Absorb clicks so they don't pass through to the video
+ MouseArea {
+ anchors.fill: parent
+ }
+
+ // Transport buttons
+ Row {
+ id: buttonRow
+
+ anchors {
+ top: parent.top
+ topMargin: 8
+ horizontalCenter: parent.horizontalCenter
+ }
+ spacing: 4
+
+ // Rewind <<
+ ControlButton {
+ onClicked: hdrWindow.triggerRewind()
+
+ Shape {
+ anchors.centerIn: parent
+ width: 22
+ height: 22
+
+ // Left chevron
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ startX: 11; startY: 2
+ PathLine { x: 3; y: 11 }
+ PathLine { x: 11; y: 20 }
+ PathLine { x: 14; y: 20 }
+ PathLine { x: 6; y: 11 }
+ PathLine { x: 14; y: 2 }
+ }
+
+ // Right chevron
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ startX: 18; startY: 2
+ PathLine { x: 10; y: 11 }
+ PathLine { x: 18; y: 20 }
+ PathLine { x: 21; y: 20 }
+ PathLine { x: 13; y: 11 }
+ PathLine { x: 21; y: 2 }
+ }
+ }
+ }
+
+ // Play / Pause
+ ControlButton {
+ onClicked: hdrWindow.triggerPlayPause()
+
+ // Play triangle (visible when paused)
+ Shape {
+ anchors.centerIn: parent
+ visible: !hdrWindow.playing
+ width: 22
+ height: 22
+
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ startX: 5; startY: 2
+ PathLine { x: 19; y: 11 }
+ PathLine { x: 5; y: 20 }
+ }
+ }
+
+ // Pause bars (visible when playing)
+ Shape {
+ anchors.centerIn: parent
+ visible: hdrWindow.playing
+ width: 22
+ height: 22
+
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ startX: 4; startY: 3
+ PathLine { x: 8; y: 3 }
+ PathLine { x: 8; y: 19 }
+ PathLine { x: 4; y: 19 }
+ }
+
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ startX: 12; startY: 3
+ PathLine { x: 16; y: 3 }
+ PathLine { x: 16; y: 19 }
+ PathLine { x: 12; y: 19 }
+ }
+ }
+ }
+
+ // Fast Forward >>
+ ControlButton {
+ onClicked: hdrWindow.triggerFastForward()
+
+ Shape {
+ anchors.centerIn: parent
+ width: 22
+ height: 22
+
+ // Left chevron
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ startX: 1; startY: 2
+ PathLine { x: 9; y: 11 }
+ PathLine { x: 1; y: 20 }
+ PathLine { x: 4; y: 20 }
+ PathLine { x: 12; y: 11 }
+ PathLine { x: 4; y: 2 }
+ }
+
+ // Right chevron
+ ShapePath {
+ fillColor: "white"
+ strokeColor: "transparent"
+ startX: 8; startY: 2
+ PathLine { x: 16; y: 11 }
+ PathLine { x: 8; y: 20 }
+ PathLine { x: 11; y: 20 }
+ PathLine { x: 19; y: 11 }
+ PathLine { x: 11; y: 2 }
+ }
+ }
+ }
+
+ // Fullscreen toggle
+ ControlButton {
+ onClicked: hdrWindow.toggleFullScreen()
+
+ Shape {
+ id: fullscreenIcon
+
+ anchors.centerIn: parent
+ width: 22
+ height: 22
+
+ // Top-left corner — expand outward
+ ShapePath {
+ strokeColor: "white"
+ strokeWidth: 2
+ fillColor: "transparent"
+ capStyle: ShapePath.RoundCap
+ joinStyle: ShapePath.RoundJoin
+ startX: hdrWindow.fullScreen ? 8 : 1; startY: hdrWindow.fullScreen ? 2 : 8
+ PathLine { x: hdrWindow.fullScreen ? 2 : 1; y: hdrWindow.fullScreen ? 2 : 1 }
+ PathLine { x: hdrWindow.fullScreen ? 2 : 8; y: hdrWindow.fullScreen ? 8 : 1 }
+ }
+
+ // Top-right corner
+ ShapePath {
+ strokeColor: "white"
+ strokeWidth: 2
+ fillColor: "transparent"
+ capStyle: ShapePath.RoundCap
+ joinStyle: ShapePath.RoundJoin
+ startX: hdrWindow.fullScreen ? 14 : 21; startY: hdrWindow.fullScreen ? 2 : 8
+ PathLine { x: hdrWindow.fullScreen ? 20 : 21; y: hdrWindow.fullScreen ? 2 : 1 }
+ PathLine { x: hdrWindow.fullScreen ? 20 : 14; y: hdrWindow.fullScreen ? 8 : 1 }
+ }
+
+ // Bottom-left corner
+ ShapePath {
+ strokeColor: "white"
+ strokeWidth: 2
+ fillColor: "transparent"
+ capStyle: ShapePath.RoundCap
+ joinStyle: ShapePath.RoundJoin
+ startX: hdrWindow.fullScreen ? 8 : 1; startY: hdrWindow.fullScreen ? 20 : 14
+ PathLine { x: hdrWindow.fullScreen ? 2 : 1; y: hdrWindow.fullScreen ? 20 : 21 }
+ PathLine { x: hdrWindow.fullScreen ? 2 : 8; y: hdrWindow.fullScreen ? 14 : 21 }
+ }
+
+ // Bottom-right corner
+ ShapePath {
+ strokeColor: "white"
+ strokeWidth: 2
+ fillColor: "transparent"
+ capStyle: ShapePath.RoundCap
+ joinStyle: ShapePath.RoundJoin
+ startX: hdrWindow.fullScreen ? 14 : 21; startY: hdrWindow.fullScreen ? 20 : 14
+ PathLine { x: hdrWindow.fullScreen ? 20 : 21; y: hdrWindow.fullScreen ? 20 : 21 }
+ PathLine { x: hdrWindow.fullScreen ? 20 : 14; y: hdrWindow.fullScreen ? 14 : 21 }
+ }
+ }
+ }
+
+ // Settings gear
+ ControlButton {
+ id: settingsButton
+
+ visible: hdrWindow.hdrTransferMode !== 0
+ active: settingsDialog.visible
+ onClicked: settingsDialog.visible = !settingsDialog.visible
+
+ onVisibleChanged: {
+ if (!visible)
+ settingsDialog.visible = false;
+ }
+
+ // ⚙ gear glyph (U+2699) — present in every desktop font
+ Text {
+ anchors.centerIn: parent
+ text: "\u2699"
+ color: "white"
+ font.pixelSize: 20
+ }
+ }
+ }
+
+ // Scrub bar row
+ Item {
+ id: scrubRow
+
+ anchors {
+ top: buttonRow.bottom
+ topMargin: 6
+ left: parent.left
+ right: parent.right
+ leftMargin: 12
+ rightMargin: 12
+ }
+ height: 20
+
+ Text {
+ id: posLabel
+
+ anchors {
+ left: parent.left
+ verticalCenter: parent.verticalCenter
+ }
+ text: hdrWindow.positionText
+ color: Qt.rgba(1, 1, 1, 0.85)
+ font.pixelSize: 11
+ }
+
+ Text {
+ id: durLabel
+
+ anchors {
+ right: parent.right
+ verticalCenter: parent.verticalCenter
+ }
+ text: hdrWindow.durationText
+ color: Qt.rgba(1, 1, 1, 0.85)
+ font.pixelSize: 11
+ }
+
+ Item {
+ id: scrubArea
+
+ anchors {
+ left: posLabel.right
+ right: durLabel.left
+ leftMargin: 8
+ rightMargin: 8
+ verticalCenter: parent.verticalCenter
+ }
+ height: parent.height
+
+ // Track background
+ Rectangle {
+ width: parent.width
+ height: 3
+ anchors.verticalCenter: parent.verticalCenter
+ radius: 1.5
+ color: Qt.rgba(1, 1, 1, 0.25)
+
+ // Filled portion
+ Rectangle {
+ width: hdrWindow.videoDuration > 0 ? parent.width * hdrWindow.videoPosition / hdrWindow.videoDuration : 0
+ height: parent.height
+ radius: 1.5
+ color: "white"
+ }
+ }
+
+ // Scrub handle
+ Rectangle {
+ x: hdrWindow.videoDuration > 0 ? (scrubArea.width - width) * hdrWindow.videoPosition / hdrWindow.videoDuration : 0
+ anchors.verticalCenter: parent.verticalCenter
+ width: 10
+ height: 10
+ radius: 5
+ color: "white"
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+
+ function seek(mx) {
+ if (hdrWindow.videoDuration > 0) {
+ const frame = Math.round(mx / width * hdrWindow.videoDuration);
+ hdrWindow.seekToFrame(Math.max(0, Math.min(frame, hdrWindow.videoDuration - 1)));
+ }
+ }
+
+ onPressed: mouse => seek(mouseX)
+ onPositionChanged: mouse => {
+ if (pressed)
+ seek(mouseX);
+ }
+ }
+ }
+ }
+ }
+
+ // HDR Display Settings dialog — centered in the window
+ Rectangle {
+ id: settingsDialog
+
+ anchors.centerIn: parent
+ width: 300
+ height: settingsColumn.implicitHeight + 28
+ radius: 12
+ color: Qt.rgba(0, 0, 0, 0.88)
+ border.color: Qt.rgba(1, 1, 1, 0.12)
+ border.width: 1
+ visible: false
+ z: 10
+
+ // Absorb mouse events and restart the hide timer so the overlay
+ // doesn't vanish while the user is interacting with the dialog.
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPositionChanged: {
+ overlay._visible = true;
+ hideTimer.restart();
+ }
+ }
+
+ Column {
+ id: settingsColumn
+
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ topMargin: 14
+ leftMargin: 14
+ rightMargin: 14
+ }
+ spacing: 10
+
+ // ── Header ──────────────────────────────────────────────
+ Item {
+ width: parent.width
+ height: 20
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: qsTr("HDR Display Settings")
+ color: "white"
+ font.pixelSize: 13
+ font.bold: true
+ }
+
+ ControlButton {
+ anchors {
+ right: parent.right
+ verticalCenter: parent.verticalCenter
+ }
+ width: 20
+ height: 20
+ radius: 10
+ onClicked: settingsDialog.visible = false
+
+ Text {
+ anchors.centerIn: parent
+ text: "\u00D7"
+ color: Qt.rgba(1, 1, 1, 0.7)
+ font.pixelSize: 14
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(1, 1, 1, 0.08)
+ }
+
+ // ── Display brightness ──────────────────────────────────
+ Column {
+ width: parent.width
+ spacing: 6
+
+ Text {
+ text: qsTr("Display brightness")
+ color: Qt.rgba(1, 1, 1, 0.6)
+ font.pixelSize: 11
+ }
+
+ Row {
+ spacing: 4
+
+ Repeater {
+ model: [{label: qsTr("Auto"), nits: 0}, {label: "400", nits: 400}, {label: "600", nits: 600}, {label: "1000", nits: 1000}, {label: "1600", nits: 1600}]
+
+ delegate: Rectangle {
+ readonly property bool _sel: hdrWindow.displayPeakNits === modelData.nits
+
+ width: _lbl.implicitWidth + 14
+ height: 24
+ radius: 4
+ color: _sel ? Qt.rgba(1, 1, 1, 0.28) : Qt.rgba(1, 1, 1, 0.1)
+ border.color: _sel ? Qt.rgba(1, 1, 1, 0.55) : Qt.rgba(1, 1, 1, 0.15)
+ border.width: 1
+
+ Text {
+ id: _lbl
+
+ anchors.centerIn: parent
+ text: modelData.label
+ color: "white"
+ font.pixelSize: 11
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: hdrWindow.displayPeakNits = modelData.nits
+ }
+ }
+ }
+ }
+ }
+
+ // ── Content brightness (PQ only) ────────────────────────
+ Column {
+ width: parent.width
+ spacing: 6
+ visible: hdrWindow.hdrTransferMode === 2
+
+ Text {
+ text: qsTr("Content brightness")
+ color: Qt.rgba(1, 1, 1, 0.6)
+ font.pixelSize: 11
+ }
+
+ Row {
+ spacing: 4
+
+ Repeater {
+ model: [{label: qsTr("Auto"), nits: 0}, {label: "400", nits: 400}, {label: "1000", nits: 1000}, {label: "4000", nits: 4000}, {label: "10000", nits: 10000}]
+
+ delegate: Rectangle {
+ readonly property bool _sel: hdrWindow.contentPeakNits === modelData.nits
+
+ width: _lbl2.implicitWidth + 14
+ height: 24
+ radius: 4
+ color: _sel ? Qt.rgba(1, 1, 1, 0.28) : Qt.rgba(1, 1, 1, 0.1)
+ border.color: _sel ? Qt.rgba(1, 1, 1, 0.55) : Qt.rgba(1, 1, 1, 0.15)
+ border.width: 1
+
+ Text {
+ id: _lbl2
+
+ anchors.centerIn: parent
+ text: modelData.label
+ color: "white"
+ font.pixelSize: 11
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: hdrWindow.contentPeakNits = modelData.nits
+ }
+ }
+ }
+ }
+ }
+
+ // ── Tone mapping (PQ only) ──────────────────────────────
+ Item {
+ width: parent.width
+ height: 24
+ visible: hdrWindow.hdrTransferMode === 2
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: qsTr("Tone mapping")
+ color: Qt.rgba(1, 1, 1, 0.6)
+ font.pixelSize: 11
+ }
+
+ Row {
+ anchors {
+ right: parent.right
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 4
+
+ Rectangle {
+ width: _onLbl.implicitWidth + 14
+ height: 24
+ radius: 4
+ color: hdrWindow.toneMapping ? Qt.rgba(1, 1, 1, 0.28) : Qt.rgba(1, 1, 1, 0.1)
+ border.color: hdrWindow.toneMapping ? Qt.rgba(1, 1, 1, 0.55) : Qt.rgba(1, 1, 1, 0.15)
+ border.width: 1
+
+ Text {
+ id: _onLbl
+
+ anchors.centerIn: parent
+ text: qsTr("On")
+ color: "white"
+ font.pixelSize: 11
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: hdrWindow.toneMapping = true
+ }
+ }
+
+ Rectangle {
+ width: _offLbl.implicitWidth + 14
+ height: 24
+ radius: 4
+ color: !hdrWindow.toneMapping ? Qt.rgba(1, 1, 1, 0.28) : Qt.rgba(1, 1, 1, 0.1)
+ border.color: !hdrWindow.toneMapping ? Qt.rgba(1, 1, 1, 0.55) : Qt.rgba(1, 1, 1, 0.15)
+ border.width: 1
+
+ Text {
+ id: _offLbl
+
+ anchors.centerIn: parent
+ text: qsTr("Off")
+ color: "white"
+ font.pixelSize: 11
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: hdrWindow.toneMapping = false
+ }
+ }
+ }
+ }
+
+ // Bottom padding
+ Item {
+ width: 1
+ height: 2
+ }
+ }
+ }
+ }
+}
+
diff --git a/src/qml/views/hdr_gain.frag b/src/qml/views/hdr_gain.frag
new file mode 100644
index 0000000000..a12992a4e5
--- /dev/null
+++ b/src/qml/views/hdr_gain.frag
@@ -0,0 +1,17 @@
+#version 440
+
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float gain;
+};
+
+layout(binding = 1) uniform sampler2D source;
+
+void main() {
+ vec4 c = texture(source, qt_TexCoord0);
+ fragColor = vec4(c.rgb * gain, c.a) * qt_Opacity;
+}
diff --git a/src/qml/views/hdr_gain.frag.qsb b/src/qml/views/hdr_gain.frag.qsb
new file mode 100644
index 0000000000..f186842c3c
Binary files /dev/null and b/src/qml/views/hdr_gain.frag.qsb differ
diff --git a/src/settings.cpp b/src/settings.cpp
index 7321de5443..fbc653a9c5 100644
--- a/src/settings.cpp
+++ b/src/settings.cpp
@@ -845,6 +845,66 @@ void ShotcutSettings::setPlayerPauseAfterSeek(bool b)
settings.setValue("player/pauseAfterSeek", b);
}
+bool ShotcutSettings::playerHdrPreview() const
+{
+ return settings.value("player/hdrPreview", false).toBool();
+}
+
+void ShotcutSettings::setPlayerHdrPreview(bool b)
+{
+ settings.setValue("player/hdrPreview", b);
+}
+
+QRect ShotcutSettings::playerHdrPreviewGeometry() const
+{
+ return settings.value("player/hdrPreviewGeometry").toRect();
+}
+
+void ShotcutSettings::setPlayerHdrPreviewGeometry(const QRect &r)
+{
+ settings.setValue("player/hdrPreviewGeometry", r);
+}
+
+bool ShotcutSettings::playerHdrPreviewFullScreen() const
+{
+ return settings.value("player/hdrPreviewFullScreen", false).toBool();
+}
+
+void ShotcutSettings::setPlayerHdrPreviewFullScreen(bool b)
+{
+ settings.setValue("player/hdrPreviewFullScreen", b);
+}
+
+int ShotcutSettings::playerHdrDisplayPeakNits() const
+{
+ return settings.value("player/hdrDisplayPeakNits", 0).toInt();
+}
+
+void ShotcutSettings::setPlayerHdrDisplayPeakNits(int nits)
+{
+ settings.setValue("player/hdrDisplayPeakNits", nits);
+}
+
+int ShotcutSettings::playerHdrContentPeakNits() const
+{
+ return settings.value("player/hdrContentPeakNits", 0).toInt();
+}
+
+void ShotcutSettings::setPlayerHdrContentPeakNits(int nits)
+{
+ settings.setValue("player/hdrContentPeakNits", nits);
+}
+
+bool ShotcutSettings::playerHdrToneMapping() const
+{
+ return settings.value("player/hdrToneMapping", true).toBool();
+}
+
+void ShotcutSettings::setPlayerHdrToneMapping(bool b)
+{
+ settings.setValue("player/hdrToneMapping", b);
+}
+
QString ShotcutSettings::playlistThumbnails() const
{
return settings.value("playlist/thumbnails", "small").toString();
diff --git a/src/settings.h b/src/settings.h
index 19d8eedcfb..2019159e50 100644
--- a/src/settings.h
+++ b/src/settings.h
@@ -22,6 +22,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -192,6 +193,18 @@ class ShotcutSettings : public QObject
void setPlayerAudioDriver(const QString &s);
bool playerPauseAfterSeek() const;
void setPlayerPauseAfterSeek(bool);
+ bool playerHdrPreview() const;
+ void setPlayerHdrPreview(bool);
+ QRect playerHdrPreviewGeometry() const;
+ void setPlayerHdrPreviewGeometry(const QRect &);
+ bool playerHdrPreviewFullScreen() const;
+ void setPlayerHdrPreviewFullScreen(bool);
+ int playerHdrDisplayPeakNits() const;
+ void setPlayerHdrDisplayPeakNits(int);
+ int playerHdrContentPeakNits() const;
+ void setPlayerHdrContentPeakNits(int);
+ bool playerHdrToneMapping() const;
+ void setPlayerHdrToneMapping(bool);
// playlist
QString playlistThumbnails() const;
diff --git a/src/util.cpp b/src/util.cpp
index 562e4dc1b6..c619d065bf 100644
--- a/src/util.cpp
+++ b/src/util.cpp
@@ -59,6 +59,14 @@
#include
#endif
+#ifdef _MSC_VER
+#include
+#endif
+
+#if defined(__x86_64__) || defined(_M_AMD64)
+#include
+#endif
+
#ifdef Q_OS_MAC
static constexpr unsigned int kLowMemoryThresholdPercent = 10U;
#else
@@ -1260,3 +1268,30 @@ bool Util::openUrl(const QUrl &url)
#endif
return success;
}
+
+bool Util::cpuHasAVX2()
+{
+ static const bool result = []() -> bool {
+#if (defined(__GNUC__) || defined(__clang__)) && (defined(__x86_64__) || defined(_M_AMD64))
+ return __builtin_cpu_supports("avx2");
+#elif defined(_MSC_VER) && (defined(_M_X64) || defined(_M_AMD64))
+ int info[4];
+ __cpuid(info, 1);
+ // Check OSXSAVE and AVX bits in ECX
+ if (!((info[2] >> 27) & 1) || !((info[2] >> 28) & 1))
+ return false;
+ // Check OS saves/restores YMM registers (XCR0[2:1] == 0b11)
+ if ((_xgetbv(0) & 0x6) != 0x6)
+ return false;
+ // Check AVX2 via structured extended feature leaf 7, EBX bit 5
+ __cpuid(info, 0);
+ if (info[0] < 7)
+ return false;
+ __cpuidex(info, 7, 0);
+ return (info[1] >> 5) & 1;
+#else
+ return false;
+#endif
+ }();
+ return result;
+}
diff --git a/src/util.h b/src/util.h
index 5e3d5c2b6b..1aabf509b4 100644
--- a/src/util.h
+++ b/src/util.h
@@ -109,6 +109,7 @@ class Util
static bool isChromiumAvailable();
static bool startDetached(const QString &program, const QStringList &arguments);
static bool openUrl(const QUrl &url);
+ static bool cpuHasAVX2();
};
#endif // UTIL_H
diff --git a/src/videowidget.cpp b/src/videowidget.cpp
index 1643dcb2ce..89b31bf579 100644
--- a/src/videowidget.cpp
+++ b/src/videowidget.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2011-2025 Meltytech, LLC
+ * Copyright (c) 2011-2026 Meltytech, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -23,8 +23,10 @@
#include "qmltypes/qmlfilter.h"
#include "qmltypes/qmlutilities.h"
#include "settings.h"
+#include "util.h"
#include
+#include
#include
#include
#include
@@ -32,6 +34,14 @@
#include
#include
+#ifdef __ARM_NEON
+#include
+#endif
+
+#if defined(__x86_64__) || defined(_M_AMD64)
+#include
+#endif
+
using namespace Mlt;
VideoWidget::VideoWidget(QObject *parent)
@@ -40,13 +50,15 @@ VideoWidget::VideoWidget(QObject *parent)
, m_grid(0)
, m_initSem(0)
, m_isInitialized(false)
- , m_frameRenderer(nullptr)
+ , m_frameSemaphore(3)
+ , m_imageRequested(false)
, m_zoom(0.0f)
, m_offset(QPoint(0, 0))
, m_snapToGrid(true)
, m_scrubAudio(false)
, m_maxTextureSize(4096)
, m_hideVui(false)
+ , m_p016Pool(std::make_shared())
{
LOG_DEBUG() << "begin";
setAttribute(Qt::WA_AcceptTouchEvents);
@@ -80,36 +92,17 @@ VideoWidget::~VideoWidget()
{
LOG_DEBUG() << "begin";
stop();
- if (m_frameRenderer && m_frameRenderer->isRunning()) {
- m_frameRenderer->quit();
- m_frameRenderer->wait();
- m_frameRenderer->deleteLater();
- }
LOG_DEBUG() << "end";
}
void VideoWidget::initialize()
{
LOG_DEBUG() << "begin";
- m_frameRenderer = new FrameRenderer();
- connect(m_frameRenderer,
- &FrameRenderer::frameDisplayed,
- this,
- &VideoWidget::onFrameDisplayed,
- Qt::QueuedConnection);
- connect(m_frameRenderer,
- &FrameRenderer::frameDisplayed,
- this,
- &VideoWidget::frameDisplayed,
- Qt::QueuedConnection);
- connect(m_frameRenderer, SIGNAL(imageReady()), SIGNAL(imageReady()));
m_initSem.release();
m_isInitialized = true;
LOG_DEBUG() << "end";
}
-void VideoWidget::renderVideo() {}
-
void VideoWidget::setBlankScene()
{
quickWindow()->setColor(palette().window().color());
@@ -213,8 +206,8 @@ void VideoWidget::mouseMoveEvent(QMouseEvent *event)
mimeData->setData(Mlt::XmlMimeType, MLT.XML().toUtf8());
drag->setMimeData(mimeData);
mimeData->setText(QString::number(MLT.producer()->get_playtime()));
- if (m_frameRenderer && m_frameRenderer->getDisplayFrame().is_valid()) {
- Mlt::Frame displayFrame(m_frameRenderer->getDisplayFrame().clone(false, true));
+ if (m_sharedFrame.is_valid()) {
+ Mlt::Frame displayFrame(m_sharedFrame.clone(false, true));
QImage displayImage
= MLT.image(&displayFrame, 45 * MLT.profile().dar(), 45).scaledToHeight(45);
drag->setPixmap(QPixmap::fromImage(displayImage));
@@ -261,8 +254,8 @@ void VideoWidget::keyPressEvent(QKeyEvent *event)
bool VideoWidget::event(QEvent *event)
{
bool result = QQuickWidget::event(event);
- if (event->type() == QEvent::PaletteChange && m_sharedFrame.is_valid())
- onFrameDisplayed(m_sharedFrame);
+ if (event->type() == QEvent::PaletteChange)
+ setClearColor(palette().window().color());
return result;
}
@@ -398,13 +391,22 @@ int VideoWidget::reconfigure(bool isMulti)
const int processingMode = property("processing_mode").toInt();
const bool isDeckLinkHLG = serviceName.startsWith("decklink")
&& property("decklinkGamma").toInt() == 1;
+ const bool hdrPreview = Settings.playerHdrPreview();
+ // Effective HDR transfer from the profile (or auto-detected from producer)
+ const QString profileTrc = MLT.colorTrc();
+ const bool isHdrActive = !profileTrc.isEmpty() || isDeckLinkHLG;
switch (processingMode) {
case ShotcutSettings::Native10Cpu:
+ m_consumer->set("mlt_image_format", isHdrActive ? "yuv420p10" : "rgba64");
+ break;
case ShotcutSettings::Linear10Cpu:
m_consumer->set("mlt_image_format", "rgba64");
break;
case ShotcutSettings::Linear10GpuCpu:
- m_consumer->set("mlt_image_format", isDeckLinkHLG ? "yuv444p10" : "rgba64");
+ m_consumer->set("mlt_image_format",
+ isDeckLinkHLG ? "yuv444p10"
+ : hdrPreview ? "yuv420p10"
+ : "rgba64");
break;
default: // Native8Cpu
m_consumer->set("mlt_image_format",
@@ -417,31 +419,35 @@ int VideoWidget::reconfigure(bool isMulti)
} else {
m_consumer->set("channel_layout", "auto");
}
- switch (MLT.profile().colorspace()) {
- case 601:
- case 170:
- m_consumer->set("color_trc", "smpte170m");
- break;
- case 240:
- m_consumer->set("color_trc", "smpte240m");
- break;
- case 470:
- m_consumer->set("color_trc", "bt470bg");
- break;
- case 2020:
- if (isDeckLinkHLG) {
- m_consumer->set("color_trc", "arib-std-b67");
- } else {
- m_consumer->clear("color_trc");
+ // Set color_trc on the consumer. profileTrc (from MLT.colorTrc()) takes precedence;
+ // DeckLink HLG is next; otherwise fall back to colorspace-based SDR defaults.
+ if (!profileTrc.isEmpty()) {
+ m_consumer->set("color_trc", profileTrc.toLatin1().constData());
+ } else if (isDeckLinkHLG) {
+ m_consumer->set("color_trc", "arib-std-b67");
+ } else {
+ switch (MLT.profile().colorspace()) {
+ case 601:
+ case 170:
+ m_consumer->set("color_trc", "smpte170m");
+ break;
+ case 240:
+ m_consumer->set("color_trc", "smpte240m");
+ break;
+ case 470:
+ m_consumer->set("color_trc", "bt470bg");
+ break;
+ default:
+ m_consumer->set("color_trc", "bt709");
+ break;
}
- break;
- default:
- m_consumer->set("color_trc", "bt709");
- break;
}
+ const char *activeTrc = m_consumer->get("color_trc");
+ HdrTransfer hdrTransfer = hdrTransferFromTrc(QLatin1String(activeTrc));
+ emit hdrTransferChanged(hdrTransfer);
if (processingMode == ShotcutSettings::Linear10Cpu
- || (processingMode == ShotcutSettings::Linear10GpuCpu
- && property("decklinkGamma").toInt() != 1)) {
+ || (processingMode == ShotcutSettings::Linear10GpuCpu && !isDeckLinkHLG
+ && !isHdrActive)) {
m_consumer->set("mlt_color_trc", "linear");
} else {
m_consumer->clear("mlt_color_trc");
@@ -520,12 +526,11 @@ QPoint VideoWidget::offset() const
QImage VideoWidget::image() const
{
- SharedFrame frame = m_frameRenderer->getDisplayFrame();
- if (frame.is_valid()) {
- const uint8_t *image = frame.get_image(mlt_image_rgba);
+ if (m_sharedFrame.is_valid()) {
+ const uint8_t *image = m_sharedFrame.get_image(mlt_image_rgba);
if (image) {
- int width = frame.get_image_width();
- int height = frame.get_image_height();
+ int width = m_sharedFrame.get_image_width();
+ int height = m_sharedFrame.get_image_height();
QImage temp(image, width, height, QImage::Format_RGBA8888);
return temp.copy();
}
@@ -536,7 +541,7 @@ QImage VideoWidget::image() const
bool VideoWidget::imageIsProxy() const
{
bool isProxy = false;
- SharedFrame frame = m_frameRenderer->getDisplayFrame();
+ SharedFrame frame = m_sharedFrame;
if (frame.is_valid()) {
Mlt::Producer *frameProducer = frame.get_original_producer();
if (frameProducer && frameProducer->is_valid() && frameProducer->get_int(kIsProxyProperty)) {
@@ -547,9 +552,9 @@ bool VideoWidget::imageIsProxy() const
return isProxy;
}
-void VideoWidget::requestImage() const
+void VideoWidget::requestImage()
{
- m_frameRenderer->requestImage();
+ m_imageRequested = true;
}
void VideoWidget::toggleVuiDisplay()
@@ -558,19 +563,347 @@ void VideoWidget::toggleVuiDisplay()
refreshConsumer();
}
-void VideoWidget::onFrameDisplayed(const SharedFrame &frame)
+void VideoWidget::setVideoSink(QVideoSink *sink)
+{
+ m_videoSink = sink;
+ if (m_videoSink && m_sharedFrame.is_valid())
+ pushFrameToSink(m_sharedFrame);
+}
+
+#if defined(__x86_64__) || defined(_M_AMD64)
+#if defined(__GNUC__) || defined(__clang__)
+__attribute__((target("avx2")))
+#endif
+static void
+shiftYPlane_AVX2(const uint16_t *src, uint16_t *dst, int n)
+{
+ int i = 0;
+ for (; i + 16 <= n; i += 16) {
+ __m256i y = _mm256_loadu_si256(reinterpret_cast(src + i));
+ _mm256_storeu_si256(reinterpret_cast<__m256i *>(dst + i), _mm256_slli_epi16(y, 6));
+ }
+ for (; i < n; ++i)
+ dst[i] = src[i] << 6;
+}
+
+#if defined(__GNUC__) || defined(__clang__)
+__attribute__((target("avx2")))
+#endif
+static void
+interleaveUVPlanes_AVX2(const uint16_t *srcU, const uint16_t *srcV, uint16_t *dst, int n)
+{
+ // AVX2 unpack operates within 128-bit lanes; permute to restore linear order.
+ // unpacklo(u,v): lane0 = u0v0u1v1u2v2u3v3, lane1 = u8v8...u11v11
+ // unpackhi(u,v): lane0 = u4v4...u7v7, lane1 = u12v12...u15v15
+ // permute2x128 0x20 → [lo.lane0 | hi.lane0] = u0v0..u7v7
+ // permute2x128 0x31 → [lo.lane1 | hi.lane1] = u8v8..u15v15
+ int j = 0;
+ for (; j + 16 <= n; j += 16) {
+ __m256i u
+ = _mm256_slli_epi16(_mm256_loadu_si256(reinterpret_cast(srcU + j)), 6);
+ __m256i v
+ = _mm256_slli_epi16(_mm256_loadu_si256(reinterpret_cast(srcV + j)), 6);
+ __m256i lo = _mm256_unpacklo_epi16(u, v);
+ __m256i hi = _mm256_unpackhi_epi16(u, v);
+ _mm256_storeu_si256(reinterpret_cast<__m256i *>(dst + j * 2),
+ _mm256_permute2x128_si256(lo, hi, 0x20));
+ _mm256_storeu_si256(reinterpret_cast<__m256i *>(dst + j * 2 + 16),
+ _mm256_permute2x128_si256(lo, hi, 0x31));
+ }
+ for (; j < n; ++j) {
+ dst[2 * j] = srcU[j] << 6;
+ dst[2 * j + 1] = srcV[j] << 6;
+ }
+}
+
+static void shiftYPlane_SSE2(const uint16_t *src, uint16_t *dst, int n)
+{
+ int i = 0;
+ for (; i + 8 <= n; i += 8) {
+ __m128i y = _mm_loadu_si128(reinterpret_cast(src + i));
+ _mm_storeu_si128(reinterpret_cast<__m128i *>(dst + i), _mm_slli_epi16(y, 6));
+ }
+ for (; i < n; ++i)
+ dst[i] = src[i] << 6;
+}
+
+static void interleaveUVPlanes_SSE2(const uint16_t *srcU, const uint16_t *srcV, uint16_t *dst, int n)
+{
+ int j = 0;
+ for (; j + 8 <= n; j += 8) {
+ __m128i u = _mm_slli_epi16(_mm_loadu_si128(reinterpret_cast(srcU + j)), 6);
+ __m128i v = _mm_slli_epi16(_mm_loadu_si128(reinterpret_cast(srcV + j)), 6);
+ _mm_storeu_si128(reinterpret_cast<__m128i *>(dst + j * 2), _mm_unpacklo_epi16(u, v));
+ _mm_storeu_si128(reinterpret_cast<__m128i *>(dst + j * 2 + 8), _mm_unpackhi_epi16(u, v));
+ }
+ for (; j < n; ++j) {
+ dst[2 * j] = srcU[j] << 6;
+ dst[2 * j + 1] = srcV[j] << 6;
+ }
+}
+#endif // defined(__x86_64__) || defined(_M_AMD64)
+
+// Convert MLT planar yuv420p10 (Y, U, V planes; 10-bit in LSBs of uint16_t) to
+// semi-planar P016 (Y plane + interleaved UV plane; 10-bit shifted to full 16-bit range).
+// `buffer` is resized as needed; passing a pre-allocated buffer avoids heap allocation.
+static void convertToP016(const uint8_t *image, int width, int height, QByteArray &buffer)
+{
+ const int uvW = width / 2;
+ const int uvH = height / 2;
+ const int ySamples = width * height;
+ const int yPlaneSize = ySamples * 2;
+ const int uvPlaneSize = uvW * uvH * 2;
+ const int interleavedUvSize = uvW * uvH * 4;
+ buffer.resize(yPlaneSize + interleavedUvSize);
+ const uint16_t *srcY = reinterpret_cast(image);
+ uint16_t *dstY = reinterpret_cast(buffer.data());
+#ifdef __ARM_NEON
+ int i = 0;
+ for (; i + 8 <= ySamples; i += 8) {
+ uint16x8_t y = vld1q_u16(srcY + i);
+ vst1q_u16(dstY + i, vshlq_n_u16(y, 6));
+ }
+ for (; i < ySamples; ++i)
+ dstY[i] = srcY[i] << 6;
+#elif defined(__x86_64__) || defined(_M_AMD64)
+ if (Util::cpuHasAVX2())
+ shiftYPlane_AVX2(srcY, dstY, ySamples);
+ else
+ shiftYPlane_SSE2(srcY, dstY, ySamples);
+#else
+ for (int i = 0; i < ySamples; ++i)
+ dstY[i] = srcY[i] << 6;
+#endif
+ const uint16_t *srcU = reinterpret_cast(image + yPlaneSize);
+ const uint16_t *srcV = reinterpret_cast(image + yPlaneSize + uvPlaneSize);
+ uint16_t *dstUV = reinterpret_cast(buffer.data() + yPlaneSize);
+ const int uvSamples = uvW * uvH;
+#ifdef __ARM_NEON
+ int j = 0;
+ for (; j + 8 <= uvSamples; j += 8) {
+ uint16x8_t u = vshlq_n_u16(vld1q_u16(srcU + j), 6);
+ uint16x8_t v = vshlq_n_u16(vld1q_u16(srcV + j), 6);
+ uint16x8x2_t uv = {u, v};
+ vst2q_u16(dstUV + j * 2, uv);
+ }
+ for (; j < uvSamples; ++j) {
+ dstUV[2 * j] = srcU[j] << 6;
+ dstUV[2 * j + 1] = srcV[j] << 6;
+ }
+#elif defined(__x86_64__) || defined(_M_AMD64)
+ if (Util::cpuHasAVX2())
+ interleaveUVPlanes_AVX2(srcU, srcV, dstUV, uvSamples);
+ else
+ interleaveUVPlanes_SSE2(srcU, srcV, dstUV, uvSamples);
+#else
+ for (int i = 0; i < uvSamples; ++i) {
+ dstUV[2 * i] = srcU[i] << 6;
+ dstUV[2 * i + 1] = srcV[i] << 6;
+ }
+#endif
+}
+
+void VideoWidget::pushFrameToSink(const SharedFrame &frame, QByteArray p016Buffer)
+{
+ if (!m_videoSink)
+ return;
+ int width = frame.get_image_width();
+ int height = frame.get_image_height();
+ if (width < 1 || height < 1)
+ return;
+
+ bool is10bit = !qstrcmp(m_consumer->get("mlt_image_format"), "yuv420p10");
+ if (!is10bit) {
+ // Validate 8-bit image is available (caches it for later use in map()).
+ if (!frame.get_image(mlt_image_yuv420p))
+ return;
+ } else if (p016Buffer.isEmpty()) {
+ // Fallback conversion on the calling thread — only reached from setVideoSink(),
+ // not the normal playback path where on_frame_show() pre-converts on the MLT thread.
+ const uint8_t *image = frame.get_image(mlt_image_yuv420p10);
+ if (!image)
+ return;
+ convertToP016(image, width, height, p016Buffer);
+ }
+
+ auto pixFmt = is10bit ? QVideoFrameFormat::Format_P016 : QVideoFrameFormat::Format_YUV420P;
+ QVideoFrameFormat fmt(QSize(width, height), pixFmt);
+ fmt.setColorRange(QVideoFrameFormat::ColorRange_Video);
+
+ // Determine the HDR transfer from the consumer's color_trc property, which
+ // reconfigure() sets correctly regardless of whether the profile colorspace
+ // is 2020 (MLT convention) or 9 (FFmpeg AVCOL_SPC_BT2020_NCL). Checking
+ // color_trc first avoids the bug where case 2020: is never reached in
+ // automatic video mode and the frame is incorrectly stamped BT.709.
+ const char *activeTrc = m_consumer->get("color_trc");
+ const bool isHlg = !qstrcmp(activeTrc, "arib-std-b67");
+ const bool isPq = !qstrcmp(activeTrc, "smpte2084");
+
+ if (isHlg || isPq) {
+ fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT2020);
+ if (isHlg) {
+ fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_STD_B67);
+ // Use user-overridden content peak, or default to 1000 nits.
+ const float hlgMaxNits = Settings.playerHdrContentPeakNits() > 0
+ ? static_cast(Settings.playerHdrContentPeakNits())
+ : 1000.0f;
+ fmt.setMaxLuminance(hlgMaxNits);
+ } else {
+ fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_ST2084);
+ // For PQ, maxLuminance drives Qt's BT.2390 tone-mapping EETF.
+ // When tone mapping is disabled, clamp at the display peak so Qt
+ // applies no compression. Otherwise use the user's content-peak
+ // setting (0 = auto → 1000 nits as a sensible default).
+ float pqMaxNits;
+ if (!Settings.playerHdrToneMapping()) {
+ const int displayPeak = Settings.playerHdrDisplayPeakNits();
+ pqMaxNits = displayPeak > 0 ? static_cast(displayPeak) : 1000.0f;
+ } else if (Settings.playerHdrContentPeakNits() > 0) {
+ pqMaxNits = static_cast(Settings.playerHdrContentPeakNits());
+ } else {
+ pqMaxNits = 1000.0f;
+ }
+ fmt.setMaxLuminance(pqMaxNits);
+ }
+ // Log whenever the stamped TRC or maxLuminance changes.
+ static QByteArray s_lastTrc;
+ static float s_lastMaxLum = -1.0f;
+ const float stamped = fmt.maxLuminance();
+ if (s_lastTrc != activeTrc || !qFuzzyCompare(s_lastMaxLum, stamped)) {
+ s_lastTrc = activeTrc;
+ s_lastMaxLum = stamped;
+ qDebug() << "HDR pushFrameToSink: colorspace =" << profile().colorspace()
+ << "color_trc =" << activeTrc << "maxLuminance =" << stamped
+ << "toneMapping =" << Settings.playerHdrToneMapping()
+ << "contentPeakNits =" << Settings.playerHdrContentPeakNits()
+ << "displayPeakNits =" << Settings.playerHdrDisplayPeakNits();
+ }
+ } else {
+ switch (profile().colorspace()) {
+ case 601:
+ case 170:
+ fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT601);
+ fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT601);
+ break;
+ case 2020:
+ fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT2020);
+ fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT709);
+ break;
+ default:
+ fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT709);
+ fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT709);
+ break;
+ }
+ }
+
+ // Zero-copy buffer for 8-bit, or P016 pre-converted buffer for 10-bit.
+ class SharedFrameVideoBuffer : public QAbstractVideoBuffer
+ {
+ public:
+ SharedFrameVideoBuffer(const SharedFrame &sf, const QVideoFrameFormat &f)
+ : m_sharedFrame(sf)
+ , m_format(f)
+ {}
+ SharedFrameVideoBuffer(QByteArray p016,
+ const QVideoFrameFormat &f,
+ std::function onFree)
+ : m_p016(std::move(p016))
+ , m_format(f)
+ , m_onFree(std::move(onFree))
+ {}
+ ~SharedFrameVideoBuffer()
+ {
+ if (m_onFree && !m_p016.isEmpty())
+ m_onFree(std::move(m_p016));
+ }
+ QVideoFrameFormat format() const override { return m_format; }
+ MapData map(QVideoFrame::MapMode) override
+ {
+ int w = m_sharedFrame.get_image_width();
+ int h = m_sharedFrame.get_image_height();
+ MapData md;
+ if (!m_p016.isEmpty()) {
+ // P016: semi-planar 16-bit, 2 planes
+ w = m_format.frameWidth();
+ h = m_format.frameHeight();
+ auto *p = reinterpret_cast(m_p016.data());
+ md.planeCount = 2;
+ // Y plane (16-bit per sample)
+ md.bytesPerLine[0] = w * 2;
+ md.data[0] = p;
+ md.dataSize[0] = w * h * 2;
+ // Interleaved UV plane (2 × 16-bit per sample pair)
+ md.bytesPerLine[1] = (w / 2) * 4;
+ md.data[1] = p + md.dataSize[0];
+ md.dataSize[1] = (w / 2) * (h / 2) * 4;
+ } else {
+ // YUV420P: planar 8-bit, 3 planes
+ auto *p = const_cast(m_sharedFrame.get_image(mlt_image_yuv420p));
+ md.planeCount = 3;
+ md.bytesPerLine[0] = w;
+ md.data[0] = p;
+ md.dataSize[0] = w * h;
+ md.bytesPerLine[1] = w / 2;
+ md.data[1] = p + md.dataSize[0];
+ md.dataSize[1] = (w / 2) * (h / 2);
+ md.bytesPerLine[2] = w / 2;
+ md.data[2] = md.data[1] + md.dataSize[1];
+ md.dataSize[2] = md.dataSize[1];
+ }
+ return md;
+ }
+
+ private:
+ SharedFrame m_sharedFrame;
+ QByteArray m_p016;
+ QVideoFrameFormat m_format;
+ std::function m_onFree;
+ };
+
+ std::unique_ptr buffer;
+ if (is10bit) {
+ buffer = std::make_unique(std::move(p016Buffer),
+ fmt,
+ [pool = std::weak_ptr(
+ m_p016Pool)](QByteArray buf) {
+ if (auto p = pool.lock()) {
+ QMutexLocker lock(&p->mutex);
+ if (p->buffers.size() < 3)
+ p->buffers.append(
+ std::move(buf));
+ }
+ });
+ } else {
+ buffer = std::make_unique(frame, fmt);
+ }
+ QVideoFrame videoFrame(std::move(buffer));
+ // Set PTS so downstream consumers (e.g. HdrPreviewWindow) can track position.
+ const double fps = profile().fps();
+ if (fps > 0.0)
+ videoFrame.setStartTime(qRound64(frame.get_position() / fps * 1000000.0));
+ m_videoSink->setVideoFrame(videoFrame);
+ emit videoFrameReady(videoFrame);
+}
+
+void VideoWidget::showFrame(Mlt::Frame frame, QByteArray p016Buffer)
{
m_mutex.lock();
- m_sharedFrame = frame;
+ m_sharedFrame = SharedFrame(frame);
m_mutex.unlock();
- bool isVui = frame.get_int(kShotcutVuiMetaProperty) && !m_hideVui;
+ bool isVui = m_sharedFrame.get_int(kShotcutVuiMetaProperty) && !m_hideVui;
if (!isVui && source() != QmlUtilities::blankVui()) {
m_savedQmlSource = source();
setSource(QmlUtilities::blankVui());
} else if (isVui && !m_savedQmlSource.isEmpty() && source() != m_savedQmlSource) {
setSource(m_savedQmlSource);
}
- quickWindow()->update();
+ pushFrameToSink(m_sharedFrame, std::move(p016Buffer));
+ emit frameDisplayed(m_sharedFrame);
+ if (m_imageRequested) {
+ m_imageRequested = false;
+ emit imageReady();
+ }
+ m_frameSemaphore.release();
}
void VideoWidget::setGrid(int grid)
@@ -623,18 +956,37 @@ void VideoWidget::setSnapToGrid(bool snap)
emit snapToGridChanged();
}
-// MLT consumer-frame-show event handler
+// MLT consumer-frame-show event handler — runs on the MLT consumer thread.
+// P016 conversion for 10-bit frames is done here so the GUI thread is not burdened.
void VideoWidget::on_frame_show(mlt_consumer, VideoWidget *widget, mlt_event_data data)
{
auto frame = Mlt::EventData(data).to_frame();
if (frame.is_valid() && frame.get_int("rendered")) {
int timeout = (widget->consumer()->get_int("real_time") > 0) ? 0 : 1000;
- if (widget->m_frameRenderer
- && widget->m_frameRenderer->semaphore()->tryAcquire(1, timeout)) {
- QMetaObject::invokeMethod(widget->m_frameRenderer,
- "showFrame",
- Qt::QueuedConnection,
- Q_ARG(Mlt::Frame, frame));
+ if (widget->m_frameSemaphore.tryAcquire(1, timeout)) {
+ QByteArray p016Buffer;
+ if (!qstrcmp(widget->consumer()->get("mlt_image_format"), "yuv420p10")) {
+ mlt_image_format mltFmt = mlt_image_yuv420p10;
+ int width = 0, height = 0;
+ const uint8_t *image = frame.get_image(mltFmt, width, height);
+ if (image && width > 0 && height > 0) {
+ // Grab a reusable buffer from the pool to avoid per-frame allocation.
+ {
+ QMutexLocker lock(&widget->m_p016Pool->mutex);
+ if (!widget->m_p016Pool->buffers.isEmpty()) {
+ p016Buffer = std::move(widget->m_p016Pool->buffers.last());
+ widget->m_p016Pool->buffers.removeLast();
+ }
+ }
+ convertToP016(image, width, height, p016Buffer);
+ }
+ }
+ QMetaObject::invokeMethod(
+ widget,
+ [widget, frame, buf = std::move(p016Buffer)]() mutable {
+ widget->showFrame(frame, std::move(buf));
+ },
+ Qt::QueuedConnection);
} else if (!Settings.playerRealtime()) {
LOG_WARNING() << "VideoWidget dropped frame" << frame.get_position();
}
@@ -673,38 +1025,3 @@ void RenderThread::run()
m_function(m_data);
m_context->doneCurrent();
}
-
-FrameRenderer::FrameRenderer()
- : QThread(nullptr)
- , m_semaphore(3)
- , m_imageRequested(false)
-{
- setObjectName("FrameRenderer");
- moveToThread(this);
- start();
-}
-
-FrameRenderer::~FrameRenderer() {}
-
-void FrameRenderer::showFrame(Mlt::Frame frame)
-{
- m_displayFrame = SharedFrame(frame);
- emit frameDisplayed(m_displayFrame);
-
- if (m_imageRequested) {
- m_imageRequested = false;
- emit imageReady();
- }
-
- m_semaphore.release();
-}
-
-void FrameRenderer::requestImage()
-{
- m_imageRequested = true;
-}
-
-SharedFrame FrameRenderer::getDisplayFrame()
-{
- return m_displayFrame;
-}
diff --git a/src/videowidget.h b/src/videowidget.h
index 12e0b77aa3..7f5503750a 100644
--- a/src/videowidget.h
+++ b/src/videowidget.h
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2011-2025 Meltytech, LLC
+ * Copyright (c) 2011-2026 Meltytech, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -22,12 +22,29 @@
#include "settings.h"
#include "sharedframe.h"
+enum class HdrTransfer { SDR = 0, HLG = 1, PQ = 2 };
+
+inline HdrTransfer hdrTransferFromTrc(const QString &trc)
+{
+ if (trc == QLatin1String("arib-std-b67"))
+ return HdrTransfer::HLG;
+ if (trc == QLatin1String("smpte2084"))
+ return HdrTransfer::PQ;
+ return HdrTransfer::SDR;
+}
+
+#include
+#include
#include
+#include
#include
#include
#include
#include
#include
+#include
+#include
+#include
class QmlFilter;
class QmlMetadata;
@@ -38,7 +55,6 @@ namespace Mlt {
class Filter;
class RenderThread;
-class FrameRenderer;
typedef void *(*thread_function_t)(void *);
@@ -91,10 +107,11 @@ class VideoWidget : public QQuickWidget, public Controller
QPoint offset() const;
QImage image() const;
bool imageIsProxy() const;
- void requestImage() const;
+ void requestImage();
bool snapToGrid() const { return m_snapToGrid; }
int maxTextureSize() const { return m_maxTextureSize; }
void toggleVuiDisplay();
+ Q_INVOKABLE void setVideoSink(QVideoSink *sink);
public slots:
void setGrid(int grid);
@@ -105,9 +122,7 @@ public slots:
void setCurrentFilter(QmlFilter *filter, QmlMetadata *meta);
void setSnapToGrid(bool snap);
virtual void initialize();
- virtual void beforeRendering(){};
- virtual void renderVideo();
- virtual void onFrameDisplayed(const SharedFrame &frame);
+ void showFrame(Mlt::Frame frame, QByteArray p016Buffer = {});
signals:
void frameDisplayed(const SharedFrame &frame);
@@ -125,6 +140,8 @@ public slots:
void snapToGridChanged();
void toggleZoom(bool);
void stepZoom(float, float);
+ void videoFrameReady(const QVideoFrame &frame);
+ void hdrTransferChanged(HdrTransfer transfer);
private:
QRectF m_rect;
@@ -137,7 +154,8 @@ public slots:
std::unique_ptr m_threadStopEvent;
std::unique_ptr m_threadCreateEvent;
std::unique_ptr m_threadJoinEvent;
- FrameRenderer *m_frameRenderer;
+ QSemaphore m_frameSemaphore;
+ bool m_imageRequested;
float m_zoom;
QPoint m_offset;
QUrl m_savedQmlSource;
@@ -147,8 +165,16 @@ public slots:
bool m_scrubAudio;
QPoint m_mousePosition;
std::unique_ptr m_renderThread;
+ QPointer m_videoSink;
static void on_frame_show(mlt_consumer, VideoWidget *widget, mlt_event_data);
+ void pushFrameToSink(const SharedFrame &frame, QByteArray p016Buffer = {});
+ struct P016Pool
+ {
+ QMutex mutex;
+ QList buffers;
+ };
+ std::shared_ptr m_p016Pool;
private slots:
void resizeVideo(int width, int height);
@@ -161,7 +187,6 @@ private slots:
void wheelEvent(QWheelEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
bool event(QEvent *event) override;
- void createShader();
int m_maxTextureSize;
SharedFrame m_sharedFrame;
@@ -185,29 +210,6 @@ class RenderThread : public QThread
std::unique_ptr m_surface;
};
-class FrameRenderer : public QThread
-{
- Q_OBJECT
-public:
- FrameRenderer();
- ~FrameRenderer();
- QSemaphore *semaphore() { return &m_semaphore; }
- SharedFrame getDisplayFrame();
- Q_INVOKABLE void showFrame(Mlt::Frame frame);
- void requestImage();
- QImage image() const { return m_image; }
-
-signals:
- void frameDisplayed(const SharedFrame &frame);
- void imageReady();
-
-private:
- QSemaphore m_semaphore;
- SharedFrame m_displayFrame;
- bool m_imageRequested;
- QImage m_image;
-};
-
} // namespace Mlt
#endif
diff --git a/src/widgets/d3dvideowidget.cpp b/src/widgets/d3dvideowidget.cpp
deleted file mode 100644
index b017d02c6c..0000000000
--- a/src/widgets/d3dvideowidget.cpp
+++ /dev/null
@@ -1,403 +0,0 @@
-/*
- * Copyright (c) 2023-2025 Meltytech, LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#include "d3dvideowidget.h"
-
-#include "Logger.h"
-
-#include
-
-D3DVideoWidget::D3DVideoWidget(QObject *parent)
- : Mlt::VideoWidget{parent}
-{
- m_maxTextureSize = D3D11_REQ_TEXTURE2D_U_OR_V_DIMENSION;
- ::memset(&m_constants, 0, sizeof(m_constants));
-}
-
-D3DVideoWidget::~D3DVideoWidget()
-{
- for (int i = 0; i < 3; i++) {
- if (m_texture[i])
- m_texture[i]->Release();
- }
- if (m_vs)
- m_vs->Release();
- if (m_ps)
- m_ps->Release();
- if (m_vbuf)
- m_vbuf->Release();
- if (m_cbuf)
- m_cbuf->Release();
- if (m_inputLayout)
- m_inputLayout->Release();
- if (m_rastState)
- m_rastState->Release();
- if (m_dsState)
- m_dsState->Release();
-}
-
-void D3DVideoWidget::initialize()
-{
- m_initialized = true;
- QSGRendererInterface *rif = quickWindow()->rendererInterface();
-
- // We are not prepared for anything other than running with the RHI and its D3D11 backend.
- Q_ASSERT(rif->graphicsApi() == QSGRendererInterface::Direct3D11);
-
- m_device = reinterpret_cast(
- rif->getResource(quickWindow(), QSGRendererInterface::DeviceResource));
- Q_ASSERT(m_device);
- m_context = reinterpret_cast(
- rif->getResource(quickWindow(), QSGRendererInterface::DeviceContextResource));
- Q_ASSERT(m_context);
-
- if (m_vert.isEmpty())
- prepareShader(VertexStage);
- if (m_frag.isEmpty())
- prepareShader(FragmentStage);
-
- const QByteArray vs = compileShader(VertexStage, m_vert, m_vertEntryPoint);
- const QByteArray fs = compileShader(FragmentStage, m_frag, m_fragEntryPoint);
-
- HRESULT hr = m_device->CreateVertexShader(vs.constData(), vs.size(), nullptr, &m_vs);
- if (FAILED(hr))
- qFatal("Failed to create vertex shader: 0x%x", uint(hr));
-
- hr = m_device->CreatePixelShader(fs.constData(), fs.size(), nullptr, &m_ps);
- if (FAILED(hr))
- qFatal("Failed to create pixel shader: 0x%x", uint(hr));
-
- D3D11_BUFFER_DESC bufDesc;
- memset(&bufDesc, 0, sizeof(bufDesc));
- bufDesc.ByteWidth = sizeof(float) * 16;
- bufDesc.Usage = D3D11_USAGE_DEFAULT;
- bufDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
- hr = m_device->CreateBuffer(&bufDesc, nullptr, &m_vbuf);
- if (FAILED(hr))
- qFatal("Failed to create buffer: 0x%x", uint(hr));
-
- bufDesc.ByteWidth = sizeof(m_constants) + 0xf & 0xfffffff0; // must be a multiple of 16
- bufDesc.Usage = D3D11_USAGE_DYNAMIC;
- bufDesc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
- bufDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
- hr = m_device->CreateBuffer(&bufDesc, nullptr, &m_cbuf);
- if (FAILED(hr))
- qFatal("Failed to create buffer: 0x%x", uint(hr));
-
- const D3D11_INPUT_ELEMENT_DESC inputDesc[] = {
- {"VERTEX", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
- {"TEXCOORD",
- 0,
- DXGI_FORMAT_R32G32B32_FLOAT,
- 0,
- sizeof(DirectX::XMFLOAT2),
- D3D11_INPUT_PER_VERTEX_DATA,
- 0},
- };
- hr = m_device->CreateInputLayout(inputDesc,
- ARRAYSIZE(inputDesc),
- vs.constData(),
- vs.size(),
- &m_inputLayout);
- if (FAILED(hr))
- qFatal("Failed to create input layout: 0x%x", uint(hr));
-
- D3D11_RASTERIZER_DESC rastDesc;
- memset(&rastDesc, 0, sizeof(rastDesc));
- rastDesc.FillMode = D3D11_FILL_SOLID;
- rastDesc.CullMode = D3D11_CULL_NONE;
- hr = m_device->CreateRasterizerState(&rastDesc, &m_rastState);
- if (FAILED(hr))
- qFatal("Failed to create rasterizer state: 0x%x", uint(hr));
-
- D3D11_DEPTH_STENCIL_DESC dsDesc;
- memset(&dsDesc, 0, sizeof(dsDesc));
- hr = m_device->CreateDepthStencilState(&dsDesc, &m_dsState);
- if (FAILED(hr))
- qFatal("Failed to create depth/stencil state: 0x%x", uint(hr));
-
- Mlt::VideoWidget::initialize();
-}
-
-void D3DVideoWidget::beforeRendering()
-{
- quickWindow()->beginExternalCommands();
- m_context->ClearState();
-
- // Provide vertices of triangle strip
- float width = rect().width() * devicePixelRatioF() / 2.0f;
- float height = rect().height() * devicePixelRatioF() / 2.0f;
- float vertexData[] = {
- // x,y plus u,v texture coordinates
- width,
- -height,
- 1.f,
- 1.f, // bottom left
- -width,
- -height,
- 0.f,
- 1.f, // bottom right
- width,
- height,
- 1.f,
- 0.f, // top left
- -width,
- height,
- 0.f,
- 0.f // top right
- };
-
- // Setup an orthographic projection
- QMatrix4x4 modelView;
- width = this->width() * devicePixelRatioF();
- height = this->height() * devicePixelRatioF();
- modelView.scale(2.0f / width, 2.0f / height);
-
- // Set model-view
- if (rect().width() > 0.0 && zoom() > 0.0) {
- if (offset().x() || offset().y())
- modelView.translate(-offset().x() * devicePixelRatioF(),
- offset().y() * devicePixelRatioF());
- modelView.scale(zoom(), zoom());
- }
- for (int i = 0; i < 4; i++) {
- vertexData[4 * i] *= modelView(0, 0);
- vertexData[4 * i] += modelView(0, 3);
- vertexData[4 * i + 1] *= modelView(1, 1);
- vertexData[4 * i + 1] += modelView(1, 3);
- }
- m_context->UpdateSubresource(m_vbuf, 0, nullptr, vertexData, 0, 0);
-
- // (Re)create the textures
- m_mutex.lock();
- if (!m_sharedFrame.is_valid()) {
- m_mutex.unlock();
- quickWindow()->endExternalCommands();
- Mlt::VideoWidget::beforeRendering();
- return;
- }
- int iwidth = m_sharedFrame.get_image_width();
- int iheight = m_sharedFrame.get_image_height();
- const uint8_t *image = m_sharedFrame.get_image(mlt_image_yuv420p);
- for (int i = 0; i < 3; i++) {
- if (m_texture[i])
- m_texture[i]->Release();
- }
- m_texture[0] = initTexture(image, iwidth, iheight);
- m_texture[1] = initTexture(image + iwidth * iheight, iwidth / 2, iheight / 2);
- m_texture[2] = initTexture(image + iwidth * iheight + iwidth / 2 * iheight / 2,
- iwidth / 2,
- iheight / 2);
- m_mutex.unlock();
-
- // Update the constants
- D3D11_MAPPED_SUBRESOURCE mp;
- // will copy the entire constant buffer every time -> pass WRITE_DISCARD -> prevent pipeline stalls
- HRESULT hr = m_context->Map(m_cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, &mp);
- if (SUCCEEDED(hr)) {
- m_constants.colorspace = MLT.profile().colorspace();
- ::memcpy(mp.pData, &m_constants, sizeof(m_constants));
- m_context->Unmap(m_cbuf, 0);
- } else {
- quickWindow()->endExternalCommands();
- qFatal("Failed to map constant buffer: 0x%x", uint(hr));
- return;
- }
-
- quickWindow()->endExternalCommands();
- Mlt::VideoWidget::beforeRendering();
-}
-
-void D3DVideoWidget::renderVideo()
-{
- if (!m_texture[0]) {
- Mlt::VideoWidget::renderVideo();
- return;
- }
- quickWindow()->beginExternalCommands();
-
- D3D11_VIEWPORT v;
- v.TopLeftX = 0.f;
- v.TopLeftY = 0.f;
- v.Width = this->width() * devicePixelRatioF();
- v.Height = this->height() * devicePixelRatioF();
- v.MinDepth = 0.f;
- v.MaxDepth = 1.f;
-
- m_context->RSSetViewports(1, &v);
- m_context->VSSetShader(m_vs, nullptr, 0);
- m_context->PSSetShader(m_ps, nullptr, 0);
- m_context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
- m_context->IASetInputLayout(m_inputLayout);
- m_context->OMSetDepthStencilState(m_dsState, 0);
- m_context->RSSetState(m_rastState);
- const UINT stride = sizeof(float) * 4;
- const UINT offset = 0;
- m_context->IASetVertexBuffers(0, 1, &m_vbuf, &stride, &offset);
- m_context->PSSetConstantBuffers(0, 1, &m_cbuf);
- m_context->PSSetShaderResources(0, 3, m_texture);
- m_context->Draw(4, 0);
-
- quickWindow()->endExternalCommands();
- Mlt::VideoWidget::renderVideo();
-}
-
-void D3DVideoWidget::prepareShader(Stage stage)
-{
- if (stage == VertexStage) {
- m_vert = "struct VSInput {"
- " float2 vertex : VERTEX;"
- " float2 coords : TEXCOORD;"
- "};"
- "struct VSOutput {"
- " float2 coords : TEXCOORD0;"
- " float4 position : SV_Position;"
- "};"
- "VSOutput main(VSInput input) {"
- " VSOutput output;"
- " output.position = float4(input.vertex, 0.0f, 1.0f);"
- " output.coords = input.coords;"
- " return output;"
- "}";
- Q_ASSERT(!m_vert.isEmpty());
- m_vertEntryPoint = QByteArrayLiteral("main");
- } else {
- m_frag = "Texture2D yTex, uTex, vTex;"
- "SamplerState yuvSampler;"
- "cbuffer buf {"
- " int colorspace;"
- "};"
- "struct PSInput {"
- " float2 coords : TEXCOORD0;"
- "};"
- "struct PSOutput {"
- " float4 color : SV_Target0;"
- "};"
- "PSOutput main(PSInput input) {"
- " float3 yuv;"
- " yuv.x = yTex.Sample(yuvSampler, input.coords).r - 16.0f/255.0f;"
- " yuv.y = uTex.Sample(yuvSampler, input.coords).r - 128.0f/255.0f;"
- " yuv.z = vTex.Sample(yuvSampler, input.coords).r - 128.0f/255.0f;"
- " float3x3 coefficients;"
- " if (colorspace == 601) {"
- " coefficients = float3x3("
- " 1.1643f, 0.0f, 1.5958f,"
- " 1.1643f, -0.39173f, -0.8129f,"
- " 1.1643f, 2.017f, 0.0f);"
- " } else if (colorspace == 2020) {" // ITU-R BT.2020
- " coefficients = float3x3("
- " 1.1643f, 0.0f, 1.7167f,"
- " 1.1643f, -0.1873f, -0.6504f,"
- " 1.1643f, 2.1418f, 0.0f);"
- " } else {" // ITU-R 709
- " coefficients = float3x3("
- " 1.1643f, 0.0f, 1.793f,"
- " 1.1643f, -0.213f, -0.533f,"
- " 1.1643f, 2.112f, 0.0f);"
- " }"
- " PSOutput output;"
- " output.color = float4(mul(coefficients, yuv), 1.0f);"
- " return output;"
- "}";
- m_fragEntryPoint = QByteArrayLiteral("main");
- }
-}
-
-QByteArray D3DVideoWidget::compileShader(Stage stage,
- const QByteArray &source,
- const QByteArray &entryPoint)
-{
- const char *target;
- switch (stage) {
- case VertexStage:
- target = "vs_5_0";
- break;
- case FragmentStage:
- target = "ps_5_0";
- break;
- default:
- qFatal("Unknown shader stage %d", stage);
- return QByteArray();
- }
-
- ID3DBlob *bytecode = nullptr;
- ID3DBlob *errors = nullptr;
- HRESULT hr = D3DCompile(source.constData(),
- source.size(),
- nullptr,
- nullptr,
- nullptr,
- entryPoint.constData(),
- target,
- 0,
- 0,
- &bytecode,
- &errors);
- if (FAILED(hr) || !bytecode) {
- LOG_WARNING("HLSL shader compilation failed: 0x%x", uint(hr));
- if (errors) {
- const QByteArray msg(static_cast(errors->GetBufferPointer()),
- errors->GetBufferSize());
- errors->Release();
- LOG_WARNING("%s", msg.constData());
- }
- return QByteArray();
- }
-
- QByteArray result;
- result.resize(bytecode->GetBufferSize());
- memcpy(result.data(), bytecode->GetBufferPointer(), result.size());
- bytecode->Release();
-
- return result;
-}
-
-ID3D11ShaderResourceView *D3DVideoWidget::initTexture(const void *p, int width, int height)
-{
- ID3D11ShaderResourceView *result;
- D3D11_TEXTURE2D_DESC desc;
- desc.Width = width;
- desc.Height = height;
- desc.MipLevels = 1;
- desc.ArraySize = 1;
- desc.Format = DXGI_FORMAT_R8_UNORM;
- desc.SampleDesc.Count = 1;
- desc.SampleDesc.Quality = 0;
- desc.Usage = D3D11_USAGE_DEFAULT;
- desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
- desc.CPUAccessFlags = 0;
- desc.MiscFlags = 0;
-
- D3D11_SUBRESOURCE_DATA subresourceData;
- subresourceData.pSysMem = p;
- subresourceData.SysMemPitch = width;
- subresourceData.SysMemSlicePitch = 0;
-
- ID3D11Texture2D *texture;
- m_device->CreateTexture2D(&desc, &subresourceData, &texture);
-
- D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
- srvDesc.Format = desc.Format;
- srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
- srvDesc.Texture2D.MipLevels = 1;
- srvDesc.Texture2D.MostDetailedMip = 0;
-
- m_device->CreateShaderResourceView(texture, &srvDesc, &result);
- texture->Release();
-
- return result;
-}
diff --git a/src/widgets/d3dvideowidget.h b/src/widgets/d3dvideowidget.h
deleted file mode 100644
index f9528e55b6..0000000000
--- a/src/widgets/d3dvideowidget.h
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright (c) 2023 Meltytech, LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#ifndef D3DVIDEOWIDGET_H
-#define D3DVIDEOWIDGET_H
-
-#include "videowidget.h"
-
-#include
-#include
-
-class D3DVideoWidget : public Mlt::VideoWidget
-{
- Q_OBJECT
-public:
- explicit D3DVideoWidget(QObject *parent = nullptr);
- virtual ~D3DVideoWidget();
-
-public slots:
- virtual void initialize();
- virtual void beforeRendering();
- virtual void renderVideo();
-
-private:
- enum Stage { VertexStage, FragmentStage };
- void prepareShader(Stage stage);
- QByteArray compileShader(Stage stage, const QByteArray &source, const QByteArray &entryPoint);
- ID3D11ShaderResourceView *initTexture(const void *p, int width, int height);
-
- ID3D11Device *m_device = nullptr;
- ID3D11DeviceContext *m_context = nullptr;
- QByteArray m_vert;
- QByteArray m_vertEntryPoint;
- QByteArray m_frag;
- QByteArray m_fragEntryPoint;
-
- bool m_initialized = false;
- ID3D11Buffer *m_vbuf = nullptr;
- ID3D11Buffer *m_cbuf = nullptr;
- ID3D11VertexShader *m_vs = nullptr;
- ID3D11PixelShader *m_ps = nullptr;
- ID3D11InputLayout *m_inputLayout = nullptr;
- ID3D11RasterizerState *m_rastState = nullptr;
- ID3D11DepthStencilState *m_dsState = nullptr;
- ID3D11ShaderResourceView *m_texture[3] = {nullptr, nullptr, nullptr};
-
- struct ConstantBuffer
- {
- int32_t colorspace;
- };
-
- ConstantBuffer m_constants;
-};
-
-#endif // D3DVIDEOWIDGET_H
diff --git a/src/widgets/metalvideowidget.h b/src/widgets/metalvideowidget.h
deleted file mode 100644
index 192162489e..0000000000
--- a/src/widgets/metalvideowidget.h
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (c) 2023 Meltytech, LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#ifndef METALVIDEOWIDGET_H
-#define METALVIDEOWIDGET_H
-
-#include "videowidget.h"
-
-class MetalVideoRenderer;
-
-class MetalVideoWidget : public Mlt::VideoWidget
-{
- Q_OBJECT
-public:
- explicit MetalVideoWidget(QObject *parent);
- virtual ~MetalVideoWidget();
-
-public slots:
- virtual void initialize();
- virtual void renderVideo();
-
-private:
- std::unique_ptr m_renderer;
-};
-
-#endif // METALVIDEOWIDGET_H
diff --git a/src/widgets/metalvideowidget.mm b/src/widgets/metalvideowidget.mm
deleted file mode 100644
index 404e45eaa7..0000000000
--- a/src/widgets/metalvideowidget.mm
+++ /dev/null
@@ -1,359 +0,0 @@
-/*
- * Copyright (c) 2023-2025 Meltytech, LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#include "metalvideowidget.h"
-
-#include "Logger.h"
-
-#include
-
-
-class MetalVideoRenderer : public QObject
-{
- Q_OBJECT
-public:
- MetalVideoRenderer()
- {
- for (int i = 0; i < 3; ++i) {
- m_ubuf[i] = nil;
- m_texture[i] = nil;
- }
- m_vbuf = nil;
- m_vs.first = nil;
- m_vs.second = nil;
- m_fs.first = nil;
- m_fs.second = nil;
- }
-
- ~MetalVideoRenderer()
- {
- LOG_DEBUG() << "cleanup";
-
- for (int i = 0; i < 3; i++) {
- [m_texture[i] release];
- [m_ubuf[i] release];
- }
- [m_vbuf release];
- [m_vs.first release];
- [m_vs.second release];
- [m_fs.first release];
- [m_fs.second release];
- }
-
- void initialize(QQuickWindow *window)
- {
- LOG_DEBUG() << "init";
- m_window = window;
-
- QSGRendererInterface *rif = m_window->rendererInterface();
-
- // We are not prepared for anything other than running with the RHI and its Metal backend.
- Q_ASSERT(rif->graphicsApi() == QSGRendererInterface::Metal);
-
- m_device = (id) rif->getResource(m_window, QSGRendererInterface::DeviceResource);
- Q_ASSERT(m_device);
-
- if (m_vert.isEmpty())
- prepareShader(VertexStage);
- if (m_frag.isEmpty())
- prepareShader(FragmentStage);
-
- m_vbuf = [m_device newBufferWithLength: 16*sizeof(float) options: MTLResourceStorageModeShared];
-
- for (int i = 0; i < m_window->graphicsStateInfo().framesInFlight && i < 3; ++i)
- m_ubuf[i] = [m_device newBufferWithLength: sizeof(int) options: MTLResourceStorageModeShared];
-
- MTLVertexDescriptor *inputLayout = [MTLVertexDescriptor vertexDescriptor];
- inputLayout.attributes[0].format = MTLVertexFormatFloat4;
- inputLayout.attributes[0].offset = 0;
- inputLayout.attributes[0].bufferIndex = 1; // ubuf is 0, vbuf is 1
- inputLayout.layouts[1].stride = 4 * sizeof(float);
-
- MTLRenderPipelineDescriptor *rpDesc = [[MTLRenderPipelineDescriptor alloc] init];
- rpDesc.vertexDescriptor = inputLayout;
-
- m_vs = compileShader(m_vert, m_vertEntryPoint);
- rpDesc.vertexFunction = m_vs.first;
- m_fs = compileShader(m_frag, m_fragEntryPoint);
- rpDesc.fragmentFunction = m_fs.first;
-
- rpDesc.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
-// if (m_device.depth24Stencil8PixelFormatSupported) {
-// rpDesc.depthAttachmentPixelFormat = MTLPixelFormatDepth24Unorm_Stencil8;
-// rpDesc.stencilAttachmentPixelFormat = MTLPixelFormatDepth24Unorm_Stencil8;
-// } else {
-// rpDesc.depthAttachmentPixelFormat = MTLPixelFormatDepth32Float_Stencil8;
-// rpDesc.stencilAttachmentPixelFormat = MTLPixelFormatDepth32Float_Stencil8;
-// }
-
- NSError *err = nil;
- m_pipeline = [m_device newRenderPipelineStateWithDescriptor: rpDesc error: &err];
- if (!m_pipeline) {
- const QString msg = QString::fromNSString(err.localizedDescription);
- qFatal("Failed to create render pipeline state: %s", qPrintable(msg));
- }
- [rpDesc release];
- }
-
- void render(const QSize& viewportSize, const QRectF& videoRect, const double devicePixelRatio,
- const double zoom, const QPoint& offset, const SharedFrame& sharedFrame)
- {
- const QQuickWindow::GraphicsStateInfo &stateInfo(m_window->graphicsStateInfo());
-
- QSGRendererInterface *rif = m_window->rendererInterface();
- id encoder = (id) rif->getResource(
- m_window, QSGRendererInterface::CommandEncoderResource);
- Q_ASSERT(encoder);
-
- // Provide vertices of triangle strip
- float width = videoRect.width() * devicePixelRatio / 2.0f;
- float height = videoRect.height() * devicePixelRatio / 2.0f;
- float vertexData[] = { // x,y plus u,v texture coordinates
- -width, height, 0.f, 0.f,
- width, height, 1.f, 0.f,
- -width, -height, 0.f, 1.f,
- width, -height, 1.f, 1.f
- };
-
- // Setup an orthographic projection
- QMatrix4x4 modelView;
- width = viewportSize.width() * devicePixelRatio;
- height = viewportSize.height() * devicePixelRatio;
- modelView.scale(2.0f / width, 2.0f / height);
-
- // Set model-view
- if (videoRect.width() > 0.0 && zoom > 0.0) {
- if (offset.x() || offset.y())
- modelView.translate(-offset.x() * devicePixelRatio,
- offset.y() * devicePixelRatio);
- modelView.scale(zoom, zoom);
- }
- for (int i = 0; i < 4; i++) {
- vertexData[4 * i] *= modelView(0, 0);
- vertexData[4 * i] += modelView(0, 3);
- vertexData[4 * i + 1] *= modelView(1, 1);
- vertexData[4 * i + 1] += modelView(1, 3);
- }
-
- m_window->beginExternalCommands();
-
- void *p = [m_vbuf contents];
- memcpy(p, vertexData, sizeof(vertexData));
-
- p = [m_ubuf[stateInfo.currentFrameSlot] contents];
- int colorspace = MLT.profile().colorspace();
- memcpy(p, &colorspace, sizeof(colorspace));
-
- MTLViewport vp;
- vp.originX = 0;
- vp.originY = 0;
- vp.width = width;
- vp.height = height;
- vp.znear = 0;
- vp.zfar = 1;
- [encoder setViewport: vp];
-
- // (Re)create the textures
- int iwidth = sharedFrame.get_image_width();
- int iheight = sharedFrame.get_image_height();
- const uint8_t *image = sharedFrame.get_image(mlt_image_yuv420p);
- for (int i = 0; i < 3; i++) {
- [m_texture[i] release];
- }
- m_texture[0] = initTexture(image, iwidth, iheight);
- m_texture[1] = initTexture(image + iwidth * iheight, iwidth / 2, iheight / 2);
- m_texture[2] = initTexture(image + iwidth * iheight + iwidth / 2 * iheight / 2, iwidth / 2,
- iheight / 2);
- // Set the texture object. The AAPLTextureIndexBaseColor enum value corresponds
- /// to the 'colorMap' argument in the 'samplingShader' function because its
- // texture attribute qualifier also uses AAPLTextureIndexBaseColor for its index.
- for (NSUInteger i = 0; i < 3; i++) {
- [encoder setFragmentTexture:m_texture[i] atIndex:i];
- }
-
-
- [encoder setFragmentBuffer: m_ubuf[stateInfo.currentFrameSlot] offset: 0 atIndex: 0];
- [encoder setVertexBuffer: m_vbuf offset: 0 atIndex: 1];
- [encoder setRenderPipelineState: m_pipeline];
- [encoder drawPrimitives: MTLPrimitiveTypeTriangleStrip vertexStart: 0 vertexCount: 4 instanceCount: 1 baseInstance: 0];
-
- m_window->endExternalCommands();
- }
-
- id initTexture(const void *p, NSUInteger width, NSUInteger height)
- {
- MTLTextureDescriptor *textureDescriptor = [[MTLTextureDescriptor alloc] init];
-
- // 8-bit unsigned normalized value (i.e. 0 maps to 0.0 and 255 maps to 1.0)
- textureDescriptor.pixelFormat = MTLPixelFormatR8Unorm;
- textureDescriptor.width = width;
- textureDescriptor.height = height;
- id texture = [m_device newTextureWithDescriptor:textureDescriptor];
- [textureDescriptor release];
-
- MTLRegion region = {
- { 0, 0, 0 }, // MTLOrigin
- {width, height, 1} // MTLSize
- };
-
- // Copy the bytes from the data object into the texture
- [texture replaceRegion:region mipmapLevel:0 withBytes:p bytesPerRow:width];
- return texture;
- }
-
-private:
- enum Stage {
- VertexStage,
- FragmentStage
- };
- using FuncAndLib = QPair, id >;
- QQuickWindow *m_window = nullptr;
- QByteArray m_vert;
- QByteArray m_vertEntryPoint;
- QByteArray m_frag;
- QByteArray m_fragEntryPoint;
- id m_device;
- id m_vbuf;
- id m_ubuf[3];
- FuncAndLib m_vs;
- FuncAndLib m_fs;
- id m_pipeline;
- id m_texture[3];
-
- void prepareShader(Stage stage)
- {
- if (stage == VertexStage) {
- m_vert ="#include \n"
- "#include \n"
- "using namespace metal;"
- "struct main0_out {"
- " float2 coords [[user(locn0)]];"
- " float4 vertices [[position]];"
- "};"
- "struct main0_in {"
- " float4 vertices [[attribute(0)]];"
- "};"
- "vertex main0_out main0(main0_in in [[stage_in]]) {"
- " main0_out out = {};"
- " out.vertices = vector_float4(in.vertices.xy, 0.0f, 1.0f);"
- " out.coords = in.vertices.zw;"
- " return out;"
- "}";
- Q_ASSERT(!m_vert.isEmpty());
- m_vertEntryPoint = QByteArrayLiteral("main0");
- } else {
- m_frag ="#include \n"
- "#include \n"
- "using namespace metal;"
- "struct buf {"
- " int colorspace;"
- "};"
- "struct main0_out {"
- " float4 fragColor [[color(0)]];"
- "};"
- "struct main0_in {"
- " float2 coords [[user(locn0)]];"
- "};"
- "fragment main0_out main0(main0_in in [[stage_in]], constant buf& ubuf [[buffer(0)]],"
- " texture2d yTex [[texture(0)]],"
- " texture2d uTex [[texture(1)]],"
- " texture2d vTex [[texture(2)]]"
- " ) {"
- " main0_out out = {};"
- " constexpr sampler yuvSampler (mag_filter::linear, min_filter::linear);"
- " float3 yuv;"
- " yuv.x = yTex.sample(yuvSampler, in.coords).r - 16.0f/255.0f;"
- " yuv.y = uTex.sample(yuvSampler, in.coords).r - 128.0f/255.0f;"
- " yuv.z = vTex.sample(yuvSampler, in.coords).r - 128.0f/255.0f;"
- " float3x3 coefficients;"
- " if (ubuf.colorspace == 601) {"
- " coefficients = float3x3("
- " {1.1643f, 1.1643f, 1.1643f},"
- " {0.0f, -0.39173f, 2.017f},"
- " {1.5958f, -0.8129f, 0.0f});"
- " } else if (ubuf.colorspace == 2020) {" // ITU-R BT.2020
- " coefficients = float3x3("
- " {1.1643f, 1.1643f, 1.1643f},"
- " {0.0f, -0.1873f, 2.1418f},"
- " {1.7167f, -0.6504f, 0.0f});"
- " } else {" // ITU-R 709
- " coefficients = float3x3("
- " {1.1643f, 1.1643f, 1.1643f},"
- " {0.0f, -0.213f, 2.112f},"
- " {1.793f, -0.533f, 0.0f});"
- " }"
- " out.fragColor = float4(coefficients * yuv, 1.0f);"
- " return out;"
- "}";
- m_fragEntryPoint = QByteArrayLiteral("main0");
- }
- }
-
- FuncAndLib compileShader(const QByteArray &source, const QByteArray &entryPoint)
- {
- FuncAndLib fl;
-
- NSString *srcstr = [NSString stringWithUTF8String: source.constData()];
- MTLCompileOptions *opts = [[MTLCompileOptions alloc] init];
- opts.languageVersion = MTLLanguageVersion1_2;
- NSError *err = nil;
- fl.second = [m_device newLibraryWithSource: srcstr options: opts error: &err];
- [opts release];
- // srcstr is autoreleased
-
- if (err) {
- const QString msg = QString::fromNSString(err.localizedDescription);
- qFatal("%s", qPrintable(msg));
- return fl;
- }
-
- NSString *name = [NSString stringWithUTF8String: entryPoint.constData()];
- fl.first = [fl.second newFunctionWithName: name];
-// [name release];
-
- return fl;
- }
-};
-
-MetalVideoWidget::MetalVideoWidget(QObject *parent)
- : Mlt::VideoWidget{parent}
- , m_renderer{new MetalVideoRenderer}
-{
- m_maxTextureSize = 16384;
-}
-
-MetalVideoWidget::~MetalVideoWidget()
-{
-}
-
-void MetalVideoWidget::initialize()
-{
- m_renderer->initialize(quickWindow());
- Mlt::VideoWidget::initialize();
-}
-
-void MetalVideoWidget::renderVideo()
-{
- m_mutex.lock();
- if (m_sharedFrame.is_valid()) {
- m_renderer->render(size(), rect(), devicePixelRatio(), zoom(), offset(), m_sharedFrame);
- }
- m_mutex.unlock();
- Mlt::VideoWidget::renderVideo();
-}
-
-#include "metalvideowidget.moc"
diff --git a/src/widgets/openglvideowidget.cpp b/src/widgets/openglvideowidget.cpp
deleted file mode 100644
index fde689fbe5..0000000000
--- a/src/widgets/openglvideowidget.cpp
+++ /dev/null
@@ -1,388 +0,0 @@
-/*
- * Copyright (c) 2011-2026 Meltytech, LLC
- *
- * Some GL shader based on BSD licensed code from Peter Bengtsson:
- * http://www.fourcc.org/source/YUV420P-OpenGL-GLSLang.c
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#include "openglvideowidget.h"
-#include "mainwindow.h"
-
-#include "Logger.h"
-
-#include
-#include
-#include
-#include
-
-#ifdef QT_NO_DEBUG
-#define check_error(fn) \
- {}
-#else
-#define check_error(fn) \
- { \
- int err = fn->glGetError(); \
- if (err != GL_NO_ERROR) { \
- LOG_ERROR() << "GL error" << Qt::hex << err << Qt::dec << "at" << __FILE__ << ":" \
- << __LINE__; \
- } \
- }
-#endif
-
-OpenGLVideoWidget::OpenGLVideoWidget(QObject *parent)
- : VideoWidget{parent}
- , m_quickContext(nullptr)
- , m_isThreadedOpenGL(false)
-{
- m_renderTexture[0] = m_renderTexture[1] = m_renderTexture[2] = 0;
- m_displayTexture[0] = m_displayTexture[1] = m_displayTexture[2] = 0;
-}
-
-OpenGLVideoWidget::~OpenGLVideoWidget()
-{
- LOG_DEBUG() << "begin";
- if (m_renderTexture[0] && m_displayTexture[0] && m_context) {
- m_context->makeCurrent(&m_offscreenSurface);
- m_context->functions()->glDeleteTextures(3, m_renderTexture);
- if (m_displayTexture[0] && m_displayTexture[1] && m_displayTexture[2])
- m_context->functions()->glDeleteTextures(3, m_displayTexture);
- m_context->doneCurrent();
- }
-}
-
-void OpenGLVideoWidget::initialize()
-{
- LOG_DEBUG() << "begin";
- auto context = static_cast(
- quickWindow()->rendererInterface()->getResource(quickWindow(),
- QSGRendererInterface::OpenGLContextResource));
- m_quickContext = context;
-
- if (!m_offscreenSurface.isValid()) {
- m_offscreenSurface.setFormat(context->format());
- m_offscreenSurface.create();
- }
- Q_ASSERT(m_offscreenSurface.isValid());
-
- initializeOpenGLFunctions();
- LOG_INFO() << "OpenGL vendor" << QString::fromUtf8((const char *) glGetString(GL_VENDOR));
- LOG_INFO() << "OpenGL renderer" << QString::fromUtf8((const char *) glGetString(GL_RENDERER));
- LOG_INFO() << "OpenGL threaded?" << context->supportsThreadedOpenGL();
- LOG_INFO() << "OpenGL ES?" << context->isOpenGLES();
- glGetIntegerv(GL_MAX_TEXTURE_SIZE, &m_maxTextureSize);
- LOG_INFO() << "OpenGL maximum texture size =" << m_maxTextureSize;
- GLint dims[2];
- glGetIntegerv(GL_MAX_VIEWPORT_DIMS, &dims[0]);
- LOG_INFO() << "OpenGL maximum viewport size =" << dims[0] << "x" << dims[1];
-
-#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
- // Turn off the hardware decoder by default on Linux with NVIDIA
- const auto nvidia
- = QString::fromUtf8((const char *) glGetString(GL_RENDERER)).toLower().contains("nv");
- if (nvidia && !Settings.playerPreviewHardwareDecoderIsSet()) {
- MAIN.turnOffHardwareDecoder();
- }
-#endif
-
- createShader();
-
- LOG_DEBUG() << "end";
- Mlt::VideoWidget::initialize();
-}
-
-void OpenGLVideoWidget::createShader()
-{
- m_shader.reset(new QOpenGLShaderProgram);
- m_shader->addShaderFromSourceCode(QOpenGLShader::Vertex,
- "uniform highp mat4 projection;"
- "uniform highp mat4 modelView;"
- "attribute highp vec4 vertex;"
- "attribute highp vec2 texCoord;"
- "varying highp vec2 coordinates;"
- "void main(void) {"
- " gl_Position = projection * modelView * vertex;"
- " coordinates = texCoord;"
- "}");
- m_shader
- ->addShaderFromSourceCode(QOpenGLShader::Fragment,
- "uniform sampler2D Ytex, Utex, Vtex;"
- "uniform lowp int colorspace;"
- "varying highp vec2 coordinates;"
- "void main(void) {"
- " mediump vec3 texel;"
- " texel.r = texture2D(Ytex, coordinates).r - 16.0/255.0;" // Y
- " texel.g = texture2D(Utex, coordinates).r - 128.0/255.0;" // U
- " texel.b = texture2D(Vtex, coordinates).r - 128.0/255.0;" // V
- " mediump mat3 coefficients;"
- " if (colorspace == 601) {"
- " coefficients = mat3("
- " 1.1643, 1.1643, 1.1643," // column 1
- " 0.0, -0.39173, 2.017," // column 2
- " 1.5958, -0.8129, 0.0);" // column 3
- " } else if (colorspace == 2020) {" // ITU-R BT.2020
- " coefficients = mat3("
- " 1.1643, 1.1643, 1.1643," // column 1
- " 0.0, -0.1873, 2.1418," // column 2
- " 1.7167, -0.6504, 0.0);" // column 3
- " } else {" // ITU-R 709
- " coefficients = mat3("
- " 1.1643, 1.1643, 1.1643," // column 1
- " 0.0, -0.213, 2.112," // column 2
- " 1.793, -0.533, 0.0);" // column 3
- " }"
- " gl_FragColor = vec4(coefficients * texel, 1.0);"
- "}");
- m_shader->link();
- m_textureLocation[0] = m_shader->uniformLocation("Ytex");
- m_textureLocation[1] = m_shader->uniformLocation("Utex");
- m_textureLocation[2] = m_shader->uniformLocation("Vtex");
- m_colorspaceLocation = m_shader->uniformLocation("colorspace");
- m_projectionLocation = m_shader->uniformLocation("projection");
- m_modelViewLocation = m_shader->uniformLocation("modelView");
- m_vertexLocation = m_shader->attributeLocation("vertex");
- m_texCoordLocation = m_shader->attributeLocation("texCoord");
-}
-
-static void uploadTextures(QOpenGLContext *context, const SharedFrame &frame, GLuint texture[])
-{
- int width = frame.get_image_width();
- int height = frame.get_image_height();
- const uint8_t *image = frame.get_image(mlt_image_yuv420p);
- QOpenGLFunctions *f = context->functions();
-
- // The planes of pixel data may not be a multiple of the default 4 bytes.
- f->glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
-
- // Upload each plane of YUV to a texture.
- if (texture[0])
- f->glDeleteTextures(3, texture);
- check_error(f);
- f->glGenTextures(3, texture);
- check_error(f);
-
- f->glBindTexture(GL_TEXTURE_2D, texture[0]);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
- check_error(f);
- f->glTexImage2D(GL_TEXTURE_2D,
- 0,
- GL_LUMINANCE,
- width,
- height,
- 0,
- GL_LUMINANCE,
- GL_UNSIGNED_BYTE,
- image);
- check_error(f);
-
- f->glBindTexture(GL_TEXTURE_2D, texture[1]);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
- check_error(f);
- f->glTexImage2D(GL_TEXTURE_2D,
- 0,
- GL_LUMINANCE,
- width / 2,
- height / 2,
- 0,
- GL_LUMINANCE,
- GL_UNSIGNED_BYTE,
- image + width * height);
- check_error(f);
-
- f->glBindTexture(GL_TEXTURE_2D, texture[2]);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
- check_error(f);
- f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
- check_error(f);
- f->glTexImage2D(GL_TEXTURE_2D,
- 0,
- GL_LUMINANCE,
- width / 2,
- height / 2,
- 0,
- GL_LUMINANCE,
- GL_UNSIGNED_BYTE,
- image + width * height + width / 2 * height / 2);
- check_error(f);
-}
-
-void OpenGLVideoWidget::renderVideo()
-{
- auto context = static_cast(
- quickWindow()->rendererInterface()->getResource(quickWindow(),
- QSGRendererInterface::OpenGLContextResource));
- if (!m_quickContext) {
- LOG_ERROR() << "No quickContext";
- return;
- }
- if (!context->isValid()) {
- LOG_ERROR() << "No QSGRendererInterface::OpenGLContextResource";
- return;
- }
-
-#ifndef QT_NO_DEBUG
- QOpenGLFunctions *f = context->functions();
-#endif
- float width = this->width() * devicePixelRatioF();
- float height = this->height() * devicePixelRatioF();
-
- glDisable(GL_BLEND);
- glDisable(GL_DEPTH_TEST);
- glDepthMask(GL_FALSE);
- glViewport(0, 0, width, height);
- check_error(f);
-
- if (!m_isThreadedOpenGL) {
- m_mutex.lock();
- if (!m_sharedFrame.is_valid()) {
- m_mutex.unlock();
- return;
- }
- uploadTextures(context, m_sharedFrame, m_displayTexture);
- m_mutex.unlock();
- }
-
- if (!m_displayTexture[0]) {
- return;
- }
-
- quickWindow()->beginExternalCommands();
-
- // Bind textures.
- for (int i = 0; i < 3; ++i) {
- if (m_displayTexture[i]) {
- glActiveTexture(GL_TEXTURE0 + i);
- glBindTexture(GL_TEXTURE_2D, m_displayTexture[i]);
- check_error(f);
- }
- }
-
- // Init shader program.
- m_shader->bind();
- m_shader->setUniformValue(m_textureLocation[0], 0);
- m_shader->setUniformValue(m_textureLocation[1], 1);
- m_shader->setUniformValue(m_textureLocation[2], 2);
- m_shader->setUniformValue(m_colorspaceLocation, MLT.profile().colorspace());
- check_error(f);
-
- // Setup an orthographic projection.
- QMatrix4x4 projection;
- projection.scale(2.0f / width, 2.0f / height);
- m_shader->setUniformValue(m_projectionLocation, projection);
- check_error(f);
-
- // Set model view.
- QMatrix4x4 modelView;
- if (rect().width() > 0.0 && zoom() > 0.0) {
- if (offset().x() || offset().y())
- modelView.translate(-offset().x() * devicePixelRatioF(),
- offset().y() * devicePixelRatioF());
- modelView.scale(zoom(), zoom());
- }
- m_shader->setUniformValue(m_modelViewLocation, modelView);
- check_error(f);
-
- // Provide vertices of triangle strip.
- QVector vertices;
- width = rect().width() * devicePixelRatioF();
- height = rect().height() * devicePixelRatioF();
- vertices << QVector2D(-width / 2.0f, -height / 2.0f);
- vertices << QVector2D(-width / 2.0f, height / 2.0f);
- vertices << QVector2D(width / 2.0f, -height / 2.0f);
- vertices << QVector2D(width / 2.0f, height / 2.0f);
- m_shader->enableAttributeArray(m_vertexLocation);
- check_error(f);
- m_shader->setAttributeArray(m_vertexLocation, vertices.constData());
- check_error(f);
-
- // Provide texture coordinates.
- QVector texCoord;
- texCoord << QVector2D(0.0f, 1.0f);
- texCoord << QVector2D(0.0f, 0.0f);
- texCoord << QVector2D(1.0f, 1.0f);
- texCoord << QVector2D(1.0f, 0.0f);
- m_shader->enableAttributeArray(m_texCoordLocation);
- check_error(f);
- m_shader->setAttributeArray(m_texCoordLocation, texCoord.constData());
- check_error(f);
-
- // Render
- glDrawArrays(GL_TRIANGLE_STRIP, 0, vertices.size());
- check_error(f);
-
- // Cleanup
- m_shader->disableAttributeArray(m_vertexLocation);
- m_shader->disableAttributeArray(m_texCoordLocation);
- m_shader->release();
- for (int i = 0; i < 3; ++i) {
- if (m_displayTexture[i]) {
- glActiveTexture(GL_TEXTURE0 + i);
- glBindTexture(GL_TEXTURE_2D, 0);
- check_error(f);
- }
- }
- glActiveTexture(GL_TEXTURE0);
- check_error(f);
-
- quickWindow()->endExternalCommands();
- Mlt::VideoWidget::renderVideo();
-}
-
-void OpenGLVideoWidget::onFrameDisplayed(const SharedFrame &frame)
-{
- if (m_isThreadedOpenGL && !m_context) {
- m_context.reset(new QOpenGLContext);
- if (m_context) {
- m_context->setFormat(m_quickContext->format());
- m_context->setShareContext(m_quickContext);
- m_context->create();
- }
- }
- if (m_context && m_context->isValid()) {
- // Using threaded OpenGL to upload textures.
- QOpenGLFunctions *f = m_context->functions();
- m_context->makeCurrent(&m_offscreenSurface);
- uploadTextures(m_context.get(), frame, m_renderTexture);
- f->glBindTexture(GL_TEXTURE_2D, 0);
- check_error(f);
- f->glFinish();
- m_context->doneCurrent();
-
- m_mutex.lock();
- for (int i = 0; i < 3; ++i)
- std::swap(m_renderTexture[i], m_displayTexture[i]);
- m_mutex.unlock();
- }
- Mlt::VideoWidget::onFrameDisplayed(frame);
-}
diff --git a/src/widgets/openglvideowidget.h b/src/widgets/openglvideowidget.h
deleted file mode 100644
index d214bceefe..0000000000
--- a/src/widgets/openglvideowidget.h
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright (c) 2023 Meltytech, LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#ifndef OPENGLVIDEOWIDGET_H
-#define OPENGLVIDEOWIDGET_H
-
-#include "videowidget.h"
-
-#include
-#include
-#include
-#include
-#include
-
-class OpenGLVideoWidget : public Mlt::VideoWidget, protected QOpenGLFunctions
-{
- Q_OBJECT
-
-public:
- explicit OpenGLVideoWidget(QObject *parent = nullptr);
- virtual ~OpenGLVideoWidget();
-
-public slots:
- virtual void initialize();
- virtual void renderVideo();
- virtual void onFrameDisplayed(const SharedFrame &frame);
-
-private:
- void createShader();
-
- QOffscreenSurface m_offscreenSurface;
- std::unique_ptr m_shader;
- GLint m_projectionLocation;
- GLint m_modelViewLocation;
- GLint m_vertexLocation;
- GLint m_texCoordLocation;
- GLint m_colorspaceLocation;
- GLint m_textureLocation[3];
- QOpenGLContext *m_quickContext;
- std::unique_ptr m_context;
- GLuint m_renderTexture[3];
- GLuint m_displayTexture[3];
- bool m_isThreadedOpenGL;
-};
-
-#endif // OPENGLVIDEOWIDGET_H
diff --git a/src/widgets/timelinepropertieswidget.cpp b/src/widgets/timelinepropertieswidget.cpp
index 3cc5511512..4ab807435c 100644
--- a/src/widgets/timelinepropertieswidget.cpp
+++ b/src/widgets/timelinepropertieswidget.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2015-2025 Meltytech, LLC
+ * Copyright (c) 2015-2026 Meltytech, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -20,6 +20,7 @@
#include "mltcontroller.h"
#include "util.h"
+#include "videowidget.h"
TimelinePropertiesWidget::TimelinePropertiesWidget(Mlt::Service &service, QWidget *parent)
: QWidget(parent)
@@ -49,6 +50,17 @@ TimelinePropertiesWidget::TimelinePropertiesWidget(Mlt::Service &service, QWidge
ui->colorspaceLabel->setText("ITU-R BT.2020");
else
ui->colorspaceLabel->setText("");
+ switch (hdrTransferFromTrc(MLT.colorTrc())) {
+ case HdrTransfer::HLG:
+ ui->dynamicRangeValueLabel->setText("HLG HDR");
+ break;
+ case HdrTransfer::PQ:
+ ui->dynamicRangeValueLabel->setText("PQ HDR");
+ break;
+ default:
+ ui->dynamicRangeValueLabel->setText("SDR");
+ break;
+ }
}
}
diff --git a/src/widgets/timelinepropertieswidget.ui b/src/widgets/timelinepropertieswidget.ui
index af0bf06a07..2e9d11e03f 100644
--- a/src/widgets/timelinepropertieswidget.ui
+++ b/src/widgets/timelinepropertieswidget.ui
@@ -240,7 +240,48 @@
+ -
+
+
+ Dynamic range
+
+
+ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter
+
+
+
+ -
+
+
+ :
+
+
+
-
+
+
-
+
+
+ SDR
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
-