| @@ -1,141 +1,218 @@ |
| 1 | 1 | // |
| 2 | | −// KasenAPIExample.cpp |
| 3 | | −// plugins/KasenAPIExample/src |
| 2 | +// JSAPIExample.cpp |
| 3 | +// plugins/JSAPIExample/src |
| 4 | 4 | // |
| 5 | | −// Created by Kasen IO on 2019.07.14 | realities.dev | kasenvr@gmail.com |
| 6 | | −// Copyright 2019 Kasen IO |
| 7 | | −// |
| 8 | | −// Authored by: Humbletim (humbletim@gmail.com) |
| 5 | +// Copyright (c) 2019-2020 humbletim (humbletim@gmail.com) |
| 6 | +// Copyright (c) 2019 Kalila L. (kasenvr@gmail.com) |
| 9 | 7 | // |
| 10 | 8 | // Distributed under the Apache License, Version 2.0. |
| 11 | 9 | // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html |
| 12 | | −// |
| 13 | | −// Example of prototyping new JS APIs by leveraging the existing plugin system. |
| 14 | 10 | |
| 15 | | −#include "ExampleScriptPlugin.h" |
| 11 | +// Example of prototyping new JS APIs by leveraging the existing plugin system. |
| 16 | 12 | |
| 17 | 13 | #include <QCoreApplication> |
| 14 | +#include <QtCore/QByteArray> |
| 15 | +#include <QtCore/QDir> |
| 16 | +#include <QtCore/QFileInfo> |
| 18 | 17 | #include <QtCore/QJsonObject> |
| 19 | 18 | #include <QtCore/QLoggingCategory> |
| 20 | 19 | #include <QtCore/QThread> |
| 21 | 20 | #include <QtCore/QTimer> |
| 21 | +#include <QtScript/QScriptEngine> |
| 22 | +#include <QtScript/QScriptable> |
| 23 | + |
| 24 | +#include <SettingHelpers.h> // for ::settingsFilename() |
| 25 | +#include <SharedUtil.h> // for usecTimestampNow() |
| 26 | +#include <shared/ScriptInitializerMixin.h> |
| 22 | 27 | |
| 23 | | −#include <SharedUtil.h> |
| 24 | | −#include <AvatarHashMap.h> |
| 28 | +// NOTE: replace this with your own namespace when starting a new plugin (to avoid .so/.dll symbol clashes) |
| 29 | +namespace REPLACE_ME_WITH_UNIQUE_NAME { |
| 25 | 30 | |
| 26 | | −namespace custom_api_example { |
| 31 | +static constexpr auto JSAPI_SEMANTIC_VERSION = "0.0.1"; |
| 32 | +static constexpr auto JSAPI_EXPORT_NAME = "JSAPIExample"; |
| 33 | + |
| 34 | +QLoggingCategory logger{ "jsapiexample" }; |
| 35 | + |
| 36 | +inline QVariant raiseScriptingError(QScriptContext* context, const QString& message, const QVariant& returnValue = QVariant()) { |
| 37 | + if (context) { |
| 38 | + // when a QScriptContext is available throw an actual JS Exception (which can be caught using try/catch on JS side) |
| 39 | + context->throwError(message); |
| 40 | + } else { |
| 41 | + // otherwise just log the error |
| 42 | + qCWarning(logger) << "error:" << message; |
| 43 | + } |
| 44 | + return returnValue; |
| 45 | +} |
| 27 | 46 | |
| 28 | | −QLoggingCategory logger{ "custom_api_example" }; |
| 47 | +QObject* createScopedSettings(const QString& scope, QObject* parent, QString& error); |
| 29 | 48 | |
| 30 | | −class KasenAPIExample : public example::ScriptPlugin { |
| 49 | +class JSAPIExample : public QObject, public QScriptable { |
| 31 | 50 | Q_OBJECT |
| 32 | | − Q_PLUGIN_METADATA(IID "KasenAPIExample" FILE "plugin.json") |
| 51 | + Q_PLUGIN_METADATA(IID "JSAPIExample" FILE "plugin.json") |
| 52 | + Q_PROPERTY(QString version MEMBER _version CONSTANT) |
| 33 | 53 | public: |
| 34 | | − KasenAPIExample() : example::ScriptPlugin("KasenAPIExample", "0.0.1") { |
| 35 | | − qCInfo(logger) << "plugin loaded" << qApp << toString() << QThread::currentThread(); |
| 54 | + JSAPIExample() { |
| 55 | + setObjectName(JSAPI_EXPORT_NAME); |
| 56 | + auto scriptInit = DependencyManager::get<ScriptInitializers>(); |
| 57 | + if (!scriptInit) { |
| 58 | + qCWarning(logger) << "COULD NOT INITIALIZE (ScriptInitializers unavailable)" << qApp << this; |
| 59 | + return; |
| 60 | + } |
| 61 | + qCWarning(logger) << "registering w/ScriptInitializerMixin..." << scriptInit.data(); |
| 62 | + scriptInit->registerScriptInitializer([this](QScriptEngine* engine) { |
| 63 | + auto value = engine->newQObject(this, QScriptEngine::QtOwnership, QScriptEngine::ExcludeDeleteLater); |
| 64 | + engine->globalObject().setProperty(objectName(), value); |
| 65 | + // qCDebug(logger) << "setGlobalInstance" << objectName() << engine->property("fileName"); |
| 66 | + }); |
| 67 | + // qCInfo(logger) << "plugin loaded" << qApp << toString() << QThread::currentThread(); |
| 36 | 68 | } |
| 37 | 69 | |
| 70 | + // NOTES: everything within the "public slots:" section below will be available from JS via overall plugin QObject |
| 71 | + // also, to demonstrate future-proofing JS API code, QVariant's are used throughout most of these examples -- |
| 72 | + // which still makes them very Qt-specific, but avoids depending directly on deprecated QtScript/QScriptValue APIs. |
| 73 | + // (as such this plugin class and its methods remain forward-compatible with other engines like QML's QJSEngine) |
| 74 | + |
| 38 | 75 | public slots: |
| 76 | + // returns a pretty-printed representation for logging eg: print(JSAPIExample) |
| 77 | + inline QString toString() const { return QString("[%1 version=%2]").arg(objectName()).arg(_version); } |
| 78 | + |
| 39 | 79 | /**jsdoc |
| 40 | 80 | * Returns current microseconds (usecs) since Epoch. note: 1000usecs == 1ms |
| 41 | 81 | * @example <caption>Measure current setTimeout accuracy.</caption> |
| 42 | 82 | * var expected = 1000; |
| 43 | | − * var start = KasenAPIExample.now(); |
| 83 | + * var start = JSAPIExample.now(); |
| 44 | 84 | * Script.setTimeout(function () { |
| 45 | | − * var elapsed = (KasenAPIExample.now() - start)/1000; |
| 85 | + * var elapsed = (JSAPIExample.now() - start)/1000; |
| 46 | 86 | * print("expected (ms):", expected, "actual (ms):", elapsed); |
| 47 | 87 | * }, expected); |
| 48 | 88 | */ |
| 49 | | − QVariant now() const { |
| 50 | | − return usecTimestampNow(); |
| 51 | | − } |
| 89 | + QVariant now() const { return usecTimestampNow(); } |
| 52 | 90 | |
| 53 | 91 | /**jsdoc |
| 54 | | − * Returns the available blendshape names for an avatar. |
| 55 | | − * @example <caption>Get blendshape names</caption> |
| 56 | | − * print(JSON.stringify(KasenAPIExample.getBlendshapeNames(MyAvatar.sessionUUID))); |
| 92 | + * Example of returning a JS Object key-value map |
| 93 | + * @example <caption>"zip" a list of keys and corresponding values to form key-value map</caption> |
| 94 | + * print(JSON.stringify(JSAPIExample.zip(["a","b"], [1,2])); // { "a": 1, "b": 2 } |
| 57 | 95 | */ |
| 58 | | − QStringList getBlendshapeNames(const QUuid& avatarID) const { |
| 59 | | − QVector<QString> out; |
| 60 | | − if (auto head = getAvatarHead(avatarID)) { |
| 61 | | − for (const auto& kv : head->getBlendshapeMap().toStdMap()) { |
| 62 | | − if (kv.second >= out.size()) out.resize(kv.second+1); |
| 63 | | − out[kv.second] = kv.first; |
| 64 | | − } |
| 96 | + QVariant zip(const QStringList& keys, const QVariantList& values) const { |
| 97 | + QVariantMap out; |
| 98 | + for (int i = 0; i < keys.size(); i++) { |
| 99 | + out[keys[i]] = i < values.size() ? values[i] : QVariant(); |
| 65 | 100 | } |
| 66 | | − return out.toList(); |
| 101 | + return out; |
| 67 | 102 | } |
| 103 | + /**jsdoc |
| 104 | + * Example of returning a JS Array result |
| 105 | + * @example <caption>emulate Object.values(keyValues)</caption> |
| 106 | + * print(JSON.stringify(JSAPIExample.values({ "a": 1, "b": 2 }))); // [1,2] |
| 107 | + */ |
| 108 | + QVariant values(const QVariantMap& keyValues) const { return keyValues.values(); } |
| 68 | 109 | |
| 69 | 110 | /**jsdoc |
| 70 | | − * Returns a key-value object with active (non-zero) blendshapes. |
| 71 | | − * eg: { JawOpen: 1.0, ... } |
| 72 | | − * @example <caption>Get active blendshape map</caption> |
| 73 | | − * print(JSON.stringify(KasenAPIExample.getActiveBlendshapes(MyAvatar.sessionUUID))); |
| 111 | + * Another example of returning JS Array data |
| 112 | + * @example <caption>generate an integer sequence (inclusive of [from, to])</caption> |
| 113 | + * print(JSON.stringify(JSAPIExample.seq(1,5)));// [1,2,3,4,5] |
| 74 | 114 | */ |
| 75 | | − QVariant getActiveBlendshapes(const QUuid& avatarID) const { |
| 76 | | − if (auto head = getAvatarHead(avatarID)) { |
| 77 | | − return head->toJson()["blendShapes"].toVariant(); |
| 115 | + QVariant seq(int from, int to) const { |
| 116 | + QVariantList out; |
| 117 | + for (int i = from; i <= to; i++) { |
| 118 | + out.append(i); |
| 78 | 119 | } |
| 79 | | − return {}; |
| 120 | + return out; |
| 80 | 121 | } |
| 81 | 122 | |
| 82 | | − QVariant getBlendshapeMapping(const QUuid& avatarID) const { |
| 83 | | − QVariantMap out; |
| 84 | | − if (auto head = getAvatarHead(avatarID)) { |
| 85 | | − for (const auto& kv : head->getBlendshapeMap().toStdMap()) { |
| 86 | | − out[kv.first] = kv.second; |
| 87 | | − } |
| 88 | | − } |
| 89 | | − return out; |
| 123 | + /**jsdoc |
| 124 | + * Example of returning arbitrary binary data from C++ (resulting in a JS ArrayBuffer) |
| 125 | + * see also: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer#Examples |
| 126 | + * @example <caption>return compressed/decompressed versions of the input data</caption> |
| 127 | + * var data = "testing 1 2 3"; |
| 128 | + * var z = JSAPIExample.qCompressString(data); // z will be an ArrayBuffer |
| 129 | + * var u = JSAPIExample.qUncompressString(z); // u will be a String value |
| 130 | + * print(JSON.stringify({ input: data, compressed: z.byteLength, output: u, uncompressed: u.length })); |
| 131 | + */ |
| 132 | + QVariant qCompressString(const QString& data, int compress_level = -1) const { |
| 133 | + return qCompress(data.toUtf8(), compress_level); |
| 90 | 134 | } |
| 135 | + QString qUncompressString(const QByteArray& data) const { return QString::fromUtf8(qUncompress(data)); } |
| 91 | 136 | |
| 92 | | − QVariant getBlendshapes(const QUuid& avatarID) const { |
| 93 | | − QVariantMap result; |
| 94 | | − if (auto head = getAvatarHead(avatarID)) { |
| 95 | | − QStringList names = getBlendshapeNames(avatarID); |
| 96 | | − auto states = head->getBlendshapeStates(); |
| 97 | | − result = { |
| 98 | | − { "base", zipNonZeroValues(names, states.base) }, |
| 99 | | − { "summed", zipNonZeroValues(names, states.summed) }, |
| 100 | | − { "transient", zipNonZeroValues(names, states.transient) }, |
| 101 | | − }; |
| 137 | + /** |
| 138 | + * Example of exposing a custom "managed" C++ QObject to JS |
| 139 | + * The lifecycle of the created QObject* instance becomes managed by the invoking QScriptEngine -- |
| 140 | + * it will be automatically cleaned up once no longer reachable from any JS variables/closures. |
| 141 | + * @example <caption>access persistent settings stored in separate .json files</caption> |
| 142 | + * var settings = JSAPIExample.getScopedSettings("example"); |
| 143 | + * print("example settings stored in:", settings.fileName()); |
| 144 | + * print("(before) example::timestamp", settings.value("timestamp")); |
| 145 | + * settings.setValue("timestamp", Date.now()); |
| 146 | + * print("(after) example::timestamp", settings.value("timestamp")); |
| 147 | + * print("all example::* keys", settings.allKeys()); |
| 148 | + * settings = null; // optional best pratice; allows the object to be reclaimed ASAP by the JS garbage collector |
| 149 | + */ |
| 150 | + QScriptValue getScopedSettings(const QString& scope) { |
| 151 | + auto engine = QScriptable::engine(); |
| 152 | + if (!engine) return QScriptValue::NullValue; |
| 153 | + QString error; |
| 154 | + auto cppValue = createScopedSettings(scope, engine, error); |
| 155 | + if (!cppValue) { |
| 156 | + raiseScriptingError(context(), "error creating scoped settings instance: " + error); |
| 157 | + return QScriptValue::NullValue; |
| 102 | 158 | } |
| 103 | | − return result; |
| 159 | + return engine->newQObject(cppValue, QScriptEngine::ScriptOwnership, QScriptEngine::ExcludeDeleteLater); |
| 104 | 160 | } |
| 105 | 161 | |
| 106 | 162 | private: |
| 107 | | − static QVariantMap zipNonZeroValues(const QStringList& keys, const QVector<float>& values) { |
| 108 | | − QVariantMap out; |
| 109 | | − for (int i=1; i < values.size(); i++) { |
| 110 | | − if (fabs(values[i]) > 1.0e-6f) { |
| 111 | | − out[keys.value(i)] = values[i]; |
| 112 | | − } |
| 113 | | − } |
| 114 | | − return out; |
| 163 | + const QString _version{ JSAPI_SEMANTIC_VERSION }; |
| 164 | +}; |
| 165 | + |
| 166 | +// Example of how to create a QObject class that can have multiple instances created from the JS side |
| 167 | +// JSSettingsHelper emulates a subset of QSetting APIs: |
| 168 | +// fileName() -- full path to the scoped settings .json file |
| 169 | +// allKeys() -- all previously stored keys available in the scoped settings file |
| 170 | +// value(key, defaultValue) -- retrieve a stored value |
| 171 | +// setValue(key, newValue) -- set/update a stored value |
| 172 | +class JSSettingsHelper : public QObject { |
| 173 | + Q_OBJECT |
| 174 | + QString _scope; |
| 175 | + QString _fileName; |
| 176 | + QSharedPointer<QSettings> _settings; |
| 177 | + |
| 178 | +public: |
| 179 | + operator bool() const { return (bool)_settings; } |
| 180 | + JSSettingsHelper(const QString& scope, QObject* parent = nullptr) : |
| 181 | + QObject(parent), _scope(scope), _fileName(getLocalSettingsPath(scope)), |
| 182 | + _settings(_fileName.isEmpty() ? nullptr : new QSettings(_fileName, JSON_FORMAT)) {} |
| 183 | + ~JSSettingsHelper() { qCDebug(logger) << "~JSSettingsHelper" << _scope << _fileName << this; } |
| 184 | +public slots: |
| 185 | + QString fileName() const { return _settings ? _settings->fileName() : ""; } |
| 186 | + QString toString() const { return QString("[JSSettingsHelper scope=%1 valid=%2]").arg(_scope).arg((bool)_settings); } |
| 187 | + QVariant value(const QString& key, const QVariant& defaultValue = QVariant()) { |
| 188 | + return _settings ? _settings->value(key, defaultValue) : defaultValue; |
| 115 | 189 | } |
| 116 | | − struct _HeadHelper : public HeadData { |
| 117 | | − QMap<QString,int> getBlendshapeMap() const { |
| 118 | | − return BLENDSHAPE_LOOKUP_MAP; |
| 190 | + bool setValue(const QString& key, const QVariant& value) { |
| 191 | + if (_settings) { |
| 192 | + _settings->setValue(key, value); |
| 193 | + return true; |
| 119 | 194 | } |
| 120 | | − struct States { QVector<float> base, summed, transient; }; |
| 121 | | − States getBlendshapeStates() const { |
| 122 | | − return { |
| 123 | | − _blendshapeCoefficients, |
| 124 | | − _summedBlendshapeCoefficients, |
| 125 | | − _transientBlendshapeCoefficients |
| 126 | | − }; |
| 127 | | − } |
| 128 | | − }; |
| 129 | | − static const _HeadHelper* getAvatarHead(const QUuid& avatarID) { |
| 130 | | − auto avatars = DependencyManager::get<AvatarHashMap>(); |
| 131 | | − auto avatar = avatars ? avatars->getAvatarBySessionID(avatarID) : nullptr; |
| 132 | | − auto head = avatar ? avatar->getHeadData() : nullptr; |
| 133 | | − return reinterpret_cast<const _HeadHelper*>(head); |
| 195 | + return false; |
| 196 | + } |
| 197 | + QStringList allKeys() const { return _settings ? _settings->allKeys() : QStringList{}; } |
| 198 | + |
| 199 | +protected: |
| 200 | + QString getLocalSettingsPath(const QString& scope) const { |
| 201 | + // generate a prefixed filename (relative to the main application's Interface.json file) |
| 202 | + const QString fileName = QString("jsapi_%1.json").arg(scope); |
| 203 | + return QFileInfo(::settingsFilename()).dir().filePath(fileName); |
| 134 | 204 | } |
| 135 | 205 | }; |
| 136 | 206 | |
| 207 | +QObject* createScopedSettings(const QString& scope, QObject* parent, QString& error) { |
| 208 | + const QRegExp VALID_SETTINGS_SCOPE{ "[-_A-Za-z0-9]{1,64}" }; |
| 209 | + if (!VALID_SETTINGS_SCOPE.exactMatch(scope)) { |
| 210 | + error = QString("invalid scope (expected alphanumeric <= 64 chars not '%1')").arg(scope); |
| 211 | + return nullptr; |
| 212 | + } |
| 213 | + return new JSSettingsHelper(scope, parent); |
| 137 | 214 | } |
| 138 | 215 | |
| 139 | | −const QLoggingCategory& example::logger{ custom_api_example::logger }; |
| 216 | +} // namespace REPLACE_ME_WITH_UNIQUE_NAME |
| 140 | 217 | |
| 141 | | −#include "KasenAPIExample.moc" |
| 218 | +#include "JSAPIExample.moc" |