From cb259b3610e2def65164e29bd6837b2c8c10b72d Mon Sep 17 00:00:00 2001 From: Brian Matherly Date: Wed, 8 Apr 2026 21:32:35 -0500 Subject: [PATCH 1/5] Add support for experimental add-on filters This includes a new selection dialog and generated UIs The --experimental option is required to expose the selection --experimental is ony available when Shotcut is compiles for debug --- src/CMakeLists.txt | 5 + src/controllers/addonmetadataparser.cpp | 100 +++ src/controllers/addonmetadataparser.h | 55 ++ src/controllers/addonqmlgenerator.cpp | 851 ++++++++++++++++++++++++ src/controllers/addonqmlgenerator.h | 35 + src/controllers/filtercontroller.cpp | 200 +++++- src/controllers/filtercontroller.h | 14 +- src/dialogs/addonfiltersdialog.cpp | 197 ++++++ src/dialogs/addonfiltersdialog.h | 52 ++ src/docks/filtersdock.cpp | 42 ++ src/docks/filtersdock.h | 9 +- src/main.cpp | 14 +- src/mainwindow.cpp | 1 + src/mltcontroller.cpp | 11 +- src/models/addonservicemodel.cpp | 218 ++++++ src/models/addonservicemodel.h | 68 ++ src/qml/views/filter/FilterMenu.qml | 21 + src/qml/views/filter/filterview.qml | 10 + src/qmltypes/qmlapplication.cpp | 14 +- src/qmltypes/qmlapplication.h | 3 +- src/qmltypes/qmlfiltermenu.cpp | 45 ++ src/qmltypes/qmlfiltermenu.h | 37 ++ src/qmltypes/qmlutilities.cpp | 2 + src/settings.cpp | 11 + src/settings.h | 2 + 25 files changed, 2008 insertions(+), 9 deletions(-) create mode 100644 src/controllers/addonmetadataparser.cpp create mode 100644 src/controllers/addonmetadataparser.h create mode 100644 src/controllers/addonqmlgenerator.cpp create mode 100644 src/controllers/addonqmlgenerator.h create mode 100644 src/dialogs/addonfiltersdialog.cpp create mode 100644 src/dialogs/addonfiltersdialog.h create mode 100644 src/models/addonservicemodel.cpp create mode 100644 src/models/addonservicemodel.h create mode 100644 src/qmltypes/qmlfiltermenu.cpp create mode 100644 src/qmltypes/qmlfiltermenu.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1662ae669f..ffc27a3fbb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,6 +10,8 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE commands/subtitlecommands.cpp commands/subtitlecommands.h commands/timelinecommands.cpp commands/timelinecommands.h commands/undohelper.cpp commands/undohelper.h + controllers/addonmetadataparser.cpp controllers/addonmetadataparser.h + controllers/addonqmlgenerator.cpp controllers/addonqmlgenerator.h controllers/filtercontroller.cpp controllers/filtercontroller.h controllers/scopecontroller.cpp controllers/scopecontroller.h database.cpp database.h @@ -18,6 +20,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE dialogs/addencodepresetdialog.ui dialogs/alignaudiodialog.cpp dialogs/alignaudiodialog.h dialogs/alignmentarray.cpp dialogs/alignmentarray.h + dialogs/addonfiltersdialog.cpp dialogs/addonfiltersdialog.h dialogs/bitratedialog.h dialogs/bitratedialog.cpp dialogs/customprofiledialog.cpp dialogs/customprofiledialog.h dialogs/customprofiledialog.ui @@ -85,6 +88,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE mltcontroller.cpp mltcontroller.h mltxmlchecker.cpp mltxmlchecker.h models/actionsmodel.cpp models/actionsmodel.h + models/addonservicemodel.cpp models/addonservicemodel.h models/alignclipsmodel.cpp models/alignclipsmodel.h models/attachedfiltersmodel.cpp models/attachedfiltersmodel.h models/audiolevelstask.cpp models/audiolevelstask.h @@ -114,6 +118,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE qmltypes/qmlextension.cpp qmltypes/qmlextension.h qmltypes/qmlfile.cpp qmltypes/qmlfile.h qmltypes/qmlfilter.cpp qmltypes/qmlfilter.h + qmltypes/qmlfiltermenu.cpp qmltypes/qmlfiltermenu.h qmltypes/qmlmarkermenu.cpp qmltypes/qmlmarkermenu.h qmltypes/qmlmetadata.cpp qmltypes/qmlmetadata.h qmltypes/qmlproducer.cpp qmltypes/qmlproducer.h diff --git a/src/controllers/addonmetadataparser.cpp b/src/controllers/addonmetadataparser.cpp new file mode 100644 index 0000000000..ef3eb5f5cc --- /dev/null +++ b/src/controllers/addonmetadataparser.cpp @@ -0,0 +1,100 @@ +/* + * 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 "addonmetadataparser.h" + +#include + +bool parseYesNoBool(const char *value) +{ + if (!value) + return false; + const QString text = QString::fromUtf8(value).trimmed().toLower(); + return text == QStringLiteral("yes") || text == QStringLiteral("true") + || text == QStringLiteral("1"); +} + +AddOnFilterDescriptor AddOnMetadataParser::parse(const QString &service, + Mlt::Properties *mltMetadata) +{ + AddOnFilterDescriptor descriptor; + descriptor.service = service; + descriptor.title = service; + + if (!mltMetadata || !mltMetadata->is_valid()) + return descriptor; + + descriptor.title = QString::fromUtf8(mltMetadata->get("title")); + if (descriptor.title.isEmpty()) + descriptor.title = service; + + descriptor.description = QString::fromUtf8(mltMetadata->get("description")); + + Mlt::Properties tags(mltMetadata->get_data("tags")); + if (tags.is_valid()) { + for (int i = 0; i < tags.count(); ++i) { + if (!qstricmp(tags.get(i), "Audio")) { + descriptor.isAudio = true; + break; + } + } + } + + Mlt::Properties parameters(mltMetadata->get_data("parameters")); + if (!parameters.is_valid()) + return descriptor; + + for (int i = 0; i < parameters.count(); ++i) { + const char *rawName = parameters.get_name(i); + Mlt::Properties parameter(rawName ? parameters.get_data(rawName) : nullptr); + + AddOnParameterDescriptor out; + if (parameter.is_valid()) { + out.title = QString::fromUtf8(parameter.get("title")); + out.defaultValue = QString::fromUtf8(parameter.get("default")); + out.type = QString::fromUtf8(parameter.get("type")); + out.isReadOnly = parseYesNoBool(parameter.get("readonly")); + out.supportsKeyframes = parseYesNoBool(parameter.get("animation")); + out.unit = QString::fromUtf8(parameter.get("unit")); + out.minimum = QString::fromUtf8(parameter.get("minimum")); + out.maximum = QString::fromUtf8(parameter.get("maximum")); + out.description = QString::fromUtf8(parameter.get("description")); + out.name = QString::fromUtf8(parameter.get("identifier")); + + Mlt::Properties values(parameter.get_data("values")); + if (values.is_valid()) { + for (int v = 0; v < values.count(); ++v) { + QString option = QString::fromUtf8(values.get(v)); + if (option.isEmpty()) { + const char *optionName = values.get_name(v); + if (optionName) + option = QString::fromUtf8(optionName); + } + if (!option.isEmpty() && !out.values.contains(option)) + out.values << option; + } + } + } + + if (out.name.isEmpty() && rawName) + out.name = QString::fromUtf8(rawName); + + descriptor.parameters.push_back(out); + } + + return descriptor; +} diff --git a/src/controllers/addonmetadataparser.h b/src/controllers/addonmetadataparser.h new file mode 100644 index 0000000000..e47e32a876 --- /dev/null +++ b/src/controllers/addonmetadataparser.h @@ -0,0 +1,55 @@ +/* + * 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 ADDONMETADATAPARSER_H +#define ADDONMETADATAPARSER_H + +#include +#include +#include + +struct AddOnParameterDescriptor +{ + QString name; + QString title; + QString type; + bool isReadOnly = false; + bool supportsKeyframes = false; + QStringList values; + QString defaultValue; + QString unit; + QString minimum; + QString maximum; + QString description; +}; + +struct AddOnFilterDescriptor +{ + QString service; + QString title; + QString description; + bool isAudio = false; + QList parameters; +}; + +class AddOnMetadataParser +{ +public: + static AddOnFilterDescriptor parse(const QString &service, Mlt::Properties *mltMetadata); +}; + +#endif // ADDONMETADATAPARSER_H diff --git a/src/controllers/addonqmlgenerator.cpp b/src/controllers/addonqmlgenerator.cpp new file mode 100644 index 0000000000..fc2c97d822 --- /dev/null +++ b/src/controllers/addonqmlgenerator.cpp @@ -0,0 +1,851 @@ +/* + * 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 "addonqmlgenerator.h" + +#include +#include +#include +#include +#include + +QString quotedJsString(const QString &value) +{ + QString s = value; + s.replace('\\', "\\\\"); + s.replace('\'', "\\'"); + return QStringLiteral("'%1'").arg(s); +} + +bool isGroupHeadingParameter(const AddOnParameterDescriptor ¶meter) +{ + const QString parameterType = parameter.type.trimmed().toLower(); + const QString parameterName = parameter.name.trimmed().toLower(); + return parameterType == QStringLiteral("group") + || (parameterName == QStringLiteral("group") + && parameterType == QStringLiteral("string")); +} + +bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, + const QDir &outputDir, + const QString &qmlFileName, + QString *errorMessage) const +{ + if (descriptor.service.isEmpty()) { + if (errorMessage) + *errorMessage = QStringLiteral("Missing add-on service identifier"); + return false; + } + + QFile file(outputDir.filePath(qmlFileName)); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + if (errorMessage) + *errorMessage = QStringLiteral("Failed to open generated add-on ui.qml for writing"); + return false; + } + + QStringList quotedProperties; + QStringList quotedTitles; + QStringList quotedDefaults; + QStringList quotedTypes; + QStringList quotedUnits; + QStringList quotedMinimums; + QStringList quotedMaximums; + QStringList quotedDescriptions; + QStringList quotedValueLists; + QStringList quotedKeyframeProperties; + QStringList quotedKeyframeMapEntries; + QStringList setControlsLines; + int parameterIndex = 0; + for (const auto ¶meter : descriptor.parameters) { + if (isGroupHeadingParameter(parameter)) + continue; + + quotedProperties << quotedJsString(parameter.name); + if (!parameter.title.isEmpty()) { + quotedTitles << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), + quotedJsString(parameter.title)); + } + if (!parameter.defaultValue.isEmpty()) { + quotedDefaults << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), + quotedJsString(parameter.defaultValue)); + } + if (!parameter.type.isEmpty()) { + quotedTypes << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), + quotedJsString(parameter.type)); + } + if (!parameter.unit.isEmpty()) { + quotedUnits << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), + quotedJsString(parameter.unit)); + } + if (!parameter.minimum.isEmpty()) { + quotedMinimums << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), + quotedJsString(parameter.minimum)); + } + if (!parameter.maximum.isEmpty()) { + quotedMaximums << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), + quotedJsString(parameter.maximum)); + } + if (!parameter.description.isEmpty()) { + quotedDescriptions << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), + quotedJsString( + parameter.description)); + } + if (!parameter.values.isEmpty()) { + QStringList quotedOptions; + for (const auto &value : parameter.values) + quotedOptions << quotedJsString(value); + quotedValueLists << QStringLiteral("%1: [%2]") + .arg(quotedJsString(parameter.name), + quotedOptions.join(QStringLiteral(", "))); + } + + const QString parameterType = parameter.type.trimmed().toLower(); + const bool supportsGeneratedKeyframes = !parameter.isReadOnly && parameter.supportsKeyframes + && (parameterType == QStringLiteral("integer") + || parameterType == QStringLiteral("float") + || parameterType == QStringLiteral("color")); + const QString parameterId = QStringLiteral("param_%1").arg(parameterIndex++); + const QString editorId = parameterId + QStringLiteral("_editor"); + const QString keyframesId = parameterId + QStringLiteral("_keyframes"); + const QString nameLiteral = quotedJsString(parameter.name); + if (supportsGeneratedKeyframes) { + quotedKeyframeProperties << nameLiteral; + quotedKeyframeMapEntries << QStringLiteral("%1: true").arg(nameLiteral); + } + if (parameter.isReadOnly) { + setControlsLines << QStringLiteral(" %1.text = root.textValue(%2);") + .arg(editorId, nameLiteral); + } else if (parameterType == QStringLiteral("string") && !parameter.values.isEmpty()) { + setControlsLines << QStringLiteral( + " var %1_options = root.propertyValues[%2] || [];") + .arg(editorId, nameLiteral); + setControlsLines << QStringLiteral(" var %1_value = String(root.textValue(%2));") + .arg(editorId, nameLiteral); + setControlsLines << QStringLiteral( + " var %1_index = %1_options.indexOf(%1_value);") + .arg(editorId); + setControlsLines << QStringLiteral( + " %1.currentIndex = %1_index >= 0 ? %1_index : 0;") + .arg(editorId); + } else if (parameterType == QStringLiteral("color")) { + setControlsLines << QStringLiteral(" %1.value = root.colorValue(%2);") + .arg(editorId, nameLiteral); + } else if (parameterType == QStringLiteral("boolean")) { + setControlsLines << QStringLiteral(" %1.checked = root.booleanValue(%2);") + .arg(editorId, nameLiteral); + } else if (parameterType == QStringLiteral("integer") + || parameterType == QStringLiteral("float")) { + setControlsLines + << QStringLiteral( + " %1.value = root.numericValue(%2, root.propertyType(%2));") + .arg(editorId, nameLiteral); + } else { + setControlsLines << QStringLiteral(" %1.text = root.textValue(%2);") + .arg(editorId, nameLiteral); + } + if (supportsGeneratedKeyframes) { + setControlsLines << QStringLiteral( + " %1.checked = filter.animateIn <= 0 && " + "filter.animateOut <= 0 && filter.keyframeCount(%2) > 0;") + .arg(keyframesId, nameLiteral); + } + } + + QTextStream stream(&file); + stream.setEncoding(QStringConverter::Utf8); + stream + << "import QtQuick\n" + "import QtQuick.Controls\n" + "import QtQuick.Layouts\n\n" + "import Shotcut.Controls as Shotcut\n\n" + "Shotcut.KeyframableFilter {\n" + " id: root\n" + " signal metadataHelpRequested(string title, string text)\n" + " property var propertyNames: [" + << quotedProperties.join(QStringLiteral(", ")) + << "]\n\n" + " property var propertyTitles: {" + << quotedTitles.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyDefaults: {" + << quotedDefaults.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyTypes: {" + << quotedTypes.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyUnits: {" + << quotedUnits.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyMinimums: {" + << quotedMinimums.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyMaximums: {" + << quotedMaximums.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyDescriptions: {" + << quotedDescriptions.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyValues: {" + << quotedValueLists.join(QStringLiteral(", ")) + << "}\n\n" + " keyframableParameters: [" + << quotedKeyframeProperties.join(QStringLiteral(", ")) + << "]\n" + " startValues: []\n" + " middleValues: []\n" + " endValues: []\n\n" + " property var propertyKeyframes: {" + << quotedKeyframeMapEntries.join(QStringLiteral(", ")) + << "}\n\n" + " property string filterTitle: " + << quotedJsString(descriptor.title) + << "\n\n" + " property string filterDescription: " + << quotedJsString(descriptor.description) + << "\n\n" + " function isKeyframableProperty(name) {\n" + " return !!(root.propertyKeyframes && root.propertyKeyframes[name]);\n" + " }\n\n" + " function propertyType(name) {\n" + " if (!root.propertyTypes)\n" + " return '';\n" + " var type = root.propertyTypes[name];\n" + " return (type !== undefined && type !== null) ? String(type).toLowerCase() : " + "'';\n" + " }\n\n" + " function isNumericType(type) {\n" + " return type === 'integer' || type === 'float';\n" + " }\n\n" + " function isIntegerType(type) {\n" + " return type === 'integer';\n" + " }\n\n" + " function numericSuffix(name) {\n" + " if (!root.propertyUnits)\n" + " return '';\n" + " var unit = root.propertyUnits[name];\n" + " if (unit === undefined || unit === null || unit === '')\n" + " return '';\n" + " unit = String(unit);\n" + " return unit.startsWith(' ') ? unit : (' ' + unit);\n" + " }\n\n" + " function isBooleanType(type) {\n" + " return type === 'boolean';\n" + " }\n\n" + " function isColorType(type) {\n" + " return type === 'color';\n" + " }\n\n" + " function colorValue(name) {\n" + " var value = filter.get(name);\n" + " if (value === undefined || value === null || value === '')\n" + " value = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " return (value !== undefined && value !== null && value !== '') ? value : " + "'#ffffffff';\n" + " }\n\n" + " function defaultColorValue(name) {\n" + " var value = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " return (value !== undefined && value !== null && value !== '') ? value : " + "'#ffffffff';\n" + " }\n\n" + " function booleanValue(name) {\n" + " var value = filter.get(name);\n" + " if (value === undefined || value === null || value === '')\n" + " value = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " if (value === undefined || value === null)\n" + " return false;\n" + " if (value === true || value === 1)\n" + " return true;\n" + " var text = String(value).toLowerCase();\n" + " return text === '1' || text === 'true';\n" + " }\n\n" + " function defaultBooleanValue(name) {\n" + " var value = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " if (value === undefined || value === null)\n" + " return false;\n" + " if (value === true || value === 1)\n" + " return true;\n" + " var text = String(value).toLowerCase();\n" + " return text === '1' || text === 'true';\n" + " }\n\n" + " function textValue(name) {\n" + " var value = filter.get(name);\n" + " if (value !== undefined && value !== null && value !== '')\n" + " return value;\n" + " var d = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " return (d !== undefined && d !== null) ? d : '';\n" + " }\n\n" + " function defaultTextValue(name) {\n" + " var value = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " return (value !== undefined && value !== null) ? value : '';\n" + " }\n\n" + " function numericBound(name, useMin) {\n" + " var map = useMin ? root.propertyMinimums : root.propertyMaximums;\n" + " if (!map)\n" + " return useMin ? 0 : 100;\n" + " var raw = map[name];\n" + " if (raw === undefined || raw === null || raw === '')\n" + " return useMin ? 0 : 100;\n" + " var parsed = Number(raw);\n" + " if (isNaN(parsed))\n" + " return useMin ? 0 : 100;\n" + " return parsed;\n" + " }\n\n" + " function numericValue(name, type) {\n" + " var value;\n" + " if (root.isKeyframableProperty(name))\n" + " value = filter.getDouble(name, root.getPosition());\n" + " else\n" + " value = filter.get(name);\n" + " if (value === undefined || value === null || value === '')\n" + " value = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " var parsed = Number(value);\n" + " if (isNaN(parsed)) {\n" + " parsed = numericBound(name, true);\n" + " var maxBound = numericBound(name, false);\n" + " if (parsed > maxBound)\n" + " parsed = maxBound;\n" + " }\n" + " if (isIntegerType(type))\n" + " parsed = Math.round(parsed);\n" + " return parsed;\n" + " }\n\n" + " function defaultNumericValue(name, type) {\n" + " var value = root.propertyDefaults ? root.propertyDefaults[name] : undefined;\n" + " var parsed = Number(value);\n" + " if (isNaN(parsed)) {\n" + " parsed = numericBound(name, true);\n" + " var maxBound = numericBound(name, false);\n" + " if (parsed > maxBound)\n" + " parsed = maxBound;\n" + " }\n" + " if (isIntegerType(type))\n" + " parsed = Math.round(parsed);\n" + " return parsed;\n" + " }\n\n" + " function setControls() {\n" + " blockUpdate = true;\n"; + + if (!setControlsLines.isEmpty()) { + stream << setControlsLines.join(QStringLiteral("\n")) << "\n"; + } + + stream + << " blockUpdate = false;\n" + << " }\n\n" + " function escapeHtml(value) {\n" + " if (value === undefined || value === null)\n" + " return '';\n" + " var text = String(value);\n" + " text = text.replace(/&/g, '&');\n" + " text = text.replace(//g, '>');\n" + " return text;\n" + " }\n\n" + " function metadataText() {\n" + " var entries = [];\n" + " var filterLines = [];\n" + " filterLines.push('' + qsTr('Filter Title') + ': ' + " + "root.escapeHtml(root.filterTitle.length > 0 ? root.filterTitle : '-'));\n" + " if (root.filterDescription.length > 0)\n" + " filterLines.push('' + qsTr('Filter Description') + ': ' + " + "root.escapeHtml(root.filterDescription));\n" + " entries.push(filterLines.join('
'));\n" + "\n" + " for (var i = 0; i < propertyNames.length; ++i) {\n" + " var name = propertyNames[i];\n" + " var title = (root.propertyTitles && root.propertyTitles[name]) ? " + "root.propertyTitles[name] : name;\n" + " var description = (root.propertyDescriptions && " + "root.propertyDescriptions[name]) ? root.propertyDescriptions[name] : '';\n" + " var entryLines = [];\n" + " entryLines.push('' + qsTr('Parameter Title') + ': ' + " + "root.escapeHtml(title));\n" + " if (description.length > 0)\n" + " entryLines.push('' + qsTr('Description') + ': ' + " + "root.escapeHtml(description));\n" + "\n" + " var minValue = root.propertyMinimums ? root.propertyMinimums[name] : " + "undefined;\n" + " var maxValue = root.propertyMaximums ? root.propertyMaximums[name] : " + "undefined;\n" + " var hasMin = minValue !== undefined && minValue !== null && " + "String(minValue) !== '';\n" + " var hasMax = maxValue !== undefined && maxValue !== null && " + "String(maxValue) !== '';\n" + " if (hasMin && hasMax)\n" + " entryLines.push('' + qsTr('Range') + ': ' + " + "root.escapeHtml(minValue) + ' - ' + root.escapeHtml(maxValue));\n" + "\n" + " entries.push(entryLines.join('
'));\n" + " }\n" + "\n" + " return entries.join('

');\n" + " }\n\n" + " width: 360\n" + " height: gridLayout.implicitHeight + 16\n\n" + " Component.onCompleted: {\n" + " if (filter.isNew) {\n" + " for (var i = 0; i < propertyNames.length; ++i) {\n" + " var p = propertyNames[i];\n" + " var d = root.propertyDefaults[p];\n" + " var type = propertyType(p);\n" + " if (d !== undefined && d !== null && d !== '')\n" + " filter.set(p, isBooleanType(type) ? (booleanValue(p) ? '1' : '0') " + ": (isNumericType(type) ? Number(d) : d));\n" + " }\n" + " filter.savePreset(propertyNames);\n" + " }\n" + " setControls();\n" + " if (keyframableParameters.length > 0)\n" + " initializeSimpleKeyframes();\n" + " }\n\n" + " GridLayout {\n" + " id: gridLayout\n" + " anchors.left: parent.left\n" + " anchors.right: parent.right\n" + " anchors.top: parent.top\n" + " anchors.margins: 8\n" + " columns: 4\n" + " columnSpacing: 8\n" + " rowSpacing: 6\n\n" + " Label {\n" + " Layout.columnSpan: 3\n" + " Layout.fillWidth: true\n" + " text: root.filterDescription.length > 0 ? qsTr('Add-on Filter: " + "%1').arg(root.filterDescription) : qsTr('Add-on Filter')\n" + " wrapMode: Text.Wrap\n" + " }\n\n" + " ToolButton {\n" + " icon.name: 'help-contextual'\n" + " icon.source: 'qrc:///icons/oxygen/32x32/actions/help-contextual.png'\n" + " text: ''\n" + " display: AbstractButton.IconOnly\n" + " ToolTip.visible: hovered\n" + " ToolTip.text: qsTr('Help')\n" + " Layout.alignment: Qt.AlignRight | Qt.AlignTop\n" + " onClicked: root.metadataHelpRequested(qsTr('Add-on Metadata'), " + "root.metadataText())\n" + " }\n\n" + " Label {\n" + " text: qsTr('Preset')\n" + " horizontalAlignment: Text.AlignRight\n" + " Layout.alignment: Qt.AlignRight | Qt.AlignVCenter\n" + " }\n" + "\n" + " Shotcut.Preset {\n" + " id: preset\n" + " Layout.columnSpan: 3\n" + " Layout.fillWidth: true\n" + " Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter\n" + " parameters: root.propertyNames ? root.propertyNames.slice(0) : []\n" + " onBeforePresetLoaded: {\n" + " filter.resetProperty('shotcut:animIn');\n" + " filter.resetProperty('shotcut:animOut');\n" + " if (keyframableParameters.length > 0)\n" + " resetSimpleKeyframes();\n" + " for (var i = 0; i < root.propertyNames.length; ++i)\n" + " filter.resetProperty(root.propertyNames[i]);\n" + " }\n" + " onPresetSelected: {\n" + " filter.animateIn = Math.round(filter.getDouble('shotcut:animIn'));\n" + " filter.animateOut = Math.round(filter.getDouble('shotcut:animOut'));\n" + " root.setControls();\n" + " if (keyframableParameters.length > 0)\n" + " initializeSimpleKeyframes();\n" + " }\n" + " }\n\n" + ""; + + int rowIndex = 0; + for (const auto ¶meter : descriptor.parameters) { + if (isGroupHeadingParameter(parameter)) { + QString sectionTitle = parameter.title; + if (sectionTitle.isEmpty()) + sectionTitle = parameter.defaultValue; + if (sectionTitle.isEmpty()) + sectionTitle = parameter.name; + + stream << " Label {\n" + " Layout.columnSpan: 4\n" + " Layout.fillWidth: true\n" + " horizontalAlignment: Text.AlignHCenter\n" + " font.bold: true\n" + " text: " + << quotedJsString(sectionTitle) + << "\n" + " }\n\n"; + continue; + } + + const QString parameterType = parameter.type.trimmed().toLower(); + const bool supportsGeneratedKeyframes = !parameter.isReadOnly && parameter.supportsKeyframes + && (parameterType == QStringLiteral("integer") + || parameterType == QStringLiteral("float") + || parameterType == QStringLiteral("color")); + const QString parameterId = QStringLiteral("param_%1").arg(rowIndex++); + const QString hoverId = parameterId + QStringLiteral("_hover"); + const QString editorId = parameterId + QStringLiteral("_editor"); + const QString keyframesId = parameterId + QStringLiteral("_keyframes"); + + const QString nameLiteral = quotedJsString(parameter.name); + + if (parameterType == QStringLiteral("boolean") && !parameter.isReadOnly) { + stream << " Item { }\n\n"; + } else { + stream << " Label {\n" + " text: (root.propertyTitles && root.propertyTitles[" + << nameLiteral << "]) ? root.propertyTitles[" << nameLiteral + << "] : " << nameLiteral + << "\n" + " horizontalAlignment: Text.AlignRight\n" + " Layout.alignment: Qt.AlignRight | Qt.AlignVCenter\n" + " elide: Text.ElideRight\n" + " ToolTip.delay: 400\n" + " ToolTip.visible: " + << hoverId + << ".containsMouse\n" + "" + " ToolTip.text: {\n" + " if (root.propertyDescriptions && root.propertyDescriptions[" + << nameLiteral + << "])\n" + " return root.propertyDescriptions[" + << nameLiteral + << "];\n" + " return text;\n" + " }\n\n" + " MouseArea {\n" + " id: " + << hoverId + << "\n" + "" + " anchors.fill: parent\n" + " hoverEnabled: true\n" + " acceptedButtons: Qt.NoButton\n" + " }\n" + " }\n\n"; + } + + if (parameter.isReadOnly) { + stream << " TextField {\n" + " id: " + << editorId + << "\n" + "" + " Layout.columnSpan: 3\n" + " Layout.fillWidth: true\n" + " Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter\n" + " readonly property string propertyName: " + << nameLiteral + << "\n" + " text: root.textValue(propertyName)\n" + " selectByMouse: true\n" + " readOnly: true\n" + " }\n"; + } else if (parameterType == QStringLiteral("string") && !parameter.values.isEmpty()) { + stream << " ComboBox {\n" + " id: " + << editorId + << "\n" + "" + " Layout.fillWidth: true\n" + " Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter\n" + " readonly property string propertyName: " + << nameLiteral + << "\n" + " model: root.propertyValues[propertyName] || []\n" + " onActivated: {\n" + " var option = model[currentIndex];\n" + " var current = filter.get(propertyName);\n" + " if (String(current) !== String(option))\n" + " filter.set(propertyName, option);\n" + " }\n" + " }\n"; + } else if (parameterType == QStringLiteral("color")) { + stream << " Shotcut.ColorPicker {\n" + " id: " + << editorId + << "\n" + "" + " Layout.fillWidth: true\n" + " Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter\n" + " readonly property string propertyName: " + << nameLiteral + << "\n" + " readonly property string typeName: " + "root.propertyType(propertyName)\n" + " property bool isReady: false\n" + " value: root.colorValue(propertyName)\n" + " alpha: true\n" + " Component.onCompleted: isReady = true\n" + " onValueChanged: {\n" + " if (!isReady)\n" + " return;\n" + " if (root.isKeyframableProperty(propertyName)) {\n" + " root.updateFilter(propertyName, Qt.color(value), " + << keyframesId + << ", root.getPosition());\n" + " } else {\n" + " var current = filter.get(propertyName);\n" + " if (String(current) !== String(value))\n" + " filter.set(propertyName, value);\n" + " }\n" + " }\n" + " }\n"; + } else if (parameterType == QStringLiteral("boolean")) { + stream + << " CheckBox {\n" + " id: " + << editorId + << "\n" + "" + " Layout.fillWidth: false\n" + " Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter\n" + " readonly property string propertyName: " + << nameLiteral + << "\n" + " readonly property string typeName: " + "root.propertyType(propertyName)\n" + " text: (root.propertyTitles && root.propertyTitles[propertyName]) ? " + "root.propertyTitles[propertyName] : propertyName\n" + " ToolTip.delay: 400\n" + " ToolTip.visible: hovered\n" + " ToolTip.text: {\n" + " if (root.propertyDescriptions && " + "root.propertyDescriptions[propertyName])\n" + " return root.propertyDescriptions[propertyName];\n" + " return text;\n" + " }\n" + " checked: root.booleanValue(propertyName)\n" + " onToggled: {\n" + " var nextValue = checked ? '1' : '0';\n" + " if (root.isKeyframableProperty(propertyName)) {\n" + " root.updateFilter(propertyName, Number(nextValue), " + << keyframesId + << ", root.getPosition());\n" + " } else {\n" + " var current = filter.get(propertyName);\n" + " if (String(current) !== nextValue)\n" + " filter.set(propertyName, nextValue);\n" + " }\n" + " }\n" + " }\n"; + } else if (parameterType == QStringLiteral("integer") + || parameterType == QStringLiteral("float")) { + stream << " Shotcut.SliderSpinner {\n" + " id: " + << editorId + << "\n" + "" + " Layout.fillWidth: true\n" + " Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter\n" + " Layout.minimumWidth: 180\n" + " readonly property string propertyName: " + << nameLiteral + << "\n" + " readonly property string typeName: " + "root.propertyType(propertyName)\n" + " value: root.numericValue(propertyName, typeName)\n" + " minimumValue: Math.min(root.numericBound(propertyName, true), " + "root.numericBound(propertyName, false))\n" + " maximumValue: Math.max(root.numericBound(propertyName, true), " + "root.numericBound(propertyName, false))\n" + " decimals: root.isIntegerType(typeName) ? 0 : 3\n" + " suffix: root.numericSuffix(propertyName)\n" + " onValueChanged: {\n" + " if (root.isKeyframableProperty(propertyName)) {\n" + " root.updateFilter(propertyName, value, " + << keyframesId + << ", root.getPosition());\n" + " } else {\n" + " var current = Number(filter.get(propertyName));\n" + " if (isNaN(current) || current !== value)\n" + " filter.set(propertyName, value);\n" + " }\n" + " }\n" + " }\n"; + } else { + stream << " TextField {\n" + " id: " + << editorId + << "\n" + "" + " Layout.fillWidth: true\n" + " Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter\n" + " selectByMouse: true\n" + " readonly property string propertyName: " + << nameLiteral + << "\n" + " readonly property string typeName: " + "root.propertyType(propertyName)\n" + " text: {\n" + " var value = filter.get(propertyName);\n" + " if (value !== undefined && value !== null && value !== '')\n" + " return value;\n" + " var d = root.propertyDefaults[propertyName];\n" + " return (d !== undefined && d !== null) ? d : '';\n" + " }\n" + " onEditingFinished: {\n" + " if (root.isKeyframableProperty(propertyName)) {\n" + " root.updateFilter(propertyName, text, " + << keyframesId + << ", root.getPosition());\n" + " } else {\n" + " var current = filter.get(propertyName);\n" + " if (String(current) !== text)\n" + " filter.set(propertyName, text);\n" + " }\n" + " }\n" + " }\n"; + } + + if (!parameter.isReadOnly) { + stream << "\n" + " Shotcut.UndoButton {\n" + " readonly property string propertyName: " + << nameLiteral << "\n"; + + if (!supportsGeneratedKeyframes) { + stream << " Layout.columnSpan: 2\n"; + } + + stream << " readonly property string typeName: " + "root.propertyType(propertyName)\n" + " onClicked: {\n"; + + if (parameterType == QStringLiteral("color")) { + stream + << " var defaultValue = root.defaultColorValue(propertyName);\n" + " if (root.isKeyframableProperty(propertyName))\n" + " root.updateFilter(propertyName, " + "Qt.color(defaultValue), " + << keyframesId + << ", root.getPosition());\n" + " else\n" + " filter.set(propertyName, defaultValue);\n" + " root.setControls();\n"; + } else if (parameterType == QStringLiteral("boolean")) { + stream + << " var defaultValue = " + "root.defaultBooleanValue(propertyName);\n" + " if (root.isKeyframableProperty(propertyName))\n" + " root.updateFilter(propertyName, defaultValue ? 1 : 0, " + << keyframesId + << ", root.getPosition());\n" + " else\n" + " filter.set(propertyName, defaultValue ? '1' : '0');\n" + " root.setControls();\n"; + } else if (parameterType == QStringLiteral("integer") + || parameterType == QStringLiteral("float")) { + stream + << " var defaultValue = root.defaultNumericValue(propertyName, " + "typeName);\n" + " if (root.isKeyframableProperty(propertyName))\n" + " root.updateFilter(propertyName, defaultValue, " + << keyframesId + << ", root.getPosition());\n" + " else\n" + " filter.set(propertyName, defaultValue);\n" + " root.setControls();\n"; + } else { + stream + << " var defaultValue = root.defaultTextValue(propertyName);\n" + " if (root.isKeyframableProperty(propertyName))\n" + " root.updateFilter(propertyName, defaultValue, " + << keyframesId + << ", root.getPosition());\n" + " else\n" + " filter.set(propertyName, defaultValue);\n" + " root.setControls();\n"; + } + + stream << " }\n" + " }\n"; + + if (supportsGeneratedKeyframes) { + stream << "\n" + " Shotcut.KeyframesButton {\n" + " id: " + << keyframesId + << "\n" + " readonly property string propertyName: " + << nameLiteral + << "\n" + " visible: root.isKeyframableProperty(propertyName)\n" + " Layout.preferredWidth: visible ? implicitWidth : 0\n" + " onToggled: {\n"; + + if (parameterType == QStringLiteral("color")) { + stream << " root.toggleKeyframes(checked, propertyName, " + "Qt.color(" + << editorId << ".value));\n"; + } else if (parameterType == QStringLiteral("boolean")) { + stream << " root.toggleKeyframes(checked, propertyName, " + << editorId << ".checked ? 1 : 0);\n"; + } else if (parameterType == QStringLiteral("integer") + || parameterType == QStringLiteral("float")) { + stream << " root.toggleKeyframes(checked, propertyName, " + << editorId << ".value);\n"; + } else { + stream << " root.toggleKeyframes(checked, propertyName, " + << editorId << ".text);\n"; + } + + stream << " }\n" + " }\n\n"; + } else { + stream << "\n"; + } + } + } + + stream << " Label {\n" + " Layout.columnSpan: 4\n" + " Layout.fillWidth: true\n" + " visible: propertyNames.length === 0\n" + " text: qsTr('No properties were discovered for this filter service.')\n" + " wrapMode: Text.Wrap\n" + " }\n" + " }\n" + "\n" + " Connections {\n" + " target: filter\n" + "\n" + " function onAnimateInChanged() {\n" + " root.setControls();\n" + " }\n" + "\n" + " function onAnimateOutChanged() {\n" + " root.setControls();\n" + " }\n" + " }\n" + "\n" + " Connections {\n" + " target: producer\n" + "\n" + " function onPositionChanged() {\n" + " root.setControls();\n" + " }\n" + " }\n" + "\n" + "}\n"; + + file.close(); + if (errorMessage) + errorMessage->clear(); + return true; +} diff --git a/src/controllers/addonqmlgenerator.h b/src/controllers/addonqmlgenerator.h new file mode 100644 index 0000000000..517b367f7d --- /dev/null +++ b/src/controllers/addonqmlgenerator.h @@ -0,0 +1,35 @@ +/* + * 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 ADDONQMLGENERATOR_H +#define ADDONQMLGENERATOR_H + +#include "addonmetadataparser.h" + +#include +#include + +class AddOnQmlGenerator +{ +public: + bool generate(const AddOnFilterDescriptor &descriptor, + const QDir &outputDir, + const QString &qmlFileName, + QString *errorMessage = nullptr) const; +}; + +#endif // ADDONQMLGENERATOR_H diff --git a/src/controllers/filtercontroller.cpp b/src/controllers/filtercontroller.cpp index 173db5c14b..270f2aee1f 100644 --- a/src/controllers/filtercontroller.cpp +++ b/src/controllers/filtercontroller.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014-2025 Meltytech, LLC + * Copyright (c) 2014-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 @@ -18,6 +18,7 @@ #include "filtercontroller.h" #include "Logger.h" +#include "addonmetadataparser.h" #include "mltcontroller.h" #include "qmltypes/qmlapplication.h" #include "qmltypes/qmlfilter.h" @@ -27,18 +28,39 @@ #include "shotcut_mlt_properties.h" #include +#include #include +#include #include #include #include +bool isExperimentalEnabled() +{ + return qApp && qApp->property("experimental").toBool(); +} + +QString addOnServiceFromObjectName(const QString &objectName) +{ + static const QString kPrefix = QStringLiteral("addOn."); + if (!objectName.startsWith(kPrefix)) + return QString(); + + return objectName.mid(kPrefix.size()); +} + FilterController::FilterController(QObject *parent) : QObject(parent) + , m_addOnTempDir(QString()) , m_metadataModel(this) , m_attachedModel(this) , m_currentFilterIndex(QmlFilter::NoCurrentFilter) { startTimer(0); + QObject::connect(&m_addOnServiceModel, + &AddOnServiceModel::enabledServicesChanged, + this, + &FilterController::handleAddOnServicesChanged); connect(&m_attachedModel, SIGNAL(changed()), this, SLOT(handleAttachedModelChange())); connect(&m_attachedModel, SIGNAL(modelAboutToBeReset()), @@ -62,6 +84,28 @@ FilterController::FilterController(QObject *parent) SLOT(handleAttachDuplicateFailed(int))); } +void FilterController::handleAddOnServicesChanged() +{ + auto *source = static_cast(m_metadataModel.sourceModel()); + if (!source) + return; + + // Remove previously generated add-on metadata entries. + for (int i = source->list().size() - 1; i >= 0; --i) { + QmlMetadata *meta = source->get(i); + if (meta && meta->objectName().startsWith(QStringLiteral("addOn."))) { + source->remove(i); + } + } + + if (!isExperimentalEnabled()) + return; + + // Rebuild from current enabled service list. + QScopedPointer mltFilters(MLT.repository()->filters()); + loadAddOnFilterMetadata(mltFilters.data()); +} + void FilterController::loadFilterMetadata() { QScopedPointer mltFilters(MLT.repository()->filters()); @@ -122,6 +166,154 @@ void FilterController::loadFilterMetadata() } } }; + + if (isExperimentalEnabled()) + loadAddOnFilterMetadata(mltFilters.data()); +} + +void FilterController::loadAddOnFilterMetadata(Mlt::Properties *mltFilters) +{ + if (!mltFilters || !mltFilters->is_valid()) + return; + + m_addOnDescriptors.clear(); + + const QStringList services = m_addOnServiceModel.enabledServices(); + for (const auto &service : services) { + if (!mltFilters->get_data(service.toLatin1().constData())) { + LOG_WARNING() << "Add-on service unavailable" << service; + continue; + } + + QScopedPointer mltMetadata( + MLT.repository()->metadata(mlt_service_filter_type, service.toLatin1().constData())); + if (!mltMetadata || !mltMetadata->is_valid()) { + LOG_WARNING() << "Failed to query metadata for add-on service" << service; + continue; + } + + const AddOnFilterDescriptor descriptor = AddOnMetadataParser::parse(service, + mltMetadata.data()); + LOG_DEBUG() << "add-on metadata" << service << "propertyCount" + << descriptor.parameters.size(); + m_addOnDescriptors[service] = descriptor; + + auto meta = new QmlMetadata; + meta->setType(QmlMetadata::Filter); + meta->setObjectName(QStringLiteral("addOn.%1").arg(service)); + meta->set_mlt_service(service); + meta->setName(descriptor.title); + meta->setProperty("keywords", service + QStringLiteral(" #addon")); + meta->loadSettings(); + meta->setIsAudio(descriptor.isAudio); + + QStringList keyframeableProperties; + for (const auto ¶meter : descriptor.parameters) { + if (!parameter.supportsKeyframes) + continue; + + auto *keyParam = new QmlKeyframesParameter(meta->keyframes()); + const QString paramType = parameter.type.trimmed().toLower(); + const QString displayName = parameter.title.isEmpty() ? parameter.name + : parameter.title; + keyParam->setProperty("name", displayName); + keyParam->setProperty("property", parameter.name); + + const bool isNumericType = paramType == QStringLiteral("integer") + || paramType == QStringLiteral("float"); + const bool isColorType = paramType == QStringLiteral("color"); + + keyParam->setProperty("isCurve", isNumericType); + keyParam->setProperty("isColor", isColorType); + if (!parameter.unit.isEmpty()) + keyParam->setProperty("units", parameter.unit); + + if (isNumericType) { + bool okMin = false; + bool okMax = false; + const double minValue = parameter.minimum.toDouble(&okMin); + const double maxValue = parameter.maximum.toDouble(&okMax); + keyParam->setProperty("minimum", okMin ? minValue : 0.0); + keyParam->setProperty("maximum", okMax ? maxValue : 100.0); + } + + auto parameterList = meta->keyframes()->parameters(); + if (parameterList.append) + parameterList.append(¶meterList, keyParam); + + keyframeableProperties << parameter.name; + } + + if (!keyframeableProperties.isEmpty()) { + meta->keyframes()->setProperty("allowAnimateIn", true); + meta->keyframes()->setProperty("allowAnimateOut", true); + meta->keyframes()->setProperty("simpleProperties", keyframeableProperties); + } + + addMetadata(meta); + } +} + +bool FilterController::ensureAddOnTempDir() +{ + if (!m_addOnTempDir.isValid()) { + m_addOnTempDir = QTemporaryDir(QDir::tempPath() + "/shotcut-addon-XXXXXX"); + } + if (!m_addOnTempDir.isValid()) { + LOG_WARNING() << "Add-on temporary directory is invalid"; + return false; + } + return true; +} + +bool FilterController::ensureAddOnFilterQml(QmlMetadata *meta) +{ + if (!meta) + return false; + + const QString service = addOnServiceFromObjectName(meta->objectName()); + if (service.isEmpty()) + return true; + + if (!ensureAddOnTempDir()) + return false; + + const QDir tempDir(m_addOnTempDir.path()); + const QString cachedFileName = service + QStringLiteral("_ui.qml"); + if (QFileInfo::exists(tempDir.filePath(cachedFileName))) { + meta->setPath(tempDir); + meta->setQmlFileName(cachedFileName); + return true; + } + + AddOnFilterDescriptor descriptor; + const auto it = m_addOnDescriptors.constFind(service); + if (it != m_addOnDescriptors.constEnd()) { + descriptor = it.value(); + } else { + LOG_WARNING() << "No cached descriptor for add-on service" << service + << "- reloading metadata"; + + QScopedPointer mltMetadata( + MLT.repository()->metadata(mlt_service_filter_type, service.toLatin1().constData())); + if (!mltMetadata || !mltMetadata->is_valid()) { + LOG_WARNING() << "Failed to query metadata for add-on service" << service; + return false; + } + + descriptor = AddOnMetadataParser::parse(service, mltMetadata.data()); + m_addOnDescriptors.insert(service, descriptor); + } + + QString generationError; + if (!m_addOnQmlGenerator.generate(descriptor, tempDir, cachedFileName, &generationError)) { + LOG_WARNING() << "Failed to generate add-on UI QML for" << service << generationError; + return false; + } + + meta->setPath(tempDir); + meta->setQmlFileName(cachedFileName); + return true; } QmlMetadata *FilterController::metadata(const QString &id) @@ -256,6 +448,12 @@ void FilterController::setCurrentFilter(int attachedIndex) QmlMetadata *meta = m_attachedModel.getMetadata(m_currentFilterIndex); QmlFilter *filter = nullptr; if (meta) { + if (meta->objectName().startsWith(QStringLiteral("addOn.")) && !ensureAddOnFilterQml(meta)) { + emit statusChanged(tr("Failed to prepare add-on filter user interface.")); + emit currentFilterChanged(nullptr, nullptr, QmlFilter::NoCurrentFilter); + m_currentFilter.reset(); + return; + } emit currentFilterChanged(nullptr, nullptr, QmlFilter::NoCurrentFilter); std::unique_ptr service(m_attachedModel.getService(m_currentFilterIndex)); if (!service || !service->is_valid()) diff --git a/src/controllers/filtercontroller.h b/src/controllers/filtercontroller.h index d61a3fa887..63670f4a5f 100644 --- a/src/controllers/filtercontroller.h +++ b/src/controllers/filtercontroller.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014-2025 Meltytech, LLC + * Copyright (c) 2014-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 @@ -18,6 +18,8 @@ #ifndef FILTERCONTROLLER_H #define FILTERCONTROLLER_H +#include "addonqmlgenerator.h" +#include "models/addonservicemodel.h" #include "models/attachedfiltersmodel.h" #include "models/metadatamodel.h" #include "models/motiontrackermodel.h" @@ -27,6 +29,7 @@ #include #include #include +#include class QTimerEvent; @@ -37,6 +40,7 @@ class FilterController : public QObject public: explicit FilterController(QObject *parent = 0); MetadataModel *metadataModel(); + AddOnServiceModel *addOnServiceModel() { return &m_addOnServiceModel; } MotionTrackerModel *motionTrackerModel() { return &m_motionTrackerModel; } AttachedFiltersModel *attachedModel(); @@ -72,6 +76,7 @@ public slots: void resumeUndoTracking(); private slots: + void handleAddOnServicesChanged(); void handleAttachedModelChange(); void handleAttachedModelAboutToReset(); void addMetadata(QmlMetadata *); @@ -82,13 +87,20 @@ private slots: void onQmlFilterChanged(const QString &name); private: + bool ensureAddOnTempDir(); + bool ensureAddOnFilterQml(QmlMetadata *meta); + void loadAddOnFilterMetadata(Mlt::Properties *mltFilters); void loadFilterSets(); void loadFilterMetadata(); QFuture m_future; QScopedPointer m_currentFilter; Mlt::Service m_mltService; + QTemporaryDir m_addOnTempDir; + QHash m_addOnDescriptors; + AddOnQmlGenerator m_addOnQmlGenerator; MetadataModel m_metadataModel; + AddOnServiceModel m_addOnServiceModel; MotionTrackerModel m_motionTrackerModel; AttachedFiltersModel m_attachedModel; int m_currentFilterIndex; diff --git a/src/dialogs/addonfiltersdialog.cpp b/src/dialogs/addonfiltersdialog.cpp new file mode 100644 index 0000000000..f195263f04 --- /dev/null +++ b/src/dialogs/addonfiltersdialog.cpp @@ -0,0 +1,197 @@ +/* + * 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 "addonfiltersdialog.h" + +#include "models/addonservicemodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +AddOnFiltersDialog::AddOnFiltersDialog(AddOnServiceModel *model, QWidget *parent) + : QDialog(parent) + , m_model(model) + , m_searchField(new QLineEdit(this)) + , m_listWidget(new QTreeWidget(this)) + , m_selectedCountLabel(new QLabel(this)) +{ + setWindowTitle(tr("Manage Add-on Filters")); + resize(760, 560); + + auto *layout = new QVBoxLayout(this); + + auto *description = new QLabel(tr("Select filters to expose as add-on filters."), this); + description->setWordWrap(true); + layout->addWidget(description); + + auto *searchLayout = new QHBoxLayout(); + m_searchField->setPlaceholderText(tr("Search service or title")); + searchLayout->addWidget(m_searchField); + auto *clearButton = new QPushButton(tr("Clear"), this); + searchLayout->addWidget(clearButton); + layout->addLayout(searchLayout); + + m_listWidget->setAlternatingRowColors(true); + m_listWidget->setRootIsDecorated(false); + m_listWidget->setUniformRowHeights(true); + m_listWidget->setSortingEnabled(true); + m_listWidget->setColumnCount(3); + m_listWidget->setHeaderLabels({tr("Title"), tr("Service"), tr("Type")}); + m_listWidget->header()->setStretchLastSection(true); + m_listWidget->header()->setSectionResizeMode(0, QHeaderView::Interactive); + m_listWidget->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + m_listWidget->header()->setSectionResizeMode(2, QHeaderView::Stretch); + m_listWidget->header()->setSortIndicatorShown(true); + m_listWidget->sortItems(0, Qt::AscendingOrder); + layout->addWidget(m_listWidget, 1); + + auto *bottomLayout = new QHBoxLayout(); + bottomLayout->addWidget(m_selectedCountLabel); + bottomLayout->addStretch(1); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + bottomLayout->addWidget(buttonBox); + layout->addLayout(bottomLayout); + + connect(m_searchField, &QLineEdit::textChanged, this, &AddOnFiltersDialog::onSearchTextChanged); + connect(clearButton, &QPushButton::clicked, this, [this]() { m_searchField->clear(); }); + connect(m_listWidget, &QTreeWidget::itemChanged, this, &AddOnFiltersDialog::onItemChanged); + connect(buttonBox, &QDialogButtonBox::accepted, this, [this]() { + onApply(); + accept(); + }); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + populate(); +} + +void AddOnFiltersDialog::populate() +{ + if (!m_model) + return; + + m_model->reload(); + + const auto enabledServices = m_model->enabledServices(); + m_listWidget->blockSignals(true); + m_listWidget->clear(); + + for (int row = 0; row < m_model->rowCount(); ++row) { + QModelIndex index = m_model->index(row, 0); + const QString title = index.data(AddOnServiceModel::TitleRole).toString(); + const QString service = index.data(AddOnServiceModel::ServiceRole).toString(); + const QString description = index.data(AddOnServiceModel::DescriptionRole).toString(); + const bool isAudio = index.data(AddOnServiceModel::IsAudioRole).toBool(); + + auto *item = new QTreeWidgetItem(m_listWidget); + item->setText(0, title); + item->setText(1, service); + item->setText(2, isAudio ? tr("Audio") : tr("Video")); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(0, enabledServices.contains(service) ? Qt::Checked : Qt::Unchecked); + item->setData(0, Qt::UserRole, service); + if (!description.isEmpty()) { + item->setToolTip(0, description); + item->setToolTip(1, description); + item->setToolTip(2, description); + } + } + + m_listWidget->blockSignals(false); + adjustColumnWidths(); + updateSelectedCount(); +} + +void AddOnFiltersDialog::onSearchTextChanged(const QString &text) +{ + const QString query = text.trimmed(); + for (int i = 0; i < m_listWidget->topLevelItemCount(); ++i) { + auto *item = m_listWidget->topLevelItem(i); + const QString service = item->text(1); + const QString title = item->text(0); + bool visible = query.isEmpty() || service.contains(query, Qt::CaseInsensitive) + || title.contains(query, Qt::CaseInsensitive); + item->setHidden(!visible); + } +} + +void AddOnFiltersDialog::onItemChanged(QTreeWidgetItem *, int) +{ + updateSelectedCount(); +} + +void AddOnFiltersDialog::onApply() +{ + if (!m_model) + return; + + QStringList services; + for (int i = 0; i < m_listWidget->topLevelItemCount(); ++i) { + auto *item = m_listWidget->topLevelItem(i); + if (item->checkState(0) == Qt::Checked) + services << item->data(0, Qt::UserRole).toString(); + } + + m_model->setEnabledServices(services); + updateSelectedCount(); +} + +void AddOnFiltersDialog::updateSelectedCount() +{ + int checked = 0; + for (int i = 0; i < m_listWidget->topLevelItemCount(); ++i) { + if (m_listWidget->topLevelItem(i)->checkState(0) == Qt::Checked) + ++checked; + } + m_selectedCountLabel->setText(tr("Selected add-ons: %1").arg(checked)); +} + +void AddOnFiltersDialog::adjustColumnWidths() +{ + int titleWidth = m_listWidget->fontMetrics().horizontalAdvance( + m_listWidget->headerItem()->text(0)); + + for (int i = 0; i < m_listWidget->topLevelItemCount(); ++i) { + auto *item = m_listWidget->topLevelItem(i); + titleWidth = qMax(titleWidth, m_listWidget->fontMetrics().horizontalAdvance(item->text(0))); + } + + const int kPadding = 32; + const int kTitleMax = 380; + const int kTitleMin = 120; + titleWidth = qBound(kTitleMin, titleWidth + kPadding, kTitleMax); + m_listWidget->setColumnWidth(0, titleWidth); + + m_listWidget->resizeColumnToContents(1); + + // Ensure dialog is wide enough for title+service columns; type column stretches. + const int treeMargins = 24; + const int dialogMargins = 48; + const int typeMin = m_listWidget->fontMetrics().horizontalAdvance(tr("Video")) + 32; + const int requiredWidth = m_listWidget->columnWidth(0) + m_listWidget->columnWidth(1) + typeMin + + treeMargins + dialogMargins; + if (width() < requiredWidth) + resize(requiredWidth, height()); + if (minimumWidth() < requiredWidth) + setMinimumWidth(requiredWidth); +} diff --git a/src/dialogs/addonfiltersdialog.h b/src/dialogs/addonfiltersdialog.h new file mode 100644 index 0000000000..9771cf1c03 --- /dev/null +++ b/src/dialogs/addonfiltersdialog.h @@ -0,0 +1,52 @@ +/* + * 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 ADDONFILTERSDIALOG_H +#define ADDONFILTERSDIALOG_H + +#include + +class AddOnServiceModel; +class QLabel; +class QLineEdit; +class QTreeWidget; +class QTreeWidgetItem; + +class AddOnFiltersDialog : public QDialog +{ + Q_OBJECT + +public: + explicit AddOnFiltersDialog(AddOnServiceModel *model, QWidget *parent = nullptr); + +private slots: + void onSearchTextChanged(const QString &text); + void onItemChanged(QTreeWidgetItem *item, int column); + void onApply(); + +private: + void populate(); + void updateSelectedCount(); + void adjustColumnWidths(); + + AddOnServiceModel *m_model; + QLineEdit *m_searchField; + QTreeWidget *m_listWidget; + QLabel *m_selectedCountLabel; +}; + +#endif // ADDONFILTERSDIALOG_H diff --git a/src/docks/filtersdock.cpp b/src/docks/filtersdock.cpp index 90f213602d..4a75951501 100644 --- a/src/docks/filtersdock.cpp +++ b/src/docks/filtersdock.cpp @@ -32,16 +32,21 @@ #include "qmltypes/qmlview.h" #include +#include +#include #include #include #include #include #include #include +#include #include +#include #include FiltersDock::FiltersDock(MetadataModel *metadataModel, + AddOnServiceModel *addOnServiceModel, AttachedFiltersModel *attachedModel, MotionTrackerModel *motionTrackerModel, SubtitlesModel *subtitlesModel, @@ -68,6 +73,8 @@ FiltersDock::FiltersDock(MetadataModel *metadataModel, QmlUtilities::setCommonProperties(m_qview.rootContext()); m_qview.rootContext()->setContextProperty("view", new QmlView(&m_qview)); m_qview.rootContext()->setContextProperty("metadatamodel", metadataModel); + m_qview.rootContext()->setContextProperty("enableAddOns", + qApp && qApp->property("experimental").toBool()); m_qview.rootContext()->setContextProperty("motionTrackerModel", motionTrackerModel); m_qview.rootContext()->setContextProperty("subtitlesModel", subtitlesModel); m_qview.rootContext()->setContextProperty("attachedfiltersmodel", attachedModel); @@ -88,6 +95,8 @@ FiltersDock::FiltersDock(MetadataModel *metadataModel, void FiltersDock::setCurrentFilter(QmlFilter *filter, QmlMetadata *meta, int index) { + closeAddOnMetadataHelp(); + if (filter && filter->producer().is_valid()) { m_producer.setProducer(filter->producer()); if (mlt_service_playlist_type != filter->producer().type() && MLT.producer() @@ -205,6 +214,39 @@ void FiltersDock::load() QObject::connect(m_qview.rootObject(), SIGNAL(copyFilterRequested()), SLOT(showCopyFilterMenu())); + QObject::connect(m_qview.rootObject(), + SIGNAL(addonFilterMetadataHelpRequested(QString, QString)), + SLOT(showAddOnMetadataHelp(QString, QString))); +} + +void FiltersDock::showAddOnMetadataHelp(const QString &title, const QString &text) +{ + closeAddOnMetadataHelp(); + + auto *dialog = new QDialog(this, Qt::Window); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setModal(false); + dialog->setWindowTitle(title.isEmpty() ? tr("Add-on Metadata") : title); + dialog->resize(560, 420); + + auto *layout = new QVBoxLayout(dialog); + auto *view = new QTextBrowser(dialog); + view->setReadOnly(true); + view->setOpenExternalLinks(true); + view->setHtml(text); + layout->addWidget(view); + + m_addOnMetadataDialog = dialog; + connect(dialog, &QObject::destroyed, this, [this]() { m_addOnMetadataDialog = nullptr; }); + dialog->show(); + dialog->raise(); + dialog->activateWindow(); +} + +void FiltersDock::closeAddOnMetadataHelp() +{ + if (m_addOnMetadataDialog) + m_addOnMetadataDialog->close(); } void FiltersDock::setupActions() diff --git a/src/docks/filtersdock.h b/src/docks/filtersdock.h index 9ddac9994f..4a5f88f98f 100644 --- a/src/docks/filtersdock.h +++ b/src/docks/filtersdock.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2013-2024 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 @@ -23,14 +23,17 @@ #include #include +#include #include class QmlFilter; class QmlMetadata; class MetadataModel; +class AddOnServiceModel; class AttachedFiltersModel; class MotionTrackerModel; class SubtitlesModel; +class QDialog; class FiltersDock : public QDockWidget { @@ -38,6 +41,7 @@ class FiltersDock : public QDockWidget public: explicit FiltersDock(MetadataModel *metadataModel, + AddOnServiceModel *addOnServiceModel, AttachedFiltersModel *attachedModel, MotionTrackerModel *motionTrackerModel, SubtitlesModel *subtitlesModel, @@ -58,6 +62,7 @@ public slots: void onShowFrame(const SharedFrame &frame); void openFilterMenu() const; void showCopyFilterMenu(); + void showAddOnMetadataHelp(const QString &title, const QString &text); void onServiceInChanged(int delta, Mlt::Service *service); void load(); @@ -67,8 +72,10 @@ public slots: private: void setupActions(); + void closeAddOnMetadataHelp(); QQuickWidget m_qview; QmlProducer m_producer; + QPointer m_addOnMetadataDialog; unsigned loadTries{0}; }; diff --git a/src/main.cpp b/src/main.cpp index afaac5a630..30606b21fe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2024 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 @@ -165,6 +165,13 @@ class Application : public QApplication QCommandLineOption gpuOption("gpu", QCoreApplication::translate("main", "Use GPU processing.")); parser.addOption(gpuOption); +#ifdef QT_DEBUG + QCommandLineOption experimentalOption( + "experimental", + QCoreApplication::translate( + "main", "Enable experimental features (OpenFX and add-on filter menu).")); + parser.addOption(experimentalOption); +#endif QCommandLineOption clearRecentOption("clear-recent", QCoreApplication::translate("main", "Clear Recent on Exit")); @@ -232,6 +239,11 @@ class Application : public QApplication #endif setProperty("noupgrade", parser.isSet(noupgradeOption)); setProperty("clearRecent", parser.isSet(clearRecentOption)); +#ifdef QT_DEBUG + setProperty("experimental", parser.isSet(experimentalOption)); +#else + setProperty("experimental", false); +#endif if (!parser.value(appDataOption).isEmpty()) { appDirArg = parser.value(appDataOption); ShotcutSettings::setAppDataForSession(appDirArg); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 637c793086..5a3c2e5082 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -598,6 +598,7 @@ void MainWindow::setupAndConnectDocks() m_filterController = new FilterController(this); m_filtersDock = new FiltersDock(m_filterController->metadataModel(), + m_filterController->addOnServiceModel(), m_filterController->attachedModel(), m_filterController->motionTrackerModel(), m_timelineDock->subtitlesModel(), diff --git a/src/mltcontroller.cpp b/src/mltcontroller.cpp index 66953e2647..df1f4a69db 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 @@ -64,8 +64,13 @@ Controller::Controller() , m_blockRefresh(false) { LOG_DEBUG() << "begin"; - if (!qEnvironmentVariableIsSet("MLT_REPOSITORY_DENY")) - ::qputenv("MLT_REPOSITORY_DENY", "libmltqt:libmltglaxnimate:libmltopenfx"); + if (!qEnvironmentVariableIsSet("MLT_REPOSITORY_DENY")) { + const bool experimental = qApp && qApp->property("experimental").toBool(); + if (experimental) + ::qputenv("MLT_REPOSITORY_DENY", "libmltqt:libmltglaxnimate"); + else + ::qputenv("MLT_REPOSITORY_DENY", "libmltqt:libmltglaxnimate:libmltopenfx"); + } m_repo = Mlt::Factory::init(); m_processingMode = Settings.processingMode(); resetLocale(); diff --git a/src/models/addonservicemodel.cpp b/src/models/addonservicemodel.cpp new file mode 100644 index 0000000000..f9d26937a6 --- /dev/null +++ b/src/models/addonservicemodel.cpp @@ -0,0 +1,218 @@ +/* + * 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 "addonservicemodel.h" + +#include "Logger.h" +#include "mltcontroller.h" +#include "settings.h" + +#include +#include + +#include + +AddOnServiceModel::AddOnServiceModel(QObject *parent) + : QAbstractListModel(parent) +{ + m_enabledServices = Settings.addOnFilterServices(); +} + +int AddOnServiceModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return m_items.size(); +} + +QVariant AddOnServiceModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return QVariant(); + + const auto &item = m_items.at(index.row()); + switch (role) { + case Qt::DisplayRole: + case TitleRole: + return item.title; + case ServiceRole: + return item.service; + case DescriptionRole: + return item.description; + case IsAudioRole: + return item.isAudio; + case EnabledRole: + return m_enabledServices.contains(item.service); + default: + break; + } + + return QVariant(); +} + +QHash AddOnServiceModel::roleNames() const +{ + auto roles = QAbstractListModel::roleNames(); + roles[ServiceRole] = "service"; + roles[TitleRole] = "title"; + roles[DescriptionRole] = "description"; + roles[IsAudioRole] = "isAudio"; + roles[EnabledRole] = "enabled"; + return roles; +} + +void AddOnServiceModel::reload() +{ + beginResetModel(); + m_items.clear(); + + // Refresh persisted selections when opening the management dialog. + m_enabledServices = Settings.addOnFilterServices(); + + QScopedPointer mltFilters(MLT.repository()->filters()); + if (!mltFilters || !mltFilters->is_valid()) { + LOG_WARNING() << "Failed to query MLT filter services"; + endResetModel(); + return; + } + + for (int i = 0; i < mltFilters->count(); ++i) { + const char *name = mltFilters->get_name(i); + if (!name || !*name) + continue; + + QString service = QString::fromLatin1(name); + QScopedPointer metadata( + MLT.repository()->metadata(mlt_service_filter_type, name)); + if (!metadata || !metadata->is_valid()) + continue; + + Item item; + item.service = service; + item.title = metadata->get("Title") ? QString::fromUtf8(metadata->get("Title")) + : QString::fromUtf8(metadata->get("title")); + if (item.title.isEmpty()) + item.title = service; + + item.description = metadata->get("Description") + ? QString::fromUtf8(metadata->get("Description")) + : QString::fromUtf8(metadata->get("description")); + + Mlt::Properties tags(metadata->get_data("tags")); + if (tags.is_valid()) { + for (int t = 0; t < tags.count(); ++t) { + if (!qstricmp(tags.get(t), "Audio")) { + item.isAudio = true; + break; + } + } + } + + m_items.push_back(item); + } + + std::sort(m_items.begin(), m_items.end(), [](const Item &a, const Item &b) { + return a.title.toLower() < b.title.toLower(); + }); + + m_enabledServices.erase(std::remove_if(m_enabledServices.begin(), + m_enabledServices.end(), + [this](const QString &service) { + return indexOfService(service) < 0; + }), + m_enabledServices.end()); + + endResetModel(); + saveEnabledServices(); +} + +bool AddOnServiceModel::isEnabled(const QString &service) const +{ + return m_enabledServices.contains(service); +} + +void AddOnServiceModel::setEnabled(const QString &service, bool enabled) +{ + int row = indexOfService(service); + if (row < 0) + return; + + bool wasEnabled = m_enabledServices.contains(service); + if (wasEnabled == enabled) + return; + + if (enabled) + m_enabledServices << service; + else + m_enabledServices.removeAll(service); + + saveEnabledServices(); + emit enabledServicesChanged(); + + QModelIndex modelIndex = index(row, 0); + emit dataChanged(modelIndex, modelIndex, QVector() << EnabledRole); +} + +void AddOnServiceModel::setEnabledServices(const QStringList &services) +{ + QStringList normalized = services; + normalized.removeDuplicates(); + + QStringList oldEnabled = m_enabledServices; + m_enabledServices.clear(); + for (const auto &service : normalized) { + if (indexOfService(service) >= 0) + m_enabledServices << service; + } + + bool changed = (oldEnabled != m_enabledServices); + saveEnabledServices(); + + for (int row = 0; row < m_items.size(); ++row) { + const QString &service = m_items.at(row).service; + bool wasEnabled = oldEnabled.contains(service); + bool isEnabledNow = m_enabledServices.contains(service); + if (wasEnabled != isEnabledNow) { + QModelIndex modelIndex = index(row, 0); + emit dataChanged(modelIndex, modelIndex, QVector() << EnabledRole); + } + } + + if (changed) + emit enabledServicesChanged(); +} + +QStringList AddOnServiceModel::enabledServices() const +{ + return m_enabledServices; +} + +int AddOnServiceModel::indexOfService(const QString &service) const +{ + for (int i = 0; i < m_items.size(); ++i) { + if (m_items.at(i).service == service) + return i; + } + return -1; +} + +void AddOnServiceModel::saveEnabledServices() +{ + QStringList services = m_enabledServices; + services.removeDuplicates(); + Settings.setAddOnFilterServices(services); +} diff --git a/src/models/addonservicemodel.h b/src/models/addonservicemodel.h new file mode 100644 index 0000000000..fceb664bea --- /dev/null +++ b/src/models/addonservicemodel.h @@ -0,0 +1,68 @@ +/* + * 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 ADDONSERVICEMODEL_H +#define ADDONSERVICEMODEL_H + +#include +#include + +class AddOnServiceModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum ModelRoles { + ServiceRole = Qt::UserRole + 1, + TitleRole, + DescriptionRole, + IsAudioRole, + EnabledRole, + }; + + explicit AddOnServiceModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + Q_INVOKABLE void reload(); + Q_INVOKABLE bool isEnabled(const QString &service) const; + Q_INVOKABLE void setEnabled(const QString &service, bool enabled); + Q_INVOKABLE void setEnabledServices(const QStringList &services); + Q_INVOKABLE QStringList enabledServices() const; + +signals: + void enabledServicesChanged(); + +private: + struct Item + { + QString service; + QString title; + QString description; + bool isAudio{false}; + }; + + QList m_items; + QStringList m_enabledServices; + + int indexOfService(const QString &service) const; + void saveEnabledServices(); +}; + +#endif // ADDONSERVICEMODEL_H diff --git a/src/qml/views/filter/FilterMenu.qml b/src/qml/views/filter/FilterMenu.qml index 0089ebd4b9..62b8216bf6 100644 --- a/src/qml/views/filter/FilterMenu.qml +++ b/src/qml/views/filter/FilterMenu.qml @@ -78,6 +78,26 @@ Rectangle { Layout.fillWidth: true + Shotcut.Button { + id: addOnMenuButton + + visible: enableAddOns + padding: 2 + implicitWidth: 20 + implicitHeight: 20 + icon.name: 'show-menu' + icon.source: 'qrc:///icons/oxygen/32x32/actions/show-menu.png' + onClicked: addOnMenu.popup() + + Shotcut.HoverTip { + text: qsTr('Add-on filter options') + } + + Shotcut.AddOnFilterMenu { + id: addOnMenu + } + } + TextField { id: searchField @@ -484,4 +504,5 @@ Rectangle { } } } + } diff --git a/src/qml/views/filter/filterview.qml b/src/qml/views/filter/filterview.qml index 3766a58799..292b8f1e61 100644 --- a/src/qml/views/filter/filterview.qml +++ b/src/qml/views/filter/filterview.qml @@ -27,6 +27,7 @@ Rectangle { signal currentFilterRequested(int attachedIndex) signal copyFilterRequested + signal addonFilterMetadataHelpRequested(string title, string text) function clearCurrentFilter() { if (filterConfig.item) { @@ -401,6 +402,15 @@ Rectangle { id: copyFiltersDialog } + Connections { + target: filterConfig.item + ignoreUnknownSignals: true + + function onMetadataHelpRequested(title, text) { + root.addonFilterMetadataHelpRequested(title, text) + } + } + Connections { function onIsProducerSelectedChanged() { filterMenu.close(); diff --git a/src/qmltypes/qmlapplication.cpp b/src/qmltypes/qmlapplication.cpp index 8f2603e5b3..c5d6bc4400 100644 --- a/src/qmltypes/qmlapplication.cpp +++ b/src/qmltypes/qmlapplication.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2013-2024 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 @@ -18,6 +18,7 @@ #include "qmlapplication.h" #include "controllers/filtercontroller.h" +#include "dialogs/addonfiltersdialog.h" #include "mainwindow.h" #include "mltcontroller.h" #include "models/attachedfiltersmodel.h" @@ -197,6 +198,17 @@ void QmlApplication::showStatusMessage(const QString &message, int timeoutSecond MAIN.showStatusMessage(message, timeoutSeconds); } +void QmlApplication::showAddOnFiltersDialog() +{ + auto *controller = MAIN.filterController(); + if (!controller) + return; + + AddOnFiltersDialog dialog(controller->addOnServiceModel(), &MAIN); + dialog.setWindowModality(dialogModality()); + dialog.exec(); +} + int QmlApplication::maxTextureSize() { auto *videoWidget = qobject_cast(MLT.videoWidget()); diff --git a/src/qmltypes/qmlapplication.h b/src/qmltypes/qmlapplication.h index 21df2f4dc2..579fec6deb 100644 --- a/src/qmltypes/qmlapplication.h +++ b/src/qmltypes/qmlapplication.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014-2024 Meltytech, LLC + * Copyright (c) 2014-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 @@ -61,6 +61,7 @@ class QmlApplication : public QObject Q_INVOKABLE static bool isProjectFolder(); static qreal devicePixelRatio(); Q_INVOKABLE void showStatusMessage(const QString &message, int timeoutSeconds = 15); + Q_INVOKABLE void showAddOnFiltersDialog(); static int maxTextureSize(); Q_INVOKABLE static bool confirmOutputFilter(); static QDir dataDir(); diff --git a/src/qmltypes/qmlfiltermenu.cpp b/src/qmltypes/qmlfiltermenu.cpp new file mode 100644 index 0000000000..de8a4e7e3c --- /dev/null +++ b/src/qmltypes/qmlfiltermenu.cpp @@ -0,0 +1,45 @@ +/* + * 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 "qmlfiltermenu.h" + +#include "qmlapplication.h" + +#include + +QmlFilterMenu::QmlFilterMenu(QObject *parent) + : QObject(parent) +{} + +void QmlFilterMenu::popup() +{ + QMenu menu; + + QAction manageAddOnFiltersAction(tr("Manage Add-on Filters")); + connect(&manageAddOnFiltersAction, + SIGNAL(triggered()), + this, + SLOT(onManageAddOnFiltersTriggered())); + menu.addAction(&manageAddOnFiltersAction); + + menu.exec(QCursor::pos()); +} + +void QmlFilterMenu::onManageAddOnFiltersTriggered() +{ + QmlApplication::singleton().showAddOnFiltersDialog(); +} diff --git a/src/qmltypes/qmlfiltermenu.h b/src/qmltypes/qmlfiltermenu.h new file mode 100644 index 0000000000..9c56e62754 --- /dev/null +++ b/src/qmltypes/qmlfiltermenu.h @@ -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 . + */ + +#ifndef QMLFILTERMENU_H +#define QMLFILTERMENU_H + +#include + +class QmlFilterMenu : public QObject +{ + Q_OBJECT + +public: + explicit QmlFilterMenu(QObject *parent = 0); + +public slots: + void popup(); + +private slots: + void onManageAddOnFiltersTriggered(); +}; + +#endif // QMLFILTERMENU_H diff --git a/src/qmltypes/qmlutilities.cpp b/src/qmltypes/qmlutilities.cpp index 2042c6b170..710f3831fa 100644 --- a/src/qmltypes/qmlutilities.cpp +++ b/src/qmltypes/qmlutilities.cpp @@ -31,6 +31,7 @@ #include "qmltypes/qmlextension.h" #include "qmltypes/qmlfile.h" #include "qmltypes/qmlfilter.h" +#include "qmltypes/qmlfiltermenu.h" #include "qmltypes/qmlmarkermenu.h" #include "qmltypes/qmlmetadata.h" #include "qmltypes/qmlprofile.h" @@ -77,6 +78,7 @@ void QmlUtilities::registerCommonTypes() "You cannot create a Settings from QML."); qmlRegisterType("Shotcut.Controls", 1, 0, "ColorPickerItem"); qmlRegisterType("Shotcut.Controls", 1, 0, "ColorWheelItem"); + qmlRegisterType("Shotcut.Controls", 1, 0, "AddOnFilterMenu"); qmlRegisterType("Shotcut.Controls", 1, 0, "MarkerMenu"); qmlRegisterType("Shotcut.Controls", 1, 0, "EditContextMenu"); qmlRegisterType("Shotcut.Controls", 1, 0, "RichTextMenu"); diff --git a/src/settings.cpp b/src/settings.cpp index 7321de5443..f48c1da611 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -1069,11 +1069,22 @@ QString ShotcutSettings::filterFavorite(const QString &filterName) { return settings.value("filter/favorite/" + filterName, "").toString(); } + void ShotcutSettings::setFilterFavorite(const QString &filterName, const QString &value) { settings.setValue("filter/favorite/" + filterName, value); } +QStringList ShotcutSettings::addOnFilterServices() const +{ + return settings.value("filter/addOnServices").toStringList(); +} + +void ShotcutSettings::setAddOnFilterServices(const QStringList &services) +{ + settings.setValue("filter/addOnServices", services); +} + double ShotcutSettings::audioInDuration() const { return settings.value("filter/audioInDuration", 1.0).toDouble(); diff --git a/src/settings.h b/src/settings.h index 19d8eedcfb..e12c58e525 100644 --- a/src/settings.h +++ b/src/settings.h @@ -240,6 +240,8 @@ class ShotcutSettings : public QObject // filter QString filterFavorite(const QString &filterName); void setFilterFavorite(const QString &filterName, const QString &value); + QStringList addOnFilterServices() const; + void setAddOnFilterServices(const QStringList &services); double audioInDuration() const; void setAudioInDuration(double); double audioOutDuration() const; From 38836025c7fb7926795f6b060cbb341e996cb70c Mon Sep 17 00:00:00 2001 From: Brian Matherly Date: Sun, 12 Apr 2026 19:41:29 -0500 Subject: [PATCH 2/5] Resolve review comments --- src/controllers/addonmetadataparser.cpp | 4 +- src/controllers/addonqmlgenerator.cpp | 55 ++++++++++++++++++++++--- src/controllers/filtercontroller.cpp | 18 ++++---- src/controllers/filtercontroller.h | 3 +- src/dialogs/addonfiltersdialog.cpp | 3 ++ src/docks/filtersdock.cpp | 1 - src/docks/filtersdock.h | 1 - src/mainwindow.cpp | 1 - src/models/addonservicemodel.cpp | 7 +--- src/qmltypes/qmlfiltermenu.h | 2 +- 10 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/controllers/addonmetadataparser.cpp b/src/controllers/addonmetadataparser.cpp index ef3eb5f5cc..fd6a181c14 100644 --- a/src/controllers/addonmetadataparser.cpp +++ b/src/controllers/addonmetadataparser.cpp @@ -17,9 +17,7 @@ #include "addonmetadataparser.h" -#include - -bool parseYesNoBool(const char *value) +static bool parseYesNoBool(const char *value) { if (!value) return false; diff --git a/src/controllers/addonqmlgenerator.cpp b/src/controllers/addonqmlgenerator.cpp index fc2c97d822..7c8168de09 100644 --- a/src/controllers/addonqmlgenerator.cpp +++ b/src/controllers/addonqmlgenerator.cpp @@ -23,15 +23,58 @@ #include #include -QString quotedJsString(const QString &value) +static QString quotedJsString(const QString &value) { - QString s = value; - s.replace('\\', "\\\\"); - s.replace('\'', "\\'"); - return QStringLiteral("'%1'").arg(s); + QString escaped; + escaped.reserve(value.size()); + + for (const QChar &ch : value) { + const ushort codepoint = ch.unicode(); + switch (codepoint) { + case '\\': + escaped += QStringLiteral("\\\\"); + break; + case '\'': + escaped += QStringLiteral("\\'"); + break; + case '\n': + escaped += QStringLiteral("\\n"); + break; + case '\r': + escaped += QStringLiteral("\\r"); + break; + case '\t': + escaped += QStringLiteral("\\t"); + break; + case '\b': + escaped += QStringLiteral("\\b"); + break; + case '\f': + escaped += QStringLiteral("\\f"); + break; + case 0x2028: + escaped += QStringLiteral("\\u2028"); + break; + case 0x2029: + escaped += QStringLiteral("\\u2029"); + break; + default: + if (codepoint < 0x20) { + escaped += QStringLiteral("\\u%1").arg(static_cast(codepoint), + 4, + 16, + QLatin1Char('0')); + } else { + escaped += ch; + } + break; + } + } + + return QStringLiteral("'%1'").arg(escaped); } -bool isGroupHeadingParameter(const AddOnParameterDescriptor ¶meter) +static bool isGroupHeadingParameter(const AddOnParameterDescriptor ¶meter) { const QString parameterType = parameter.type.trimmed().toLower(); const QString parameterName = parameter.name.trimmed().toLower(); diff --git a/src/controllers/filtercontroller.cpp b/src/controllers/filtercontroller.cpp index 270f2aee1f..a8012bf17a 100644 --- a/src/controllers/filtercontroller.cpp +++ b/src/controllers/filtercontroller.cpp @@ -35,12 +35,12 @@ #include #include -bool isExperimentalEnabled() +static bool isExperimentalEnabled() { return qApp && qApp->property("experimental").toBool(); } -QString addOnServiceFromObjectName(const QString &objectName) +static QString addOnServiceFromObjectName(const QString &objectName) { static const QString kPrefix = QStringLiteral("addOn."); if (!objectName.startsWith(kPrefix)) @@ -51,7 +51,6 @@ QString addOnServiceFromObjectName(const QString &objectName) FilterController::FilterController(QObject *parent) : QObject(parent) - , m_addOnTempDir(QString()) , m_metadataModel(this) , m_attachedModel(this) , m_currentFilterIndex(QmlFilter::NoCurrentFilter) @@ -84,6 +83,11 @@ FilterController::FilterController(QObject *parent) SLOT(handleAttachDuplicateFailed(int))); } +FilterController::~FilterController() +{ + delete m_addOnTempDir; +} + void FilterController::handleAddOnServicesChanged() { auto *source = static_cast(m_metadataModel.sourceModel()); @@ -256,10 +260,10 @@ void FilterController::loadAddOnFilterMetadata(Mlt::Properties *mltFilters) bool FilterController::ensureAddOnTempDir() { - if (!m_addOnTempDir.isValid()) { - m_addOnTempDir = QTemporaryDir(QDir::tempPath() + "/shotcut-addon-XXXXXX"); + if (!m_addOnTempDir) { + m_addOnTempDir = new QTemporaryDir(QDir::tempPath() + "/shotcut-addon-XXXXXX"); } - if (!m_addOnTempDir.isValid()) { + if (!m_addOnTempDir || !m_addOnTempDir->isValid()) { LOG_WARNING() << "Add-on temporary directory is invalid"; return false; } @@ -278,7 +282,7 @@ bool FilterController::ensureAddOnFilterQml(QmlMetadata *meta) if (!ensureAddOnTempDir()) return false; - const QDir tempDir(m_addOnTempDir.path()); + const QDir tempDir(m_addOnTempDir->path()); const QString cachedFileName = service + QStringLiteral("_ui.qml"); if (QFileInfo::exists(tempDir.filePath(cachedFileName))) { meta->setPath(tempDir); diff --git a/src/controllers/filtercontroller.h b/src/controllers/filtercontroller.h index 63670f4a5f..8f1c0462b9 100644 --- a/src/controllers/filtercontroller.h +++ b/src/controllers/filtercontroller.h @@ -39,6 +39,7 @@ class FilterController : public QObject public: explicit FilterController(QObject *parent = 0); + ~FilterController(); MetadataModel *metadataModel(); AddOnServiceModel *addOnServiceModel() { return &m_addOnServiceModel; } MotionTrackerModel *motionTrackerModel() { return &m_motionTrackerModel; } @@ -96,7 +97,7 @@ private slots: QFuture m_future; QScopedPointer m_currentFilter; Mlt::Service m_mltService; - QTemporaryDir m_addOnTempDir; + QTemporaryDir *m_addOnTempDir = nullptr; QHash m_addOnDescriptors; AddOnQmlGenerator m_addOnQmlGenerator; MetadataModel m_metadataModel; diff --git a/src/dialogs/addonfiltersdialog.cpp b/src/dialogs/addonfiltersdialog.cpp index f195263f04..4621e4c161 100644 --- a/src/dialogs/addonfiltersdialog.cpp +++ b/src/dialogs/addonfiltersdialog.cpp @@ -94,6 +94,7 @@ void AddOnFiltersDialog::populate() const auto enabledServices = m_model->enabledServices(); m_listWidget->blockSignals(true); + m_listWidget->setSortingEnabled(false); m_listWidget->clear(); for (int row = 0; row < m_model->rowCount(); ++row) { @@ -117,6 +118,8 @@ void AddOnFiltersDialog::populate() } } + m_listWidget->setSortingEnabled(true); + m_listWidget->sortItems(0, Qt::AscendingOrder); m_listWidget->blockSignals(false); adjustColumnWidths(); updateSelectedCount(); diff --git a/src/docks/filtersdock.cpp b/src/docks/filtersdock.cpp index 4a75951501..fd5b481329 100644 --- a/src/docks/filtersdock.cpp +++ b/src/docks/filtersdock.cpp @@ -46,7 +46,6 @@ #include FiltersDock::FiltersDock(MetadataModel *metadataModel, - AddOnServiceModel *addOnServiceModel, AttachedFiltersModel *attachedModel, MotionTrackerModel *motionTrackerModel, SubtitlesModel *subtitlesModel, diff --git a/src/docks/filtersdock.h b/src/docks/filtersdock.h index 4a5f88f98f..dcff7f134f 100644 --- a/src/docks/filtersdock.h +++ b/src/docks/filtersdock.h @@ -41,7 +41,6 @@ class FiltersDock : public QDockWidget public: explicit FiltersDock(MetadataModel *metadataModel, - AddOnServiceModel *addOnServiceModel, AttachedFiltersModel *attachedModel, MotionTrackerModel *motionTrackerModel, SubtitlesModel *subtitlesModel, diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 5a3c2e5082..637c793086 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -598,7 +598,6 @@ void MainWindow::setupAndConnectDocks() m_filterController = new FilterController(this); m_filtersDock = new FiltersDock(m_filterController->metadataModel(), - m_filterController->addOnServiceModel(), m_filterController->attachedModel(), m_filterController->motionTrackerModel(), m_timelineDock->subtitlesModel(), diff --git a/src/models/addonservicemodel.cpp b/src/models/addonservicemodel.cpp index f9d26937a6..9e721f2edc 100644 --- a/src/models/addonservicemodel.cpp +++ b/src/models/addonservicemodel.cpp @@ -103,14 +103,11 @@ void AddOnServiceModel::reload() Item item; item.service = service; - item.title = metadata->get("Title") ? QString::fromUtf8(metadata->get("Title")) - : QString::fromUtf8(metadata->get("title")); + item.title = QString::fromUtf8(metadata->get("title")); if (item.title.isEmpty()) item.title = service; - item.description = metadata->get("Description") - ? QString::fromUtf8(metadata->get("Description")) - : QString::fromUtf8(metadata->get("description")); + item.description = QString::fromUtf8(metadata->get("description")); Mlt::Properties tags(metadata->get_data("tags")); if (tags.is_valid()) { diff --git a/src/qmltypes/qmlfiltermenu.h b/src/qmltypes/qmlfiltermenu.h index 9c56e62754..635e095b22 100644 --- a/src/qmltypes/qmlfiltermenu.h +++ b/src/qmltypes/qmlfiltermenu.h @@ -25,7 +25,7 @@ class QmlFilterMenu : public QObject Q_OBJECT public: - explicit QmlFilterMenu(QObject *parent = 0); + explicit QmlFilterMenu(QObject *parent = nullptr); public slots: void popup(); From a4e539d3364e78bd12538410676d378c90d32fec Mon Sep 17 00:00:00 2001 From: Brian Matherly Date: Sun, 12 Apr 2026 21:15:07 -0500 Subject: [PATCH 3/5] Refactor addon filter help dialog Include all the metadata fields --- src/CMakeLists.txt | 1 + src/controllers/addonqmlgenerator.cpp | 58 +------- src/dialogs/addonmetadatahelpdialog.cpp | 176 ++++++++++++++++++++++++ src/dialogs/addonmetadatahelpdialog.h | 36 +++++ src/docks/filtersdock.cpp | 38 ++--- src/docks/filtersdock.h | 7 +- src/qml/views/filter/filterview.qml | 11 +- 7 files changed, 240 insertions(+), 87 deletions(-) create mode 100644 src/dialogs/addonmetadatahelpdialog.cpp create mode 100644 src/dialogs/addonmetadatahelpdialog.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ffc27a3fbb..07518048c3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE dialogs/alignaudiodialog.cpp dialogs/alignaudiodialog.h dialogs/alignmentarray.cpp dialogs/alignmentarray.h dialogs/addonfiltersdialog.cpp dialogs/addonfiltersdialog.h + dialogs/addonmetadatahelpdialog.cpp dialogs/addonmetadatahelpdialog.h dialogs/bitratedialog.h dialogs/bitratedialog.cpp dialogs/customprofiledialog.cpp dialogs/customprofiledialog.h dialogs/customprofiledialog.ui diff --git a/src/controllers/addonqmlgenerator.cpp b/src/controllers/addonqmlgenerator.cpp index 7c8168de09..b3b0741ca3 100644 --- a/src/controllers/addonqmlgenerator.cpp +++ b/src/controllers/addonqmlgenerator.cpp @@ -218,7 +218,7 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, "import Shotcut.Controls as Shotcut\n\n" "Shotcut.KeyframableFilter {\n" " id: root\n" - " signal metadataHelpRequested(string title, string text)\n" + " signal metadataHelpRequested(string service)\n" " property var propertyNames: [" << quotedProperties.join(QStringLiteral(", ")) << "]\n\n" @@ -255,8 +255,8 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, " property var propertyKeyframes: {" << quotedKeyframeMapEntries.join(QStringLiteral(", ")) << "}\n\n" - " property string filterTitle: " - << quotedJsString(descriptor.title) + " property string filterService: " + << quotedJsString(descriptor.service) << "\n\n" " property string filterDescription: " << quotedJsString(descriptor.description) @@ -388,55 +388,6 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, stream << " blockUpdate = false;\n" - << " }\n\n" - " function escapeHtml(value) {\n" - " if (value === undefined || value === null)\n" - " return '';\n" - " var text = String(value);\n" - " text = text.replace(/&/g, '&');\n" - " text = text.replace(//g, '>');\n" - " return text;\n" - " }\n\n" - " function metadataText() {\n" - " var entries = [];\n" - " var filterLines = [];\n" - " filterLines.push('' + qsTr('Filter Title') + ': ' + " - "root.escapeHtml(root.filterTitle.length > 0 ? root.filterTitle : '-'));\n" - " if (root.filterDescription.length > 0)\n" - " filterLines.push('' + qsTr('Filter Description') + ': ' + " - "root.escapeHtml(root.filterDescription));\n" - " entries.push(filterLines.join('
'));\n" - "\n" - " for (var i = 0; i < propertyNames.length; ++i) {\n" - " var name = propertyNames[i];\n" - " var title = (root.propertyTitles && root.propertyTitles[name]) ? " - "root.propertyTitles[name] : name;\n" - " var description = (root.propertyDescriptions && " - "root.propertyDescriptions[name]) ? root.propertyDescriptions[name] : '';\n" - " var entryLines = [];\n" - " entryLines.push('' + qsTr('Parameter Title') + ': ' + " - "root.escapeHtml(title));\n" - " if (description.length > 0)\n" - " entryLines.push('' + qsTr('Description') + ': ' + " - "root.escapeHtml(description));\n" - "\n" - " var minValue = root.propertyMinimums ? root.propertyMinimums[name] : " - "undefined;\n" - " var maxValue = root.propertyMaximums ? root.propertyMaximums[name] : " - "undefined;\n" - " var hasMin = minValue !== undefined && minValue !== null && " - "String(minValue) !== '';\n" - " var hasMax = maxValue !== undefined && maxValue !== null && " - "String(maxValue) !== '';\n" - " if (hasMin && hasMax)\n" - " entryLines.push('' + qsTr('Range') + ': ' + " - "root.escapeHtml(minValue) + ' - ' + root.escapeHtml(maxValue));\n" - "\n" - " entries.push(entryLines.join('
'));\n" - " }\n" - "\n" - " return entries.join('

');\n" " }\n\n" " width: 360\n" " height: gridLayout.implicitHeight + 16\n\n" @@ -480,8 +431,7 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, " ToolTip.visible: hovered\n" " ToolTip.text: qsTr('Help')\n" " Layout.alignment: Qt.AlignRight | Qt.AlignTop\n" - " onClicked: root.metadataHelpRequested(qsTr('Add-on Metadata'), " - "root.metadataText())\n" + " onClicked: root.metadataHelpRequested(root.filterService)\n" " }\n\n" " Label {\n" " text: qsTr('Preset')\n" diff --git a/src/dialogs/addonmetadatahelpdialog.cpp b/src/dialogs/addonmetadatahelpdialog.cpp new file mode 100644 index 0000000000..3c21728d2f --- /dev/null +++ b/src/dialogs/addonmetadatahelpdialog.cpp @@ -0,0 +1,176 @@ +/* + * 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 "addonmetadatahelpdialog.h" + +#include "Logger.h" +#include "mltcontroller.h" + +#include +#include +#include +#include + +static QString htmlEscapedMetadataText(const QString &text) +{ + QString escaped = text.toHtmlEscaped(); + escaped.replace(QStringLiteral("\r\n"), QStringLiteral("\n")); + escaped.replace(QLatin1Char('\r'), QLatin1Char('\n')); + escaped.replace(QLatin1Char('\n'), QStringLiteral("
")); + escaped.replace(QLatin1Char('\t'), QStringLiteral("    ")); + return escaped; +} + +static bool isPurelyNumeric(const char *s) +{ + if (!s || !*s) + return false; + while (*s) { + if (*s < '0' || *s > '9') + return false; + ++s; + } + return true; +} + +static QString renderMetadataPropertiesHtml(Mlt::Properties &properties, + int depth = 0, + bool separateIndexed = false) +{ + if (!properties.is_valid()) + return QString(); + + QString html = QStringLiteral("") + .arg(depth * 16); + + for (int i = 0; i < properties.count(); ++i) { + const char *rawName = properties.get_name(i); + const bool isIndexed = isPurelyNumeric(rawName); + const QString name = (rawName && *rawName) ? QString::fromUtf8(rawName) : QString(); + const char *rawValue = properties.get(i); + const QString value = rawValue ? QString::fromUtf8(rawValue) : QString(); + Mlt::Properties child; + if (rawName) + child = Mlt::Properties(properties.get_data(rawName)); + const bool hasChild = child.is_valid() && child.count() > 0; + + // Detect a named key whose only content is a purely-indexed list (e.g. "parameters", "tags") + bool isListField = false; + if (!isIndexed && hasChild && value.isEmpty()) { + isListField = true; + for (int j = 0; j < child.count(); ++j) { + if (!isPurelyNumeric(child.get_name(j))) { + isListField = false; + break; + } + } + } + + // "parameters" entries get a separator rule; other lists (tags, values) do not + const bool childSeparated = isListField && (name == QLatin1String("parameters")); + + if (isIndexed) { + if (separateIndexed) + html += QStringLiteral(""); + html += QStringLiteral("") + .arg(htmlEscapedMetadataText(name)); + html += QStringLiteral(""); + } + + html += QStringLiteral("

"); + } else if (isListField) { + // Named list field: label on its own full-width row, list below + html += QStringLiteral("
%1
"); + } else { + html += QStringLiteral( + "
%1") + .arg(htmlEscapedMetadataText(name)); + } + + if (!value.isEmpty()) + html += htmlEscapedMetadataText(value); + else if (!hasChild && !isIndexed) + html += QStringLiteral("(empty)"); + + if (hasChild) { + if (!value.isEmpty()) + html += QStringLiteral("
"); + // List fields are already in a full-width cell; don't add extra indent depth + html += renderMetadataPropertiesHtml(child, + isListField ? depth : depth + 1, + childSeparated); + } + + html += QStringLiteral("
"); + return html; +} + +static QString renderAddOnMetadataHtml(const QString &serviceName, Mlt::Properties &metadata) +{ + QString title = QString::fromUtf8(metadata.get("title")).trimmed(); + if (title.isEmpty()) + title = serviceName; + + QString html = QStringLiteral(""); + html += QStringLiteral("

%1

").arg(htmlEscapedMetadataText(title)); + html += QStringLiteral("

%1: %2

") + .arg(QObject::tr("Service")) + .arg(htmlEscapedMetadataText(serviceName)); + html += renderMetadataPropertiesHtml(metadata); + html += QStringLiteral(""); + return html; +} + +AddOnMetadataHelpDialog *AddOnMetadataHelpDialog::create(const QString &serviceName, QWidget *parent) +{ + if (serviceName.isEmpty()) + return nullptr; + + QScopedPointer metadata( + MLT.repository()->metadata(mlt_service_filter_type, serviceName.toUtf8().constData())); + if (!metadata || !metadata->is_valid()) { + LOG_WARNING() << "Failed to query add-on metadata for" << serviceName; + return nullptr; + } + + QString title = QString::fromUtf8(metadata->get("title")).trimmed(); + if (title.isEmpty()) + title = serviceName; + + return new AddOnMetadataHelpDialog(title, + renderAddOnMetadataHtml(serviceName, *metadata), + parent); +} + +AddOnMetadataHelpDialog::AddOnMetadataHelpDialog(const QString &title, + const QString &html, + QWidget *parent) + : QDialog(parent, Qt::Window) +{ + setAttribute(Qt::WA_DeleteOnClose); + setModal(false); + setWindowTitle(tr("Add-on Metadata: %1").arg(title)); + resize(560, 420); + + auto *layout = new QVBoxLayout(this); + auto *view = new QTextBrowser(this); + view->setReadOnly(true); + view->setOpenExternalLinks(true); + view->setHtml(html); + layout->addWidget(view); +} \ No newline at end of file diff --git a/src/dialogs/addonmetadatahelpdialog.h b/src/dialogs/addonmetadatahelpdialog.h new file mode 100644 index 0000000000..c14bc958eb --- /dev/null +++ b/src/dialogs/addonmetadatahelpdialog.h @@ -0,0 +1,36 @@ +/* + * 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 ADDONMETADATAHELPDIALOG_H +#define ADDONMETADATAHELPDIALOG_H + +#include + +class AddOnMetadataHelpDialog : public QDialog +{ + Q_OBJECT + +public: + static AddOnMetadataHelpDialog *create(const QString &serviceName, QWidget *parent = nullptr); + +private: + explicit AddOnMetadataHelpDialog(const QString &title, + const QString &html, + QWidget *parent = nullptr); +}; + +#endif // ADDONMETADATAHELPDIALOG_H \ No newline at end of file diff --git a/src/docks/filtersdock.cpp b/src/docks/filtersdock.cpp index fd5b481329..5bf7fc06d1 100644 --- a/src/docks/filtersdock.cpp +++ b/src/docks/filtersdock.cpp @@ -20,6 +20,7 @@ #include "Logger.h" #include "actions.h" #include "controllers/filtercontroller.h" +#include "dialogs/addonmetadatahelpdialog.h" #include "mainwindow.h" #include "mltcontroller.h" #include "models/attachedfiltersmodel.h" @@ -33,16 +34,13 @@ #include #include -#include #include #include #include #include #include #include -#include #include -#include #include FiltersDock::FiltersDock(MetadataModel *metadataModel, @@ -214,32 +212,24 @@ void FiltersDock::load() SIGNAL(copyFilterRequested()), SLOT(showCopyFilterMenu())); QObject::connect(m_qview.rootObject(), - SIGNAL(addonFilterMetadataHelpRequested(QString, QString)), - SLOT(showAddOnMetadataHelp(QString, QString))); + SIGNAL(addonFilterMetadataHelpRequested(QString)), + SLOT(showAddOnMetadataHelp(QString))); } -void FiltersDock::showAddOnMetadataHelp(const QString &title, const QString &text) +void FiltersDock::showAddOnMetadataHelp(const QString &serviceName) { closeAddOnMetadataHelp(); - auto *dialog = new QDialog(this, Qt::Window); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setModal(false); - dialog->setWindowTitle(title.isEmpty() ? tr("Add-on Metadata") : title); - dialog->resize(560, 420); - - auto *layout = new QVBoxLayout(dialog); - auto *view = new QTextBrowser(dialog); - view->setReadOnly(true); - view->setOpenExternalLinks(true); - view->setHtml(text); - layout->addWidget(view); - - m_addOnMetadataDialog = dialog; - connect(dialog, &QObject::destroyed, this, [this]() { m_addOnMetadataDialog = nullptr; }); - dialog->show(); - dialog->raise(); - dialog->activateWindow(); + m_addOnMetadataDialog = AddOnMetadataHelpDialog::create(serviceName, this); + if (!m_addOnMetadataDialog) + return; + + connect(m_addOnMetadataDialog, &QObject::destroyed, this, [this]() { + m_addOnMetadataDialog = nullptr; + }); + m_addOnMetadataDialog->show(); + m_addOnMetadataDialog->raise(); + m_addOnMetadataDialog->activateWindow(); } void FiltersDock::closeAddOnMetadataHelp() diff --git a/src/docks/filtersdock.h b/src/docks/filtersdock.h index dcff7f134f..4a04058472 100644 --- a/src/docks/filtersdock.h +++ b/src/docks/filtersdock.h @@ -29,11 +29,10 @@ class QmlFilter; class QmlMetadata; class MetadataModel; -class AddOnServiceModel; class AttachedFiltersModel; class MotionTrackerModel; class SubtitlesModel; -class QDialog; +class AddOnMetadataHelpDialog; class FiltersDock : public QDockWidget { @@ -61,7 +60,7 @@ public slots: void onShowFrame(const SharedFrame &frame); void openFilterMenu() const; void showCopyFilterMenu(); - void showAddOnMetadataHelp(const QString &title, const QString &text); + void showAddOnMetadataHelp(const QString &serviceName); void onServiceInChanged(int delta, Mlt::Service *service); void load(); @@ -74,7 +73,7 @@ public slots: void closeAddOnMetadataHelp(); QQuickWidget m_qview; QmlProducer m_producer; - QPointer m_addOnMetadataDialog; + QPointer m_addOnMetadataDialog; unsigned loadTries{0}; }; diff --git a/src/qml/views/filter/filterview.qml b/src/qml/views/filter/filterview.qml index 292b8f1e61..ccb6dd3b0a 100644 --- a/src/qml/views/filter/filterview.qml +++ b/src/qml/views/filter/filterview.qml @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014-2025 Meltytech, LLC + * Copyright (c) 2014-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 @@ -27,7 +27,7 @@ Rectangle { signal currentFilterRequested(int attachedIndex) signal copyFilterRequested - signal addonFilterMetadataHelpRequested(string title, string text) + signal addonFilterMetadataHelpRequested(string service) function clearCurrentFilter() { if (filterConfig.item) { @@ -403,12 +403,13 @@ Rectangle { } Connections { - target: filterConfig.item ignoreUnknownSignals: true - function onMetadataHelpRequested(title, text) { - root.addonFilterMetadataHelpRequested(title, text) + function onMetadataHelpRequested(service) { + root.addonFilterMetadataHelpRequested(service) } + + target: filterConfig.item } Connections { From 1f516da156c4c51624b5378f4594e2d3d6bbd5f8 Mon Sep 17 00:00:00 2001 From: Brian Matherly Date: Tue, 14 Apr 2026 21:21:15 -0500 Subject: [PATCH 4/5] Resovle review comments Use LineEditClear Replace hamburger menu with "configure" button --- src/CMakeLists.txt | 1 - src/dialogs/addonfiltersdialog.cpp | 8 ++--- src/dialogs/addonfiltersdialog.h | 4 +-- src/qml/views/filter/FilterMenu.qml | 35 ++++++++++------------ src/qmltypes/qmlfiltermenu.cpp | 45 ----------------------------- src/qmltypes/qmlfiltermenu.h | 37 ------------------------ src/qmltypes/qmlutilities.cpp | 4 +-- 7 files changed, 20 insertions(+), 114 deletions(-) delete mode 100644 src/qmltypes/qmlfiltermenu.cpp delete mode 100644 src/qmltypes/qmlfiltermenu.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 07518048c3..5e025d8f2a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -119,7 +119,6 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE qmltypes/qmlextension.cpp qmltypes/qmlextension.h qmltypes/qmlfile.cpp qmltypes/qmlfile.h qmltypes/qmlfilter.cpp qmltypes/qmlfilter.h - qmltypes/qmlfiltermenu.cpp qmltypes/qmlfiltermenu.h qmltypes/qmlmarkermenu.cpp qmltypes/qmlmarkermenu.h qmltypes/qmlmetadata.cpp qmltypes/qmlmetadata.h qmltypes/qmlproducer.cpp qmltypes/qmlproducer.h diff --git a/src/dialogs/addonfiltersdialog.cpp b/src/dialogs/addonfiltersdialog.cpp index 4621e4c161..36a56e8d14 100644 --- a/src/dialogs/addonfiltersdialog.cpp +++ b/src/dialogs/addonfiltersdialog.cpp @@ -18,20 +18,19 @@ #include "addonfiltersdialog.h" #include "models/addonservicemodel.h" +#include "widgets/lineeditclear.h" #include #include #include #include -#include -#include #include #include AddOnFiltersDialog::AddOnFiltersDialog(AddOnServiceModel *model, QWidget *parent) : QDialog(parent) , m_model(model) - , m_searchField(new QLineEdit(this)) + , m_searchField(new LineEditClear(this)) , m_listWidget(new QTreeWidget(this)) , m_selectedCountLabel(new QLabel(this)) { @@ -47,8 +46,6 @@ AddOnFiltersDialog::AddOnFiltersDialog(AddOnServiceModel *model, QWidget *parent auto *searchLayout = new QHBoxLayout(); m_searchField->setPlaceholderText(tr("Search service or title")); searchLayout->addWidget(m_searchField); - auto *clearButton = new QPushButton(tr("Clear"), this); - searchLayout->addWidget(clearButton); layout->addLayout(searchLayout); m_listWidget->setAlternatingRowColors(true); @@ -74,7 +71,6 @@ AddOnFiltersDialog::AddOnFiltersDialog(AddOnServiceModel *model, QWidget *parent layout->addLayout(bottomLayout); connect(m_searchField, &QLineEdit::textChanged, this, &AddOnFiltersDialog::onSearchTextChanged); - connect(clearButton, &QPushButton::clicked, this, [this]() { m_searchField->clear(); }); connect(m_listWidget, &QTreeWidget::itemChanged, this, &AddOnFiltersDialog::onItemChanged); connect(buttonBox, &QDialogButtonBox::accepted, this, [this]() { onApply(); diff --git a/src/dialogs/addonfiltersdialog.h b/src/dialogs/addonfiltersdialog.h index 9771cf1c03..9f852874fb 100644 --- a/src/dialogs/addonfiltersdialog.h +++ b/src/dialogs/addonfiltersdialog.h @@ -22,7 +22,7 @@ class AddOnServiceModel; class QLabel; -class QLineEdit; +class LineEditClear; class QTreeWidget; class QTreeWidgetItem; @@ -44,7 +44,7 @@ private slots: void adjustColumnWidths(); AddOnServiceModel *m_model; - QLineEdit *m_searchField; + LineEditClear *m_searchField; QTreeWidget *m_listWidget; QLabel *m_selectedCountLabel; }; diff --git a/src/qml/views/filter/FilterMenu.qml b/src/qml/views/filter/FilterMenu.qml index 62b8216bf6..a3268dac00 100644 --- a/src/qml/views/filter/FilterMenu.qml +++ b/src/qml/views/filter/FilterMenu.qml @@ -78,26 +78,6 @@ Rectangle { Layout.fillWidth: true - Shotcut.Button { - id: addOnMenuButton - - visible: enableAddOns - padding: 2 - implicitWidth: 20 - implicitHeight: 20 - icon.name: 'show-menu' - icon.source: 'qrc:///icons/oxygen/32x32/actions/show-menu.png' - onClicked: addOnMenu.popup() - - Shotcut.HoverTip { - text: qsTr('Add-on filter options') - } - - Shotcut.AddOnFilterMenu { - id: addOnMenu - } - } - TextField { id: searchField @@ -183,6 +163,21 @@ Rectangle { id: typeGroup } + Shotcut.Button { + id: manageAddOnFiltersButton + + visible: enableAddOns + implicitWidth: 24 + implicitHeight: 24 + icon.name: 'run-build' + icon.source: 'qrc:///icons/oxygen/32x32/run-build.png' + onClicked: application.showAddOnFiltersDialog() + + Shotcut.HoverTip { + text: qsTr('Manage Add-on Filters') + } + } + Shotcut.ToggleButton { id: favButton diff --git a/src/qmltypes/qmlfiltermenu.cpp b/src/qmltypes/qmlfiltermenu.cpp deleted file mode 100644 index de8a4e7e3c..0000000000 --- a/src/qmltypes/qmlfiltermenu.cpp +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 "qmlfiltermenu.h" - -#include "qmlapplication.h" - -#include - -QmlFilterMenu::QmlFilterMenu(QObject *parent) - : QObject(parent) -{} - -void QmlFilterMenu::popup() -{ - QMenu menu; - - QAction manageAddOnFiltersAction(tr("Manage Add-on Filters")); - connect(&manageAddOnFiltersAction, - SIGNAL(triggered()), - this, - SLOT(onManageAddOnFiltersTriggered())); - menu.addAction(&manageAddOnFiltersAction); - - menu.exec(QCursor::pos()); -} - -void QmlFilterMenu::onManageAddOnFiltersTriggered() -{ - QmlApplication::singleton().showAddOnFiltersDialog(); -} diff --git a/src/qmltypes/qmlfiltermenu.h b/src/qmltypes/qmlfiltermenu.h deleted file mode 100644 index 635e095b22..0000000000 --- a/src/qmltypes/qmlfiltermenu.h +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 QMLFILTERMENU_H -#define QMLFILTERMENU_H - -#include - -class QmlFilterMenu : public QObject -{ - Q_OBJECT - -public: - explicit QmlFilterMenu(QObject *parent = nullptr); - -public slots: - void popup(); - -private slots: - void onManageAddOnFiltersTriggered(); -}; - -#endif // QMLFILTERMENU_H diff --git a/src/qmltypes/qmlutilities.cpp b/src/qmltypes/qmlutilities.cpp index 710f3831fa..8880387cde 100644 --- a/src/qmltypes/qmlutilities.cpp +++ b/src/qmltypes/qmlutilities.cpp @@ -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 @@ -31,7 +31,6 @@ #include "qmltypes/qmlextension.h" #include "qmltypes/qmlfile.h" #include "qmltypes/qmlfilter.h" -#include "qmltypes/qmlfiltermenu.h" #include "qmltypes/qmlmarkermenu.h" #include "qmltypes/qmlmetadata.h" #include "qmltypes/qmlprofile.h" @@ -78,7 +77,6 @@ void QmlUtilities::registerCommonTypes() "You cannot create a Settings from QML."); qmlRegisterType("Shotcut.Controls", 1, 0, "ColorPickerItem"); qmlRegisterType("Shotcut.Controls", 1, 0, "ColorWheelItem"); - qmlRegisterType("Shotcut.Controls", 1, 0, "AddOnFilterMenu"); qmlRegisterType("Shotcut.Controls", 1, 0, "MarkerMenu"); qmlRegisterType("Shotcut.Controls", 1, 0, "EditContextMenu"); qmlRegisterType("Shotcut.Controls", 1, 0, "RichTextMenu"); From 3c1a9108b0083656e4f12330f9e54a835f164542 Mon Sep 17 00:00:00 2001 From: Dan Dennedy Date: Sat, 18 Apr 2026 11:42:33 -0700 Subject: [PATCH 5/5] Move 720p video modes to Other --- src/mainwindow.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 637c793086..ccc5df82dd 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2979,8 +2979,6 @@ void MainWindow::buildVideoModeMenu(QMenu *topMenu, QAction *removeAction) { QMenu *menu = topMenu; - menu->addAction(addProfile(group, "HD 720p 50 fps", "atsc_720p_50")); - menu->addAction(addProfile(group, "HD 720p 59.94 fps", "atsc_720p_5994")); menu->addAction(addProfile(group, "HD 1080p 23.98 fps", "atsc_1080p_2398")); menu->addAction(addProfile(group, "HD 1080p 24 fps", "atsc_1080p_24")); menu->addAction(addProfile(group, "HD 1080p 25 fps", "atsc_1080p_25")); @@ -3008,6 +3006,8 @@ void MainWindow::buildVideoModeMenu(QMenu *topMenu, menu->addAction(addProfile(group, "HD 720p 25 fps", "atsc_720p_25")); menu->addAction(addProfile(group, "HD 720p 29.97 fps", "atsc_720p_2997")); menu->addAction(addProfile(group, "HD 720p 30 fps", "atsc_720p_30")); + menu->addAction(addProfile(group, "HD 720p 50 fps", "atsc_720p_50")); + menu->addAction(addProfile(group, "HD 720p 59.94 fps", "atsc_720p_5994")); menu->addAction(addProfile(group, "HD 720p 60 fps", "atsc_720p_60")); menu->addAction(addProfile(group, "HD 1080p 30 fps", "atsc_1080p_30")); menu->addAction(addProfile(group, "HD 1080p 60 fps", "atsc_1080p_60"));