From 243cb5ab49388e3eeb2c00449da50b6cdcdf44de Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sun, 10 May 2026 21:07:19 -0700 Subject: [PATCH 01/42] Replace custom VideoWidgets with QML VideoOutput --- src/CMakeLists.txt | 9 +- src/mainwindow.cpp | 10 - src/mltcontroller.cpp | 22 +- src/qml/modules/Shotcut/Controls/VuiBase.qml | 17 + src/videowidget.cpp | 178 ++++---- src/videowidget.h | 44 +- src/widgets/d3dvideowidget.cpp | 403 ------------------- src/widgets/d3dvideowidget.h | 69 ---- src/widgets/metalvideowidget.h | 40 -- src/widgets/metalvideowidget.mm | 359 ----------------- src/widgets/openglvideowidget.cpp | 388 ------------------ src/widgets/openglvideowidget.h | 60 --- 12 files changed, 138 insertions(+), 1461 deletions(-) delete mode 100644 src/widgets/d3dvideowidget.cpp delete mode 100644 src/widgets/d3dvideowidget.h delete mode 100644 src/widgets/metalvideowidget.h delete mode 100644 src/widgets/metalvideowidget.mm delete mode 100644 src/widgets/openglvideowidget.cpp delete mode 100644 src/widgets/openglvideowidget.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1662ae669f..57d6f78f48 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -310,9 +310,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 +329,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/mainwindow.cpp b/src/mainwindow.cpp index b93889ad47..d30b51ec87 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -976,16 +976,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, diff --git a/src/mltcontroller.cpp b/src/mltcontroller.cpp index 66953e2647..8203047b3e 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; } 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/videowidget.cpp b/src/videowidget.cpp index 1643dcb2ce..3ffc084fb5 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 @@ -40,7 +40,8 @@ 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) @@ -80,36 +81,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 +195,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 +243,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; } @@ -520,12 +502,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 +517,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 +528,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 +539,102 @@ 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); +} + +void VideoWidget::pushFrameToSink(const SharedFrame &frame) +{ + if (!m_videoSink) + return; + int width = frame.get_image_width(); + int height = frame.get_image_height(); + if (width < 1 || height < 1) + return; + const uint8_t *image = frame.get_image(mlt_image_yuv420p); + if (!image) + return; + + QVideoFrameFormat fmt(QSize(width, height), QVideoFrameFormat::Format_YUV420P); + switch (profile().colorspace()) { + case 601: + case 170: + fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT601); + break; + case 2020: + fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT2020); + break; + default: + fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT709); + break; + } + + // Zero-copy buffer: SharedFrame is ref-counted, so the captured copy keeps + // the underlying MLT frame (and its image data) alive until QVideoFrame is + // destroyed. + class SharedFrameVideoBuffer : public QAbstractVideoBuffer + { + public: + SharedFrameVideoBuffer(const SharedFrame &sf, const QVideoFrameFormat &f) + : m_sharedFrame(sf) + , m_format(f) + { + } + 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(); + auto *p = const_cast(m_sharedFrame.get_image(mlt_image_yuv420p)); + MapData md; + md.planeCount = 3; + // Y plane + md.bytesPerLine[0] = w; + md.data[0] = p; + md.dataSize[0] = w * h; + // U plane + md.bytesPerLine[1] = w / 2; + md.data[1] = p + w * h; + md.dataSize[1] = (w / 2) * (h / 2); + // V plane + md.bytesPerLine[2] = w / 2; + md.data[2] = p + w * h + md.dataSize[1]; + md.dataSize[2] = md.dataSize[1]; + return md; + } + + private: + SharedFrame m_sharedFrame; + QVideoFrameFormat m_format; + }; + + auto buffer = std::make_unique(frame, fmt); + QVideoFrame videoFrame(std::move(buffer)); + m_videoSink->setVideoFrame(videoFrame); +} + +void VideoWidget::showFrame(Mlt::Frame frame) { 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); + emit frameDisplayed(m_sharedFrame); + if (m_imageRequested) { + m_imageRequested = false; + emit imageReady(); + } + m_frameSemaphore.release(); } void VideoWidget::setGrid(int grid) @@ -629,9 +693,8 @@ void VideoWidget::on_frame_show(mlt_consumer, VideoWidget *widget, mlt_event_dat 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, + if (widget->m_frameSemaphore.tryAcquire(1, timeout)) { + QMetaObject::invokeMethod(widget, "showFrame", Qt::QueuedConnection, Q_ARG(Mlt::Frame, frame)); @@ -673,38 +736,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..1346c440f7 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 @@ -23,11 +23,16 @@ #include "sharedframe.h" #include +#include #include #include #include #include #include +#include +#include +#include +#include class QmlFilter; class QmlMetadata; @@ -38,7 +43,6 @@ namespace Mlt { class Filter; class RenderThread; -class FrameRenderer; typedef void *(*thread_function_t)(void *); @@ -91,10 +95,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 +110,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); + Q_INVOKABLE void showFrame(Mlt::Frame frame); signals: void frameDisplayed(const SharedFrame &frame); @@ -137,7 +140,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 +151,10 @@ 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); private slots: void resizeVideo(int width, int height); @@ -161,7 +167,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 +190,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 From b1dd45e4764af8af1c701353665e3442d6d11997 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Tue, 12 May 2026 21:33:47 -0700 Subject: [PATCH 02/42] Settings > Player > External Monitor > HDR Preview Window --- src/CMakeLists.txt | 18 ++++ src/hdrpreviewwindow.cpp | 169 ++++++++++++++++++++++++++++++++ src/hdrpreviewwindow.h | 61 ++++++++++++ src/macos.h | 24 +++++ src/macos.mm | 70 +++++++++++++ src/mainwindow.cpp | 67 +++++++++++++ src/mainwindow.h | 2 + src/qml/views/HdrPreview.qml | 37 +++++++ src/qml/views/hdr_gain.frag | 17 ++++ src/qml/views/hdr_gain.frag.qsb | Bin 0 -> 1375 bytes src/settings.cpp | 30 ++++++ src/settings.h | 7 ++ src/videowidget.cpp | 154 ++++++++++++++++++++++++----- src/videowidget.h | 4 +- 14 files changed, 633 insertions(+), 27 deletions(-) create mode 100644 src/hdrpreviewwindow.cpp create mode 100644 src/hdrpreviewwindow.h create mode 100644 src/qml/views/HdrPreview.qml create mode 100644 src/qml/views/hdr_gain.frag create mode 100644 src/qml/views/hdr_gain.frag.qsb diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 57d6f78f48..bc4611179d 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 diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp new file mode 100644 index 0000000000..8670c5caa3 --- /dev/null +++ b/src/hdrpreviewwindow.cpp @@ -0,0 +1,169 @@ +/* + * 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 "qmltypes/qmlutilities.h" + +#include +#include +#include +#include +#include +#include + +#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; +} + +HdrPreviewWindow::HdrPreviewWindow(QWindow *parent) + : QQuickView(QmlUtilities::sharedEngine(), parent) +{ + setTitle(tr("HDR Preview")); + setResizeMode(QQuickView::SizeRootObjectToView); + setColor(Qt::black); + + // Request HDR swapchain via the internal Qt scene graph property. + // Qt's video fragment shaders only select the linear HDR output path + // (nv12_bt2020_hlg_linear.frag) for HDRExtendedSrgbLinear, not for + // HDRExtendedDisplayP3Linear. So we must use "scrgb" on all platforms. + setProperty("_qt_sg_hdr_format", QByteArrayLiteral("scrgb")); + + rootContext()->setContextProperty("hdrWindow", this); + + QDir qmlDir = QmlUtilities::qmlDir(); + setSource(QUrl::fromLocalFile(qmlDir.filePath("views/HdrPreview.qml"))); + + resize(960, 540); + +#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; +} + +void HdrPreviewWindow::pushFrame(const QVideoFrame &frame) +{ + if (m_videoSink && isVisible()) { + if (!m_loggedSwapChain) { + m_loggedSwapChain = true; + auto *sc = swapChain(); + if (sc) { + qDebug() << "HDR Preview: swapChain format =" << sc->format() + << "hdrInfo =" << sc->hdrInfo(); + } else { + qDebug() << "HDR Preview: swapChain() returned nullptr!"; + } + qDebug() << "HDR Preview frame: pixelFormat =" << frame.surfaceFormat().pixelFormat() + << "colorTransfer =" << frame.surfaceFormat().colorTransfer() + << "maxLuminance =" << frame.surfaceFormat().maxLuminance(); +#ifdef Q_OS_MACOS + auto wid = winId(); + qDebug() << "HDR Preview EDR:" + << "current =" << macosCurrentEdrHeadroom(wid) + << "potential =" << macosPotentialEdrHeadroom(wid) + << "reference =" << macosReferenceEdrHeadroom(wid); +#endif + } + updateHdrGain(); + m_videoSink->setVideoFrame(frame); + } +} + +void HdrPreviewWindow::setHlg(bool isHlg) +{ + if (m_isHlg != isHlg) { + m_isHlg = isHlg; + if (!m_isHlg && !qFuzzyCompare(m_hdrGain, 1.0f)) { + m_hdrGain = 1.0f; + emit hdrGainChanged(); + } + } +} + +void HdrPreviewWindow::updateHdrGain() +{ + if (!m_isHlg) + return; + + auto *sc = swapChain(); + if (!sc || sc->format() != QRhiSwapChain::HDRExtendedSrgbLinear) + return; + + auto info = sc->hdrInfo(); + float headroom = 1.0f; + if (info.limitsType == QRhiSwapChainHdrInfo::ColorComponentValue) + headroom = info.limits.colorComponentValue.maxColorComponentValue; + + if (headroom <= 1.0f) + return; + + // Qt's HLG shader has a bug: maxLum is HLG-encoded but used as a linear + // multiplier in the OOTF. Compensate by multiplying the rendered output + // by the ratio of the correct linear value to the HLG-encoded one. + float newGain = headroom / hlgOetf(headroom); + if (!qFuzzyCompare(newGain, m_hdrGain)) { + m_hdrGain = newGain; + qDebug() << "HDR Preview: gain =" << m_hdrGain << "(headroom =" << headroom << ")"; + 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..7dc3e6b30e --- /dev/null +++ b/src/hdrpreviewwindow.h @@ -0,0 +1,61 @@ +/* + * 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 +#include +#include +#include +#include + +class HdrPreviewWindow : public QQuickView +{ + Q_OBJECT + Q_PROPERTY(float hdrGain READ hdrGain NOTIFY hdrGainChanged) + +public: + explicit HdrPreviewWindow(QWindow *parent = nullptr); + ~HdrPreviewWindow(); + + Q_INVOKABLE void setVideoSink(QVideoSink *sink); + float hdrGain() const { return m_hdrGain; } + +public slots: + void pushFrame(const QVideoFrame &frame); + void setHlg(bool isHlg); + +signals: + void hdrGainChanged(); + +private slots: + void checkEdrHeadroom(); + +private: + void updateHdrGain(); + + QPointer m_videoSink; + QTimer m_edrTimer; + bool m_loggedSwapChain{false}; + bool m_isHlg{false}; + float m_lastLoggedHeadroom{0.0f}; + int m_edrCheckCount{0}; + float m_hdrGain{1.0f}; +}; + +#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/mainwindow.cpp b/src/mainwindow.cpp index d30b51ec87..af3264e38f 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) { @@ -1271,6 +1273,59 @@ void MainWindow::setupSettingsMenu() action->setData(i); m_externalGroup->addAction(action); } + + auto hdrAction = m_externalGroup->addAction(tr("HDR Preview Window")); + hdrAction->setCheckable(true); + Actions.add("hdrPreviewAction", hdrAction, tr("Player")); + connect(hdrAction, &QAction::toggled, this, [this, hdrAction](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::hlgActiveChanged, + m_hdrPreviewWindow, + &HdrPreviewWindow::setHlg); + connect(m_hdrPreviewWindow, + &QWindow::visibleChanged, + this, + [this, hdrAction](bool visible) { + if (!visible) { + Settings.setPlayerHdrPreviewFullScreen( + m_hdrPreviewWindow->windowStates() & Qt::WindowFullScreen); + Settings.setPlayerHdrPreviewGeometry(m_hdrPreviewWindow->geometry()); + hdrAction->setChecked(false); + } + }); + auto savedGeometry = Settings.playerHdrPreviewGeometry(); + if (savedGeometry.isValid()) + m_hdrPreviewWindow->setGeometry(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()); + delete m_hdrPreviewWindow; + m_hdrPreviewWindow = nullptr; + } + } + Settings.setPlayerHdrPreview(checked); + }); + 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; @@ -2573,6 +2628,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()) @@ -2852,6 +2913,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(); } diff --git a/src/mainwindow.h b/src/mainwindow.h index fac0c7bf15..bfd07d42f9 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 { @@ -224,6 +225,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); diff --git a/src/qml/views/HdrPreview.qml b/src/qml/views/HdrPreview.qml new file mode 100644 index 0000000000..6a7681590c --- /dev/null +++ b/src/qml/views/HdrPreview.qml @@ -0,0 +1,37 @@ +/* + * 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 QtMultimedia + +Rectangle { + color: "black" + + Component.onCompleted: hdrWindow.setVideoSink(videoOutput.videoSink) + + VideoOutput { + id: videoOutput + + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectFit + layer.enabled: true + layer.effect: ShaderEffect { + property real gain: hdrWindow.hdrGain + fragmentShader: "hdr_gain.frag.qsb" + } + } +} 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 0000000000000000000000000000000000000000..f186842c3ca67298ada1e9eef49eaf27ee3f00ff GIT binary patch literal 1375 zcmV-l1)%x>01$R~oYhzVPuo@yfB6C#fwrtGP+HcS!rG+ETrWx|GzwxO+ghnrx(2aH zQDhm%3EmPrwV!EN#lO%W_qXnsX}f#x?7Wyjt4-Q8S8m?jyZhYT=kC1(#@HNVEDvu6 z-ka<-`3kbmh+J!6`nxcbQpUrmFpViiq9utl@)SMFoEl3 zSRuh0yenW|Aj0!Pmj`xbPWZ0Efhp2{y0fu-8}M)_i{!sRdem)`e1&uv?;7D{$$l~E zkF;N;zGP_s29w93K>H{!kq+^e2@mC)Ag_{bUi3urw@Nm%gn66d-y%$u*GRVn@~6ZP z=I}Ymca)s((t4KcH^`omJ<6Mu6WV`9In63~6~g}ta?G;vkwc%~K{G?SVL#@`e+Ojb z^(t|?LwsH*&VB^1g6O-nLH#Zu&XI11`n^J!4M1NOyh+`P(p${w8^F6uwXG2ch=Fyi zll~ppAjUnyStmcN`98(jRQw*0{s*u@xx~iv8|%uX%P6{sq{F;kLKOwUrSvb;{sSfF zFDU1V5?d$zJL2gPVd%uu66yYc$Xjy4^K@6B&|%5gXAU<`oFH_4Py5m^io?LMT_GL2 zz2l+Q?fVwrKBsm)P!>ZTL^jusqHghRQG;h-@tXFUn=dRLxT9^M69}km3@zK`r>eFG zDQy?e04nm_t{)5#OEarmibe(ut--MG1l770`cYszU~pmur>@u2P8_?6_y^8t-}i%# zfqTB&(XilBnNkC*nr3S|8h1uK3Y==aB#|l;zGazSuWx=ASiQQ%t(MUSFLW>2`snmm z?I9+pW9k*n*58y#p;mZSET$MAvE<2Yjv22S{}PM;cm94;_^XWhd;A~r=X%qq^CK>~ z>O%ede%}v1h|bH%OpyK|k9gr!$2{f?8|v&jfhhNDEpU2n$eo~cWW-qp{u^^({S1)> zQ?*eILzoG-;02+6@w?s7;UzmsSkOwAluXLBcFq(FNgL^>W!ad6c<7$UM0TkSYT6vb zH)Wri+~SUAN+Xk+ry7T745MRl*Op178>#M1c9RCK`SRJTLvugy!_a)@4I@sZO+kam z2jBj*-)Ow58?vA&7>$UhK$Nh6&^$B`tf1#`f)GQ62IjMV7KR(F0SLmCkWe7E%eFG#EDYfQZXg8MDT?mjZ}?3QR}$9 zV(uN7HZKKmrk*AIk+U#9OWF2@>)HLN<7iI?4!8Oy{CMxXN4v$bK6D42NBEz9ybBjg zhZP`f #include #include +#include #include #include #include @@ -192,6 +193,12 @@ 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); // playlist QString playlistThumbnails() const; diff --git a/src/videowidget.cpp b/src/videowidget.cpp index 3ffc084fb5..34b8274df5 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -32,6 +32,10 @@ #include #include +#ifdef __ARM_NEON +#include +#endif + using namespace Mlt; VideoWidget::VideoWidget(QObject *parent) @@ -380,13 +384,19 @@ 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(); switch (processingMode) { case ShotcutSettings::Native10Cpu: + m_consumer->set("mlt_image_format", hdrPreview ? "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", @@ -411,7 +421,7 @@ int VideoWidget::reconfigure(bool isMulti) m_consumer->set("color_trc", "bt470bg"); break; case 2020: - if (isDeckLinkHLG) { + if (isDeckLinkHLG || hdrPreview) { m_consumer->set("color_trc", "arib-std-b67"); } else { m_consumer->clear("color_trc"); @@ -421,9 +431,10 @@ int VideoWidget::reconfigure(bool isMulti) m_consumer->set("color_trc", "bt709"); break; } + emit hlgActiveChanged(!qstrcmp(m_consumer->get("color_trc"), "arib-std-b67")); if (processingMode == ShotcutSettings::Linear10Cpu - || (processingMode == ShotcutSettings::Linear10GpuCpu - && property("decklinkGamma").toInt() != 1)) { + || (processingMode == ShotcutSettings::Linear10GpuCpu && !isDeckLinkHLG + && !hdrPreview)) { m_consumer->set("mlt_color_trc", "linear"); } else { m_consumer->clear("mlt_color_trc"); @@ -554,66 +565,157 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) int height = frame.get_image_height(); if (width < 1 || height < 1) return; - const uint8_t *image = frame.get_image(mlt_image_yuv420p); + + bool is10bit = !qstrcmp(m_consumer->get("mlt_image_format"), "yuv420p10"); + mlt_image_format mltFormat = is10bit ? mlt_image_yuv420p10 : mlt_image_yuv420p; + const uint8_t *image = frame.get_image(mltFormat); if (!image) return; - QVideoFrameFormat fmt(QSize(width, height), QVideoFrameFormat::Format_YUV420P); + auto pixFmt = is10bit ? QVideoFrameFormat::Format_P016 : QVideoFrameFormat::Format_YUV420P; + QVideoFrameFormat fmt(QSize(width, height), pixFmt); + fmt.setColorRange(QVideoFrameFormat::ColorRange_Video); 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); + if (!qstrcmp(m_consumer->get("color_trc"), "arib-std-b67")) { + fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_STD_B67); + fmt.setMaxLuminance(1000.0f); + } else { + fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT709); + } break; default: fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT709); + fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT709); break; } - // Zero-copy buffer: SharedFrame is ref-counted, so the captured copy keeps - // the underlying MLT frame (and its image data) alive until QVideoFrame is - // destroyed. + // For BT.2020/HLG, convert MLT's planar yuv420p10 (3 planes: Y, U, V with + // 16-bit samples, 10 significant bits in LSBs) to semi-planar P016 (2 planes: + // Y, interleaved UV with full 16-bit range) so that Qt selects the + // nv12_bt2020_hlg fragment shader for proper HLG tone mapping. + QByteArray p016Buffer; + if (is10bit) { + int uvW = width / 2; + int uvH = height / 2; + int ySamples = width * height; + int yPlaneSize = ySamples * 2; // 16-bit per sample + int uvPlaneSize = uvW * uvH * 2; // per U or V plane in source + int interleavedUvSize = uvW * uvH * 4; // interleaved UV in dest + p016Buffer.resize(yPlaneSize + interleavedUvSize); + + // Copy Y plane, shifting 10-bit values (LSBs) to full 16-bit range + const uint16_t *srcY = reinterpret_cast(image); + uint16_t *dstY = reinterpret_cast(p016Buffer.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; +#else + for (int i = 0; i < ySamples; ++i) { + dstY[i] = srcY[i] << 6; + } +#endif + + // Interleave U and V planes, also shifting to full 16-bit range + const uint16_t *srcU = reinterpret_cast(image + yPlaneSize); + const uint16_t *srcV = reinterpret_cast(image + yPlaneSize + uvPlaneSize); + uint16_t *dstUV = reinterpret_cast(p016Buffer.data() + yPlaneSize); + 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; + } +#else + for (int i = 0; i < uvSamples; ++i) { + dstUV[2 * i] = srcU[i] << 6; + dstUV[2 * i + 1] = srcV[i] << 6; + } +#endif + } + + // Zero-copy buffer for 8-bit, or P016 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) + : m_p016(std::move(p016)) + , m_format(f) + {} 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(); - auto *p = const_cast(m_sharedFrame.get_image(mlt_image_yuv420p)); MapData md; - md.planeCount = 3; - // Y plane - md.bytesPerLine[0] = w; - md.data[0] = p; - md.dataSize[0] = w * h; - // U plane - md.bytesPerLine[1] = w / 2; - md.data[1] = p + w * h; - md.dataSize[1] = (w / 2) * (h / 2); - // V plane - md.bytesPerLine[2] = w / 2; - md.data[2] = p + w * h + md.dataSize[1]; - md.dataSize[2] = md.dataSize[1]; + 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; }; - auto buffer = std::make_unique(frame, fmt); + std::unique_ptr buffer; + if (is10bit) { + buffer = std::make_unique(std::move(p016Buffer), fmt); + } else { + buffer = std::make_unique(frame, fmt); + } QVideoFrame videoFrame(std::move(buffer)); m_videoSink->setVideoFrame(videoFrame); + emit videoFrameReady(videoFrame); } void VideoWidget::showFrame(Mlt::Frame frame) diff --git a/src/videowidget.h b/src/videowidget.h index 1346c440f7..0c968a6cfc 100644 --- a/src/videowidget.h +++ b/src/videowidget.h @@ -22,6 +22,7 @@ #include "settings.h" #include "sharedframe.h" +#include #include #include #include @@ -29,7 +30,6 @@ #include #include #include -#include #include #include #include @@ -128,6 +128,8 @@ public slots: void snapToGridChanged(); void toggleZoom(bool); void stepZoom(float, float); + void videoFrameReady(const QVideoFrame &frame); + void hlgActiveChanged(bool isHlg); private: QRectF m_rect; From fb5c76762282bc4e6c7bf30bfe70258ee655ae94 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 09:56:42 -0700 Subject: [PATCH 03/42] fix cmake configure --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 750e143441..2df1e6a5d3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON) find_package(Qt6 6.4 REQUIRED COMPONENTS Charts + GuiPrivate Multimedia Network OpenGL From a7d041795c013d6b13e18e8d02db98b4400f5678 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 12:21:28 -0700 Subject: [PATCH 04/42] fix HDR on Windows --- src/hdrpreviewwindow.cpp | 22 ++++++++++++++-------- src/qml/views/HdrPreview.qml | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index 8670c5caa3..b4eaee8c03 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -135,20 +135,26 @@ void HdrPreviewWindow::updateHdrGain() return; auto info = sc->hdrInfo(); - float headroom = 1.0f; + float maxNits = 100.0f; if (info.limitsType == QRhiSwapChainHdrInfo::ColorComponentValue) - headroom = info.limits.colorComponentValue.maxColorComponentValue; + maxNits = 100.0f * info.limits.colorComponentValue.maxColorComponentValue; + else if (info.limitsType == QRhiSwapChainHdrInfo::LuminanceInNits) + maxNits = info.limits.luminanceInNits.maxLuminance; - if (headroom <= 1.0f) + float displayMaxLinear = maxNits / 100.0f; + if (displayMaxLinear <= 1.0f) return; - // Qt's HLG shader has a bug: maxLum is HLG-encoded but used as a linear - // multiplier in the OOTF. Compensate by multiplying the rendered output - // by the ratio of the correct linear value to the HLG-encoded one. - float newGain = headroom / hlgOetf(headroom); + // 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. + float newGain = displayMaxLinear / hlgOetf(displayMaxLinear); if (!qFuzzyCompare(newGain, m_hdrGain)) { m_hdrGain = newGain; - qDebug() << "HDR Preview: gain =" << m_hdrGain << "(headroom =" << headroom << ")"; + qDebug() << "HDR Preview: gain =" << m_hdrGain << "(maxNits =" << maxNits << ")"; emit hdrGainChanged(); } } diff --git a/src/qml/views/HdrPreview.qml b/src/qml/views/HdrPreview.qml index 6a7681590c..b057120885 100644 --- a/src/qml/views/HdrPreview.qml +++ b/src/qml/views/HdrPreview.qml @@ -29,6 +29,7 @@ Rectangle { 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" From 9270a80297c192b448ca8849c569a8a86b1f7182 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 12:40:39 -0700 Subject: [PATCH 05/42] Propagate keyboard events from HdrPreviewWindow --- src/hdrpreviewwindow.cpp | 34 ++++++++++++++++++++++++++++++++++ src/hdrpreviewwindow.h | 5 +++++ 2 files changed, 39 insertions(+) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index b4eaee8c03..334266eec6 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -17,6 +17,8 @@ #include "hdrpreviewwindow.h" +#include "actions.h" +#include "mainwindow.h" #include "qmltypes/qmlutilities.h" #include @@ -114,6 +116,38 @@ void HdrPreviewWindow::pushFrame(const QVideoFrame &frame) } } +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::setHlg(bool isHlg) { if (m_isHlg != isHlg) { diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index 7dc3e6b30e..d6cd46bc54 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -18,6 +18,7 @@ #ifndef HDRPREVIEWWINDOW_H #define HDRPREVIEWWINDOW_H +#include #include #include #include @@ -43,6 +44,10 @@ public slots: signals: void hdrGainChanged(); +protected: + void keyPressEvent(QKeyEvent *event) override; + void keyReleaseEvent(QKeyEvent *event) override; + private slots: void checkEdrHeadroom(); From 3093e0f71e637b2144f64be6e33b0578c7e888a4 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 14:57:35 -0700 Subject: [PATCH 06/42] add playback controls --- scripts/build-shotcut-msys2.sh | 2 +- scripts/build-shotcut.sh | 2 +- src/hdrpreviewwindow.cpp | 172 ++++++++++++++++ src/hdrpreviewwindow.h | 32 +++ src/mainwindow.cpp | 4 + src/mainwindow.h | 1 + src/qml/views/HdrPreview.qml | 366 ++++++++++++++++++++++++++++++++- src/videowidget.cpp | 4 + 8 files changed, 580 insertions(+), 3 deletions(-) diff --git a/scripts/build-shotcut-msys2.sh b/scripts/build-shotcut-msys2.sh index 74a15d9410..b1124c79dd 100755 --- a/scripts/build-shotcut-msys2.sh +++ b/scripts/build-shotcut-msys2.sh @@ -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 diff --git a/scripts/build-shotcut.sh b/scripts/build-shotcut.sh index 8876e8d92d..5ea8488bf2 100755 --- a/scripts/build-shotcut.sh +++ b/scripts/build-shotcut.sh @@ -1202,7 +1202,7 @@ 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 diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index 334266eec6..8b18ef097c 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -19,8 +19,14 @@ #include "actions.h" #include "mainwindow.h" +#include "mltcontroller.h" +#include "player.h" #include "qmltypes/qmlutilities.h" +#ifdef Q_OS_WIN +#include +#endif + #include #include #include @@ -44,6 +50,19 @@ static float hlgOetf(float 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) { @@ -64,6 +83,8 @@ HdrPreviewWindow::HdrPreviewWindow(QWindow *parent) 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 @@ -113,9 +134,160 @@ void HdrPreviewWindow::pushFrame(const QVideoFrame &frame) } updateHdrGain(); 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::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; + 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 diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index d6cd46bc54..4f0862d188 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -21,6 +21,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -29,6 +32,12 @@ class HdrPreviewWindow : public QQuickView { Q_OBJECT Q_PROPERTY(float hdrGain READ hdrGain NOTIFY hdrGainChanged) + 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); @@ -36,17 +45,36 @@ class HdrPreviewWindow : public QQuickView Q_INVOKABLE void setVideoSink(QVideoSink *sink); float hdrGain() const { return m_hdrGain; } + bool isPlaying() const { return m_isPlaying; } + 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 setHlg(bool isHlg); + void setPlaying(bool playing); signals: void hdrGainChanged(); + 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(); @@ -58,9 +86,13 @@ private slots: QTimer m_edrTimer; bool m_loggedSwapChain{false}; bool m_isHlg{false}; + bool m_isPlaying{false}; float m_lastLoggedHeadroom{0.0f}; int m_edrCheckCount{0}; float m_hdrGain{1.0f}; + QRect m_normalGeometry; + int m_videoPosition{0}; + int m_videoDuration{0}; }; #endif // HDRPREVIEWWINDOW_H diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index af3264e38f..ac7da6dcbc 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1290,6 +1290,10 @@ void MainWindow::setupSettingsMenu() &Mlt::VideoWidget::hlgActiveChanged, m_hdrPreviewWindow, &HdrPreviewWindow::setHlg); + 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); }); connect(m_hdrPreviewWindow, &QWindow::visibleChanged, this, diff --git a/src/mainwindow.h b/src/mainwindow.h index bfd07d42f9..062c33f210 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -86,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 *); diff --git a/src/qml/views/HdrPreview.qml b/src/qml/views/HdrPreview.qml index b057120885..44668179a5 100644 --- a/src/qml/views/HdrPreview.qml +++ b/src/qml/views/HdrPreview.qml @@ -16,10 +16,32 @@ */ import QtQuick +import QtQuick.Shapes import QtMultimedia Rectangle { - color: "black" + color: palette.window + + 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) @@ -32,7 +54,349 @@ Rectangle { 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 } + } + } + } + } + + // 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); + } + } + } + } + } + } } + diff --git a/src/videowidget.cpp b/src/videowidget.cpp index 34b8274df5..cf8e194f1b 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -714,6 +714,10 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) 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); } From 64ccba4f1f435fa57760d9ace08b6b7345ac54cf Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 15:46:31 -0700 Subject: [PATCH 07/42] AVX2/SSE2 optimizations in VideoWidget --- src/util.cpp | 31 ++++++++++++++++ src/util.h | 1 + src/videowidget.cpp | 88 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/src/util.cpp b/src/util.cpp index 562e4dc1b6..e22514d04b 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -59,6 +59,10 @@ #include #endif +#if defined(__x86_64__) || defined(_M_AMD64) +#include +#endif + #ifdef Q_OS_MAC static constexpr unsigned int kLowMemoryThresholdPercent = 10U; #else @@ -1260,3 +1264,30 @@ bool Util::openUrl(const QUrl &url) #endif return success; } + +bool Util::cpuHasAVX2() +{ + static const bool result = []() -> bool { +#if defined(__GNUC__) || defined(__clang__) + return __builtin_cpu_supports("avx2"); +#elif defined(_MSC_VER) + 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 cf8e194f1b..323c8a7670 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -23,6 +23,7 @@ #include "qmltypes/qmlfilter.h" #include "qmltypes/qmlutilities.h" #include "settings.h" +#include "util.h" #include #include @@ -36,6 +37,10 @@ #include #endif +#if defined(__x86_64__) || defined(_M_AMD64) +#include +#endif + using namespace Mlt; VideoWidget::VideoWidget(QObject *parent) @@ -557,6 +562,79 @@ void VideoWidget::setVideoSink(QVideoSink *sink) 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) + void VideoWidget::pushFrameToSink(const SharedFrame &frame) { if (!m_videoSink) @@ -621,6 +699,11 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) } 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; @@ -644,6 +727,11 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) 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; From 9b4641215d568d83254deb1154f22e44bebfdc8e Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 18:49:37 -0700 Subject: [PATCH 08/42] build and use a minimal Qt media plugin on Linux --- CMakeLists.txt | 1 + MinimalMediaBackend/CMakeLists.txt | 23 +++++++ MinimalMediaBackend/minimalmediaplugin.cpp | 76 +++++++++++++++++++++ MinimalMediaBackend/minimalmediaplugin.json | 3 + src/main.cpp | 2 +- src/mainwindow.cpp | 3 +- 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 MinimalMediaBackend/CMakeLists.txt create mode 100644 MinimalMediaBackend/minimalmediaplugin.cpp create mode 100644 MinimalMediaBackend/minimalmediaplugin.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 2df1e6a5d3..d4750ab23e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,7 @@ if(NOT FFTW_FOUND) endif() add_subdirectory(CuteLogger) +add_subdirectory(MinimalMediaBackend) add_subdirectory(src) add_subdirectory(translations) diff --git a/MinimalMediaBackend/CMakeLists.txt b/MinimalMediaBackend/CMakeLists.txt new file mode 100644 index 0000000000..be08c1feee --- /dev/null +++ b/MinimalMediaBackend/CMakeLists.txt @@ -0,0 +1,23 @@ +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 +) + +install(TARGETS minimalmediaplugin + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/qt6/plugins/multimedia +) 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/src/main.cpp b/src/main.cpp index afaac5a630..ad63863876 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -371,7 +371,7 @@ int main(int argc, char **argv) if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "windows:altgr"); #else - ; + qputenv("QT_MEDIA_BACKEND", "minimal"); #endif #ifdef Q_OS_MAC diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index ac7da6dcbc..b559c08d9a 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1263,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()), From cda08d16ec93b2d3597cb0a6b3fce8936a38a066 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 19:05:28 -0700 Subject: [PATCH 09/42] only build and install minimal media for linux --- CMakeLists.txt | 4 +++- MinimalMediaBackend/CMakeLists.txt | 4 ---- scripts/build-shotcut.sh | 2 ++ 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d4750ab23e..4ea5c2a6e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,9 +65,11 @@ if(NOT FFTW_FOUND) endif() add_subdirectory(CuteLogger) -add_subdirectory(MinimalMediaBackend) add_subdirectory(src) add_subdirectory(translations) +if(UNIX AND NOT APPLE) + add_subdirectory(MinimalMediaBackend) +endif() feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES) diff --git a/MinimalMediaBackend/CMakeLists.txt b/MinimalMediaBackend/CMakeLists.txt index be08c1feee..f9498d5376 100644 --- a/MinimalMediaBackend/CMakeLists.txt +++ b/MinimalMediaBackend/CMakeLists.txt @@ -17,7 +17,3 @@ target_link_libraries(minimalmediaplugin PRIVATE Qt6::Multimedia Qt6::MultimediaPrivate ) - -install(TARGETS minimalmediaplugin - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/qt6/plugins/multimedia -) diff --git a/scripts/build-shotcut.sh b/scripts/build-shotcut.sh index 5ea8488bf2..7a6ef971b7 100755 --- a/scripts/build-shotcut.sh +++ b/scripts/build-shotcut.sh @@ -1209,6 +1209,8 @@ function install_shotcut_linux { 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 install -d "$FINAL_INSTALL_DIR"/lib/qt6/multimedia + cmd cp -p MinimalMediaBackend/libminimalmediaplugin.so "$FINAL_INSTALL_DIR"/lib/qt6/multimedia } function build_vmaf_darwin { From 394ca465cb6fb4f64ad34636f43188f843201951 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 21:44:05 -0700 Subject: [PATCH 10/42] fix build on macos --- CMakeLists.txt | 4 +++- src/util.cpp | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ea5c2a6e6..cbbd287afc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,7 +39,6 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON) find_package(Qt6 6.4 REQUIRED COMPONENTS Charts - GuiPrivate Multimedia Network OpenGL @@ -51,6 +50,9 @@ find_package(Qt6 6.4 REQUIRED Widgets Xml ) +if(NOT APPLE) + 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) diff --git a/src/util.cpp b/src/util.cpp index e22514d04b..c619d065bf 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -59,6 +59,10 @@ #include #endif +#ifdef _MSC_VER +#include +#endif + #if defined(__x86_64__) || defined(_M_AMD64) #include #endif @@ -1268,9 +1272,9 @@ bool Util::openUrl(const QUrl &url) bool Util::cpuHasAVX2() { static const bool result = []() -> bool { -#if defined(__GNUC__) || defined(__clang__) +#if (defined(__GNUC__) || defined(__clang__)) && (defined(__x86_64__) || defined(_M_AMD64)) return __builtin_cpu_supports("avx2"); -#elif defined(_MSC_VER) +#elif defined(_MSC_VER) && (defined(_M_X64) || defined(_M_AMD64)) int info[4]; __cpuid(info, 1); // Check OSXSAVE and AVX bits in ECX From 9040d3a5a22f4c359b389c8d171dc991854eaa0a Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 13 May 2026 21:55:04 -0700 Subject: [PATCH 11/42] fix HDR levels on macOS --- src/qml/views/HdrPreview.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qml/views/HdrPreview.qml b/src/qml/views/HdrPreview.qml index 44668179a5..c2e46117e3 100644 --- a/src/qml/views/HdrPreview.qml +++ b/src/qml/views/HdrPreview.qml @@ -20,7 +20,7 @@ import QtQuick.Shapes import QtMultimedia Rectangle { - color: palette.window + color: "black" component ControlButton: Rectangle { id: _btn From c870ecdc82ffd7b1e4ed89368198a563145cd1da Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Thu, 14 May 2026 10:12:42 -0700 Subject: [PATCH 12/42] fix: install qml/QtMultimedia --- scripts/build-shotcut-msys2.sh | 2 +- scripts/build-shotcut.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/build-shotcut-msys2.sh b/scripts/build-shotcut-msys2.sh index b1124c79dd..b66db99e9f 100755 --- a/scripts/build-shotcut-msys2.sh +++ b/scripts/build-shotcut-msys2.sh @@ -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 7a6ef971b7..c74618702e 100755 --- a/scripts/build-shotcut.sh +++ b/scripts/build-shotcut.sh @@ -1208,7 +1208,7 @@ function install_shotcut_linux { 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 } @@ -1954,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 From 32be11388a47224716f06d266a2adc5da44ca95d Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Thu, 14 May 2026 11:20:57 -0700 Subject: [PATCH 13/42] Stop hardcoding the Quick graphics API It already defaults to Metal on macOS, and Vulkan is required for HDR on Linux. But FYI, Vulkan still needs to be explicitly requested with env var QSG_RHI_BACKEND. --- src/hdrpreviewwindow.cpp | 12 +++++++++++- src/hdrpreviewwindow.h | 1 + src/main.cpp | 34 ++++++++++++++++++++++++---------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index 8b18ef097c..17d377c88a 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -337,8 +337,18 @@ void HdrPreviewWindow::updateHdrGain() return; auto *sc = swapChain(); - if (!sc || sc->format() != QRhiSwapChain::HDRExtendedSrgbLinear) + if (!sc || sc->format() != QRhiSwapChain::HDRExtendedSrgbLinear) { + 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 HDRExtendedSrgbLinear (1)." + << "Try QSG_RHI_BACKEND=vulkan on Linux."; + } return; + } auto info = sc->hdrInfo(); float maxNits = 100.0f; diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index 4f0862d188..920f7b2e19 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -85,6 +85,7 @@ private slots: QPointer m_videoSink; QTimer m_edrTimer; bool m_loggedSwapChain{false}; + bool m_loggedGainSkip{false}; bool m_isHlg{false}; bool m_isPlaying{false}; float m_lastLoggedHeadroom{0.0f}; diff --git a/src/main.cpp b/src/main.cpp index ad63863876..80f88844d4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -394,15 +394,11 @@ 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); +#if defined(Q_OS_MAC) QCoreApplication::setAttribute(Qt::AA_DontShowIconsInMenus); -#else - QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); - QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); - QCoreApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings); +#elif !defined(Q_OS_WIN) + if (::qgetenv("QSG_RHI_BACKEND").toLower() != QByteArrayLiteral("vulkan")) + QCoreApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings); #endif Application a(argc, argv); @@ -425,6 +421,26 @@ int main(int argc, char **argv) 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 +478,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); From fc5d3470dddd851a3a1b08d0a19d4c023a5458cf Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Thu, 14 May 2026 15:29:24 -0700 Subject: [PATCH 14/42] add Settings > Drawing Method > Vulkan on Linux and remove the Softare option (doesn't work) --- src/hdrpreviewwindow.cpp | 97 ++++++++++++++++++++++++++++++++++------ src/main.cpp | 21 ++------- src/mainwindow.cpp | 45 ++++++++----------- src/mainwindow.ui | 28 +++++++----- 4 files changed, 122 insertions(+), 69 deletions(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index 17d377c88a..f0b4e7e142 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -33,6 +33,11 @@ #include #include #include +#include +#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC) +#include +#include +#endif #ifdef Q_OS_MACOS #include "macos.h" @@ -71,10 +76,18 @@ HdrPreviewWindow::HdrPreviewWindow(QWindow *parent) setColor(Qt::black); // Request HDR swapchain via the internal Qt scene graph property. - // Qt's video fragment shaders only select the linear HDR output path - // (nv12_bt2020_hlg_linear.frag) for HDRExtendedSrgbLinear, not for - // HDRExtendedDisplayP3Linear. So we must use "scrgb" on all platforms. + // 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); @@ -117,13 +130,59 @@ void HdrPreviewWindow::pushFrame(const QVideoFrame &frame) auto *sc = swapChain(); if (sc) { qDebug() << "HDR Preview: swapChain format =" << sc->format() - << "hdrInfo =" << sc->hdrInfo(); + << "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:" @@ -337,15 +396,16 @@ void HdrPreviewWindow::updateHdrGain() return; auto *sc = swapChain(); - if (!sc || sc->format() != QRhiSwapChain::HDRExtendedSrgbLinear) { + 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 HDRExtendedSrgbLinear (1)." - << "Try QSG_RHI_BACKEND=vulkan on Linux."; + << "is not HDR. Try QSG_RHI_BACKEND=vulkan on Linux."; } return; } @@ -361,13 +421,22 @@ void HdrPreviewWindow::updateHdrGain() if (displayMaxLinear <= 1.0f) return; - // 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. - float newGain = displayMaxLinear / hlgOetf(displayMaxLinear); + float newGain; + if (sc->format() == QRhiSwapChain::HDR10) { + // HDR10 uses PQ (ST.2084) — Qt applies the PQ EOTF in its shader. + // No additional gain correction needed beyond what Qt already does, + // but we still expose the gain property for the QML overlay. + newGain = 1.0f; + } else { + // scRGB (HDRExtendedSrgbLinear) path. + // 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 << "(maxNits =" << maxNits << ")"; diff --git a/src/main.cpp b/src/main.cpp index 80f88844d4..ae50784687 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(); @@ -392,13 +388,7 @@ int main(int argc, char **argv) } } removeMacosTabBar(); -#endif - -#if defined(Q_OS_MAC) QCoreApplication::setAttribute(Qt::AA_DontShowIconsInMenus); -#elif !defined(Q_OS_WIN) - if (::qgetenv("QSG_RHI_BACKEND").toLower() != QByteArrayLiteral("vulkan")) - QCoreApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings); #endif Application a(argc, argv); @@ -415,8 +405,11 @@ 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(); @@ -498,12 +491,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 b559c08d9a..1fadf6ac27 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1580,33 +1580,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; diff --git a/src/mainwindow.ui b/src/mainwindow.ui index bb4c67c94a..5df8c7a3a1 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -186,7 +186,7 @@ - + @@ -355,8 +355,8 @@ - - + + @@ -905,14 +905,6 @@ DirectX (ANGLE) - - - true - - - Software (Mesa) - - true @@ -1560,6 +1552,20 @@ Shift+F1 + + + true + + + Vulkan + + + Vulkan + + + Vulkan + + From 335362261d12a1a384ff4110cb0853cf134c924b Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 11:35:06 -0700 Subject: [PATCH 15/42] fix: clear video frame buffer on sink set to prevent distorted frames --- src/hdrpreviewwindow.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index f0b4e7e142..d83799c397 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -120,6 +120,14 @@ HdrPreviewWindow::~HdrPreviewWindow() 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) From 10ea198cd90f5c6903b153936f00a9651ee372a6 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 12:02:13 -0700 Subject: [PATCH 16/42] Move External Menu to Player menu - It was inconveniently deep in that menu. - Added keyboard shortcut to open/raise it to Ctrl+`. - Rename it "Preview Window (HDR)" to clarify it is not only for HDR but can provide HDR. --- src/hdrpreviewwindow.cpp | 2 +- src/mainwindow.cpp | 7 ++++++- src/mainwindow.ui | 15 ++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index d83799c397..44852e7d0f 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -71,7 +71,7 @@ static QString formatTimecode(int frames, double fps) HdrPreviewWindow::HdrPreviewWindow(QWindow *parent) : QQuickView(QmlUtilities::sharedEngine(), parent) { - setTitle(tr("HDR Preview")); + setTitle(tr("Shotcut Preview")); setResizeMode(QQuickView::SizeRootObjectToView); setColor(Qt::black); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 1fadf6ac27..b33409583e 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1275,8 +1275,13 @@ void MainWindow::setupSettingsMenu() m_externalGroup->addAction(action); } - auto hdrAction = m_externalGroup->addAction(tr("HDR Preview Window")); + 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, [this, hdrAction](bool checked) { if (checked) { diff --git a/src/mainwindow.ui b/src/mainwindow.ui index 5df8c7a3a1..c6a72eb802 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -121,6 +121,14 @@ &Player + + + External Monitor + + + + + @@ -273,12 +281,6 @@ - - - External Monitor - - - @@ -286,7 +288,6 @@ - From dc7b9214f4f9c0e59b2910e58b1af7f66c83da39 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 17:35:59 -0700 Subject: [PATCH 17/42] add smtpe2048 PQ HDR --- src/dialogs/customprofiledialog.cpp | 36 +++++++++++++++++- src/dialogs/customprofiledialog.h | 4 +- src/dialogs/customprofiledialog.ui | 51 ++++++++++++++++++++++++- src/docks/encodedock.cpp | 12 ++++-- src/hdrpreviewwindow.cpp | 20 +++++----- src/hdrpreviewwindow.h | 5 ++- src/mainwindow.cpp | 4 +- src/mltcontroller.cpp | 44 +++++++++++++++++++++ src/mltcontroller.h | 5 ++- src/videowidget.cpp | 59 ++++++++++++++++++----------- src/videowidget.h | 4 +- 11 files changed, 198 insertions(+), 46 deletions(-) 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..7a0dc06236 100644 --- a/src/docks/encodedock.cpp +++ b/src/docks/encodedock.cpp @@ -1225,15 +1225,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"); diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index 44852e7d0f..0c0ff48fef 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -387,11 +387,11 @@ void HdrPreviewWindow::keyReleaseEvent(QKeyEvent *event) QQuickView::keyReleaseEvent(event); } -void HdrPreviewWindow::setHlg(bool isHlg) +void HdrPreviewWindow::setHdrTransfer(HdrTransfer transfer) { - if (m_isHlg != isHlg) { - m_isHlg = isHlg; - if (!m_isHlg && !qFuzzyCompare(m_hdrGain, 1.0f)) { + if (m_hdrTransfer != transfer) { + m_hdrTransfer = transfer; + if (m_hdrTransfer == HdrTransfer::SDR && !qFuzzyCompare(m_hdrGain, 1.0f)) { m_hdrGain = 1.0f; emit hdrGainChanged(); } @@ -400,7 +400,7 @@ void HdrPreviewWindow::setHlg(bool isHlg) void HdrPreviewWindow::updateHdrGain() { - if (!m_isHlg) + if (m_hdrTransfer == HdrTransfer::SDR) return; auto *sc = swapChain(); @@ -430,13 +430,13 @@ void HdrPreviewWindow::updateHdrGain() return; float newGain; - if (sc->format() == QRhiSwapChain::HDR10) { - // HDR10 uses PQ (ST.2084) — Qt applies the PQ EOTF in its shader. - // No additional gain correction needed beyond what Qt already does, - // but we still expose the gain property for the QML overlay. + if (sc->format() == QRhiSwapChain::HDR10 || m_hdrTransfer == HdrTransfer::PQ) { + // PQ (ST.2084) — Qt applies the PQ EOTF correctly in its shader, + // both for HDR10 swapchains and for scRGB with ColorTransfer_ST2084. + // No additional gain correction is needed. newGain = 1.0f; } else { - // scRGB (HDRExtendedSrgbLinear) path. + // 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) diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index 920f7b2e19..eeee6fed72 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -18,6 +18,7 @@ #ifndef HDRPREVIEWWINDOW_H #define HDRPREVIEWWINDOW_H +#include "videowidget.h" #include #include #include @@ -60,7 +61,7 @@ class HdrPreviewWindow : public QQuickView public slots: void pushFrame(const QVideoFrame &frame); - void setHlg(bool isHlg); + void setHdrTransfer(HdrTransfer transfer); void setPlaying(bool playing); signals: @@ -86,7 +87,7 @@ private slots: QTimer m_edrTimer; bool m_loggedSwapChain{false}; bool m_loggedGainSkip{false}; - bool m_isHlg{false}; + HdrTransfer m_hdrTransfer{HdrTransfer::SDR}; bool m_isPlaying{false}; float m_lastLoggedHeadroom{0.0f}; int m_edrCheckCount{0}; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index b33409583e..a501e28762 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1293,9 +1293,9 @@ void MainWindow::setupSettingsMenu() m_hdrPreviewWindow, &HdrPreviewWindow::pushFrame); connect(videoWidget, - &Mlt::VideoWidget::hlgActiveChanged, + &Mlt::VideoWidget::hdrTransferChanged, m_hdrPreviewWindow, - &HdrPreviewWindow::setHlg); + &HdrPreviewWindow::setHdrTransfer); 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); }); diff --git a/src/mltcontroller.cpp b/src/mltcontroller.cpp index 8203047b3e..d7f3cf3b0b 100644 --- a/src/mltcontroller.cpp +++ b/src/mltcontroller.cpp @@ -594,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. @@ -631,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/videowidget.cpp b/src/videowidget.cpp index 323c8a7670..56b273ddf9 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -390,9 +390,12 @@ int VideoWidget::reconfigure(bool isMulti) 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", hdrPreview ? "yuv420p10" : "rgba64"); + m_consumer->set("mlt_image_format", isHdrActive ? "yuv420p10" : "rgba64"); break; case ShotcutSettings::Linear10Cpu: m_consumer->set("mlt_image_format", "rgba64"); @@ -414,32 +417,39 @@ 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 || hdrPreview) { - 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; } - emit hlgActiveChanged(!qstrcmp(m_consumer->get("color_trc"), "arib-std-b67")); + const char *activeTrc = m_consumer->get("color_trc"); + HdrTransfer hdrTransfer = HdrTransfer::SDR; + if (!qstrcmp(activeTrc, "arib-std-b67")) + hdrTransfer = HdrTransfer::HLG; + else if (!qstrcmp(activeTrc, "smpte2084")) + hdrTransfer = HdrTransfer::PQ; + emit hdrTransferChanged(hdrTransfer); if (processingMode == ShotcutSettings::Linear10Cpu || (processingMode == ShotcutSettings::Linear10GpuCpu && !isDeckLinkHLG - && !hdrPreview)) { + && !isHdrActive)) { m_consumer->set("mlt_color_trc", "linear"); } else { m_consumer->clear("mlt_color_trc"); @@ -664,6 +674,9 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) if (!qstrcmp(m_consumer->get("color_trc"), "arib-std-b67")) { fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_STD_B67); fmt.setMaxLuminance(1000.0f); + } else if (!qstrcmp(m_consumer->get("color_trc"), "smpte2084")) { + fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_ST2084); + fmt.setMaxLuminance(1000.0f); } else { fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT709); } diff --git a/src/videowidget.h b/src/videowidget.h index 0c968a6cfc..7e21f3347c 100644 --- a/src/videowidget.h +++ b/src/videowidget.h @@ -22,6 +22,8 @@ #include "settings.h" #include "sharedframe.h" +enum class HdrTransfer { SDR = 0, HLG = 1, PQ = 2 }; + #include #include #include @@ -129,7 +131,7 @@ public slots: void toggleZoom(bool); void stepZoom(float, float); void videoFrameReady(const QVideoFrame &frame); - void hlgActiveChanged(bool isHlg); + void hdrTransferChanged(HdrTransfer transfer); private: QRectF m_rect; From 1b6ecb66fce9bbf2e58762af86e9134bd2115a71 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 17:40:47 -0700 Subject: [PATCH 18/42] Fix Preview Window increases its size on start --- src/hdrpreviewwindow.cpp | 14 ++++++++++++++ src/hdrpreviewwindow.h | 3 +++ src/mainwindow.cpp | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index 0c0ff48fef..a791603e86 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -238,6 +238,18 @@ 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) { @@ -324,6 +336,8 @@ void HdrPreviewWindow::resizeEvent(QResizeEvent *event) // 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()) diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index eeee6fed72..0c4520b875 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -47,6 +47,9 @@ class HdrPreviewWindow : public QQuickView Q_INVOKABLE void setVideoSink(QVideoSink *sink); float hdrGain() const { return m_hdrGain; } 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; } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index a501e28762..e856f86be0 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1313,7 +1313,7 @@ void MainWindow::setupSettingsMenu() }); auto savedGeometry = Settings.playerHdrPreviewGeometry(); if (savedGeometry.isValid()) - m_hdrPreviewWindow->setGeometry(savedGeometry); + m_hdrPreviewWindow->restoreGeometry(savedGeometry); } m_hdrPreviewWindow->show(); if (Settings.playerHdrPreviewFullScreen()) From 23c19b84ad206dcf11404013dbb6995bf09407fa Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 18:14:55 -0700 Subject: [PATCH 19/42] Implement HDR display settings with user-configurable peak brightness and tone mapping --- src/hdrpreviewwindow.cpp | 125 ++++++++++++++-- src/hdrpreviewwindow.h | 21 +++ src/qml/views/HdrPreview.qml | 267 ++++++++++++++++++++++++++++++++++- src/settings.cpp | 30 ++++ src/settings.h | 6 + src/videowidget.cpp | 78 +++++++--- 6 files changed, 500 insertions(+), 27 deletions(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index a791603e86..c22fb779e0 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -22,6 +22,7 @@ #include "mltcontroller.h" #include "player.h" #include "qmltypes/qmlutilities.h" +#include "settings.h" #ifdef Q_OS_WIN #include @@ -91,6 +92,11 @@ HdrPreviewWindow::HdrPreviewWindow(QWindow *parent) 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"))); @@ -133,6 +139,23 @@ void HdrPreviewWindow::setVideoSink(QVideoSink *sink) 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(); @@ -200,6 +223,16 @@ void HdrPreviewWindow::pushFrame(const QVideoFrame &frame) #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 @@ -405,6 +438,7 @@ 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(); @@ -412,6 +446,58 @@ void HdrPreviewWindow::setHdrTransfer(HdrTransfer transfer) } } +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) @@ -433,22 +519,41 @@ void HdrPreviewWindow::updateHdrGain() } auto info = sc->hdrInfo(); - float maxNits = 100.0f; + // Determine actual display peak from swap chain hdrInfo. + float actualMaxNits = 100.0f; if (info.limitsType == QRhiSwapChainHdrInfo::ColorComponentValue) - maxNits = 100.0f * info.limits.colorComponentValue.maxColorComponentValue; + actualMaxNits = 100.0f * info.limits.colorComponentValue.maxColorComponentValue; else if (info.limitsType == QRhiSwapChainHdrInfo::LuminanceInNits) - maxNits = info.limits.luminanceInNits.maxLuminance; + actualMaxNits = info.limits.luminanceInNits.maxLuminance; - float displayMaxLinear = maxNits / 100.0f; + // 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 applies the PQ EOTF correctly in its shader, - // both for HDR10 swapchains and for scRGB with ColorTransfer_ST2084. - // No additional gain correction is needed. - newGain = 1.0f; + // 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 @@ -461,7 +566,9 @@ void HdrPreviewWindow::updateHdrGain() } if (!qFuzzyCompare(newGain, m_hdrGain)) { m_hdrGain = newGain; - qDebug() << "HDR Preview: gain =" << m_hdrGain << "(maxNits =" << maxNits << ")"; + qDebug() << "HDR Preview: gain =" << m_hdrGain + << "(effectiveMaxNits =" << effectiveMaxNits + << "actualMaxNits =" << actualMaxNits << ")"; emit hdrGainChanged(); } } diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index 0c4520b875..b61f1a8218 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -33,6 +33,10 @@ 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) @@ -46,6 +50,13 @@ class HdrPreviewWindow : public QQuickView 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). @@ -69,6 +80,10 @@ public slots: signals: void hdrGainChanged(); + void hdrTransferModeChanged(); + void displayPeakNitsChanged(); + void contentPeakNitsChanged(); + void toneMappingChanged(); void playingChanged(); void fullScreenChanged(); void videoPositionChanged(); @@ -85,16 +100,22 @@ private slots: 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}; diff --git a/src/qml/views/HdrPreview.qml b/src/qml/views/HdrPreview.qml index c2e46117e3..a5462e6885 100644 --- a/src/qml/views/HdrPreview.qml +++ b/src/qml/views/HdrPreview.qml @@ -1,6 +1,6 @@ /* * 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 @@ -298,6 +298,28 @@ Rectangle { } } } + + // 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 @@ -397,6 +419,249 @@ Rectangle { } } } + + // 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/settings.cpp b/src/settings.cpp index 1fc049dbb5..fbc653a9c5 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -875,6 +875,36 @@ 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 c5952b7dca..2019159e50 100644 --- a/src/settings.h +++ b/src/settings.h @@ -199,6 +199,12 @@ class ShotcutSettings : public QObject 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/videowidget.cpp b/src/videowidget.cpp index 56b273ddf9..6bbe5827c6 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -663,28 +663,72 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) auto pixFmt = is10bit ? QVideoFrameFormat::Format_P016 : QVideoFrameFormat::Format_YUV420P; QVideoFrameFormat fmt(QSize(width, height), pixFmt); fmt.setColorRange(QVideoFrameFormat::ColorRange_Video); - switch (profile().colorspace()) { - case 601: - case 170: - fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT601); - fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT601); - break; - case 2020: + + // 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 (!qstrcmp(m_consumer->get("color_trc"), "arib-std-b67")) { + if (isHlg) { fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_STD_B67); - fmt.setMaxLuminance(1000.0f); - } else if (!qstrcmp(m_consumer->get("color_trc"), "smpte2084")) { - fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_ST2084); - fmt.setMaxLuminance(1000.0f); + // 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; } - break; - default: - fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT709); - fmt.setColorTransfer(QVideoFrameFormat::ColorTransfer_BT709); - break; } // For BT.2020/HLG, convert MLT's planar yuv420p10 (3 planes: Y, U, V with From 76a906f0f12d513d1a48c85f9351fa6f6e7181a6 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 19:53:35 -0700 Subject: [PATCH 20/42] Add HDR transfer and metadata to Export --- src/docks/encodedock.cpp | 158 +++++++++++++++++++++++++++++++++++++++ src/docks/encodedock.h | 8 ++ src/docks/encodedock.ui | 13 ++++ 3 files changed, 179 insertions(+) diff --git a/src/docks/encodedock.cpp b/src/docks/encodedock.cpp index 7a0dc06236..0d9fcfddbe 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,31 @@ 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 when the source is PQ. + if ((m_hdrMaxCll > 0 || m_hdrMaxFall > 0) + && MLT.colorTrc() == QLatin1String("smpte2084")) { + if (!x265params.contains(QLatin1String("max-cll="))) { + x265params = QStringLiteral("max-cll=%1,%2:%3") + .arg(m_hdrMaxCll) + .arg(m_hdrMaxFall) + .arg(x265params); + } + if (!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 +926,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 +994,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 +1262,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 @@ -1876,6 +1958,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) @@ -3063,6 +3156,71 @@ void EncodeDock::on_coverArtButton_clicked() } } +void EncodeDock::on_hdrMetaButton_clicked() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("HDR Metadata")); + + auto *form = new QFormLayout; + + 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..f38b683ade 100644 --- a/src/docks/encodedock.ui +++ b/src/docks/encodedock.ui @@ -970,6 +970,19 @@ with parallel processing enabled. + + + + false + + + Set HDR mastering display and content light level metadata + + + HDR... + + + From 67172a77a1a952146a790fd1e662c13d8b88c5a5 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 19:54:11 -0700 Subject: [PATCH 21/42] clang-format --- src/hdrpreviewwindow.cpp | 13 +++++-------- src/hdrpreviewwindow.h | 6 ++++-- src/mltcontroller.cpp | 4 ++-- src/videowidget.cpp | 5 ++--- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index c22fb779e0..a920f7b876 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -527,9 +527,8 @@ void HdrPreviewWindow::updateHdrGain() actualMaxNits = info.limits.luminanceInNits.maxLuminance; // User-overridden display peak, or actual. - float effectiveMaxNits = (m_displayPeakNits > 0) - ? static_cast(m_displayPeakNits) - : actualMaxNits; + float effectiveMaxNits = (m_displayPeakNits > 0) ? static_cast(m_displayPeakNits) + : actualMaxNits; float displayMaxLinear = effectiveMaxNits / 100.0f; if (displayMaxLinear <= 1.0f) return; @@ -547,9 +546,8 @@ void HdrPreviewWindow::updateHdrGain() // 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; + float contentPeak = (m_contentPeakNits > 0) ? static_cast(m_contentPeakNits) + : 1000.0f; if (contentPeak > effectiveMaxNits) { newGain *= effectiveMaxNits / contentPeak; } @@ -566,8 +564,7 @@ void HdrPreviewWindow::updateHdrGain() } if (!qFuzzyCompare(newGain, m_hdrGain)) { m_hdrGain = newGain; - qDebug() << "HDR Preview: gain =" << m_hdrGain - << "(effectiveMaxNits =" << effectiveMaxNits + qDebug() << "HDR Preview: gain =" << m_hdrGain << "(effectiveMaxNits =" << effectiveMaxNits << "actualMaxNits =" << actualMaxNits << ")"; emit hdrGainChanged(); } diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index b61f1a8218..4ade527404 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -34,8 +34,10 @@ 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(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) diff --git a/src/mltcontroller.cpp b/src/mltcontroller.cpp index d7f3cf3b0b..ed097b13ca 100644 --- a/src/mltcontroller.cpp +++ b/src/mltcontroller.cpp @@ -658,10 +658,10 @@ QString Controller::colorTrc() const 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 + return QStringLiteral("smpte2084"); // PQ if (trc == 18) return QStringLiteral("arib-std-b67"); // HLG - return QString(); // SDR or unsupported TRC + return QString(); // SDR or unsupported TRC } ++videoCount; } diff --git a/src/videowidget.cpp b/src/videowidget.cpp index 6bbe5827c6..30e4a46ab9 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -671,7 +671,7 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) // 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"); + const bool isPq = !qstrcmp(activeTrc, "smpte2084"); if (isHlg || isPq) { fmt.setColorSpace(QVideoFrameFormat::ColorSpace_BT2020); @@ -707,8 +707,7 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) s_lastTrc = activeTrc; s_lastMaxLum = stamped; qDebug() << "HDR pushFrameToSink: colorspace =" << profile().colorspace() - << "color_trc =" << activeTrc - << "maxLuminance =" << stamped + << "color_trc =" << activeTrc << "maxLuminance =" << stamped << "toneMapping =" << Settings.playerHdrToneMapping() << "contentPeakNits =" << Settings.playerHdrContentPeakNits() << "displayPeakNits =" << Settings.playerHdrDisplayPeakNits(); From 9b755dcf996e1fea60dbfff7cc882d067c816daf Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 22:25:18 -0700 Subject: [PATCH 22/42] Fix open/close preview window restarts playback --- src/mainwindow.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e856f86be0..134d787bbe 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1323,7 +1323,7 @@ void MainWindow::setupSettingsMenu() Settings.setPlayerHdrPreviewFullScreen(m_hdrPreviewWindow->windowStates() & Qt::WindowFullScreen); Settings.setPlayerHdrPreviewGeometry(m_hdrPreviewWindow->geometry()); - delete m_hdrPreviewWindow; + m_hdrPreviewWindow->deleteLater(); m_hdrPreviewWindow = nullptr; } } @@ -4634,6 +4634,16 @@ void MainWindow::on_actionJack_triggered(bool checked) 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()) { From 578b3dda6e85b44cb091fce4383f520daf3a1128 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 22:36:45 -0700 Subject: [PATCH 23/42] fix some crashes in GPU processing mode --- src/mainwindow.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 134d787bbe..bb7af874dc 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1304,7 +1304,7 @@ void MainWindow::setupSettingsMenu() &QWindow::visibleChanged, this, [this, hdrAction](bool visible) { - if (!visible) { + if (!visible && m_hdrPreviewWindow) { Settings.setPlayerHdrPreviewFullScreen( m_hdrPreviewWindow->windowStates() & Qt::WindowFullScreen); Settings.setPlayerHdrPreviewGeometry(m_hdrPreviewWindow->geometry()); @@ -1328,6 +1328,12 @@ void MainWindow::setupSettingsMenu() } } Settings.setPlayerHdrPreview(checked); + if (checked && Settings.playerGPU() && MLT.producer()) { + if (confirmRestartExternalMonitor()) { + m_exitCode = EXIT_RESTART; + QApplication::closeAllWindows(); + } + } }); connect(hdrAction, &QAction::triggered, this, [this, hdrAction]() { if (hdrAction->isChecked() && m_hdrPreviewWindow) { From 143923c5ad67b25a47ecdbad098940800be88577 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 22:41:08 -0700 Subject: [PATCH 24/42] Refactor HDR preview action handling into a separate slot --- src/mainwindow.cpp | 104 ++++++++++++++++++++++----------------------- src/mainwindow.h | 1 + 2 files changed, 53 insertions(+), 52 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index bb7af874dc..7663267f70 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1283,58 +1283,7 @@ void MainWindow::setupSettingsMenu() hdrAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_QuoteLeft)); #endif Actions.add("hdrPreviewAction", hdrAction, tr("Player")); - connect(hdrAction, &QAction::toggled, this, [this, hdrAction](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); - 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); }); - connect(m_hdrPreviewWindow, - &QWindow::visibleChanged, - this, - [this, hdrAction](bool visible) { - if (!visible && m_hdrPreviewWindow) { - Settings.setPlayerHdrPreviewFullScreen( - m_hdrPreviewWindow->windowStates() & Qt::WindowFullScreen); - Settings.setPlayerHdrPreviewGeometry(m_hdrPreviewWindow->geometry()); - hdrAction->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(); - } - } - }); + connect(hdrAction, &QAction::toggled, this, &MainWindow::onHdrPreviewToggled); connect(hdrAction, &QAction::triggered, this, [this, hdrAction]() { if (hdrAction->isChecked() && m_hdrPreviewWindow) { m_hdrPreviewWindow->show(); @@ -4637,6 +4586,57 @@ 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); + 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); }); + 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(); diff --git a/src/mainwindow.h b/src/mainwindow.h index 062c33f210..d637e4c35d 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -311,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 *); From 05852681c25ac2cf8d44c236019d558f34b94f39 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 15 May 2026 23:06:32 -0700 Subject: [PATCH 25/42] Right align labels in HDR Metadata dialog --- src/docks/encodedock.cpp | 1 + src/docks/encodedock.ui | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/docks/encodedock.cpp b/src/docks/encodedock.cpp index 0d9fcfddbe..ceeef2d1b8 100644 --- a/src/docks/encodedock.cpp +++ b/src/docks/encodedock.cpp @@ -3162,6 +3162,7 @@ void EncodeDock::on_hdrMetaButton_clicked() dialog.setWindowTitle(tr("HDR Metadata")); auto *form = new QFormLayout; + form->setLabelAlignment(Qt::AlignRight); auto *maxCllSpin = new QSpinBox; maxCllSpin->setRange(0, 10000); diff --git a/src/docks/encodedock.ui b/src/docks/encodedock.ui index f38b683ade..df34bd1149 100644 --- a/src/docks/encodedock.ui +++ b/src/docks/encodedock.ui @@ -967,11 +967,20 @@ with parallel processing enabled. + + 4 + + + + 40 + 16777215 + + false From 14b567e2f5ffb9e6ce5bd6d71cd1daf8625d65ad Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 15:30:25 -0700 Subject: [PATCH 26/42] Make MinimalMediaBackend optional --- CMakeLists.txt | 9 ++++++--- CMakePresets.json | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cbbd287afc..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,7 +53,7 @@ find_package(Qt6 6.4 REQUIRED Widgets Xml ) -if(NOT APPLE) +if(Qt6_VERSION VERSION_GREATER_EQUAL "6.9.0") find_package(Qt6 6.4 REQUIRED GuiPrivate) endif() if(UNIX AND NOT APPLE) @@ -69,7 +72,7 @@ endif() add_subdirectory(CuteLogger) add_subdirectory(src) add_subdirectory(translations) -if(UNIX AND NOT APPLE) +if(BUILD_MINIMAL_MEDIA_BACKEND) add_subdirectory(MinimalMediaBackend) endif() @@ -87,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..fd615452d2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -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" } } ], From 9d1e32a80239f90f5a4deb5135538020b76fe2a3 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 15:30:54 -0700 Subject: [PATCH 27/42] Update Qt version to 6.10.3 in build scripts --- .github/copilot-instructions.md | 2 +- .github/workflows/build-sdk-windows.yml | 6 +++--- .github/workflows/build-windows.yml | 6 +++--- CMakePresets.json | 2 +- scripts/build-shotcut-msys2.sh | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) 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-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/CMakePresets.json b/CMakePresets.json index fd615452d2..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}" }, diff --git a/scripts/build-shotcut-msys2.sh b/scripts/build-shotcut-msys2.sh index b66db99e9f..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 From e6aff87624e8ac786933612f5d4b7382b5caf043 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 15:54:59 -0700 Subject: [PATCH 28/42] Restrict appimage and snap jobs to run only on master branch --- .github/workflows/build-linux.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From ee5dcac60e80f489b650738c778d2eb407e239fa Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 17:00:29 -0700 Subject: [PATCH 29/42] Add dynamic range display to timeline properties --- src/mainwindow.cpp | 4 +-- src/widgets/timelinepropertieswidget.cpp | 9 +++++- src/widgets/timelinepropertieswidget.ui | 41 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7663267f70..830f7ef63f 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1308,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); } diff --git a/src/widgets/timelinepropertieswidget.cpp b/src/widgets/timelinepropertieswidget.cpp index 3cc5511512..6c0367ba2d 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 @@ -49,6 +49,13 @@ TimelinePropertiesWidget::TimelinePropertiesWidget(Mlt::Service &service, QWidge ui->colorspaceLabel->setText("ITU-R BT.2020"); else ui->colorspaceLabel->setText(""); + const QString trc = MLT.colorTrc(); + if (trc == QLatin1String("arib-std-b67")) + ui->dynamicRangeValueLabel->setText("HLG HDR"); + else if (trc == QLatin1String("smpte2084")) + ui->dynamicRangeValueLabel->setText("PQ HDR"); + else + ui->dynamicRangeValueLabel->setText("SDR"); } } 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 + + + + + + + From 9c6464abe3bd1cb63759d8844ffb18b733924681 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 17:02:55 -0700 Subject: [PATCH 30/42] Linux needs BUILD_MINIMAL_MEDIA_BACKEND=ON --- scripts/build-shotcut.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-shotcut.sh b/scripts/build-shotcut.sh index c74618702e..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" From dfaaee16424762faa211d9d6cf678d370470e43c Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 17:37:05 -0700 Subject: [PATCH 31/42] Only use QT_MEDIA_BACKEND=minimal if BUILD_MINIMAL_MEDIA_BACKEND --- src/CMakeLists.txt | 3 +++ src/main.cpp | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index bc4611179d..c4a6f6812f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -319,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 diff --git a/src/main.cpp b/src/main.cpp index ae50784687..454bca8510 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -366,8 +366,10 @@ int main(int argc, char **argv) qputenv("QT_MEDIA_BACKEND", "windows"); if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "windows:altgr"); -#else +#elif defined(BUILD_MINIMAL_MEDIA_BACKEND) qputenv("QT_MEDIA_BACKEND", "minimal"); +#else + ; #endif #ifdef Q_OS_MAC From 0b525ff0720f6ce233d57ce4d3523f1af2281d39 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 17:56:42 -0700 Subject: [PATCH 32/42] Refactor P016 conversion support ...to the MLT consumer thread and use a buffer pool. --- src/qml/views/HdrPreview.qml | 2 +- src/videowidget.cpp | 199 +++++++++++++++++++++-------------- src/videowidget.h | 10 +- 3 files changed, 128 insertions(+), 83 deletions(-) diff --git a/src/qml/views/HdrPreview.qml b/src/qml/views/HdrPreview.qml index a5462e6885..1a7158942c 100644 --- a/src/qml/views/HdrPreview.qml +++ b/src/qml/views/HdrPreview.qml @@ -1,6 +1,6 @@ /* * 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 diff --git a/src/videowidget.cpp b/src/videowidget.cpp index 30e4a46ab9..b8340c0545 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #ifdef __ARM_NEON #include @@ -57,6 +58,7 @@ VideoWidget::VideoWidget(QObject *parent) , m_scrubAudio(false) , m_maxTextureSize(4096) , m_hideVui(false) + , m_p016Pool(std::make_shared()) { LOG_DEBUG() << "begin"; setAttribute(Qt::WA_AcceptTouchEvents); @@ -645,7 +647,67 @@ static void interleaveUVPlanes_SSE2(const uint16_t *srcU, const uint16_t *srcV, } #endif // defined(__x86_64__) || defined(_M_AMD64) -void VideoWidget::pushFrameToSink(const SharedFrame &frame) +// 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; @@ -655,10 +717,18 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) return; bool is10bit = !qstrcmp(m_consumer->get("mlt_image_format"), "yuv420p10"); - mlt_image_format mltFormat = is10bit ? mlt_image_yuv420p10 : mlt_image_yuv420p; - const uint8_t *image = frame.get_image(mltFormat); - if (!image) - return; + 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); @@ -730,73 +800,7 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) } } - // For BT.2020/HLG, convert MLT's planar yuv420p10 (3 planes: Y, U, V with - // 16-bit samples, 10 significant bits in LSBs) to semi-planar P016 (2 planes: - // Y, interleaved UV with full 16-bit range) so that Qt selects the - // nv12_bt2020_hlg fragment shader for proper HLG tone mapping. - QByteArray p016Buffer; - if (is10bit) { - int uvW = width / 2; - int uvH = height / 2; - int ySamples = width * height; - int yPlaneSize = ySamples * 2; // 16-bit per sample - int uvPlaneSize = uvW * uvH * 2; // per U or V plane in source - int interleavedUvSize = uvW * uvH * 4; // interleaved UV in dest - p016Buffer.resize(yPlaneSize + interleavedUvSize); - - // Copy Y plane, shifting 10-bit values (LSBs) to full 16-bit range - const uint16_t *srcY = reinterpret_cast(image); - uint16_t *dstY = reinterpret_cast(p016Buffer.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 - - // Interleave U and V planes, also shifting to full 16-bit range - const uint16_t *srcU = reinterpret_cast(image + yPlaneSize); - const uint16_t *srcV = reinterpret_cast(image + yPlaneSize + uvPlaneSize); - uint16_t *dstUV = reinterpret_cast(p016Buffer.data() + yPlaneSize); - 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 - } - - // Zero-copy buffer for 8-bit, or P016 converted buffer for 10-bit. + // Zero-copy buffer for 8-bit, or P016 pre-converted buffer for 10-bit. class SharedFrameVideoBuffer : public QAbstractVideoBuffer { public: @@ -804,10 +808,18 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) : m_sharedFrame(sf) , m_format(f) {} - SharedFrameVideoBuffer(QByteArray p016, const QVideoFrameFormat &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 { @@ -849,11 +861,19 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) 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); + 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); } @@ -866,7 +886,7 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame) emit videoFrameReady(videoFrame); } -void VideoWidget::showFrame(Mlt::Frame frame) +void VideoWidget::showFrame(Mlt::Frame frame, QByteArray p016Buffer) { m_mutex.lock(); m_sharedFrame = SharedFrame(frame); @@ -878,7 +898,7 @@ void VideoWidget::showFrame(Mlt::Frame frame) } else if (isVui && !m_savedQmlSource.isEmpty() && source() != m_savedQmlSource) { setSource(m_savedQmlSource); } - pushFrameToSink(m_sharedFrame); + pushFrameToSink(m_sharedFrame, std::move(p016Buffer)); emit frameDisplayed(m_sharedFrame); if (m_imageRequested) { m_imageRequested = false; @@ -937,17 +957,36 @@ 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_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, - "showFrame", - Qt::QueuedConnection, - Q_ARG(Mlt::Frame, frame)); + [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(); } diff --git a/src/videowidget.h b/src/videowidget.h index 7e21f3347c..4a42087d01 100644 --- a/src/videowidget.h +++ b/src/videowidget.h @@ -35,6 +35,7 @@ enum class HdrTransfer { SDR = 0, HLG = 1, PQ = 2 }; #include #include #include +#include class QmlFilter; class QmlMetadata; @@ -112,7 +113,7 @@ public slots: void setCurrentFilter(QmlFilter *filter, QmlMetadata *meta); void setSnapToGrid(bool snap); virtual void initialize(); - Q_INVOKABLE void showFrame(Mlt::Frame frame); + void showFrame(Mlt::Frame frame, QByteArray p016Buffer = {}); signals: void frameDisplayed(const SharedFrame &frame); @@ -158,7 +159,12 @@ public slots: QPointer m_videoSink; static void on_frame_show(mlt_consumer, VideoWidget *widget, mlt_event_data); - void pushFrameToSink(const SharedFrame &frame); + 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); From 828178ae7792d01673641f16724e7c4b2b00d95c Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 18:05:05 -0700 Subject: [PATCH 33/42] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/docks/encodedock.cpp | 11 ++++++----- src/mainwindow.cpp | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/docks/encodedock.cpp b/src/docks/encodedock.cpp index ceeef2d1b8..256d02878e 100644 --- a/src/docks/encodedock.cpp +++ b/src/docks/encodedock.cpp @@ -836,16 +836,17 @@ 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 when the source is PQ. - if ((m_hdrMaxCll > 0 || m_hdrMaxFall > 0) - && MLT.colorTrc() == QLatin1String("smpte2084")) { - if (!x265params.contains(QLatin1String("max-cll="))) { + // 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 (!x265params.contains(QLatin1String("master-display="))) { + if ((m_hdrMaxLuminance > 0.0 || m_hdrMinLuminance > 0.0) + && !x265params.contains(QLatin1String("master-display="))) { const QString primaries = (m_hdrMasterPreset == 1) // Display P3 (D65) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 830f7ef63f..a80916e0fe 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -4600,10 +4600,12 @@ void MainWindow::onHdrPreviewToggled(bool checked) &Mlt::VideoWidget::hdrTransferChanged, m_hdrPreviewWindow, &HdrPreviewWindow::setHdrTransfer); + m_hdrPreviewWindow->setHdrTransfer(videoWidget->hdrTransfer()); 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(m_player->isPlaying()); connect(m_hdrPreviewWindow, &QWindow::visibleChanged, this, [this](bool visible) { if (!visible && m_hdrPreviewWindow) { Settings.setPlayerHdrPreviewFullScreen(m_hdrPreviewWindow->windowStates() From 4906f4c8f80e60349f20852445be8dc475c4747e Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 17:58:26 -0700 Subject: [PATCH 34/42] clang-format --- src/main.cpp | 2 +- src/videowidget.cpp | 32 ++++++++++++++++++-------------- src/videowidget.h | 5 +++-- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 454bca8510..a075035f9b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -369,7 +369,7 @@ int main(int argc, char **argv) #elif defined(BUILD_MINIMAL_MEDIA_BACKEND) qputenv("QT_MEDIA_BACKEND", "minimal"); #else - ; + ; #endif #ifdef Q_OS_MAC diff --git a/src/videowidget.cpp b/src/videowidget.cpp index b8340c0545..898e97209e 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -26,13 +26,13 @@ #include "util.h" #include +#include #include #include #include #include #include #include -#include #ifdef __ARM_NEON #include @@ -866,14 +866,17 @@ void VideoWidget::pushFrameToSink(const SharedFrame &frame, QByteArray p016Buffe 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)); - } - }); + 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); } @@ -982,11 +985,12 @@ void VideoWidget::on_frame_show(mlt_consumer, VideoWidget *widget, mlt_event_dat convertToP016(image, width, height, p016Buffer); } } - QMetaObject::invokeMethod(widget, - [widget, frame, buf = std::move(p016Buffer)]() mutable { - widget->showFrame(frame, std::move(buf)); - }, - Qt::QueuedConnection); + 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(); } diff --git a/src/videowidget.h b/src/videowidget.h index 4a42087d01..76f0ca6eec 100644 --- a/src/videowidget.h +++ b/src/videowidget.h @@ -24,6 +24,7 @@ enum class HdrTransfer { SDR = 0, HLG = 1, PQ = 2 }; +#include #include #include #include @@ -35,7 +36,6 @@ enum class HdrTransfer { SDR = 0, HLG = 1, PQ = 2 }; #include #include #include -#include class QmlFilter; class QmlMetadata; @@ -160,7 +160,8 @@ public slots: static void on_frame_show(mlt_consumer, VideoWidget *widget, mlt_event_data); void pushFrameToSink(const SharedFrame &frame, QByteArray p016Buffer = {}); - struct P016Pool { + struct P016Pool + { QMutex mutex; QList buffers; }; From 61c10f49ac3c834f7018fa500549afd7790ce518 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 18:19:28 -0700 Subject: [PATCH 35/42] Fix compile and refactor HDR transfer mapping --- src/mainwindow.cpp | 4 ++-- src/videowidget.cpp | 6 +----- src/videowidget.h | 9 +++++++++ src/widgets/timelinepropertieswidget.cpp | 13 +++++++++---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index a80916e0fe..52e71bd04a 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -4600,12 +4600,12 @@ void MainWindow::onHdrPreviewToggled(bool checked) &Mlt::VideoWidget::hdrTransferChanged, m_hdrPreviewWindow, &HdrPreviewWindow::setHdrTransfer); - m_hdrPreviewWindow->setHdrTransfer(videoWidget->hdrTransfer()); + 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(m_player->isPlaying()); + win->setPlaying(!MLT.isPaused()); connect(m_hdrPreviewWindow, &QWindow::visibleChanged, this, [this](bool visible) { if (!visible && m_hdrPreviewWindow) { Settings.setPlayerHdrPreviewFullScreen(m_hdrPreviewWindow->windowStates() diff --git a/src/videowidget.cpp b/src/videowidget.cpp index 898e97209e..89b31bf579 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -443,11 +443,7 @@ int VideoWidget::reconfigure(bool isMulti) } } const char *activeTrc = m_consumer->get("color_trc"); - HdrTransfer hdrTransfer = HdrTransfer::SDR; - if (!qstrcmp(activeTrc, "arib-std-b67")) - hdrTransfer = HdrTransfer::HLG; - else if (!qstrcmp(activeTrc, "smpte2084")) - hdrTransfer = HdrTransfer::PQ; + HdrTransfer hdrTransfer = hdrTransferFromTrc(QLatin1String(activeTrc)); emit hdrTransferChanged(hdrTransfer); if (processingMode == ShotcutSettings::Linear10Cpu || (processingMode == ShotcutSettings::Linear10GpuCpu && !isDeckLinkHLG diff --git a/src/videowidget.h b/src/videowidget.h index 76f0ca6eec..7f5503750a 100644 --- a/src/videowidget.h +++ b/src/videowidget.h @@ -24,6 +24,15 @@ 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 diff --git a/src/widgets/timelinepropertieswidget.cpp b/src/widgets/timelinepropertieswidget.cpp index 6c0367ba2d..4ab807435c 100644 --- a/src/widgets/timelinepropertieswidget.cpp +++ b/src/widgets/timelinepropertieswidget.cpp @@ -20,6 +20,7 @@ #include "mltcontroller.h" #include "util.h" +#include "videowidget.h" TimelinePropertiesWidget::TimelinePropertiesWidget(Mlt::Service &service, QWidget *parent) : QWidget(parent) @@ -49,13 +50,17 @@ TimelinePropertiesWidget::TimelinePropertiesWidget(Mlt::Service &service, QWidge ui->colorspaceLabel->setText("ITU-R BT.2020"); else ui->colorspaceLabel->setText(""); - const QString trc = MLT.colorTrc(); - if (trc == QLatin1String("arib-std-b67")) + switch (hdrTransferFromTrc(MLT.colorTrc())) { + case HdrTransfer::HLG: ui->dynamicRangeValueLabel->setText("HLG HDR"); - else if (trc == QLatin1String("smpte2084")) + break; + case HdrTransfer::PQ: ui->dynamicRangeValueLabel->setText("PQ HDR"); - else + break; + default: ui->dynamicRangeValueLabel->setText("SDR"); + break; + } } } From 3b587eb9f6f3f95234ad6a3a1f1b3088740dbc2d Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Mon, 18 May 2026 19:05:32 -0700 Subject: [PATCH 36/42] Fix merge --- src/main.cpp | 10 +++++----- src/mainwindow.ui | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0aeaf24053..0226465243 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -420,11 +420,11 @@ 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(); + 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(); diff --git a/src/mainwindow.ui b/src/mainwindow.ui index 22ca6a2834..b340cd2981 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -1566,6 +1566,7 @@ Vulkan + From 92333c130915d2e351c75b2bbf80e7dfa2dae512 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Mon, 18 May 2026 21:32:36 -0700 Subject: [PATCH 37/42] Fix HDR in the timeline and saved project --- src/dialogs/customprofiledialog.cpp | 3 ++- src/mltcontroller.cpp | 38 +++++++++++++++++------------ src/models/multitrackmodel.cpp | 4 +++ src/models/playlistmodel.cpp | 6 ++++- src/shotcut_mlt_properties.h | 3 ++- 5 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/dialogs/customprofiledialog.cpp b/src/dialogs/customprofiledialog.cpp index 40feba0813..025b255541 100644 --- a/src/dialogs/customprofiledialog.cpp +++ b/src/dialogs/customprofiledialog.cpp @@ -20,6 +20,7 @@ #include "mltcontroller.h" #include "settings.h" +#include "shotcut_mlt_properties.h" #include "util.h" #include @@ -139,7 +140,7 @@ void CustomProfileDialog::on_buttonBox_accepted() 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.set(kShotcutColorTransfer, MLT.colorTrc().toLatin1().constData()); p.save(dir.filePath(profileName()).toUtf8().constData()); } } diff --git a/src/mltcontroller.cpp b/src/mltcontroller.cpp index 2610291006..3490dc38b1 100644 --- a/src/mltcontroller.cpp +++ b/src/mltcontroller.cpp @@ -606,7 +606,7 @@ void Controller::setProfile(const QString &profile_name) // 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"); + const char *trc = profileProps.get(kShotcutColorTransfer); m_colorTrc = (trc && *trc) ? QString::fromLatin1(trc) : QString(); } else { m_colorTrc.clear(); @@ -655,22 +655,26 @@ QString Controller::colorTrc() const // 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 + if (m_producer->property_exists(kShotcutColorTransfer)) { + return QString::fromLatin1(m_producer->get(kShotcutColorTransfer)); + } else { + 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; } - ++videoCount; } } } @@ -680,6 +684,8 @@ QString Controller::colorTrc() const void Controller::setColorTrc(const QString &trc) { m_colorTrc = trc; + if (m_producer && m_producer->is_valid()) + m_producer->set(kShotcutColorTransfer, trc.toLatin1().constData()); } QString Controller::resource() const diff --git a/src/models/multitrackmodel.cpp b/src/models/multitrackmodel.cpp index 7e0f4c8fb9..28f72f28f5 100644 --- a/src/models/multitrackmodel.cpp +++ b/src/models/multitrackmodel.cpp @@ -2658,6 +2658,7 @@ bool MultitrackModel::createIfNeeded() if (!m_tractor) { m_tractor = new Mlt::Tractor(MLT.profile()); MLT.profile().set_explicit(true); + m_tractor->set(kShotcutColorTransfer, MLT.colorTrc().toLatin1().constData()); m_tractor->set(kShotcutXmlProperty, 1); retainPlaylist(); addBackgroundTrack(); @@ -2929,6 +2930,7 @@ int MultitrackModel::addAudioTrack() if (!m_tractor) { m_tractor = new Mlt::Tractor(MLT.profile()); MLT.profile().set_explicit(true); + m_tractor->set(kShotcutColorTransfer, MLT.colorTrc().toLatin1().constData()); m_tractor->set(kShotcutXmlProperty, 1); retainPlaylist(); addBackgroundTrack(); @@ -3597,6 +3599,8 @@ void MultitrackModel::load() MLT.producer()->set("resource", ""); MLT.profile().set_explicit(true); m_tractor = new Mlt::Tractor(*MLT.producer()); + if (m_tractor->property_exists(kShotcutColorTransfer)) + MLT.setColorTrc(QString::fromLatin1(m_tractor->get(kShotcutColorTransfer))); if (!m_tractor->is_valid()) { delete m_tractor; m_tractor = 0; diff --git a/src/models/playlistmodel.cpp b/src/models/playlistmodel.cpp index 6e1c56c865..ebeaabd530 100644 --- a/src/models/playlistmodel.cpp +++ b/src/models/playlistmodel.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2025 Meltytech, LLC + * Copyright (c) 2012-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 @@ -735,6 +735,8 @@ void PlaylistModel::load() } // do not let opening a clip change the profile! MLT.profile().set_explicit(true); + if (m_playlist->property_exists(kShotcutColorTransfer)) + MLT.setColorTrc(QString::fromLatin1(m_playlist->get(kShotcutColorTransfer))); if (Settings.playerGPU() && Settings.playlistThumbnails() != "hidden") refreshThumbnails(); emit loaded(); @@ -871,6 +873,7 @@ void PlaylistModel::createIfNeeded() m_playlist = new Mlt::Playlist(MLT.profile()); // do not let opening a clip change the profile! MLT.profile().set_explicit(true); + m_playlist->set(kShotcutColorTransfer, MLT.colorTrc().toLatin1().constData()); emit created(); } } @@ -924,6 +927,7 @@ void PlaylistModel::setPlaylist(Mlt::Playlist &playlist) } // do not let opening a clip change the profile! MLT.profile().set_explicit(true); + m_playlist->set(kShotcutColorTransfer, MLT.colorTrc().toLatin1().constData()); if (Settings.playerGPU() && Settings.playlistThumbnails() != "hidden") refreshThumbnails(); emit loaded(); diff --git a/src/shotcut_mlt_properties.h b/src/shotcut_mlt_properties.h index 9011ca04b5..459a962940 100644 --- a/src/shotcut_mlt_properties.h +++ b/src/shotcut_mlt_properties.h @@ -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 @@ -70,6 +70,7 @@ #define kShotcutProjectFolder "shotcut:projectFolder" #define kShotcutProjectNote "shotcut:projectNote" #define kShotcutProjectProcessingMode "shotcut:processingMode" +#define kShotcutColorTransfer "shotcut:colorTransfer" /* Ideally all shotcut properties should begin with "shotcut:", but these * do not and kept for legacy reasons? */ From 6e3ce0b3245d9d0479e848e58acf272f5f8bbdca Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 20 May 2026 13:40:43 -0700 Subject: [PATCH 38/42] Stop rebuilding the HDR shader if it exists --- CMakePresets.json | 85 +++++++++++++++++++++++++++++++++++++++++----- src/CMakeLists.txt | 14 ++++---- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index 86e868740a..1e32c5b44a 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -2,23 +2,72 @@ "version": 8, "configurePresets": [ { - "name": "cc-debug", - "displayName": "default system compiler (debug)", - "description": "Use C/C++ compilers in the $PATH with Ninja", + "name": "_cc-debug-base", + "hidden": true, "binaryDir": "${sourceDir}/build/${presetName}", "generator": "Ninja", "environment": { - "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}" + "QT_VERSION": "6.10.3" }, "cacheVariables": { "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", "BUILD_MINIMAL_MEDIA_BACKEND": "ON" } + }, + { + "name": "cc-debug-linux", + "displayName": "default system compiler (debug)", + "description": "Use C/C++ compilers in the $PATH with Ninja", + "inherits": "_cc-debug-base", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + }, + "environment": { + "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}" + }, + "cacheVariables": { + "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", + "CMAKE_C_FLAGS": "-I/usr/local/include" + } + }, + { + "name": "cc-debug-macos", + "displayName": "default system compiler (debug)", + "description": "Use C/C++ compilers in the $PATH with Ninja", + "inherits": "_cc-debug-base", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "cacheVariables": { + "CMAKE_PREFIX_PATH": "$env{HOME}/Qt/$env{QT_VERSION}/macos;$env{HOME}/.local;$env{HOME}/opt;/usr/local;/opt/local" + } + }, + { + "name": "msys2-debug", + "displayName": "msys2 (debug)", + "description": "Use MinGW compilers in MSYS2 with Ninja", + "inherits": "_cc-debug-base", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "environment": { + "PKG_CONFIG_PATH": "C:/Projects/Shotcut/lib/pkgconfig;C:/msys64/mingw64/lib/pkgconfig;$penv{PKG_CONFIG_PATH}", + "Path": "C:/msys64/usr/local/bin;C:/msys64/usr/bin;C:/msys64/mingw64/bin;$penv{Path}" + }, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_PREFIX_PATH": "C:/Qt/$env{QT_VERSION}/mingw_64;C:/msys64/mingw64", + "CMAKE_INSTALL_PREFIX": "C:/Projects/Shotcut" + } } ], "buildPresets": [ @@ -26,11 +75,31 @@ "name": "format-build", "description": "Run clang-format and then build", "displayName": "clang-format and build", - "configurePreset": "cc-debug", + "configurePreset": "cc-debug-linux", + "targets": [ + "clang-format", + "all" + ] + }, + { + "name": "format-build-macos", + "description": "Run clang-format and then build", + "displayName": "clang-format and build (macOS)", + "configurePreset": "cc-debug-macos", "targets": [ "clang-format", "all" ] + }, + { + "name": "format-build-windows", + "description": "Run clang-format and then build", + "displayName": "clang-format and build (Windows)", + "configurePreset": "msys2-debug", + "targets": [ + "clang-format", + "install" + ] } ] } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 06be418325..92187b4043 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -278,12 +278,14 @@ find_program(QSB_EXECUTABLE qsb HINTS 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" - ) + if(NOT EXISTS ${HDR_GAIN_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" + ) + endif() add_custom_target(hdr_shaders ALL DEPENDS ${HDR_GAIN_QSB}) add_dependencies(shotcut hdr_shaders) endif() From ab8b112e482e35f2a504e18ceb7a78a2f3abfec7 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 20 May 2026 13:42:02 -0700 Subject: [PATCH 39/42] Remove Decklink Gamma menu & add HDR dialog Its gamma now follows the video mode's dynamic range (transfer). --- src/mainwindow.cpp | 186 ++++++++++++++++++++++++++++---------------- src/mainwindow.h | 6 +- src/settings.cpp | 48 +++++++++++- src/settings.h | 12 ++- src/videowidget.cpp | 71 ++++++++++++++--- 5 files changed, 237 insertions(+), 86 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index ce4cf55f7c..f248934bea 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1307,15 +1307,8 @@ void MainWindow::setupSettingsMenu() action->setData(QStringLiteral("decklink:%1").arg(i)); m_externalGroup->addAction(action); - if (!m_decklinkGammaGroup) { - m_decklinkGammaGroup = new QActionGroup(this); - action = new QAction("SDR", m_decklinkGammaGroup); - action->setData(QVariant(0)); - action->setCheckable(true); - action = new QAction("HLG HDR", m_decklinkGammaGroup); - action->setData(QVariant(1)); - action->setCheckable(true); - } + if (!m_decklinkHdrAction) + m_decklinkHdrAction = new QAction(tr("DeckLink PQ HDR Metadata..."), this); if (!m_keyerGroup) { m_keyerGroup = new QActionGroup(this); action = new QAction(tr("Off"), m_keyerGroup); @@ -1337,26 +1330,26 @@ void MainWindow::setupSettingsMenu() delete ui->menuExternal; ui->menuExternal = 0; } - if (m_decklinkGammaGroup) { - m_decklinkGammaMenu = ui->menuExternal->addMenu(tr("DeckLink Gamma")); - m_decklinkGammaMenu->addActions(m_decklinkGammaGroup->actions()); - m_decklinkGammaMenu->setDisabled(true); - connect(m_decklinkGammaGroup, - &QActionGroup::triggered, - this, - &MainWindow::onDecklinkGammaTriggered); - } if (m_keyerGroup) { m_keyerMenu = ui->menuExternal->addMenu(tr("DeckLink Keyer")); m_keyerMenu->addActions(m_keyerGroup->actions()); m_keyerMenu->setDisabled(true); connect(m_keyerGroup, &QActionGroup::triggered, this, &MainWindow::onKeyerTriggered); } + if (m_decklinkHdrAction) { + ui->menuExternal->addAction(m_decklinkHdrAction); + m_decklinkHdrAction->setDisabled(true); + connect(m_decklinkHdrAction, + &QAction::triggered, + this, + &MainWindow::onDecklinkHdrMetadataTriggered); + } connect(m_externalGroup, SIGNAL(triggered(QAction *)), this, SLOT(onExternalTriggered(QAction *))); connect(m_profileGroup, SIGNAL(triggered(QAction *)), this, SLOT(onProfileTriggered(QAction *))); + updateDecklinkActions(); // Setup the language menu actions m_languagesGroup = new QActionGroup(this); @@ -2556,25 +2549,9 @@ void MainWindow::readPlayerSettings() #endif if (a->data() == external) { a->setChecked(true); - if (a->data().toString().startsWith("decklink")) { - if (m_decklinkGammaMenu) - m_decklinkGammaMenu->setEnabled(true); - if (m_keyerMenu) - m_keyerMenu->setEnabled(true); - } break; } } - - if (m_decklinkGammaGroup) { - auto gamma = Settings.playerDecklinkGamma(); - for (auto a : m_decklinkGammaGroup->actions()) { - if (a->data() == gamma) { - a->setChecked(true); - break; - } - } - } if (m_keyerGroup) { auto keyer = Settings.playerKeyerMode(); for (auto a : m_keyerGroup->actions()) { @@ -2584,6 +2561,7 @@ void MainWindow::readPlayerSettings() } } } + updateDecklinkActions(); if (Settings.playerHdrPreview()) { auto hdr = Actions["hdrPreviewAction"]; @@ -2919,11 +2897,17 @@ void MainWindow::configureVideoWidget() MLT.videoWidget()->setProperty("rescale", "bicubic"); else MLT.videoWidget()->setProperty("rescale", "hyper"); - if (m_decklinkGammaGroup && m_decklinkGammaGroup->isEnabled()) - MLT.videoWidget()->setProperty("decklinkGamma", - m_decklinkGammaGroup->checkedAction()->data()); if (m_keyerGroup) MLT.videoWidget()->setProperty("keyer", m_keyerGroup->checkedAction()->data()); + MLT.videoWidget()->setProperty("decklinkHdrMaxCll", Settings.playerDecklinkHdrMaxCll()); + MLT.videoWidget()->setProperty("decklinkHdrMaxFall", Settings.playerDecklinkHdrMaxFall()); + MLT.videoWidget()->setProperty("decklinkHdrMasterPreset", + Settings.playerDecklinkHdrMasterPreset()); + MLT.videoWidget()->setProperty("decklinkHdrMaxLuminance", + Settings.playerDecklinkHdrMaxLuminance()); + MLT.videoWidget()->setProperty("decklinkHdrMinLuminance", + Settings.playerDecklinkHdrMinLuminance()); + updateDecklinkActions(); LOG_DEBUG() << "end"; } @@ -4668,15 +4652,10 @@ void MainWindow::onExternalTriggered(QAction *action) for (auto a : m_externalGroup->actions()) { if (a->data() == Settings.playerExternal()) { a->setChecked(true); - if (a->data().toString().startsWith("decklink")) { - if (m_decklinkGammaMenu) - m_decklinkGammaMenu->setEnabled(true); - if (m_keyerMenu) - m_keyerMenu->setEnabled(true); - } break; } } + updateDecklinkActions(); } return; } @@ -4721,12 +4700,7 @@ void MainWindow::onExternalTriggered(QAction *action) MLT.consumer()->set("progressive", isProgressive); MLT.consumerChanged(); } - if (action->data().toString().startsWith("decklink")) { - if (m_decklinkGammaMenu) - m_decklinkGammaMenu->setEnabled(true); - if (m_keyerMenu) - m_keyerMenu->setEnabled(true); - } + updateDecklinkActions(); // Preview scaling not permitted for SDI/HDMI if (isExternal) { @@ -4739,28 +4713,95 @@ void MainWindow::onExternalTriggered(QAction *action) setPreviewScale(Settings.playerPreviewScale()); } -void MainWindow::onDecklinkGammaTriggered(QAction *action) +void MainWindow::onDecklinkHdrMetadataTriggered() { - LOG_DEBUG() << action->data().toString(); - MLT.videoWidget()->setProperty("decklinkGamma", action->data()); + QDialog dialog(this); + dialog.setWindowTitle(tr("DeckLink HDR Metadata")); + dialog.setWindowModality(QmlApplication::dialogModality()); + + auto *form = new QFormLayout; + form->setLabelAlignment(Qt::AlignRight); + + auto *maxCllSpin = new QSpinBox; + maxCllSpin->setRange(1, 65535); + maxCllSpin->setValue(Settings.playerDecklinkHdrMaxCll()); + maxCllSpin->setSuffix(tr(" nits", "a measure of brightness")); + maxCllSpin->setToolTip( + tr("Maximum Content Light Level (MaxCLL): the brightest single pixel in the signal")); + form->addRow(tr("MaxCLL"), maxCllSpin); + + auto *maxFallSpin = new QSpinBox; + maxFallSpin->setRange(1, 65535); + maxFallSpin->setValue(Settings.playerDecklinkHdrMaxFall()); + maxFallSpin->setSuffix(tr(" nits", "a measure of brightness")); + maxFallSpin->setToolTip( + tr("Maximum Frame-Average Light Level (MaxFALL): the brightest average 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(Settings.playerDecklinkHdrMasterPreset()); + form->addRow(tr("Color primaries"), primaryCombo); + + auto *maxLumSpin = new QSpinBox; + maxLumSpin->setRange(1, 65535); + maxLumSpin->setValue(Settings.playerDecklinkHdrMaxLuminance()); + 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.0001, 6.5535); + minLumSpin->setDecimals(4); + minLumSpin->setSingleStep(0.0001); + minLumSpin->setValue(Settings.playerDecklinkHdrMinLuminance()); + 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) + return; + + const int maxCll = maxCllSpin->value(); + const int maxFall = maxFallSpin->value(); + const int masterPreset = primaryCombo->currentIndex(); + const int maxLuminance = maxLumSpin->value(); + const double minLuminance = minLumSpin->value(); + if (Settings.playerGPU() && MLT.producer()) { - if (confirmRestartExternalMonitor()) { - Settings.setPlayerDecklinkGamma(action->data().toInt()); - m_exitCode = EXIT_RESTART; - QApplication::closeAllWindows(); - } else { - auto gamma = Settings.playerDecklinkGamma(); - for (auto a : m_decklinkGammaGroup->actions()) { - if (a->data() == gamma) { - a->setChecked(true); - break; - } - } - } + if (!confirmRestartExternalMonitor()) + return; + Settings.setPlayerDecklinkHdrMaxCll(maxCll); + Settings.setPlayerDecklinkHdrMaxFall(maxFall); + Settings.setPlayerDecklinkHdrMasterPreset(masterPreset); + Settings.setPlayerDecklinkHdrMaxLuminance(maxLuminance); + Settings.setPlayerDecklinkHdrMinLuminance(minLuminance); + m_exitCode = EXIT_RESTART; + QApplication::closeAllWindows(); return; } + + Settings.setPlayerDecklinkHdrMaxCll(maxCll); + Settings.setPlayerDecklinkHdrMaxFall(maxFall); + Settings.setPlayerDecklinkHdrMasterPreset(masterPreset); + Settings.setPlayerDecklinkHdrMaxLuminance(maxLuminance); + Settings.setPlayerDecklinkHdrMinLuminance(minLuminance); + MLT.videoWidget()->setProperty("decklinkHdrMaxCll", maxCll); + MLT.videoWidget()->setProperty("decklinkHdrMaxFall", maxFall); + MLT.videoWidget()->setProperty("decklinkHdrMasterPreset", masterPreset); + MLT.videoWidget()->setProperty("decklinkHdrMaxLuminance", maxLuminance); + MLT.videoWidget()->setProperty("decklinkHdrMinLuminance", minLuminance); MLT.consumerChanged(); - Settings.setPlayerDecklinkGamma(action->data().toInt()); } void MainWindow::onKeyerTriggered(QAction *action) @@ -4822,6 +4863,7 @@ void MainWindow::onProfileChanged() && (m_timelineDock->selection().isEmpty() || m_timelineDock->currentTrack() == -1)) { emit m_timelineDock->selected(multitrack()); } + updateDecklinkActions(); updateWindowTitle(); } @@ -4939,6 +4981,17 @@ bool MainWindow::confirmRestartExternalMonitor() return dialog.exec() == QMessageBox::Yes; } +void MainWindow::updateDecklinkActions() +{ + const QAction *checkedExternal = m_externalGroup ? m_externalGroup->checkedAction() : nullptr; + const bool isDecklink = checkedExternal + && checkedExternal->data().toString().startsWith("decklink"); + if (m_keyerMenu) + m_keyerMenu->setEnabled(isDecklink); + if (m_decklinkHdrAction) + m_decklinkHdrAction->setEnabled(isDecklink && MLT.colorTrc() == QLatin1String("smpte2084")); +} + void MainWindow::resetFilterMenuIfNeeded() { // Reset to Favorites if currently set to Color or Audio @@ -5051,6 +5104,7 @@ void MainWindow::onToolbarVisibilityChanged(bool visible) void MainWindow::on_menuExternal_aboutToShow() { + updateDecklinkActions(); #ifdef USE_SCREENS_FOR_EXTERNAL_MONITORING foreach (QAction *action, m_externalGroup->actions()) { bool ok = false; diff --git a/src/mainwindow.h b/src/mainwindow.h index a67821ae61..f37c45624f 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -180,6 +180,7 @@ class MainWindow : public QMainWindow bool confirmProfileChange(); bool confirmRestartExternalMonitor(); void resetFilterMenuIfNeeded(); + void updateDecklinkActions(); Ui::MainWindow *ui; Player *m_player; @@ -195,7 +196,6 @@ class MainWindow : public QMainWindow QDockWidget *m_historyDock; QActionGroup *m_profileGroup; QActionGroup *m_externalGroup; - QActionGroup *m_decklinkGammaGroup{nullptr}; QActionGroup *m_keyerGroup; QActionGroup *m_layoutGroup; QActionGroup *m_previewScaleGroup; @@ -203,7 +203,7 @@ class MainWindow : public QMainWindow FilterController *m_filterController; ScopeController *m_scopeController; QMenu *m_customProfileMenu; - QMenu *m_decklinkGammaMenu{nullptr}; + QAction *m_decklinkHdrAction{nullptr}; QMenu *m_keyerMenu; QStringList m_multipleFiles; bool m_multipleFilesLoading; @@ -313,7 +313,7 @@ private slots: void on_actionJack_triggered(bool checked); void onHdrPreviewToggled(bool checked); void onExternalTriggered(QAction *); - void onDecklinkGammaTriggered(QAction *); + void onDecklinkHdrMetadataTriggered(); void onKeyerTriggered(QAction *); void onProfileTriggered(QAction *); void onProfileChanged(); diff --git a/src/settings.cpp b/src/settings.cpp index c06bd042ca..797fbc0ea5 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -681,14 +681,54 @@ void ShotcutSettings::setPlayerJACK(bool b) settings.setValue("player/jack", b); } -int ShotcutSettings::playerDecklinkGamma() const +int ShotcutSettings::playerDecklinkHdrMaxCll() const { - return settings.value("player/decklinkGamma", 0).toInt(); + return settings.value("player/decklinkHdrMaxCll", 1000).toInt(); } -void ShotcutSettings::setPlayerDecklinkGamma(int i) +void ShotcutSettings::setPlayerDecklinkHdrMaxCll(int nits) { - settings.setValue("player/decklinkGamma", i); + settings.setValue("player/decklinkHdrMaxCll", nits); +} + +int ShotcutSettings::playerDecklinkHdrMaxFall() const +{ + return settings.value("player/decklinkHdrMaxFall", 400).toInt(); +} + +void ShotcutSettings::setPlayerDecklinkHdrMaxFall(int nits) +{ + settings.setValue("player/decklinkHdrMaxFall", nits); +} + +int ShotcutSettings::playerDecklinkHdrMasterPreset() const +{ + return settings.value("player/decklinkHdrMasterPreset", 0).toInt(); +} + +void ShotcutSettings::setPlayerDecklinkHdrMasterPreset(int preset) +{ + settings.setValue("player/decklinkHdrMasterPreset", preset); +} + +int ShotcutSettings::playerDecklinkHdrMaxLuminance() const +{ + return settings.value("player/decklinkHdrMaxLuminance", 1000).toInt(); +} + +void ShotcutSettings::setPlayerDecklinkHdrMaxLuminance(int nits) +{ + settings.setValue("player/decklinkHdrMaxLuminance", nits); +} + +double ShotcutSettings::playerDecklinkHdrMinLuminance() const +{ + return settings.value("player/decklinkHdrMinLuminance", 0.01).toDouble(); +} + +void ShotcutSettings::setPlayerDecklinkHdrMinLuminance(double nits) +{ + settings.setValue("player/decklinkHdrMinLuminance", nits); } int ShotcutSettings::playerKeyerMode() const diff --git a/src/settings.h b/src/settings.h index 9b22baad43..e6cd95ed20 100644 --- a/src/settings.h +++ b/src/settings.h @@ -162,8 +162,16 @@ class ShotcutSettings : public QObject void setPlayerInterpolation(const QString &); bool playerJACK() const; void setPlayerJACK(bool); - int playerDecklinkGamma() const; - void setPlayerDecklinkGamma(int); + int playerDecklinkHdrMaxCll() const; + void setPlayerDecklinkHdrMaxCll(int); + int playerDecklinkHdrMaxFall() const; + void setPlayerDecklinkHdrMaxFall(int); + int playerDecklinkHdrMasterPreset() const; + void setPlayerDecklinkHdrMasterPreset(int); + int playerDecklinkHdrMaxLuminance() const; + void setPlayerDecklinkHdrMaxLuminance(int); + double playerDecklinkHdrMinLuminance() const; + void setPlayerDecklinkHdrMinLuminance(double); int playerKeyerMode() const; void setPlayerKeyerMode(int); bool playerMuted() const; diff --git a/src/videowidget.cpp b/src/videowidget.cpp index 89b31bf579..a1088b8451 100644 --- a/src/videowidget.cpp +++ b/src/videowidget.cpp @@ -44,6 +44,29 @@ using namespace Mlt; +namespace { + +struct DecklinkHdrPrimaries +{ + double redX; + double redY; + double greenX; + double greenY; + double blueX; + double blueY; + double whiteX; + double whiteY; +}; + +DecklinkHdrPrimaries decklinkHdrPrimaries(int preset) +{ + if (preset == 1) + return {0.6800, 0.3200, 0.2650, 0.6900, 0.1500, 0.0600, 0.3127, 0.3290}; + return {0.7080, 0.2920, 0.1700, 0.7970, 0.1310, 0.0460, 0.3127, 0.3290}; +} + +} // namespace + VideoWidget::VideoWidget(QObject *parent) : QQuickWidget(QmlUtilities::sharedEngine(), (QWidget *) parent) , Controller() @@ -389,12 +412,12 @@ int VideoWidget::reconfigure(bool isMulti) m_consumer->set("real_time", MLT.realTime()); m_consumer->set("scale", double(Settings.playerPreviewScale()) / MLT.profile().height()); 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; + const HdrTransfer videoModeTransfer = hdrTransferFromTrc(profileTrc); + const bool isDeckLinkHdr = serviceName.startsWith("decklink") + && videoModeTransfer != HdrTransfer::SDR; + const bool hdrPreview = Settings.playerHdrPreview(); + const bool isHdrActive = videoModeTransfer != HdrTransfer::SDR; switch (processingMode) { case ShotcutSettings::Native10Cpu: m_consumer->set("mlt_image_format", isHdrActive ? "yuv420p10" : "rgba64"); @@ -404,7 +427,7 @@ int VideoWidget::reconfigure(bool isMulti) break; case ShotcutSettings::Linear10GpuCpu: m_consumer->set("mlt_image_format", - isDeckLinkHLG ? "yuv444p10" + isDeckLinkHdr ? "yuv444p10" : hdrPreview ? "yuv420p10" : "rgba64"); break; @@ -419,12 +442,10 @@ int VideoWidget::reconfigure(bool isMulti) } else { m_consumer->set("channel_layout", "auto"); } - // Set color_trc on the consumer. profileTrc (from MLT.colorTrc()) takes precedence; - // DeckLink HLG is next; otherwise fall back to colorspace-based SDR defaults. + // Follow the active video mode transfer when it is explicit; 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: @@ -446,12 +467,40 @@ int VideoWidget::reconfigure(bool isMulti) HdrTransfer hdrTransfer = hdrTransferFromTrc(QLatin1String(activeTrc)); emit hdrTransferChanged(hdrTransfer); if (processingMode == ShotcutSettings::Linear10Cpu - || (processingMode == ShotcutSettings::Linear10GpuCpu && !isDeckLinkHLG + || (processingMode == ShotcutSettings::Linear10GpuCpu && !isDeckLinkHdr && !isHdrActive)) { m_consumer->set("mlt_color_trc", "linear"); } else { m_consumer->clear("mlt_color_trc"); } + + if (serviceName.startsWith("decklink")) { + const QString prefix = isMulti ? QStringLiteral("0.") : QString(); + const DecklinkHdrPrimaries primaries = decklinkHdrPrimaries( + property("decklinkHdrMasterPreset").toInt()); + const auto setConsumerInt = [&](const QString &name, int value) { + m_consumer->set(name.toLatin1().constData(), value); + }; + const auto setConsumerDouble = [&](const QString &name, double value) { + m_consumer->set(name.toLatin1().constData(), value); + }; + setConsumerDouble(prefix + QStringLiteral("hdr_red_x"), primaries.redX); + setConsumerDouble(prefix + QStringLiteral("hdr_red_y"), primaries.redY); + setConsumerDouble(prefix + QStringLiteral("hdr_green_x"), primaries.greenX); + setConsumerDouble(prefix + QStringLiteral("hdr_green_y"), primaries.greenY); + setConsumerDouble(prefix + QStringLiteral("hdr_blue_x"), primaries.blueX); + setConsumerDouble(prefix + QStringLiteral("hdr_blue_y"), primaries.blueY); + setConsumerDouble(prefix + QStringLiteral("hdr_white_x"), primaries.whiteX); + setConsumerDouble(prefix + QStringLiteral("hdr_white_y"), primaries.whiteY); + setConsumerInt(prefix + QStringLiteral("hdr_max_cll"), + property("decklinkHdrMaxCll").toInt()); + setConsumerInt(prefix + QStringLiteral("hdr_max_fall"), + property("decklinkHdrMaxFall").toInt()); + setConsumerInt(prefix + QStringLiteral("hdr_max_luminance"), + property("decklinkHdrMaxLuminance").toInt()); + setConsumerDouble(prefix + QStringLiteral("hdr_min_luminance"), + property("decklinkHdrMinLuminance").toDouble()); + } if (isMulti) { m_consumer->set("terminate_on_pause", 0); m_consumer->set("0", serviceName.toLatin1().constData()); From d2fd22a1f287d18ea1bcfdcf1c193752d9fee2c3 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Wed, 20 May 2026 16:07:10 -0700 Subject: [PATCH 40/42] Prompt restart if preview window dragged from SDR screen in HDR project --- src/hdrpreviewwindow.cpp | 82 +++++++++++++++++++++++++++++++++++++++- src/hdrpreviewwindow.h | 10 +++++ src/mainwindow.cpp | 16 ++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index a920f7b876..d3f477d863 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -31,10 +31,12 @@ #include #include #include +#include #include #include #include #include +#include #if defined(Q_OS_UNIX) && !defined(Q_OS_MAC) #include #include @@ -44,6 +46,8 @@ #include "macos.h" #endif +static constexpr int kScreenChangeDelayMs = 500; + // HLG OETF: scene-referred linear to HLG electrical signal. // See ITU-R BT.2100-2. static float hlgOetf(float linear) @@ -103,6 +107,18 @@ HdrPreviewWindow::HdrPreviewWindow(QWindow *parent) resize(960, 540); connect(this, &QWindow::windowStateChanged, this, [this]() { emit fullScreenChanged(); }); + connect(this, &QWindow::screenChanged, this, &HdrPreviewWindow::onScreenChanged); + m_screenChangeTimer.setSingleShot(true); + connect(&m_screenChangeTimer, + &QTimer::timeout, + this, + &HdrPreviewWindow::processPendingScreenChange); + auto restartScreenChangeTimer = [this]() { + if (m_screenChangeTimer.isActive()) + m_screenChangeTimer.start(kScreenChangeDelayMs); + }; + connect(this, &QWindow::xChanged, this, restartScreenChangeTimer); + connect(this, &QWindow::yChanged, this, restartScreenChangeTimer); #ifdef Q_OS_MACOS // Override NSScreen.maximumExtendedDynamicRangeColorComponentValue so that @@ -139,6 +155,9 @@ void HdrPreviewWindow::setVideoSink(QVideoSink *sink) void HdrPreviewWindow::pushFrame(const QVideoFrame &frame) { if (m_videoSink && isVisible()) { + if (!m_lastScreen && screen()) + m_lastScreen = screen(); + // Qt 6 caches the video format in QSGVideoMaterialRhiShader and // never updates masteringWhite when maxLuminance changes. To // work around this, invalidateVideoNode() pushes an empty frame @@ -223,6 +242,7 @@ void HdrPreviewWindow::pushFrame(const QVideoFrame &frame) #endif } updateHdrGain(); + m_lastKnownHdrMode = isHdrMode(); // 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; @@ -273,11 +293,32 @@ void HdrPreviewWindow::triggerFastForward() void HdrPreviewWindow::restoreGeometry(const QRect &r) { + QRect geometry = r; + bool isVisibleOnCurrentScreen = false; + for (auto *availableScreen : QGuiApplication::screens()) { + if (availableScreen->availableGeometry().intersects(geometry)) { + isVisibleOnCurrentScreen = true; + break; + } + } + + if (!isVisibleOnCurrentScreen) { + auto *fallbackScreen = screen() ? screen() : QGuiApplication::primaryScreen(); + if (fallbackScreen) { + const QRect availableGeometry = fallbackScreen->availableGeometry(); + geometry.setSize(geometry.size().boundedTo(availableGeometry.size())); + const int maxX = availableGeometry.right() - geometry.width() + 1; + const int maxY = availableGeometry.bottom() - geometry.height() + 1; + geometry.moveTopLeft(QPoint(qBound(availableGeometry.left(), geometry.x(), maxX), + qBound(availableGeometry.top(), geometry.y(), maxY))); + } + } + // 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); + setGeometry(geometry); // 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; }); @@ -498,6 +539,45 @@ void HdrPreviewWindow::invalidateVideoNode() } } +void HdrPreviewWindow::onScreenChanged(QScreen *screen) +{ + const bool movedToAnotherScreen = m_lastScreen && screen && m_lastScreen != screen; + m_pendingWasHdrMode = m_lastKnownHdrMode; + m_lastScreen = screen; + if (!movedToAnotherScreen || m_warnedAboutScreenChange) + return; + + m_pendingScreen = screen; + m_screenChangeTimer.start(kScreenChangeDelayMs); +} + +void HdrPreviewWindow::processPendingScreenChange() +{ + if (m_warnedAboutScreenChange || m_pendingWasHdrMode) { + m_pendingScreen = nullptr; + return; + } + + auto *producer = MLT.producer(); + if (!m_pendingScreen || !producer || !producer->is_valid() + || m_hdrTransfer == HdrTransfer::SDR) { + m_pendingScreen = nullptr; + return; + } + + m_warnedAboutScreenChange = true; + m_pendingScreen = nullptr; + emit hdrModeRestartRequested(); +} + +bool HdrPreviewWindow::isHdrMode() const +{ + auto *sc = swapChain(); + return sc + && (sc->format() == QRhiSwapChain::HDRExtendedSrgbLinear + || sc->format() == QRhiSwapChain::HDR10); +} + void HdrPreviewWindow::updateHdrGain() { if (m_hdrTransfer == HdrTransfer::SDR) diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index 4ade527404..879c7ca579 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -83,6 +83,7 @@ public slots: signals: void hdrGainChanged(); void hdrTransferModeChanged(); + void hdrModeRestartRequested(); void displayPeakNitsChanged(); void contentPeakNitsChanged(); void toneMappingChanged(); @@ -99,15 +100,24 @@ public slots: private slots: void checkEdrHeadroom(); + void onScreenChanged(QScreen *screen); + void processPendingScreenChange(); private: + bool isHdrMode() const; void updateHdrGain(); void invalidateVideoNode(); QPointer m_videoSink; + QPointer m_lastScreen; + QPointer m_pendingScreen; QTimer m_edrTimer; + QTimer m_screenChangeTimer; bool m_loggedSwapChain{false}; bool m_loggedGainSkip{false}; + bool m_lastKnownHdrMode{false}; + bool m_pendingWasHdrMode{false}; + bool m_warnedAboutScreenChange{false}; bool m_skipNextFrame{false}; HdrTransfer m_hdrTransfer{HdrTransfer::SDR}; bool m_isPlaying{false}; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index f248934bea..95191eb828 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -4591,6 +4591,22 @@ void MainWindow::onHdrPreviewToggled(bool checked) 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, &HdrPreviewWindow::hdrModeRestartRequested, this, [this]() { + QMessageBox dialog( + QMessageBox::Question, + qApp->applicationName(), + tr("Moving the preview window from an SDR to HDR screen requires a restart.\n" + "Do you want to restart now?"), + QMessageBox::No | QMessageBox::Yes, + this); + dialog.setDefaultButton(QMessageBox::No); + dialog.setEscapeButton(QMessageBox::No); + dialog.setWindowModality(QmlApplication::dialogModality()); + if (dialog.exec() == QMessageBox::Yes) { + m_exitCode = EXIT_RESTART; + QApplication::closeAllWindows(); + } + }); connect(m_hdrPreviewWindow, &QWindow::visibleChanged, this, [this](bool visible) { if (!visible && m_hdrPreviewWindow) { Settings.setPlayerHdrPreviewFullScreen(m_hdrPreviewWindow->windowStates() From 04b97244055ae86990cf2c2c541f67e8b14e6213 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 16 May 2026 19:59:32 -0700 Subject: [PATCH 41/42] fix black when open preview window while paused --- CMakeLists.txt | 2 +- CMakePresets.json | 3 +++ src/mainwindow.cpp | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2991c5b762..294c091d20 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,7 +72,7 @@ endif() add_subdirectory(CuteLogger) add_subdirectory(src) add_subdirectory(translations) -if(BUILD_MINIMAL_MEDIA_BACKEND) +if(UNIX AND NOT APPLE AND BUILD_MINIMAL_MEDIA_BACKEND) add_subdirectory(MinimalMediaBackend) endif() diff --git a/CMakePresets.json b/CMakePresets.json index 1e32c5b44a..0ed99d4e51 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -45,6 +45,9 @@ "lhs": "${hostSystemName}", "rhs": "Darwin" }, + "environment": { + "QT_VERSION": "6.8.3" + }, "cacheVariables": { "CMAKE_PREFIX_PATH": "$env{HOME}/Qt/$env{QT_VERSION}/macos;$env{HOME}/.local;$env{HOME}/opt;/usr/local;/opt/local" } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 95191eb828..7d69caa614 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -4615,6 +4615,11 @@ void MainWindow::onHdrPreviewToggled(bool checked) Actions["hdrPreviewAction"]->setChecked(false); } }); + win->setPlaying(!MLT.isPaused()); + if (MLT.isPaused()) { + // Ensure the last frame is shown in the preview when paused. + MLT.refreshConsumer(); + } auto savedGeometry = Settings.playerHdrPreviewGeometry(); if (savedGeometry.isValid()) m_hdrPreviewWindow->restoreGeometry(savedGeometry); From fb0956dad5232f80bb32c775b58621927bce409b Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Fri, 22 May 2026 19:01:26 -0700 Subject: [PATCH 42/42] Fix Preview Window startup fullscreen on other monitor --- src/hdrpreviewwindow.cpp | 5 +++++ src/hdrpreviewwindow.h | 1 + src/mainwindow.cpp | 25 +++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/hdrpreviewwindow.cpp b/src/hdrpreviewwindow.cpp index d3f477d863..bf20a22a1b 100644 --- a/src/hdrpreviewwindow.cpp +++ b/src/hdrpreviewwindow.cpp @@ -324,6 +324,11 @@ void HdrPreviewWindow::restoreGeometry(const QRect &r) QTimer::singleShot(300, this, [this]() { m_skipDarSnap = false; }); } +void HdrPreviewWindow::setNormalGeometry(const QRect &r) +{ + m_normalGeometry = r; +} + void HdrPreviewWindow::toggleFullScreen() { if (windowStates() & Qt::WindowFullScreen) { diff --git a/src/hdrpreviewwindow.h b/src/hdrpreviewwindow.h index 879c7ca579..7abc68a11c 100644 --- a/src/hdrpreviewwindow.h +++ b/src/hdrpreviewwindow.h @@ -63,6 +63,7 @@ class HdrPreviewWindow : public QQuickView /// 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); + void setNormalGeometry(const QRect &r); bool isFullScreen() const { return windowStates() & Qt::WindowFullScreen; } int videoPosition() const { return m_videoPosition; } int videoDuration() const { return m_videoDuration; } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7d69caa614..d87c416904 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -4621,8 +4621,29 @@ void MainWindow::onHdrPreviewToggled(bool checked) MLT.refreshConsumer(); } auto savedGeometry = Settings.playerHdrPreviewGeometry(); - if (savedGeometry.isValid()) - m_hdrPreviewWindow->restoreGeometry(savedGeometry); + if (savedGeometry.isValid()) { + if (Settings.playerHdrPreviewFullScreen()) { + // Find the saved screen and position the window there so + // Qt's initialScreen() picks the right monitor for showFullScreen(). + QScreen *targetScreen = nullptr; + for (auto *scr : QGuiApplication::screens()) { + if (scr->geometry().contains(savedGeometry.center())) { + targetScreen = scr; + break; + } + } + if (targetScreen) { + m_hdrPreviewWindow->setGeometry( + QRect(targetScreen->geometry().topLeft(), QSize(960, 540))); + m_hdrPreviewWindow->setScreen(targetScreen); + QRect normalGeom(0, 0, 960, 540); + normalGeom.moveCenter(targetScreen->availableGeometry().center()); + m_hdrPreviewWindow->setNormalGeometry(normalGeom); + } + } else { + m_hdrPreviewWindow->restoreGeometry(savedGeometry); + } + } } m_hdrPreviewWindow->show(); if (Settings.playerHdrPreviewFullScreen())