From 5a586577577f6a55f739f87debcba2a227c301f8 Mon Sep 17 00:00:00 2001 From: Brian Matherly Date: Wed, 10 Jun 2026 22:16:51 -0500 Subject: [PATCH 1/2] Add Number2D QML control type This enables size/position controls for OFX addon filters Tested with CropOFX --- src/controllers/addonmetadataparser.cpp | 1 + src/controllers/addonmetadataparser.h | 1 + src/controllers/addonqmlgenerator.cpp | 97 ++++++++++++++++++- src/qml/modules/Shotcut/Controls/Number2D.qml | 91 +++++++++++++++++ src/qml/modules/Shotcut/Controls/qmldir | 1 + 5 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 src/qml/modules/Shotcut/Controls/Number2D.qml diff --git a/src/controllers/addonmetadataparser.cpp b/src/controllers/addonmetadataparser.cpp index 85e6dd4121..46f7d92b3e 100644 --- a/src/controllers/addonmetadataparser.cpp +++ b/src/controllers/addonmetadataparser.cpp @@ -80,6 +80,7 @@ AddOnFilterDescriptor AddOnMetadataParser::parse(const QString &service, out.minimum = QString::fromUtf8(parameter.get("minimum")); out.maximum = QString::fromUtf8(parameter.get("maximum")); out.description = QString::fromUtf8(parameter.get("description")); + out.normalizedCoordinates = parseYesNoBool(parameter.get("normalized_coordinates")); out.name = QString::fromUtf8(parameter.get("identifier")); const QString parameterType = out.type.trimmed().toLower(); diff --git a/src/controllers/addonmetadataparser.h b/src/controllers/addonmetadataparser.h index 4855cf45de..5c238231e1 100644 --- a/src/controllers/addonmetadataparser.h +++ b/src/controllers/addonmetadataparser.h @@ -39,6 +39,7 @@ struct AddOnParameterDescriptor QString minimum; QString maximum; QString description; + bool normalizedCoordinates = false; }; struct AddOnFilterDescriptor diff --git a/src/controllers/addonqmlgenerator.cpp b/src/controllers/addonqmlgenerator.cpp index 61c24f9a31..6cbf2945f1 100644 --- a/src/controllers/addonqmlgenerator.cpp +++ b/src/controllers/addonqmlgenerator.cpp @@ -124,6 +124,7 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, QStringList quotedMaximums; QStringList quotedDescriptions; QStringList quotedValueLists; + QStringList quotedNormalizedCoordinates; QStringList quotedKeyframeProperties; QStringList quotedKeyframeMapEntries; QStringList setControlsLines; @@ -161,6 +162,10 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, quotedMaximums << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), quotedJsString(parameter.maximum)); } + if (parameter.normalizedCoordinates) { + quotedNormalizedCoordinates + << QStringLiteral("%1: 'yes'").arg(quotedJsString(parameter.name)); + } if (!parameter.description.isEmpty()) { quotedDescriptions << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), quotedJsString( @@ -216,6 +221,14 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, } else if (parameterType == QStringLiteral("boolean")) { setControlsLines << QStringLiteral(" %1.checked = root.booleanValue(%2);") .arg(editorId, nameLiteral); + } else if (parameterType == QStringLiteral("rect") + && (parameterWidget == QStringLiteral("point") + || parameterWidget == QStringLiteral("size"))) { + setControlsLines + << QStringLiteral( + " { var _r = filter.getRect(%2); %1.valueX = isNaN(_r.x) ? 0 : " + "_r.x; %1.valueY = isNaN(_r.y) ? 0 : _r.y; }") + .arg(editorId, nameLiteral); } else if ((parameterType == QStringLiteral("integer") || parameterType == QStringLiteral("float")) && !useTextForNumericEditor) { @@ -274,6 +287,9 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, << "}\n\n" " property var propertyValues: {" << quotedValueLists.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyNormalizedCoordinates: {" + << quotedNormalizedCoordinates.join(QStringLiteral(", ")) << "}\n\n" " keyframableParameters: [" << quotedKeyframeProperties.join(QStringLiteral(", ")) @@ -434,10 +450,27 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, " var d = root.propertyDefaults[p];\n" " var type = propertyType(p);\n" " var widget = propertyWidget(p);\n" - " if (d !== undefined && d !== null && d !== '')\n" - " filter.set(p, isBooleanType(type) ? (booleanValue(p) ? '1' : '0') " + " if (d !== undefined && d !== null && d !== '') {\n" + " if (type === 'rect' && root.propertyNormalizedCoordinates\n" + " && root.propertyNormalizedCoordinates[p] === 'yes') {\n" + " var _np = String(d).trim().split(/\\s+/);\n" + " var _nx = _np.length > 0 ? parseFloat(_np[0]) : 0;\n" + " var _ny = _np.length > 1 ? parseFloat(_np[1]) : 0;\n" + " filter.set(p, (isNaN(_nx) ? 0 : _nx * profile.width) + ' ' + " + "(isNaN(_ny) ? 0 : _ny * profile.height) + ' 0 0 1');\n" + " } else if (type === 'rect' && widget === 'size') {\n" + " // OFX plugins that use kParamDoubleTypeXY (size/extent)\n" + " // but do not declare kOfxParamPropDefaultCoordinateSystem\n" + " // (e.g. CropOFX in filter context). Default to full frame.\n" + " filter.set(p, profile.width + ' ' + profile.height + ' 0 0 " + "1');\n" + " } else {\n" + " filter.set(p, isBooleanType(type) ? (booleanValue(p) ? '1' : " + "'0') " ": ((isNumericType(type) && widget === 'text') ? String(d) : (isNumericType(type) ? " "Number(d) : d)));\n" + " }\n" + " }\n" " }\n" " filter.savePreset(propertyNames);\n" " }\n" @@ -740,6 +773,48 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, " }\n" " }\n" " }\n"; + } else if (parameterType == QStringLiteral("rect") + && (parameterWidget == QStringLiteral("point") + || parameterWidget == QStringLiteral("size"))) { + const QString labelFirst = (parameterWidget == QStringLiteral("size")) + ? QStringLiteral("W") + : QStringLiteral("X"); + const QString labelSecond = (parameterWidget == QStringLiteral("size")) + ? QStringLiteral("H") + : QStringLiteral("Y"); + stream + << " Shotcut.Number2D {\n" + " id: " + << editorId + << "\n" + " Layout.columnSpan: " + << (parameter.hideLabel ? "2" : "1") + << "\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" + " decimals: root.isIntegerType(typeName) ? 0 : 3\n" + " from: (root.propertyMinimums && " + "root.propertyMinimums[propertyName] !== undefined && " + "root.propertyMinimums[propertyName] !== '') ? " + "Number(root.propertyMinimums[propertyName]) : -99999\n" + " to: (root.propertyMaximums && root.propertyMaximums[propertyName] " + "!== undefined && root.propertyMaximums[propertyName] !== '') ? " + "Number(root.propertyMaximums[propertyName]) : 99999\n" + " labelFirst: " + << quotedJsString(labelFirst) + << "\n" + " labelSecond: " + << quotedJsString(labelSecond) + << "\n" + " onValuesModified: {\n" + " filter.set(propertyName, valueX + ' ' + valueY + ' 0 0 1');\n" + " }\n" + " }\n"; } else if ((parameterType == QStringLiteral("integer") || parameterType == QStringLiteral("float")) && !useTextForNumericEditor) { @@ -914,6 +989,24 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, " filter.set(propertyName, defaultValue);\n" " root.setControls();\n"; } + } else if (parameterType == QStringLiteral("rect") + && (parameterWidget == QStringLiteral("point") + || parameterWidget == QStringLiteral("size"))) { + stream << " var _ds = root.propertyDefaults ? " + "(root.propertyDefaults[propertyName] || '') : '';\n" + " var _dp = String(_ds).trim().split(/\\s+/);\n" + " var _dx = _dp.length > 0 ? parseFloat(_dp[0]) : 0;\n" + " var _dy = _dp.length > 1 ? parseFloat(_dp[1]) : 0;\n" + " if (isNaN(_dx)) _dx = 0;\n" + " if (isNaN(_dy)) _dy = 0;\n" + " if (root.propertyNormalizedCoordinates &&\n" + " " + "root.propertyNormalizedCoordinates[propertyName] === 'yes') {\n" + " _dx *= profile.width;\n" + " _dy *= profile.height;\n" + " }\n" + " filter.set(propertyName, _dx + ' ' + _dy + ' 0 0 1');\n" + " root.setControls();\n"; } else { stream << " var defaultValue = root.defaultTextValue(propertyName);\n" diff --git a/src/qml/modules/Shotcut/Controls/Number2D.qml b/src/qml/modules/Shotcut/Controls/Number2D.qml new file mode 100644 index 0000000000..1e6cc5ee03 --- /dev/null +++ b/src/qml/modules/Shotcut/Controls/Number2D.qml @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 Meltytech, LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Shotcut.Controls as Shotcut + +// A pair of spinboxes for two-dimensional parameters (point or size). +// The caller sets valueX / valueY and connects to valuesModified to write back. +// Set decimals: 0 for integer parameters, > 0 for floating-point parameters. +RowLayout { + id: root + + property real valueX: 0 + property real valueY: 0 + property int decimals: 0 + property real from: -99999 + property real to: 99999 + property real stepSize: 1 + property string labelFirst: 'X' + property string labelSecond: 'Y' + + signal valuesModified + + spacing: 4 + + onValueXChanged: { + if (Math.abs(xBox.value - valueX) > 1e-9) + xBox.value = valueX; + } + onValueYChanged: { + if (Math.abs(yBox.value - valueY) > 1e-9) + yBox.value = valueY; + } + + Label { + text: root.labelFirst + textFormat: Text.PlainText + verticalAlignment: Text.AlignVCenter + } + + Shotcut.DoubleSpinBox { + id: xBox + + Layout.fillWidth: true + value: root.valueX + decimals: root.decimals + from: root.from + to: root.to + stepSize: root.stepSize + onValueModified: { + root.valueX = value; + root.valuesModified(); + } + } + + Label { + text: root.labelSecond + textFormat: Text.PlainText + verticalAlignment: Text.AlignVCenter + } + + Shotcut.DoubleSpinBox { + id: yBox + + Layout.fillWidth: true + value: root.valueY + decimals: root.decimals + from: root.from + to: root.to + stepSize: root.stepSize + onValueModified: { + root.valueY = value; + root.valuesModified(); + } + } +} diff --git a/src/qml/modules/Shotcut/Controls/qmldir b/src/qml/modules/Shotcut/Controls/qmldir index ef6e222724..95887a56d4 100644 --- a/src/qml/modules/Shotcut/Controls/qmldir +++ b/src/qml/modules/Shotcut/Controls/qmldir @@ -29,3 +29,4 @@ MotionTrackerDialog 1.0 MotionTrackerDialog.qml HorizontalScrollBar 1.0 HorizontalScrollBar.qml VerticalScrollBar 1.0 VerticalScrollBar.qml ChannelMask 1.0 ChannelMask.qml +Number2D 1.0 Number2D.qml From 704207aa67a8f21945d9a8706388ab343a16a859 Mon Sep 17 00:00:00 2001 From: Brian Matherly Date: Sat, 13 Jun 2026 12:45:24 -0500 Subject: [PATCH 2/2] Handle normalized default coordinate values --- src/controllers/addonmetadataparser.cpp | 1 + src/controllers/addonmetadataparser.h | 1 + src/controllers/addonqmlgenerator.cpp | 38 ++++++++++++++----------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/controllers/addonmetadataparser.cpp b/src/controllers/addonmetadataparser.cpp index 46f7d92b3e..683fa92c6b 100644 --- a/src/controllers/addonmetadataparser.cpp +++ b/src/controllers/addonmetadataparser.cpp @@ -81,6 +81,7 @@ AddOnFilterDescriptor AddOnMetadataParser::parse(const QString &service, out.maximum = QString::fromUtf8(parameter.get("maximum")); out.description = QString::fromUtf8(parameter.get("description")); out.normalizedCoordinates = parseYesNoBool(parameter.get("normalized_coordinates")); + out.normalizedDefault = parseYesNoBool(parameter.get("normalized_default")); out.name = QString::fromUtf8(parameter.get("identifier")); const QString parameterType = out.type.trimmed().toLower(); diff --git a/src/controllers/addonmetadataparser.h b/src/controllers/addonmetadataparser.h index 5c238231e1..5429c6ce47 100644 --- a/src/controllers/addonmetadataparser.h +++ b/src/controllers/addonmetadataparser.h @@ -40,6 +40,7 @@ struct AddOnParameterDescriptor QString maximum; QString description; bool normalizedCoordinates = false; + bool normalizedDefault = false; }; struct AddOnFilterDescriptor diff --git a/src/controllers/addonqmlgenerator.cpp b/src/controllers/addonqmlgenerator.cpp index 6cbf2945f1..b79f259065 100644 --- a/src/controllers/addonqmlgenerator.cpp +++ b/src/controllers/addonqmlgenerator.cpp @@ -125,6 +125,7 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, QStringList quotedDescriptions; QStringList quotedValueLists; QStringList quotedNormalizedCoordinates; + QStringList quotedNormalizedDefault; QStringList quotedKeyframeProperties; QStringList quotedKeyframeMapEntries; QStringList setControlsLines; @@ -166,6 +167,10 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, quotedNormalizedCoordinates << QStringLiteral("%1: 'yes'").arg(quotedJsString(parameter.name)); } + if (parameter.normalizedDefault) { + quotedNormalizedDefault + << QStringLiteral("%1: 'yes'").arg(quotedJsString(parameter.name)); + } if (!parameter.description.isEmpty()) { quotedDescriptions << QStringLiteral("%1: %2").arg(quotedJsString(parameter.name), quotedJsString( @@ -290,6 +295,9 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, << "}\n\n" " property var propertyNormalizedCoordinates: {" << quotedNormalizedCoordinates.join(QStringLiteral(", ")) + << "}\n\n" + " property var propertyNormalizedDefault: {" + << quotedNormalizedDefault.join(QStringLiteral(", ")) << "}\n\n" " keyframableParameters: [" << quotedKeyframeProperties.join(QStringLiteral(", ")) @@ -451,19 +459,16 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, " var type = propertyType(p);\n" " var widget = propertyWidget(p);\n" " if (d !== undefined && d !== null && d !== '') {\n" - " if (type === 'rect' && root.propertyNormalizedCoordinates\n" - " && root.propertyNormalizedCoordinates[p] === 'yes') {\n" - " var _np = String(d).trim().split(/\\s+/);\n" - " var _nx = _np.length > 0 ? parseFloat(_np[0]) : 0;\n" - " var _ny = _np.length > 1 ? parseFloat(_np[1]) : 0;\n" - " filter.set(p, (isNaN(_nx) ? 0 : _nx * profile.width) + ' ' + " - "(isNaN(_ny) ? 0 : _ny * profile.height) + ' 0 0 1');\n" - " } else if (type === 'rect' && widget === 'size') {\n" - " // OFX plugins that use kParamDoubleTypeXY (size/extent)\n" - " // but do not declare kOfxParamPropDefaultCoordinateSystem\n" - " // (e.g. CropOFX in filter context). Default to full frame.\n" - " filter.set(p, profile.width + ' ' + profile.height + ' 0 0 " - "1');\n" + " if (type === 'rect' && root.propertyNormalizedDefault\n" + " && root.propertyNormalizedDefault[p] === 'yes') {\n" + " var _dp = String(d).trim().split(/\\s+/);\n" + " var _dx = _dp.length > 0 ? parseFloat(_dp[0]) : 0;\n" + " var _dy = _dp.length > 1 ? parseFloat(_dp[1]) : 0;\n" + " if (isNaN(_dx)) _dx = 0;\n" + " if (isNaN(_dy)) _dy = 0;\n" + " _dx *= profile.width;\n" + " _dy *= profile.height;\n" + " filter.set(p, _dx + ' ' + _dy + ' 0 0 0');\n" " } else {\n" " filter.set(p, isBooleanType(type) ? (booleanValue(p) ? '1' : " "'0') " @@ -999,13 +1004,12 @@ bool AddOnQmlGenerator::generate(const AddOnFilterDescriptor &descriptor, " var _dy = _dp.length > 1 ? parseFloat(_dp[1]) : 0;\n" " if (isNaN(_dx)) _dx = 0;\n" " if (isNaN(_dy)) _dy = 0;\n" - " if (root.propertyNormalizedCoordinates &&\n" - " " - "root.propertyNormalizedCoordinates[propertyName] === 'yes') {\n" + " if (root.propertyNormalizedDefault &&\n" + "root.propertyNormalizedDefault[propertyName] === 'yes') {\n" " _dx *= profile.width;\n" " _dy *= profile.height;\n" " }\n" - " filter.set(propertyName, _dx + ' ' + _dy + ' 0 0 1');\n" + " filter.set(propertyName, _dx + ' ' + _dy + ' 0 0 0');\n" " root.setControls();\n"; } else { stream