Prechádzať zdrojové kódy

fix: UserSetting parallaxDepth, scripted property evaluation, and CImage rotation (#492)

* fix: support UserSetting objects for parallaxDepth

parallaxDepth was declared as glm::vec2 but some wallpapers (e.g. OS
Waves 3014738359) use it as a UserSetting object with a property
reference. This caused a crash during JSON parsing.

Changed parallaxDepth to UserSettingUniquePtr in both ImageData and
ParticleData, updated all 3 parse sites to use it.user() instead of
it.optional(), and updated the render access to dereference through
the UserSetting wrapper.

Follows the same pattern already used by origin, scale, angles,
visible, alpha, and color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add QuickJS-NG script engine for UserSetting scripts

Integrate QuickJS-NG to evaluate embedded JavaScript in scene.json
that dynamically computes property values (angles, origin, scale, etc).
~30% of wallpapers use these scripts, which were previously silently
ignored. Also apply angles rotation in CImage rendering, which was
missing entirely.

Fixes OS Waves (3014738359) wave angles: -160° and 10° instead of
the raw base values -2.76° and 0.19°.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct CImage rotation and override texture combo detection

CImage rotation had three bugs:
1. glm::radians() applied to values already in radians (scene.json
   angles are radians, as documented in CParticle.cpp:2119)
2. Missing X/Z negation for Y-flipped coordinate system
3. Rotation only updated for parallax-enabled images, not dynamically

Additionally, null/empty override texture entries in the texture map
caused incorrect shader combo detection (ShaderUnit checked map key
existence, not value), breaking effect masks like waterwaves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Brunno Vert <brunnover@yahoo.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
caniswim 6 mesiacov pred
rodič
commit
cb0a0f6e1e

+ 4 - 1
.gitmodules

@@ -28,4 +28,7 @@
 	branch = master
 [submodule "src/External/Catch2"]
 	path = src/External/Catch2
-	url = https://github.com/catchorg/Catch2.git
+	url = https://github.com/catchorg/Catch2.git
+[submodule "src/External/quickjs"]
+	path = src/External/quickjs
+	url = https://github.com/quickjs-ng/quickjs.git

+ 11 - 3
CMakeLists.txt

@@ -136,6 +136,8 @@ add_subdirectory(src/External/glslang-WallpaperEngine glslang)
 add_subdirectory(src/External/SPIRV-Cross-WallpaperEngine spirv-cross)
 add_subdirectory(src/External/kissfft kissfft)
 add_subdirectory(src/External/argparse argparse)
+set(QJS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
+add_subdirectory(src/External/quickjs quickjs)
 if(BUILD_TESTING)
     add_subdirectory(src/External/Catch2)
 endif()
@@ -468,7 +470,11 @@ set(COMMON_SOURCES
     src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp
     src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
     src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.cpp
-    src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.h)
+    src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.h
+    src/WallpaperEngine/Scripting/ScriptEngine.h
+    src/WallpaperEngine/Scripting/ScriptEngine.cpp
+    src/WallpaperEngine/Data/Model/ScriptedDynamicValue.h
+    src/WallpaperEngine/Data/Model/ScriptedDynamicValue.cpp)
 
 if(BUILD_TESTING)
     add_executable(
@@ -513,7 +519,8 @@ target_link_libraries (linux-wallpaperengine-lib PUBLIC
     glfw
     libcef_lib
     libcef_dll_wrapper
-    argparse)
+    argparse
+    qjs)
 
 if (BUILD_TESTING)
     target_link_libraries (tests PRIVATE
@@ -536,7 +543,8 @@ if (BUILD_TESTING)
         glfw
         libcef_lib
         libcef_dll_wrapper
-        argparse)
+        argparse
+        qjs)
 endif()
 
 target_compile_definitions(linux-wallpaperengine-lib PUBLIC ERRORONLY=${ERRORONLY})

+ 130 - 0
docs/OS-Waves-rendering-issues.md

@@ -0,0 +1,130 @@
+# OS Waves (3014738359) - Rendering Issues Investigation
+
+Investigation of rendering differences between linux-wallpaperengine and the native
+Windows Wallpaper Engine for the OS Waves wallpaper (workshop ID 3014738359).
+
+## Wallpaper Structure
+
+The wallpaper consists of 8 scene objects rendered in this order:
+
+| Object ID | Name | Type | Effects | Status |
+|-----------|------|------|---------|--------|
+| 85 | Solid1 | Image (solid) | None | Renders correctly |
+| 173 | Windows_logo_-_2021.svg | Image | Pulse (audio) | Renders, audio response may differ |
+| 57 | 1024px-Archlinux-icon-crystal-64.svg | Image | Pulse (audio) | Renders, audio response may differ |
+| 61 | ubuntu-icon-logo-png-transparent | Image | Pulse (audio) | Renders, audio response may differ |
+| 140 | Clock | Text | N/A | **Not rendered** (text unsupported) |
+| 45 | cust | Image (passthrough) | None | **Possibly skipped** (passthrough w/o effects) |
+| 71 | 13 | Image | Water waves | Renders via shader |
+| 36 | 14 | Image | Water waves | Renders via shader |
+
+Bloom is applied as a post-processing pass (via `wpenginelinux.json`).
+
+## Issues Found
+
+### 1. Script-based UserSettings not executed (CRITICAL)
+
+**Files:** `src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp:21-24`
+
+The wallpaper defines 4 user settings with embedded JavaScript that dynamically
+control object position (Y and Z axes) via slider properties. The engine currently
+logs a warning and falls back to the default value, meaning objects are placed at
+their raw default positions instead of the script-computed ones.
+
+Example script from the wallpaper:
+```javascript
+export function update(value) {
+    value.y = scriptProperties.posY;  // slider 0-100
+    return value;
+}
+```
+
+**Impact:** Object positioning differs from Windows. Sliders for position
+adjustments are non-functional.
+
+### 2. Text objects not supported (MEDIUM)
+
+**Files:** `src/WallpaperEngine/Data/Parsers/ObjectParser.cpp:55-56`
+
+Object 140 ("Clock") is a text object and is silently skipped. On Windows this
+would render as a visible clock element.
+
+**Impact:** Missing UI element in the wallpaper.
+
+### 3. Orthogonal camera auto-size not implemented (MEDIUM)
+
+**Files:** `src/WallpaperEngine/Render/Wallpapers/CScene.cpp:37-39`
+
+When the scene camera uses `isAuto=true` for orthogonal projection, the engine
+has a TODO to calculate the projection based on content size. Currently falls
+through without adjusting.
+
+**Impact:** If enabled, overall scale and positioning of all objects could be wrong.
+
+### 4. Passthrough images without effects are skipped (LOW-MEDIUM)
+
+**Files:** `src/WallpaperEngine/Render/Objects/CImage.cpp:226-230`
+
+Object 45 ("cust") uses passthrough mode. The engine explicitly skips passthrough
+images that have no effects attached:
+```cpp
+if (this->m_image.model->passthrough && this->m_image.effects.empty()) {
+    return;
+}
+```
+
+**Impact:** If this object is meant to composite content from behind, it won't
+appear.
+
+### 5. Audio spectrum processing may differ (MEDIUM)
+
+**Files:** `src/WallpaperEngine/Render/Objects/Effects/CPass.cpp:625-630`
+
+The pulse effect on the OS logos uses:
+- `AUDIOPROCESSING=3`
+- `audioamount=1.0`, `audiobounds=0.5-1.0`, `audioexponent=0.35`
+
+The DSP algorithm for audio spectrum analysis may produce different results than
+the Windows implementation.
+
+**Impact:** Logo pulse animations may respond differently to audio.
+
+### 6. Fullscreen/autosize behavior incomplete (LOW)
+
+**Files:** `src/WallpaperEngine/Render/Objects/CImage.cpp:66`
+
+There is a TODO asking what `autosize` should do. Fullscreen layers force size to
+scene dimensions, but autosize behavior is undefined.
+
+**Impact:** Minor sizing differences for affected layers.
+
+## Fix Applied: parallaxDepth UserSetting Support
+
+**Commit:** `fix: support UserSetting objects for parallaxDepth`
+
+The original crash was caused by `parallaxDepth` being declared as `glm::vec2`
+while the wallpaper's scene.json uses it as a UserSetting object:
+```json
+{"user": "parallaxstrength", "value": "0.19000 0.19000"}
+```
+
+### Changes made:
+- `src/WallpaperEngine/Data/Model/Object.h`: Changed `parallaxDepth` from
+  `glm::vec2` to `UserSettingUniquePtr` in both `ImageData` and `ParticleData`
+- `src/WallpaperEngine/Data/Parsers/ObjectParser.cpp`: Changed 3 parse sites from
+  `it.optional()` to `it.user()` to handle both plain values and UserSetting objects
+- `src/WallpaperEngine/Render/Objects/CImage.cpp`: Updated access to dereference
+  through the UserSetting wrapper (`->value->getVec2()`)
+
+This follows the same pattern already used by `origin`, `scale`, `angles`,
+`visible`, `alpha`, and `color`.
+
+## Reproduction
+
+```bash
+linux-wallpaperengine \
+  --assets-dir ~/.local/share/Steam/steamapps/common/wallpaper_engine/assets \
+  --screen-root DP-2 --screen-root HDMI-A-1 \
+  --fps 60 --silent \
+  ~/.local/share/Steam/steamapps/workshop/content/431960/3014738359
+```

+ 1 - 0
src/External/quickjs

@@ -0,0 +1 @@
+Subproject commit 72ba50f63ee31202f8c18b8d07ab1e1c3486ee6f

+ 2 - 2
src/WallpaperEngine/Data/Model/Object.h

@@ -105,7 +105,7 @@ struct ImageData {
     /** The size of the image in pixels */
     glm::vec2 size;
     /** Parallax depth used for parallax scrolling */
-    glm::vec2 parallaxDepth;
+    UserSettingUniquePtr parallaxDepth;
     /** The color blending mode for this image */
     int colorBlendMode;
     /** The brightness of the image */
@@ -543,7 +543,7 @@ struct ParticleData {
     UserSettingUniquePtr visible;
 
     /** Parallax depth */
-    glm::vec2 parallaxDepth;
+    UserSettingUniquePtr parallaxDepth;
 
     /** Reference to particle definition file */
     std::string particleFile;

+ 42 - 0
src/WallpaperEngine/Data/Model/ScriptedDynamicValue.cpp

@@ -0,0 +1,42 @@
+#include "ScriptedDynamicValue.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+using namespace WallpaperEngine::Data::Model;
+
+ScriptedDynamicValue::ScriptedDynamicValue (
+    std::string scriptSource,
+    std::map<std::string, DynamicValueUniquePtr> scriptProps,
+    DynamicValue baseValue
+) :
+    DynamicValue (),
+    m_scriptSource (std::move (scriptSource)),
+    m_scriptProps (std::move (scriptProps)),
+    m_baseValue (std::move (baseValue)) {
+    // Listen for changes on each script property
+    for (auto& [name, prop] : this->m_scriptProps) {
+	if (prop) {
+	    prop->listen ([this] (const DynamicValue&) {
+		this->reevaluate ();
+	    });
+	}
+    }
+
+    // Do an initial evaluation
+    this->reevaluate ();
+}
+
+void ScriptedDynamicValue::reevaluate () {
+    // Build raw pointer map for the engine
+    std::map<std::string, DynamicValue*> propsMap;
+    for (const auto& [name, prop] : this->m_scriptProps) {
+	propsMap[name] = prop.get ();
+    }
+
+    auto result = WallpaperEngine::Scripting::ScriptEngine::instance ().evaluate (
+	this->m_scriptSource, propsMap, this->m_baseValue);
+
+    if (result) {
+	this->update (*result);
+    }
+}

+ 35 - 0
src/WallpaperEngine/Data/Model/ScriptedDynamicValue.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <map>
+#include <string>
+
+#include "DynamicValue.h"
+#include "Types.h"
+
+namespace WallpaperEngine::Data::Model {
+
+/**
+ * A DynamicValue whose value is computed by evaluating a WallpaperEngine script.
+ *
+ * Holds the script source, a map of script property names to their DynamicValue
+ * pointers (connected to user Properties), and the base value from the JSON.
+ * When any connected scriptProperty changes, re-evaluates the script.
+ */
+class ScriptedDynamicValue : public DynamicValue {
+public:
+    ScriptedDynamicValue (
+	std::string scriptSource,
+	std::map<std::string, DynamicValueUniquePtr> scriptProps,
+	DynamicValue baseValue
+    );
+
+    ~ScriptedDynamicValue () override = default;
+
+private:
+    void reevaluate ();
+
+    std::string m_scriptSource;
+    std::map<std::string, DynamicValueUniquePtr> m_scriptProps;
+    DynamicValue m_baseValue;
+};
+} // namespace WallpaperEngine::Data::Model

+ 8 - 5
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -117,7 +117,7 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	    .color = it.user ("color", properties, glm::vec4 (1.0f)),
 	    .alignment = it.optional ("alignment", std::string ("center")),
 	    .size = it.optional ("size", glm::vec2 (0.0f)),
-	    .parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
+	    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 	    .colorBlendMode = it.optional ("colorBlendMode", 0),
 	    .brightness = it.optional ("brightness", 1.0f),
 	    .model = ModelParser::load (project, image),
@@ -204,9 +204,12 @@ TextureMap ObjectParser::parseTextureMap (const JSON& it) {
 	textureIndex++;
 
 	if (cur.is_null ()) {
-	    result.emplace (textureIndex, "");
+	    continue;
 	} else {
-	    result.emplace (textureIndex, cur);
+	    std::string texName = cur;
+	    if (!texName.empty ()) {
+		result.emplace (textureIndex, texName);
+	    }
 	}
     }
 
@@ -264,7 +267,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		    .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		    .visible = it.user ("visible", properties, true),
-		    .parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
+		    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 		    .particleFile = "",
 		    .animationMode = "sequence",
 		    .sequenceMultiplier = 1.0f,
@@ -513,7 +516,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		.scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		.angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		.visible = it.user ("visible", properties, true),
-		.parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
+		.parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 		.particleFile = particleFile,
 		.animationMode = animationMode,
 		.sequenceMultiplier = sequenceMultiplier,

+ 25 - 3
src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp

@@ -1,6 +1,7 @@
 #include "UserSettingParser.h"
 
 #include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Data/Model/UserSetting.h"
 
 using namespace WallpaperEngine::Data::Parsers;
@@ -13,14 +14,17 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
     auto valueIt = data;
     std::string content = data.dump ();
 
+    std::optional<std::string> scriptSource;
+    std::optional<json> scriptPropsJson;
+
     if (data.is_object ()) {
 	const auto user = data.optional ("user");
 	const auto script = data.optional ("script");
 	valueIt = data.require ("value", "User setting must have a value");
 
-	// TODO: PARSE SCRIPT VALUES
 	if (script.has_value () && !script->is_null ()) {
-	    sLog.error ("Found user setting with script value: ", script.value ().dump ());
+	    scriptSource = script->get<std::string> ();
+	    scriptPropsJson = data.optional ("scriptproperties");
 	}
 
 	if (user.has_value () && !user->is_null ()) {
@@ -67,6 +71,24 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	value->update ();
     }
 
+    // If the setting has a script, wrap the base value in a ScriptedDynamicValue
+    if (scriptSource.has_value ()) {
+	std::map<std::string, DynamicValueUniquePtr> scriptProps;
+
+	if (scriptPropsJson.has_value () && scriptPropsJson->is_object ()) {
+	    for (const auto& [key, propData] : scriptPropsJson->items ()) {
+		auto propSetting = UserSettingParser::parse (propData, properties);
+		scriptProps[key] = std::move (propSetting->value);
+	    }
+	}
+
+	value = std::make_unique<ScriptedDynamicValue> (
+	    std::move (scriptSource.value ()),
+	    std::move (scriptProps),
+	    std::move (*value)
+	);
+    }
+
     // TODO: This might need to be removed if it causes issues with default values
     // Connect to property if one is specified (this allows property overrides to propagate)
     if (property != nullptr) {
@@ -82,4 +104,4 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	.property = property,
 	.condition = condition,
     });
-}
+}

+ 38 - 19
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -4,6 +4,8 @@
 
 #include <sstream>
 
+#include <glm/gtc/matrix_transform.hpp>
+
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Parsers/MaterialParser.h"
@@ -204,11 +206,16 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texcoordPass);
     glBufferData (GL_ARRAY_BUFFER, sizeof (texcoordPass), texcoordPass, GL_STATIC_DRAW);
 
+    // compute the center of the image in scene space for rotation
+    this->m_sceneCenter = glm::vec3 (
+	(this->m_pos.x + this->m_pos.z) / 2.0f,
+	(this->m_pos.y + this->m_pos.w) / 2.0f,
+	0.0f
+    );
+
     this->m_modelViewProjectionScreen
 	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt ();
 
-    this->m_modelViewProjectionScreenInverse = glm::inverse (this->m_modelViewProjectionScreen);
-
     this->m_modelViewProjectionCopy = glm::ortho<float> (0.0, size.x, 0.0, size.y);
     this->m_modelViewProjectionCopyInverse = glm::inverse (this->m_modelViewProjectionCopy);
     this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
@@ -468,11 +475,8 @@ void CImage::render () {
 
     glColorMask (true, true, true, true);
 
-    // update the position if required
-    // TODO: There's more images that are not affected by parallax, autosize or fullscreen are not affected
-    if (this->getScene ().getScene ().camera.parallax.enabled && !this->getImage ().model->fullscreen) {
-	this->updateScreenSpacePosition ();
-    }
+    // Always update screen transform (handles rotation + parallax dynamically)
+    this->updateScreenSpacePosition ();
 
 #if !NDEBUG
     std::string str = "Rendering ";
@@ -515,21 +519,36 @@ const glm::vec4& CImage::getColor4 () const { return this->m_image.color->value-
 const glm::vec3& CImage::getCompositeColor () const { return this->m_image.color->value->getVec3 (); }
 
 void CImage::updateScreenSpacePosition () {
-    // do not perform any changes to the image based on the parallax if it was explicitly disabled
-    if (this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
-	return;
+    // Build rotation from angles (already in radians from scene.json — see CParticle.cpp:2119)
+    // Negate X and Z rotations to account for Y-flipped coordinate system (CParticle.cpp:2120)
+    glm::vec3 angles = this->getImage ().angles->value->getVec3 ();
+    glm::mat4 rotModel = glm::mat4 (1.0f);
+    if (angles.x != 0.0f || angles.y != 0.0f || angles.z != 0.0f) {
+	rotModel = glm::translate (rotModel, this->m_sceneCenter);
+	rotModel = glm::rotate (rotModel, -angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
+	rotModel = glm::rotate (rotModel, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
+	rotModel = glm::rotate (rotModel, -angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
+	rotModel = glm::translate (rotModel, -this->m_sceneCenter);
     }
 
-    const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
-    const glm::vec2 depth = this->getImage ().parallaxDepth;
-    const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
-
-    float x = (depth.x + parallaxAmount) * displacement->x * this->getSize ().x;
-    float y = (depth.y + parallaxAmount) * displacement->y * this->getSize ().x;
+    glm::mat4 mvp = this->getScene ().getCamera ().getProjection ()
+		   * this->getScene ().getCamera ().getLookAt ()
+		   * rotModel;
+
+    // Apply parallax displacement if enabled
+    if (this->getScene ().getScene ().camera.parallax.enabled
+	&& !this->getImage ().model->fullscreen
+	&& !this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
+	const glm::vec2 depth = this->getImage ().parallaxDepth->value->getVec2 ();
+	const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
+	float x = (depth.x + parallaxAmount) * displacement->x * this->getSize ().x;
+	float y = (depth.y + parallaxAmount) * displacement->y * this->getSize ().x;
+	mvp = glm::translate (mvp, { x, y, 0.0f });
+    }
 
-    this->m_modelViewProjectionScreen = glm::translate (
-	this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt (), { x, y, 0.0f }
-    );
+    this->m_modelViewProjectionScreen = mvp;
+    this->m_modelViewProjectionScreenInverse = glm::inverse (mvp);
 }
 
 const Image& CImage::getImage () const { return this->m_image; }

+ 1 - 0
src/WallpaperEngine/Render/Objects/CImage.h

@@ -94,6 +94,7 @@ private:
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
 
     glm::vec4 m_pos = {};
+    glm::vec3 m_sceneCenter = {};
 
     bool m_initialized = false;
 

+ 330 - 0
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -0,0 +1,330 @@
+#include "ScriptEngine.h"
+#include "WallpaperEngine/Logging/Log.h"
+
+#include <sstream>
+
+using namespace WallpaperEngine::Scripting;
+using namespace WallpaperEngine::Data::Model;
+
+static std::unique_ptr<ScriptEngine> sScriptEngine;
+
+ScriptEngine& ScriptEngine::instance () {
+    if (!sScriptEngine) {
+	sScriptEngine = std::unique_ptr<ScriptEngine> (new ScriptEngine ());
+    }
+    return *sScriptEngine;
+}
+
+ScriptEngine::ScriptEngine () {
+    this->m_runtime = JS_NewRuntime ();
+    if (!this->m_runtime) {
+	sLog.error ("ScriptEngine: Failed to create JS runtime");
+	return;
+    }
+
+    this->m_context = JS_NewContext (this->m_runtime);
+    if (!this->m_context) {
+	sLog.error ("ScriptEngine: Failed to create JS context");
+	JS_FreeRuntime (this->m_runtime);
+	this->m_runtime = nullptr;
+	return;
+    }
+}
+
+ScriptEngine::~ScriptEngine () {
+    if (this->m_context) {
+	JS_FreeContext (this->m_context);
+    }
+    if (this->m_runtime) {
+	JS_FreeRuntime (this->m_runtime);
+    }
+}
+
+JSValue ScriptEngine::dynamicValueToJS (const DynamicValue& value) const {
+    JSContext* ctx = this->m_context;
+
+    switch (value.getType ()) {
+	case DynamicValue::Float:
+	    return JS_NewFloat64 (ctx, value.getFloat ());
+	case DynamicValue::Int:
+	    return JS_NewInt32 (ctx, value.getInt ());
+	case DynamicValue::Boolean:
+	    return JS_NewBool (ctx, value.getBool ());
+	case DynamicValue::Vec2: {
+	    JSValue obj = JS_NewObject (ctx);
+	    JS_SetPropertyStr (ctx, obj, "x", JS_NewFloat64 (ctx, value.getVec2 ().x));
+	    JS_SetPropertyStr (ctx, obj, "y", JS_NewFloat64 (ctx, value.getVec2 ().y));
+	    return obj;
+	}
+	case DynamicValue::Vec3: {
+	    JSValue obj = JS_NewObject (ctx);
+	    JS_SetPropertyStr (ctx, obj, "x", JS_NewFloat64 (ctx, value.getVec3 ().x));
+	    JS_SetPropertyStr (ctx, obj, "y", JS_NewFloat64 (ctx, value.getVec3 ().y));
+	    JS_SetPropertyStr (ctx, obj, "z", JS_NewFloat64 (ctx, value.getVec3 ().z));
+	    return obj;
+	}
+	case DynamicValue::Vec4: {
+	    JSValue obj = JS_NewObject (ctx);
+	    JS_SetPropertyStr (ctx, obj, "x", JS_NewFloat64 (ctx, value.getVec4 ().x));
+	    JS_SetPropertyStr (ctx, obj, "y", JS_NewFloat64 (ctx, value.getVec4 ().y));
+	    JS_SetPropertyStr (ctx, obj, "z", JS_NewFloat64 (ctx, value.getVec4 ().z));
+	    JS_SetPropertyStr (ctx, obj, "w", JS_NewFloat64 (ctx, value.getVec4 ().w));
+	    return obj;
+	}
+	case DynamicValue::IVec2: {
+	    JSValue obj = JS_NewObject (ctx);
+	    JS_SetPropertyStr (ctx, obj, "x", JS_NewInt32 (ctx, value.getIVec2 ().x));
+	    JS_SetPropertyStr (ctx, obj, "y", JS_NewInt32 (ctx, value.getIVec2 ().y));
+	    return obj;
+	}
+	case DynamicValue::IVec3: {
+	    JSValue obj = JS_NewObject (ctx);
+	    JS_SetPropertyStr (ctx, obj, "x", JS_NewInt32 (ctx, value.getIVec3 ().x));
+	    JS_SetPropertyStr (ctx, obj, "y", JS_NewInt32 (ctx, value.getIVec3 ().y));
+	    JS_SetPropertyStr (ctx, obj, "z", JS_NewInt32 (ctx, value.getIVec3 ().z));
+	    return obj;
+	}
+	case DynamicValue::IVec4: {
+	    JSValue obj = JS_NewObject (ctx);
+	    JS_SetPropertyStr (ctx, obj, "x", JS_NewInt32 (ctx, value.getIVec4 ().x));
+	    JS_SetPropertyStr (ctx, obj, "y", JS_NewInt32 (ctx, value.getIVec4 ().y));
+	    JS_SetPropertyStr (ctx, obj, "z", JS_NewInt32 (ctx, value.getIVec4 ().z));
+	    JS_SetPropertyStr (ctx, obj, "w", JS_NewInt32 (ctx, value.getIVec4 ().w));
+	    return obj;
+	}
+	default:
+	    return JS_UNDEFINED;
+    }
+}
+
+DynamicValueUniquePtr ScriptEngine::jsToDynamicValue (JSValue val, DynamicValue::UnderlyingType hint) const {
+    JSContext* ctx = this->m_context;
+    auto result = std::make_unique<DynamicValue> ();
+
+    if (JS_IsException (val)) {
+	return result;
+    }
+
+    // scalar types returned directly
+    int tag = JS_VALUE_GET_TAG (val);
+    if (tag == JS_TAG_INT) {
+	int32_t i;
+	JS_ToInt32 (ctx, &i, val);
+	if (hint == DynamicValue::Float) {
+	    result->update (static_cast<float> (i));
+	} else {
+	    result->update (static_cast<int> (i));
+	}
+	return result;
+    }
+    if (tag == JS_TAG_BOOL) {
+	result->update (static_cast<bool> (JS_ToBool (ctx, val)));
+	return result;
+    }
+    if (JS_TAG_IS_FLOAT64 (tag)) {
+	double d;
+	JS_ToFloat64 (ctx, &d, val);
+	result->update (static_cast<float> (d));
+	return result;
+    }
+
+    // object - extract x/y/z/w properties based on the hint type
+    if (tag == JS_TAG_OBJECT) {
+	auto readFloat = [&] (const char* prop) -> float {
+	    JSValue v = JS_GetPropertyStr (ctx, val, prop);
+	    double d = 0.0;
+	    if (!JS_IsException (v) && !JS_IsUndefined (v)) {
+		JS_ToFloat64 (ctx, &d, v);
+	    }
+	    JS_FreeValue (ctx, v);
+	    return static_cast<float> (d);
+	};
+
+	auto readInt = [&] (const char* prop) -> int {
+	    JSValue v = JS_GetPropertyStr (ctx, val, prop);
+	    int32_t i = 0;
+	    if (!JS_IsException (v) && !JS_IsUndefined (v)) {
+		JS_ToInt32 (ctx, &i, v);
+	    }
+	    JS_FreeValue (ctx, v);
+	    return static_cast<int> (i);
+	};
+
+	switch (hint) {
+	    case DynamicValue::Vec2:
+		result->update (glm::vec2 (readFloat ("x"), readFloat ("y")));
+		break;
+	    case DynamicValue::Vec3:
+		result->update (glm::vec3 (readFloat ("x"), readFloat ("y"), readFloat ("z")));
+		break;
+	    case DynamicValue::Vec4:
+		result->update (
+		    glm::vec4 (readFloat ("x"), readFloat ("y"), readFloat ("z"), readFloat ("w")));
+		break;
+	    case DynamicValue::IVec2:
+		result->update (glm::ivec2 (readInt ("x"), readInt ("y")));
+		break;
+	    case DynamicValue::IVec3:
+		result->update (glm::ivec3 (readInt ("x"), readInt ("y"), readInt ("z")));
+		break;
+	    case DynamicValue::IVec4:
+		result->update (
+		    glm::ivec4 (readInt ("x"), readInt ("y"), readInt ("z"), readInt ("w")));
+		break;
+	    default: {
+		// try to read as vec3 by default (most common for origin/angles)
+		float x = readFloat ("x");
+		float y = readFloat ("y");
+		float z = readFloat ("z");
+		result->update (glm::vec3 (x, y, z));
+		break;
+	    }
+	}
+	return result;
+    }
+
+    // fallback: try as float
+    double d;
+    if (JS_ToFloat64 (ctx, &d, val) == 0) {
+	result->update (static_cast<float> (d));
+    }
+    return result;
+}
+
+/// Helper to check for and log JS exceptions
+static void logJSException (JSContext* ctx, const char* context) {
+    JSValue exc = JS_GetException (ctx);
+    if (!JS_IsNull (exc) && !JS_IsUndefined (exc)) {
+	const char* str = JS_ToCString (ctx, exc);
+	if (str) {
+	    sLog.error ("ScriptEngine [", context, "]: ", str);
+	    JS_FreeCString (ctx, str);
+	}
+    }
+    JS_FreeValue (ctx, exc);
+}
+
+DynamicValueUniquePtr ScriptEngine::evaluate (
+    const std::string& scriptSource,
+    const std::map<std::string, DynamicValue*>& scriptProperties,
+    const DynamicValue& currentValue
+) {
+    if (!this->m_context) {
+	sLog.error ("ScriptEngine: No JS context available");
+	auto fallback = std::make_unique<DynamicValue> ();
+	fallback->update (currentValue);
+	return fallback;
+    }
+
+    JSContext* ctx = this->m_context;
+
+    // Build the scriptProperties object that createScriptProperties() will return
+    JSValue propsObj = JS_NewObject (ctx);
+    for (const auto& [name, dynVal] : scriptProperties) {
+	if (dynVal) {
+	    JS_SetPropertyStr (ctx, propsObj, name.c_str (), this->dynamicValueToJS (*dynVal));
+	}
+    }
+
+    // We need to rewrite the ES6 module into a regular script that we can evaluate
+    // and extract the update() function from, because QuickJS module evaluation
+    // via JS_EVAL_TYPE_MODULE doesn't easily let us get at exported functions
+    // from C in a straightforward way.
+    //
+    // Strategy: Replace the ES6 module pattern with a plain script that:
+    // 1. Has createScriptProperties() available as a global
+    // 2. Defines update() in global scope
+    // 3. We call update() with the value object
+    //
+    // The script pattern is always:
+    //   'use strict';
+    //   export var scriptProperties = createScriptProperties()...finish();
+    //   export function update(value) { ... }
+    //
+    // We transform this to a self-contained IIFE that we evaluate directly.
+
+    // Set createScriptProperties as a global that returns a builder
+    // The builder supports .addSlider({...}).addCheckbox({...}).finish()
+    // and returns an object with the property values
+
+    // Create the builder as a JS object with fluent methods
+    // that ultimately resolves to the propsObj
+    std::ostringstream wrapper;
+    wrapper << "(function() {\n"
+	    << "  var __props = globalThis.__scriptProps;\n"
+	    << "  function createScriptProperties() {\n"
+	    << "    var builder = {\n"
+	    << "      addSlider: function(opts) {\n"
+	    << "        if (!(opts.name in __props)) __props[opts.name] = opts.value;\n"
+	    << "        return builder;\n"
+	    << "      },\n"
+	    << "      addCheckbox: function(opts) {\n"
+	    << "        if (!(opts.name in __props)) __props[opts.name] = opts.value;\n"
+	    << "        return builder;\n"
+	    << "      },\n"
+	    << "      addCombo: function(opts) {\n"
+	    << "        if (!(opts.name in __props)) __props[opts.name] = opts.value;\n"
+	    << "        return builder;\n"
+	    << "      },\n"
+	    << "      addColor: function(opts) {\n"
+	    << "        if (!(opts.name in __props)) __props[opts.name] = opts.value;\n"
+	    << "        return builder;\n"
+	    << "      },\n"
+	    << "      addText: function(opts) {\n"
+	    << "        if (!(opts.name in __props)) __props[opts.name] = opts.value;\n"
+	    << "        return builder;\n"
+	    << "      },\n"
+	    << "      finish: function() { return __props; }\n"
+	    << "    };\n"
+	    << "    return builder;\n"
+	    << "  }\n";
+
+    // Strip 'use strict'; and export keywords, embed the script body
+    std::string body = scriptSource;
+
+    // Remove 'use strict'; declarations
+    size_t pos;
+    while ((pos = body.find ("'use strict';")) != std::string::npos) {
+	body.erase (pos, 13);
+    }
+    while ((pos = body.find ("\"use strict\";")) != std::string::npos) {
+	body.erase (pos, 13);
+    }
+
+    // Remove export keywords (export var ..., export function ...)
+    while ((pos = body.find ("export ")) != std::string::npos) {
+	body.erase (pos, 7);
+    }
+
+    wrapper << body << "\n"
+	    << "  return update(globalThis.__currentValue);\n"
+	    << "})();\n";
+
+    std::string evalScript = wrapper.str ();
+
+    // Set globals: __scriptProps and __currentValue
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JS_SetPropertyStr (ctx, globalObj, "__scriptProps", JS_DupValue (ctx, propsObj));
+    JS_SetPropertyStr (ctx, globalObj, "__currentValue", this->dynamicValueToJS (currentValue));
+
+    // Evaluate
+    JSValue result = JS_Eval (ctx, evalScript.c_str (), evalScript.size (), "<script>", JS_EVAL_TYPE_GLOBAL);
+
+    // Clean up globals
+    JS_SetPropertyStr (ctx, globalObj, "__scriptProps", JS_UNDEFINED);
+    JS_SetPropertyStr (ctx, globalObj, "__currentValue", JS_UNDEFINED);
+    JS_FreeValue (ctx, globalObj);
+    JS_FreeValue (ctx, propsObj);
+
+    if (JS_IsException (result)) {
+	logJSException (ctx, "evaluate");
+	JS_FreeValue (ctx, result);
+	auto fallback = std::make_unique<DynamicValue> ();
+	fallback->update (currentValue);
+	return fallback;
+    }
+
+    auto dynResult = this->jsToDynamicValue (result, currentValue.getType ());
+    JS_FreeValue (ctx, result);
+    return dynResult;
+}

+ 48 - 0
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <map>
+#include <memory>
+#include <string>
+
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
+#include "WallpaperEngine/Data/Model/Types.h"
+
+extern "C" {
+#include "quickjs.h"
+}
+
+namespace WallpaperEngine::Scripting {
+using namespace WallpaperEngine::Data::Model;
+
+class ScriptEngine {
+public:
+    static ScriptEngine& instance ();
+
+    ~ScriptEngine ();
+    ScriptEngine (const ScriptEngine&) = delete;
+    ScriptEngine& operator= (const ScriptEngine&) = delete;
+
+    /**
+     * Evaluate a WallpaperEngine script's update() function.
+     *
+     * @param scriptSource The full JS script text (ES6 module with export function update(value))
+     * @param scriptProperties Map of property name to current DynamicValue*
+     * @param currentValue The current value to pass to update()
+     * @return The modified value from update(), or a copy of currentValue on error
+     */
+    DynamicValueUniquePtr evaluate (
+	const std::string& scriptSource,
+	const std::map<std::string, DynamicValue*>& scriptProperties,
+	const DynamicValue& currentValue
+    );
+
+private:
+    ScriptEngine ();
+
+    JSValue dynamicValueToJS (const DynamicValue& value) const;
+    DynamicValueUniquePtr jsToDynamicValue (JSValue val, DynamicValue::UnderlyingType hint) const;
+
+    JSRuntime* m_runtime = nullptr;
+    JSContext* m_context = nullptr;
+};
+} // namespace WallpaperEngine::Scripting