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 + + + External Monitor + + + + + @@ -186,7 +194,7 @@ - + @@ -273,12 +281,6 @@ - - - External Monitor - - - @@ -286,7 +288,6 @@ - @@ -355,8 +356,8 @@ - - + + @@ -905,14 +906,6 @@ DirectX (ANGLE) - - - 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 + + + + + + +