diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2345732e59..8a5d737532 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -77,6 +77,12 @@ if(UNIX AND NOT APPLE AND BUILD_MINIMAL_MEDIA_BACKEND)
add_subdirectory(MinimalMediaBackend)
endif()
+option(SHOTCUT_BUILD_TESTS "Build unit tests" OFF)
+if(SHOTCUT_BUILD_TESTS)
+ enable_testing()
+ add_subdirectory(tests)
+endif()
+
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
add_custom_target(codespell COMMAND
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index d7a802906e..b69e9833ca 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -66,6 +66,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE
docks/subtitlesdock.cpp docks/subtitlesdock.h
docks/timelinedock.cpp docks/timelinedock.h
FlatpakWrapperGenerator.cpp FlatpakWrapperGenerator.h
+ gpuinfo.cpp gpuinfo.h
htmlgenerator.h htmlgenerator.cpp
jobqueue.cpp jobqueue.h
jobs/abstractjob.cpp jobs/abstractjob.h
@@ -340,7 +341,7 @@ if(WIN32)
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 d3d11 d3dcompiler dxgi dxguid ole32)
# Runtime exception handler for debug only
if(CMAKE_SYSTEM_PROCESSOR STREQUAL "AMD64")
diff --git a/src/docks/encodedock.cpp b/src/docks/encodedock.cpp
index 3975b1f93b..6a8991e9e7 100644
--- a/src/docks/encodedock.cpp
+++ b/src/docks/encodedock.cpp
@@ -23,6 +23,7 @@
#include "dialogs/listselectiondialog.h"
#include "dialogs/multifileexportdialog.h"
#include "findanalysisfilterparser.h"
+#include "gpuinfo.h"
#include "jobqueue.h"
#include "jobs/encodejob.h"
#include "mainwindow.h"
@@ -211,16 +212,18 @@ void EncodeDock::loadPresetFromProperties(Mlt::Properties &preset)
ui->metaLanguageLineEdit->clear();
if (ui->hwencodeCheckBox->isChecked()) {
+ // Prefer the hardware encoder family that matches the user-selected GPU vendor
+ // (NVIDIA -> *_nvenc, AMD -> *_amf, Intel -> *_qsv) so that selecting the
+ // discrete NVIDIA GPU drives export through NVENC even when an AMD (AMF) encoder
+ // also passed detection and happens to come first. 10-bit video skips H.264
+ // hardware encoders, which do not support it. See gpuinfo.cpp.
const bool is10bit = QString::fromLatin1(preset.get("pix_fmt")).contains("p10le");
- for (const QString &hw : Settings.encodeHardware()) {
- if ((vcodec == "libx264" && hw.startsWith("h264") && !is10bit)
- || (vcodec == "libx265" && hw.startsWith("hevc"))
- || (vcodec == "libvpx-vp9" && hw.startsWith("vp9"))
- || (vcodec == "libsvtav1" && hw.startsWith("av1"))) {
- vcodec = hw;
- break;
- }
- }
+ const QString chosen = preferredHardwareVcodec(Settings.encodeHardware(),
+ vcodec,
+ Settings.gpuAdapterVendorId(),
+ is10bit);
+ if (!chosen.isEmpty())
+ vcodec = chosen;
}
ui->disableAudioCheckbox->setChecked(preset.get_int("an"));
diff --git a/src/gpuinfo.cpp b/src/gpuinfo.cpp
new file mode 100644
index 0000000000..49b65494a1
--- /dev/null
+++ b/src/gpuinfo.cpp
@@ -0,0 +1,157 @@
+/*
+ * 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 "gpuinfo.h"
+
+#include
+#include
+
+// Platform-independent: pick the hardware encoder matching the GPU vendor, with a
+// fallback to the first type-compatible encoder. Kept out of the Windows-only block
+// so it builds and is unit-testable everywhere.
+QString preferredHardwareVcodec(const QStringList &hardwareCodecs,
+ const QString &softwareVcodec,
+ uint vendorId,
+ bool is10bit)
+{
+ auto matchesType = [&softwareVcodec, is10bit](const QString &hw) {
+ // No known H.264 hardware encoder supports 10-bit, so skip it in that case.
+ return (softwareVcodec == QLatin1String("libx264")
+ && hw.startsWith(QLatin1String("h264")) && !is10bit)
+ || (softwareVcodec == QLatin1String("libx265")
+ && hw.startsWith(QLatin1String("hevc")))
+ || (softwareVcodec == QLatin1String("libvpx-vp9")
+ && hw.startsWith(QLatin1String("vp9")))
+ || (softwareVcodec == QLatin1String("libsvtav1")
+ && hw.startsWith(QLatin1String("av1")));
+ };
+ QString preferredSuffix;
+ switch (vendorId) {
+ case kGpuVendorNvidia:
+ preferredSuffix = QStringLiteral("_nvenc");
+ break;
+ case kGpuVendorAmd:
+ preferredSuffix = QStringLiteral("_amf");
+ break;
+ case kGpuVendorIntel:
+ preferredSuffix = QStringLiteral("_qsv");
+ break;
+ default:
+ break;
+ }
+ if (!preferredSuffix.isEmpty()) {
+ for (const QString &hw : hardwareCodecs) {
+ if (matchesType(hw) && hw.endsWith(preferredSuffix))
+ return hw;
+ }
+ }
+ for (const QString &hw : hardwareCodecs) {
+ if (matchesType(hw))
+ return hw;
+ }
+ return QString();
+}
+
+#ifdef Q_OS_WIN
+#include
+
+QList enumerateGpuAdapters()
+{
+ QList result;
+ IDXGIFactory1 *factory = nullptr;
+ // IID_IDXGIFactory1 is provided by the dxguid import library (linked in CMake),
+ // which keeps this portable across MinGW/GCC and MSVC.
+ if (FAILED(CreateDXGIFactory1(IID_IDXGIFactory1, reinterpret_cast(&factory)))
+ || !factory)
+ return result;
+
+ // Some drivers (notably AMD integrated graphics) enumerate the same physical GPU
+ // many times with identical hardware ids but different LUIDs. De-duplicate by a
+ // stable hardware key so each GPU appears once in the menu, while preserving the
+ // true DXGI adapter index (which is what QT_D3D_ADAPTER_INDEX expects).
+ QSet seen;
+ IDXGIAdapter1 *adapter = nullptr;
+ for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) {
+ if (adapter) {
+ DXGI_ADAPTER_DESC1 desc;
+ if (SUCCEEDED(adapter->GetDesc1(&desc))
+ // Hide the Microsoft Basic Render Driver (software rasterizer).
+ && !(desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE)) {
+ const QString key = QStringLiteral("%1:%2:%3:%4")
+ .arg(desc.VendorId)
+ .arg(desc.DeviceId)
+ .arg(desc.SubSysId)
+ .arg(desc.Revision);
+ if (!seen.contains(key)) {
+ seen.insert(key);
+ GpuAdapterInfo info;
+ info.index = static_cast(i);
+ info.name = QString::fromWCharArray(desc.Description);
+ info.vendorId = desc.VendorId;
+ info.deviceId = desc.DeviceId;
+ result.append(info);
+ }
+ }
+ adapter->Release();
+ adapter = nullptr;
+ }
+ }
+ factory->Release();
+ return result;
+}
+
+int gpuAdapterIndexFor(uint vendorId, uint deviceId)
+{
+ if (vendorId == 0)
+ return -1;
+ IDXGIFactory1 *factory = nullptr;
+ if (FAILED(CreateDXGIFactory1(IID_IDXGIFactory1, reinterpret_cast(&factory)))
+ || !factory)
+ return -1;
+ int found = -1;
+ IDXGIAdapter1 *adapter = nullptr;
+ for (UINT i = 0; factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) {
+ if (adapter) {
+ DXGI_ADAPTER_DESC1 desc;
+ if (found < 0 && SUCCEEDED(adapter->GetDesc1(&desc))
+ && !(desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) && desc.VendorId == vendorId
+ && desc.DeviceId == deviceId) {
+ found = static_cast(i);
+ }
+ adapter->Release();
+ adapter = nullptr;
+ }
+ }
+ factory->Release();
+ return found;
+}
+
+#else // !Q_OS_WIN
+
+QList enumerateGpuAdapters()
+{
+ // Index-based GPU selection is currently only implemented for the Windows
+ // Direct3D RHI backend. Returning empty hides the selection UI elsewhere.
+ return QList();
+}
+
+int gpuAdapterIndexFor(uint, uint)
+{
+ return -1;
+}
+
+#endif
diff --git a/src/gpuinfo.h b/src/gpuinfo.h
new file mode 100644
index 0000000000..ceaa2b349b
--- /dev/null
+++ b/src/gpuinfo.h
@@ -0,0 +1,63 @@
+/*
+ * 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 GPUINFO_H
+#define GPUINFO_H
+
+#include
+#include
+#include
+
+// PCI vendor ids of the common GPU vendors.
+enum GpuVendorId {
+ kGpuVendorNvidia = 0x10DE,
+ kGpuVendorAmd = 0x1002,
+ kGpuVendorIntel = 0x8086,
+};
+
+struct GpuAdapterInfo
+{
+ int index = -1; // current DXGI adapter index; matches QT_D3D_ADAPTER_INDEX
+ QString name; // human-readable description, e.g. "NVIDIA GeForce RTX 3090"
+ uint vendorId = 0; // PCI vendor id (see GpuVendorId)
+ uint deviceId = 0; // PCI device id; together with vendorId identifies the GPU
+};
+
+// Enumerate the physical GPU adapters usable by the renderer.
+// On Windows this uses DXGI (the same enumeration order Qt's D3D RHI backend uses).
+// Returns an empty list on platforms/backends where index-based selection is not
+// supported, in which case the caller should hide the selection UI.
+QList enumerateGpuAdapters();
+
+// Resolve the CURRENT DXGI adapter index (as QT_D3D_ADAPTER_INDEX expects) of the GPU
+// identified by vendorId+deviceId, or -1 if not found. Some drivers enumerate adapters
+// in an unstable order across runs, so the index must be resolved live at startup from
+// the GPU's stable identity rather than persisted from a previous session.
+int gpuAdapterIndexFor(uint vendorId, uint deviceId);
+
+// Choose the hardware video encoder for a given software codec, preferring the encoder
+// family matching the selected GPU vendor (NVIDIA->*_nvenc, AMD->*_amf, Intel->*_qsv)
+// and otherwise falling back to the first type-compatible encoder in hardwareCodecs.
+// When is10bit is true, H.264 hardware encoders are skipped because they do not support
+// 10-bit. Returns an empty string when no compatible hardware encoder is available.
+// This is a pure, platform-independent function so it can be unit tested.
+QString preferredHardwareVcodec(const QStringList &hardwareCodecs,
+ const QString &softwareVcodec,
+ uint vendorId,
+ bool is10bit);
+
+#endif // GPUINFO_H
diff --git a/src/main.cpp b/src/main.cpp
index 013d110a85..c76e333227 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -18,6 +18,7 @@
#include "ConsoleAppender.h"
#include "FileAppender.h"
#include "Logger.h"
+#include "gpuinfo.h"
#include "mainwindow.h"
#include "settings.h"
@@ -432,6 +433,30 @@ int main(int argc, char **argv)
Application a(argc, argv);
int result = EXIT_SUCCESS;
+#ifdef Q_OS_WIN
+ // Direct the user-selected physical GPU to be used by the Qt RHI (Direct3D)
+ // preview/UI via QT_D3D_ADAPTER_INDEX and by the FFmpeg hardware decoder via
+ // MLT_AVFORMAT_HWACCEL_DEVICE. These must be set before the RHI is initialized (the
+ // adapter index is read lazily when the first scene-graph window is created). In
+ // release builds the watchdog child process, and the melt export subprocess, inherit
+ // this environment. Values already set by the user take precedence. The adapter index
+ // is resolved live from the GPU's stable vendor+device identity because some drivers
+ // enumerate adapters in an unstable order across runs. See gpuinfo.cpp and Settings.
+ {
+ const uint gpuVendorId = Settings.gpuAdapterVendorId();
+ if (gpuVendorId != 0) {
+ const int gpuAdapterIndex = gpuAdapterIndexFor(gpuVendorId,
+ Settings.gpuAdapterDeviceId());
+ if (gpuAdapterIndex >= 0) {
+ const QByteArray index = QByteArray::number(gpuAdapterIndex);
+ if (!qEnvironmentVariableIsSet("QT_D3D_ADAPTER_INDEX"))
+ ::qputenv("QT_D3D_ADAPTER_INDEX", index);
+ if (!qEnvironmentVariableIsSet("MLT_AVFORMAT_HWACCEL_DEVICE"))
+ ::qputenv("MLT_AVFORMAT_HWACCEL_DEVICE", index);
+ }
+ }
+ }
+#endif
#ifdef QT_DEBUG
::qputenv(kWatchdogEnvVar, "1");
#endif
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 39191e5a38..73db33d3f5 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 "gpuinfo.h"
#include "hdrpreviewwindow.h"
#include "jobqueue.h"
#include "jobs/screencapturejob.h"
@@ -1626,6 +1627,43 @@ void MainWindow::setupSettingsMenu()
ui->menuDrawingMethod = 0;
#endif
+ // Setup the Graphics Adapter (GPU) selection menu. This is currently only
+ // populated where index-based GPU selection is supported (Windows D3D RHI);
+ // enumerateGpuAdapters() returns empty elsewhere and the menu is hidden.
+ {
+ const QList adapters = enumerateGpuAdapters();
+ if (adapters.isEmpty()) {
+ delete ui->menuGpuAdapter;
+ ui->menuGpuAdapter = nullptr;
+ } else {
+ QActionGroup *gpuGroup = new QActionGroup(this);
+ const uint currentVendor = Settings.gpuAdapterVendorId();
+ const uint currentDevice = Settings.gpuAdapterDeviceId();
+ QAction *autoAction = ui->menuGpuAdapter->addAction(tr("Automatic"));
+ autoAction->setCheckable(true);
+ autoAction->setProperty("vendorId", 0u);
+ autoAction->setProperty("deviceId", 0u);
+ autoAction->setChecked(currentVendor == 0);
+ gpuGroup->addAction(autoAction);
+ for (const GpuAdapterInfo &gpu : adapters) {
+ QAction *action = ui->menuGpuAdapter->addAction(gpu.name);
+ action->setCheckable(true);
+ action->setProperty("vendorId", gpu.vendorId);
+ action->setProperty("deviceId", gpu.deviceId);
+ action->setChecked(currentVendor == gpu.vendorId && currentDevice == gpu.deviceId);
+ gpuGroup->addAction(action);
+ LOG_INFO() << "GPU adapter" << gpu.index << gpu.name
+ << QString::asprintf("vendor=0x%04X device=0x%04X",
+ gpu.vendorId,
+ gpu.deviceId);
+ }
+ connect(gpuGroup,
+ SIGNAL(triggered(QAction *)),
+ this,
+ SLOT(onGpuAdapterTriggered(QAction *)));
+ }
+ }
+
// Setup the job priority actions
group = new QActionGroup(this);
group->addAction(ui->actionJobPriorityLow);
@@ -5383,6 +5421,25 @@ void MainWindow::onDrawingMethodTriggered(QAction *action)
}
#endif
+void MainWindow::onGpuAdapterTriggered(QAction *action)
+{
+ Settings.setGpuAdapterVendorId(action->property("vendorId").toUInt());
+ Settings.setGpuAdapterDeviceId(action->property("deviceId").toUInt());
+ QMessageBox dialog(QMessageBox::Information,
+ qApp->applicationName(),
+ tr("You must restart Shotcut to change the graphics adapter.\n"
+ "Do you want to restart now?"),
+ QMessageBox::No | QMessageBox::Yes,
+ this);
+ dialog.setDefaultButton(QMessageBox::Yes);
+ dialog.setEscapeButton(QMessageBox::No);
+ dialog.setWindowModality(QmlApplication::dialogModality());
+ if (dialog.exec() == QMessageBox::Yes) {
+ m_exitCode = EXIT_RESTART;
+ QApplication::closeAllWindows();
+ }
+}
+
void MainWindow::on_actionResources_triggered()
{
ResourceDialog dialog(this);
diff --git a/src/mainwindow.h b/src/mainwindow.h
index f37c45624f..e855fe2268 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -346,6 +346,7 @@ private slots:
#if !defined(Q_OS_MAC)
void onDrawingMethodTriggered(QAction *);
#endif
+ void onGpuAdapterTriggered(QAction *);
void on_actionResources_triggered();
void on_actionApplicationLog_triggered();
void on_actionClose_triggered();
diff --git a/src/mainwindow.ui b/src/mainwindow.ui
index b340cd2981..7f09ef8b99 100644
--- a/src/mainwindow.ui
+++ b/src/mainwindow.ui
@@ -196,6 +196,11 @@
+