Kaynağa Gözat

Merge upstream Almamu/linux-wallpaperengine main

Brings in 6 upstream commits:
- refactor: remove subprocess in favor of dbus and wire up to javascript (#606)
- refactor: load scene-script builtins from a generated header (#608)
- refactor: read puppet mesh data via BinaryReader/MemoryStream (#604)
- fix: mute video wallpapers when --silent is used (#605)
- chore: cleanup and optimization of scripting engine (#599)
- refactor: make CImage::resolveTransform iterative (#602)

Conflict resolved: kept "desktop" namespace string in WaylandOutputViewport.cpp
for KDE compatibility (prevents wallpaper minimizing on Super+D).
UwU 3 ay önce
ebeveyn
işleme
cdd3c1555f
100 değiştirilmiş dosya ile 4989 ekleme ve 2025 silme
  1. 47 95
      .github/workflows/cmake.yml
  2. 63 3
      CMakeLists.txt
  3. 59 0
      CMakeModules/FindDBus.cmake
  4. 3 2
      src/WallpaperEngine/Application/ApplicationContext.cpp
  5. 114 98
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  6. 10 3
      src/WallpaperEngine/Application/WallpaperApplication.h
  7. 69 0
      src/WallpaperEngine/Data/Builders/ColorBuilder.cpp
  8. 22 0
      src/WallpaperEngine/Data/Builders/ColorBuilder.h
  9. 1 1
      src/WallpaperEngine/Data/Builders/VectorBuilder.h
  10. 7 1
      src/WallpaperEngine/Data/JSON.cpp
  11. 17 0
      src/WallpaperEngine/Data/JSON.h
  12. 21 0
      src/WallpaperEngine/Data/Model/Color.h
  13. 76 131
      src/WallpaperEngine/Data/Model/DynamicValue.cpp
  14. 61 29
      src/WallpaperEngine/Data/Model/DynamicValue.h
  15. 4 5
      src/WallpaperEngine/Data/Model/Object.h
  16. 24 58
      src/WallpaperEngine/Data/Model/Property.h
  17. 0 74
      src/WallpaperEngine/Data/Model/ScriptedDynamicValue.cpp
  18. 0 52
      src/WallpaperEngine/Data/Model/ScriptedDynamicValue.h
  19. 6 6
      src/WallpaperEngine/Data/Model/Wallpaper.h
  20. 80 0
      src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp
  21. 14 0
      src/WallpaperEngine/Data/Parsers/DynamicValueParser.h
  22. 1 0
      src/WallpaperEngine/Data/Parsers/EffectParser.cpp
  23. 3 27
      src/WallpaperEngine/Data/Parsers/MaterialParser.cpp
  24. 0 1
      src/WallpaperEngine/Data/Parsers/MaterialParser.h
  25. 1 0
      src/WallpaperEngine/Data/Parsers/ModelParser.cpp
  26. 48 278
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  27. 0 1
      src/WallpaperEngine/Data/Parsers/ObjectParser.h
  28. 31 0
      src/WallpaperEngine/Data/Parsers/TextureParser.cpp
  29. 4 0
      src/WallpaperEngine/Data/Parsers/TextureParser.h
  30. 4 79
      src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp
  31. 2 1
      src/WallpaperEngine/Data/Parsers/UserSettingParser.h
  32. 6 6
      src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp
  33. 35 0
      src/WallpaperEngine/Data/Utils/ScopeGuard.h
  34. 5 7
      src/WallpaperEngine/Data/Utils/TypeCaster.h
  35. 7 3
      src/WallpaperEngine/FileSystem/Adapters/Directory.cpp
  36. 64 0
      src/WallpaperEngine/FileSystem/Adapters/MediaCover.cpp
  37. 30 0
      src/WallpaperEngine/FileSystem/Adapters/MediaCover.h
  38. 9 5
      src/WallpaperEngine/FileSystem/Adapters/Package.cpp
  39. 9 1
      src/WallpaperEngine/FileSystem/Container.cpp
  40. 5 0
      src/WallpaperEngine/FileSystem/Container.h
  41. 23 26
      src/WallpaperEngine/Input/Drivers/WaylandMouseInput.cpp
  42. 5 1
      src/WallpaperEngine/Input/Drivers/WaylandMouseInput.h
  43. 1 1
      src/WallpaperEngine/Logging/Log.cpp
  44. 2 2
      src/WallpaperEngine/Logging/Log.h
  45. 32 0
      src/WallpaperEngine/Maths.cpp
  46. 11 0
      src/WallpaperEngine/Maths.h
  47. 370 0
      src/WallpaperEngine/Media/DBusMediaSource.cpp
  48. 32 0
      src/WallpaperEngine/Media/DBusMediaSource.h
  49. 80 0
      src/WallpaperEngine/Media/MediaSource.cpp
  50. 62 0
      src/WallpaperEngine/Media/MediaSource.h
  51. 136 0
      src/WallpaperEngine/Render/AlbumTexture.cpp
  52. 43 0
      src/WallpaperEngine/Render/AlbumTexture.h
  53. 2 0
      src/WallpaperEngine/Render/CFBO.cpp
  54. 1 0
      src/WallpaperEngine/Render/CFBO.h
  55. 1 0
      src/WallpaperEngine/Render/CObject.cpp
  56. 2 19
      src/WallpaperEngine/Render/CObject.h
  57. 4 1
      src/WallpaperEngine/Render/CTexture.cpp
  58. 1 0
      src/WallpaperEngine/Render/CTexture.h
  59. 10 8
      src/WallpaperEngine/Render/CWallpaper.cpp
  60. 6 21
      src/WallpaperEngine/Render/CWallpaper.h
  61. 5 5
      src/WallpaperEngine/Render/Camera.cpp
  62. 2 2
      src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h
  63. 136 127
      src/WallpaperEngine/Render/Objects/CImage.cpp
  64. 16 16
      src/WallpaperEngine/Render/Objects/CImage.h
  65. 164 175
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  66. 2 1
      src/WallpaperEngine/Render/Objects/CParticle.h
  67. 2 0
      src/WallpaperEngine/Render/Objects/CRenderable.cpp
  68. 2 2
      src/WallpaperEngine/Render/Objects/CRenderable.h
  69. 95 52
      src/WallpaperEngine/Render/Objects/CText.cpp
  70. 5 4
      src/WallpaperEngine/Render/Objects/CText.h
  71. 186 78
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  72. 15 9
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  73. 10 4
      src/WallpaperEngine/Render/RenderContext.cpp
  74. 8 2
      src/WallpaperEngine/Render/RenderContext.h
  75. 7 7
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  76. 30 376
      src/WallpaperEngine/Render/TextureCache.cpp
  77. 9 0
      src/WallpaperEngine/Render/TextureCache.h
  78. 6 2
      src/WallpaperEngine/Render/TextureProvider.h
  79. 24 111
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  80. 4 5
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  81. 4 1
      src/WallpaperEngine/Render/Wallpapers/CVideo.cpp
  82. 30 0
      src/WallpaperEngine/Scripting/Adapters/ObjectAdapter.cpp
  83. 32 0
      src/WallpaperEngine/Scripting/Adapters/ObjectAdapter.h
  84. 90 0
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
  85. 17 0
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h
  86. 1005 0
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
  87. 34 0
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.h
  88. 69 0
      src/WallpaperEngine/Scripting/ConsoleObject.cpp
  89. 25 0
      src/WallpaperEngine/Scripting/ConsoleObject.h
  90. 291 0
      src/WallpaperEngine/Scripting/EngineObject.cpp
  91. 47 0
      src/WallpaperEngine/Scripting/EngineObject.h
  92. 66 0
      src/WallpaperEngine/Scripting/InputObject.cpp
  93. 25 0
      src/WallpaperEngine/Scripting/InputObject.h
  94. 266 0
      src/WallpaperEngine/Scripting/Modules/ColorModule.cpp
  95. 16 0
      src/WallpaperEngine/Scripting/Modules/ColorModule.h
  96. 94 0
      src/WallpaperEngine/Scripting/Modules/MathModule.cpp
  97. 16 0
      src/WallpaperEngine/Scripting/Modules/MathModule.h
  98. 12 0
      src/WallpaperEngine/Scripting/Modules/ScriptModule.cpp
  99. 26 0
      src/WallpaperEngine/Scripting/Modules/ScriptModule.h
  100. 310 0
      src/WallpaperEngine/Scripting/SceneObject.cpp

+ 47 - 95
.github/workflows/cmake.yml

@@ -20,17 +20,17 @@ on:
   workflow_dispatch:
 
 jobs:
-#  formatting:
-#    name: Formatting check
-#    runs-on: ubuntu-latest
-#    steps:
-#      - uses: actions/checkout@v4
-#      - name: Run clang-format style check for C/C++ programs.
-#        uses: jidicula/clang-format-action@v4.16.0
-#        with:
-#          check-path: "src"
-#          exclude-regex: "(src/External)"
-#      - run: echo "Formatted with clang-format version ${{ steps.clang-format.outputs.clang-format-version }}"
+  formatting:
+    name: Formatting check
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Run clang-format style check for C/C++ programs.
+        uses: jidicula/clang-format-action@v4.18.0
+        with:
+          check-path: "src"
+          exclude-regex: "(src/External)"
+      - run: echo "Formatted with clang-format version ${{ steps.clang-format.outputs.clang-format-version }}"
 
   tests:
     #needs: formatting
@@ -54,10 +54,9 @@ jobs:
       - name: ccache
         uses: hendrikmuhs/ccache-action@v1.2
         with:
-          key: ${{ github.job }}-${{ matrix.os }}  # Eg. "some_build-ubuntu-latest"
+          key: ${{ github.job }}-${{ matrix.os }}
 
       - name: Install dependencies
-        if: matrix.os == 'ubuntu-24.04'
         run: sudo apt-get update && sudo apt-get -y install libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libfftw3-dev libfreetype-dev
 
       - name: Configure CMake
@@ -67,53 +66,13 @@ jobs:
 
       - name: Build
         # Build your program with the given configuration
-        run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}} --target tests
+        run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}} --target tests --verbose
 
       - name: Run tests
         # Run the unit tests available
         run: ${{github.workspace}}/build/output/tests
 
-
-  build-x11:
-    needs: tests
-    strategy:
-      matrix:
-        os: [ubuntu-22.04, ubuntu-24.04]
-        build_type: [Release, Debug]
-
-    # The CMake configure and build commands are platform agnostic and should work equally well on Windows or Mac.
-    # You can convert this to a matrix build if you need cross-platform coverage.
-    # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
-    runs-on: ${{ matrix.os }}
-
-    steps:
-    - uses: actions/checkout@v3
-      with:
-        submodules: true
-
-    # cache some build information
-    - name: ccache
-      uses: hendrikmuhs/ccache-action@v1.2
-      with:
-        key: ${{ github.job }}-${{ matrix.os }}-${{ matrix.build_type }}  # Eg. "some_build-ubuntu-latest"
-
-    - name: Install dependencies
-      if: matrix.os == 'ubuntu-22.04'
-      run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
-    - name: Install dependencies
-      if: matrix.os == 'ubuntu-24.04'
-      run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
-
-    - name: Configure CMake
-      # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
-      # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type
-      run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{matrix.build_type}} -D CMAKE_C_COMPILER_LAUNCHER=ccache -D CMAKE_CXX_COMPILER_LAUNCHER=ccache
-
-    - name: Build
-      # Build your program with the given configuration
-      run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}}
-
-  build-x11-wayland:
+  build:
     needs: tests
     permissions:
       actions: read
@@ -122,12 +81,11 @@ jobs:
 
     strategy:
       matrix:
-        os: [ubuntu-22.04, ubuntu-24.04]
         build_type: [Release, Debug]
     # The CMake configure and build commands are platform agnostic and should work equally well on Windows or Mac.
     # You can convert this to a matrix build if you need cross-platform coverage.
     # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
-    runs-on: ${{ matrix.os }}
+    runs-on: ubuntu-24.04
 
     steps:
       - uses: actions/checkout@v3
@@ -137,21 +95,16 @@ jobs:
       - name: ccache
         uses: hendrikmuhs/ccache-action@v1.2
         with:
-          key: ${{ github.job }}-${{ matrix.os }}-${{ matrix.build_type }}  # Eg. "some_build-ubuntu-latest"
+          key: ${{ github.job }}-ubuntu-24.04-${{ matrix.build_type }}
 
       - name: Initialize CodeQL
         # Initializes the CodeQL tools for scanning.
         uses: github/codeql-action/init@v3
-        if: ${{ matrix.os == 'ubuntu-24.04' && matrix.build_type == 'Debug' }}
+        if: ${{ matrix.build_type == 'Debug' }}
         with:
           languages: 'cpp'
 
       - name: Install dependencies
-        if: matrix.os == 'ubuntu-22.04'
-        run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev wayland-scanner++ wayland-protocols libwayland-dev
-
-      - name: Install dependencies
-        if: matrix.os == 'ubuntu-24.04'
         run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev wayland-scanner++ wayland-protocols libwayland-dev
 
       - name: Configure CMake
@@ -161,46 +114,45 @@ jobs:
 
       - name: Build
         # Build your program with the given configuration
-        run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}}
+        run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}} --verbose
 
       - name: Perform CodeQL Analysis
-        if: ${{ matrix.os == 'ubuntu-24.04' && matrix.build_type == 'Debug' }}
+        if: ${{ matrix.build_type == 'Debug' }}
         uses: github/codeql-action/analyze@v3
 
-  build-wayland:
+  clang-build:
     needs: tests
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
     strategy:
       matrix:
-        os: [ubuntu-22.04, ubuntu-24.04]
-        build_type: [Release, Debug]
+        build_type: [Debug, Release]
     # The CMake configure and build commands are platform agnostic and should work equally well on Windows or Mac.
     # You can convert this to a matrix build if you need cross-platform coverage.
     # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
-    runs-on: ${{ matrix.os }}
+    runs-on: ubuntu-24.04
 
     steps:
-    - uses: actions/checkout@v3
-      with:
-        submodules: true
-
-    - name: ccache
-      uses: hendrikmuhs/ccache-action@v1.2
-      with:
-        key: ${{ github.job }}-${{ matrix.os }}-${{ matrix.build_type }}  # Eg. "some_build-ubuntu-latest"
-
-    - name: Install dependencies
-      if: matrix.os == 'ubuntu-22.04'
-      run: sudo apt-get update && sudo apt-get -y install wayland-scanner++ wayland-protocols libwayland-dev libwayland-egl-backend-dev libglfw3-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libmpv-dev mpv libmpv1 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
-
-    - name: Install dependencies
-      if: matrix.os == 'ubuntu-24.04'
-      run: sudo apt-get update && sudo apt-get -y install wayland-scanner++ wayland-protocols libwayland-dev libwayland-egl-backend-dev libglfw3-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev
-
-    - name: Configure CMake
-      # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
-      # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type
-      run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{matrix.build_type}} -D CMAKE_C_COMPILER_LAUNCHER=ccache -D CMAKE_CXX_COMPILER_LAUNCHER=ccache
-
-    - name: Build
-      # Build your program with the given configuration
-      run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}}
+      - uses: actions/checkout@v3
+        with:
+          submodules: true
+
+      - name: ccache
+        uses: hendrikmuhs/ccache-action@v1.2
+        with:
+          key: ${{ github.job }}-ubuntu-24.04-${{ matrix.build_type }}
+
+      - name: Install dependencies
+        run: sudo apt-get update && sudo apt-get -y install libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libxxf86vm-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libpulse-dev libpulse0 libfftw3-dev libfreetype-dev wayland-scanner++ wayland-protocols libwayland-dev
+
+      - name: Configure CMake
+        # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
+        # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type
+        run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{matrix.build_type}} -D CMAKE_C_COMPILER_LAUNCHER=ccache -D CMAKE_CXX_COMPILER_LAUNCHER=ccache -D CMAKE_C_COMPILER=clang -D CMAKE_CXX_COMPILER=clang++
+
+      - name: Build
+        # Build your program with the given configuration
+        run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}} --verbose

+ 63 - 3
CMakeLists.txt

@@ -28,6 +28,7 @@ find_package(X11)
 find_package(OpenGL REQUIRED)
 find_package(GLEW REQUIRED)
 
+find_package(DBus REQUIRED)
 find_package(GLUT REQUIRED)
 find_package(ZLIB REQUIRED)
 find_package(SDL2 REQUIRED)
@@ -275,7 +276,22 @@ set_target_properties(ceflib
 ADD_LOGICAL_TARGET(libcef_lib "${CEF_LIB_DEBUG}" "${CEF_LIB_RELEASE}")
 PRINT_CEF_CONFIG()
 
+# Embed scene-script builtins (builtins.js) into a generated header, so the JS can
+# live in its own file (lint/syntax-highlight friendly) instead of an inline literal.
+# Added to the global include_directories so both the library and the tests target
+# (which recompiles the common sources) can find the generated header.
+set(WPENGINE_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated")
+set(WPENGINE_BUILTINS_JS "${CMAKE_CURRENT_SOURCE_DIR}/src/WallpaperEngine/Scripting/resources/builtins.js")
+file(READ "${WPENGINE_BUILTINS_JS}" WPENGINE_SCRIPT_BUILTINS)
+configure_file(
+    "${CMAKE_CURRENT_SOURCE_DIR}/src/WallpaperEngine/Scripting/resources/Builtins.generated.h.in"
+    "${WPENGINE_GENERATED_DIR}/WallpaperEngine/Scripting/Builtins.generated.h"
+    @ONLY)
+# re-run configuration (regenerating the header) whenever the .js changes
+set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${WPENGINE_BUILTINS_JS}")
+
 include_directories(
+    ${WPENGINE_GENERATED_DIR}
     src
     src/External/glslang-WallpaperEngine
     src/External/SPIRV-Cross-WallpaperEngine
@@ -291,7 +307,8 @@ include_directories(
     ${PULSEAUDIO_INCLUDE_DIR}
     ${CEF_INCLUDE_PATH}
     ${CMAKE_SOURCE_DIR}
-    ${X11_INCLUDES})
+    ${X11_INCLUDES}
+    ${DBUS_INCLUDE_DIRS})
 
 set(COMMON_SOURCES
     src/External/MimeTypes/MimeTypes.cpp
@@ -325,6 +342,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/FileSystem/Adapters/Package.h
     src/WallpaperEngine/FileSystem/Adapters/Virtual.cpp
     src/WallpaperEngine/FileSystem/Adapters/Virtual.h
+    src/WallpaperEngine/FileSystem/Adapters/MediaCover.h
+    src/WallpaperEngine/FileSystem/Adapters/MediaCover.cpp
 
     src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp
     src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.h
@@ -351,6 +370,11 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Input/Drivers/GLFWMouseInput.cpp
     src/WallpaperEngine/Input/Drivers/GLFWMouseInput.h
 
+    src/WallpaperEngine/Media/MediaSource.h
+    src/WallpaperEngine/Media/MediaSource.cpp
+    src/WallpaperEngine/Media/DBusMediaSource.h
+    src/WallpaperEngine/Media/DBusMediaSource.cpp
+
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariable.h
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariable.cpp
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariableFloat.h
@@ -418,6 +442,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/CObject.cpp
     src/WallpaperEngine/Render/CTexture.cpp
     src/WallpaperEngine/Render/CTexture.h
+    src/WallpaperEngine/Render/AlbumTexture.h
+    src/WallpaperEngine/Render/AlbumTexture.cpp
     src/WallpaperEngine/Render/TextureProvider.h
 
     src/WallpaperEngine/Render/Objects/CRenderable.cpp
@@ -470,6 +496,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Utils/BinaryReader.h
     src/WallpaperEngine/Data/Utils/MemoryStream.h
     src/WallpaperEngine/Data/Utils/SFINAE.h
+    src/WallpaperEngine/Data/Parsers/DynamicValueParser.h
+    src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp
     src/WallpaperEngine/Data/Parsers/EffectParser.cpp
     src/WallpaperEngine/Data/Parsers/EffectParser.h
     src/WallpaperEngine/Data/Parsers/ProjectParser.cpp
@@ -492,6 +520,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Parsers/PropertyParser.h
     src/WallpaperEngine/Data/Parsers/TextureParser.cpp
     src/WallpaperEngine/Data/Parsers/TextureParser.h
+    src/WallpaperEngine/Data/Builders/ColorBuilder.h
+    src/WallpaperEngine/Data/Builders/ColorBuilder.cpp
     src/WallpaperEngine/Data/Builders/UserSettingBuilder.h
     src/WallpaperEngine/Data/Builders/VectorBuilder.cpp
     src/WallpaperEngine/Data/Builders/VectorBuilder.h
@@ -503,10 +533,38 @@ set(COMMON_SOURCES
     src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
     src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.cpp
     src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.h
+
+    src/WallpaperEngine/Scripting/Adapters/ObjectAdapter.h
+    src/WallpaperEngine/Scripting/Adapters/ObjectAdapter.cpp
+    src/WallpaperEngine/Scripting/Adapters/VectorAdapter.h
+    src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
+    src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h
+    src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
+
+    src/WallpaperEngine/Scripting/Modules/ColorModule.h
+    src/WallpaperEngine/Scripting/Modules/ColorModule.cpp
+    src/WallpaperEngine/Scripting/Modules/MathModule.h
+    src/WallpaperEngine/Scripting/Modules/MathModule.cpp
+    src/WallpaperEngine/Scripting/Modules/ScriptModule.h
+    src/WallpaperEngine/Scripting/Modules/ScriptModule.cpp
+
+    src/WallpaperEngine/Scripting/ConsoleObject.h
+    src/WallpaperEngine/Scripting/ConsoleObject.cpp
+    src/WallpaperEngine/Scripting/EngineObject.h
+    src/WallpaperEngine/Scripting/EngineObject.cpp
+    src/WallpaperEngine/Scripting/InputObject.h
+    src/WallpaperEngine/Scripting/InputObject.cpp
+    src/WallpaperEngine/Scripting/SceneObject.h
+    src/WallpaperEngine/Scripting/SceneObject.cpp
     src/WallpaperEngine/Scripting/ScriptEngine.h
+    src/WallpaperEngine/Scripting/ScriptPropertiesObject.h
+    src/WallpaperEngine/Scripting/ScriptPropertiesObject.cpp
     src/WallpaperEngine/Scripting/ScriptEngine.cpp
-    src/WallpaperEngine/Data/Model/ScriptedDynamicValue.h
-    src/WallpaperEngine/Data/Model/ScriptedDynamicValue.cpp)
+    src/WallpaperEngine/Scripting/ScriptableObject.h
+    src/WallpaperEngine/Scripting/ScriptableObject.cpp
+
+    src/WallpaperEngine/Maths.h
+    src/WallpaperEngine/Maths.cpp)
 
 if(BUILD_TESTING)
     add_executable(
@@ -546,6 +604,7 @@ target_link_libraries (linux-wallpaperengine-lib PUBLIC
     Freetype::Freetype
     ${WAYLAND_LIBRARIES}
     ${X11_LIBRARIES}
+    ${DBUS_LIBRARIES}
     kissfft
     glslang
     spirv-cross-core
@@ -571,6 +630,7 @@ if (BUILD_TESTING)
         Freetype::Freetype
         ${WAYLAND_LIBRARIES}
         ${X11_LIBRARIES}
+        ${DBUS_LIBRARIES}
         kissfft
         glslang
         spirv-cross-core

+ 59 - 0
CMakeModules/FindDBus.cmake

@@ -0,0 +1,59 @@
+# - Try to find DBus
+# Once done, this will define
+#
+#  DBUS_FOUND - system has DBus
+#  DBUS_INCLUDE_DIRS - the DBus include directories
+#  DBUS_LIBRARIES - link these to use DBus
+#
+# Copyright (C) 2012 Raphael Kubo da Costa <rakuco@webkit.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND ITS CONTRIBUTORS ``AS
+# IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR ITS
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+FIND_PACKAGE(PkgConfig)
+PKG_CHECK_MODULES(PC_DBUS QUIET dbus-1)
+
+FIND_LIBRARY(DBUS_LIBRARIES
+    NAMES dbus-1
+    HINTS ${PC_DBUS_LIBDIR}
+          ${PC_DBUS_LIBRARY_DIRS}
+)
+
+FIND_PATH(DBUS_INCLUDE_DIR
+    NAMES dbus/dbus.h
+    HINTS ${PC_DBUS_INCLUDEDIR}
+          ${PC_DBUS_INCLUDE_DIRS}
+)
+
+GET_FILENAME_COMPONENT(_DBUS_LIBRARY_DIR ${DBUS_LIBRARIES} PATH)
+FIND_PATH(DBUS_ARCH_INCLUDE_DIR
+    NAMES dbus/dbus-arch-deps.h
+    HINTS ${PC_DBUS_INCLUDEDIR}
+          ${PC_DBUS_INCLUDE_DIRS}
+          ${_DBUS_LIBRARY_DIR}
+          ${DBUS_INCLUDE_DIR}
+    PATH_SUFFIXES include
+)
+
+SET(DBUS_INCLUDE_DIRS ${DBUS_INCLUDE_DIR} ${DBUS_ARCH_INCLUDE_DIR})
+
+INCLUDE(FindPackageHandleStandardArgs)
+FIND_PACKAGE_HANDLE_STANDARD_ARGS(DBUS REQUIRED_VARS DBUS_INCLUDE_DIRS DBUS_LIBRARIES)

+ 3 - 2
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -9,8 +9,8 @@
 #include <cstring>
 #include <fstream>
 #include <iostream>
-#include <sstream>
 #include <optional>
+#include <sstream>
 #include <stdexcept>
 #include <string_view>
 
@@ -604,7 +604,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 
     debuggingGroup.add_argument ("--render-debug")
 	.help (
-		    "Scene render debug mode: base-only, no-solid-final, pass-log, object=<id>, skip-object=<id>, or skip-effect=<id>. Can be repeated."
+	    "Scene render debug mode: base-only, no-solid-final, pass-log, object=<id>, skip-object=<id>, or "
+	    "skip-effect=<id>. Can be repeated."
 	)
 	.action ([this] (const std::string& value) -> void {
 	    const auto parseDebugId = [&value] (const std::string& prefix) -> std::optional<int> {

+ 114 - 98
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -15,6 +15,8 @@
 #include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Debugging/CallStack.h"
+#include "WallpaperEngine/FileSystem/Adapters/MediaCover.h"
+#include "WallpaperEngine/Media/DBusMediaSource.h"
 
 #if DEMOMODE
 #include "recording.h"
@@ -61,18 +63,27 @@ void CustomGLDebugCallback (
 }
 
 WallpaperApplication::WallpaperApplication (ApplicationContext& context) : m_context (context) {
+    this->initializeSubsystems ();
     this->loadBackgrounds ();
     this->setupProperties ();
     this->setupBrowser ();
     this->initializePlaylists ();
 }
 
+void WallpaperApplication::initializeSubsystems () {
+    // initialize player dbus (update every 2 seconds)
+    m_mediaSource = std::make_unique<WallpaperEngine::Media::DBusMediaSource> (std::chrono::milliseconds (2000));
+}
+
 AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string& bg) const {
     auto container = std::make_unique<Container> ();
 
     const std::filesystem::path path = bg;
 
+    container->registerAdapterFactory (std::make_unique<MediaCoverFactory> (*this->m_mediaSource));
+    container->mount ("$mediaThumbnail", "$mediaThumbnail");
     container->mount (path, "/");
+
     try {
 	container->mount (path / "scene.pkg", "/");
     } catch (std::runtime_error&) { }
@@ -497,7 +508,7 @@ void WallpaperApplication::setupPropertiesForProject (const Project& project) {
 	if (override != this->m_context.settings.general.properties.end ()) {
 	    sLog.out ("Applying override value for ", key);
 
-	    cur->update (override->second);
+	    cur->update (override->second, DynamicValue::UpdateSource::User);
 	}
 
 	if (this->m_context.settings.general.onlyListProperties) {
@@ -710,7 +721,8 @@ void WallpaperApplication::setupAudio () {
 
 void WallpaperApplication::prepareOutputs () {
     // initialize render context
-    m_renderContext = std::make_unique<WallpaperEngine::Render::RenderContext> (*m_videoDriver, *this);
+    m_renderContext
+	= std::make_unique<WallpaperEngine::Render::RenderContext> (*m_videoDriver, *this, *this->m_mediaSource);
     // create a new background for each screen
 
     // set all the specific wallpapers required (skip span group synthetic keys)
@@ -764,8 +776,10 @@ void WallpaperApplication::prepareOutputs () {
 	    const int y = vp->globalPosition.y;
 	    const int w = vp->logicalSize.x;
 	    const int h = vp->logicalSize.y;
-	    sLog.debug ("SPAN DEBUG prepareOutputs: screen '", screenName,
-		"' globalPos=(", x, ",", y, ") logicalSize=", w, "x", h);
+	    sLog.debug (
+		"SPAN DEBUG prepareOutputs: screen '", screenName, "' globalPos=(", x, ",", y, ") logicalSize=", w, "x",
+		h
+	    );
 	    minX = std::min (minX, x);
 	    minY = std::min (minY, y);
 	    maxX = std::max (maxX, x + w);
@@ -777,7 +791,9 @@ void WallpaperApplication::prepareOutputs () {
 	    continue;
 	}
 
-	sLog.debug ("SPAN DEBUG prepareOutputs: bounding box=(", minX, ",", minY, ",", maxX - minX, ",", maxY - minY, ")");
+	sLog.debug (
+	    "SPAN DEBUG prepareOutputs: bounding box=(", minX, ",", minY, ",", maxX - minX, ",", maxY - minY, ")"
+	);
 
 	WallpaperEngine::Render::CWallpaper::SpanInfo spanInfo;
 	spanInfo.totalBounds = { minX, minY, maxX - minX, maxY - minY };
@@ -840,115 +856,117 @@ void WallpaperApplication::render () {
     static time_t seconds;
     static struct tm* timeinfo;
 
-	if (this->m_isPaused) {
-		usleep (FULLSCREEN_CHECK_WAIT_TIME);
-		if (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning) {
-			return;
-		}
-	    m_renderContext->setPause (false);
+    if (this->m_isPaused) {
+	usleep (FULLSCREEN_CHECK_WAIT_TIME);
+	if (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning) {
+	    return;
+	}
+	m_renderContext->setPause (false);
 
-	    // account for paused duration in playlist timers
-	    const auto pausedNow = std::chrono::steady_clock::now ();
-	    const auto pausedDuration = pausedNow - this->m_pauseStart;
+	// account for paused duration in playlist timers
+	const auto pausedNow = std::chrono::steady_clock::now ();
+	const auto pausedDuration = pausedNow - this->m_pauseStart;
 
-	    for (auto& [_, playlist] : this->m_activePlaylists) {
-		if (!playlist.definition.settings.updateOnPause) {
-		    playlist.nextSwitch += pausedDuration;
-		    playlist.lastUpdate += pausedDuration;
-		}
+	for (auto& [_, playlist] : this->m_activePlaylists) {
+	    if (!playlist.definition.settings.updateOnPause) {
+		playlist.nextSwitch += pausedDuration;
+		playlist.lastUpdate += pausedDuration;
 	    }
+	}
 
-	    this->m_isPaused = false;
-	} else {
-		// update g_Daytime
-		time (&seconds);
-		timeinfo = localtime (&seconds);
-		g_Daytime = static_cast<float>((timeinfo->tm_hour * 60) + timeinfo->tm_min) / (24.0f * 60.0f);
-
-		// keep track of the previous frame's time
-		g_TimeLast = g_Time;
-		// calculate the current time value
-		g_Time = m_videoDriver->getRenderTime ();
-		// update audio recorder
-		m_audioDriver->update ();
-		// update input information
-		m_videoDriver->getInputContext ().update ();
-		// process driver events
-		m_videoDriver->dispatchEventQueue ();
-
-		if (m_videoDriver->closeRequested ()) {
-			sLog.out ("Stop requested by driver");
-			this->m_context.state.general.keepRunning = false;
-		}
+	this->m_isPaused = false;
+    } else {
+	// update g_Daytime
+	time (&seconds);
+	timeinfo = localtime (&seconds);
+	g_Daytime = static_cast<float> ((timeinfo->tm_hour * 60) + timeinfo->tm_min) / (24.0f * 60.0f);
+
+	// keep track of the previous frame's time
+	g_TimeLast = g_Time;
+	// calculate the current time value
+	g_Time = m_videoDriver->getRenderTime ();
+	// update audio recorder
+	m_audioDriver->update ();
+	// update the media source
+	m_mediaSource->update ();
+	// update input information
+	m_videoDriver->getInputContext ().update ();
+	// process driver events
+	m_videoDriver->dispatchEventQueue ();
+
+	if (m_videoDriver->closeRequested ()) {
+	    sLog.out ("Stop requested by driver");
+	    this->m_context.state.general.keepRunning = false;
+	}
 
 #if DEMOMODE
-		// wait for a full render cycle before actually starting
-		// this gives some extra time for video and web decoders to set themselves up
-		// because of size changes
-		if (m_videoDriver->getFrameCounter () > (uint32_t)this->m_context.settings.render.maximumFPS) {
-			if (!initialized) {
-			width = this->m_renderContext->getWallpapers ().begin ()->second->getWidth ();
-			height = this->m_renderContext->getWallpapers ().begin ()->second->getHeight ();
-			pixels.reserve (width * height * 3);
-			init_encoder ("output.webm", width, height);
-			initialized = true;
-			}
-
-			glBindFramebuffer (
-			GL_FRAMEBUFFER, this->m_renderContext->getWallpapers ().begin ()->second->getWallpaperFramebuffer ()
-			);
-
-			glPixelStorei (GL_PACK_ALIGNMENT, 1);
-			glReadPixels (0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels.data ());
-			write_video_frame (pixels.data ());
-			frame++;
-
-			// stop after the given framecount
-			if (frame >= FRAME_COUNT) {
-			this->m_context.state.general.keepRunning = false;
-			}
-		}
-#endif /* DEMOMODE */
-		// check for fullscreen windows and wait until there's none fullscreen
-		if (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning) {
-			this->m_isPaused = true;
-			this->m_pauseStart = std::chrono::steady_clock::now ();
+	// wait for a full render cycle before actually starting
+	// this gives some extra time for video and web decoders to set themselves up
+	// because of size changes
+	if (m_videoDriver->getFrameCounter () > (uint32_t)this->m_context.settings.render.maximumFPS) {
+	    if (!initialized) {
+		width = this->m_renderContext->getWallpapers ().begin ()->second->getWidth ();
+		height = this->m_renderContext->getWallpapers ().begin ()->second->getHeight ();
+		pixels.reserve (width * height * 3);
+		init_encoder ("output.webm", width, height);
+		initialized = true;
+	    }
 
-			m_renderContext->setPause (true);
-			return;
-		}
-	}
+	    glBindFramebuffer (
+		GL_FRAMEBUFFER, this->m_renderContext->getWallpapers ().begin ()->second->getWallpaperFramebuffer ()
+	    );
 
-	this->updatePlaylists ();
+	    glPixelStorei (GL_PACK_ALIGNMENT, 1);
+	    glReadPixels (0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels.data ());
+	    write_video_frame (pixels.data ());
+	    frame++;
 
-	if (!this->m_context.settings.screenshot.take || this->m_screenShotTaken == true) {
-	    return;
+	    // stop after the given framecount
+	    if (frame >= FRAME_COUNT) {
+		this->m_context.state.general.keepRunning = false;
+	    }
 	}
+#endif /* DEMOMODE */
+	// check for fullscreen windows and wait until there's none fullscreen
+	if (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning) {
+	    this->m_isPaused = true;
+	    this->m_pauseStart = std::chrono::steady_clock::now ();
 
-	if (this->m_videoDriver->getFrameCounter () < this->m_nextFrameScreenshot) {
+	    m_renderContext->setPause (true);
 	    return;
 	}
+    }
 
-	this->takeScreenshot (this->m_context.settings.screenshot.path);
-	this->m_screenShotTaken = true;
+    this->updatePlaylists ();
+
+    if (!this->m_context.settings.screenshot.take || this->m_screenShotTaken == true) {
+	return;
+    }
+
+    if (this->m_videoDriver->getFrameCounter () < this->m_nextFrameScreenshot) {
+	return;
+    }
+
+    this->takeScreenshot (this->m_context.settings.screenshot.path);
+    this->m_screenShotTaken = true;
 }
 
 void WallpaperApplication::cleanup () {
-	sLog.out ("Stopping");
+    sLog.out ("Stopping");
 
-	#if DEMOMODE
-		close_encoder ();
-	#endif /* DEMOMODE */
+#if DEMOMODE
+    close_encoder ();
+#endif /* DEMOMODE */
 
-		SDL_Quit ();
+    SDL_Quit ();
 }
 
 void WallpaperApplication::show () {
-	setup();
+    setup ();
     while (this->m_context.state.general.keepRunning) {
-		render();
+	render ();
     }
-    cleanup();
+    cleanup ();
 }
 
 void WallpaperApplication::update (Render::Drivers::Output::OutputViewport* viewport) {
@@ -972,13 +990,11 @@ const WallpaperEngine::Render::Drivers::Output::Output& WallpaperApplication::ge
 }
 
 void WallpaperApplication::setDestinationFramebuffer (GLuint framebuffer) {
-	this->m_destinationFramebuffer = framebuffer;
-	// Update all wallpapers with the new destination framebuffer
-	for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
-		wallpaper->setDestinationFramebuffer (framebuffer);
-	};
+    this->m_destinationFramebuffer = framebuffer;
+    // Update all wallpapers with the new destination framebuffer
+    for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	wallpaper->setDestinationFramebuffer (framebuffer);
+    };
 }
 
-GLuint WallpaperApplication::getDestinationFramebuffer () const { 
-	return this->m_destinationFramebuffer;
-}
+GLuint WallpaperApplication::getDestinationFramebuffer () const { return this->m_destinationFramebuffer; }

+ 10 - 3
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -18,6 +18,7 @@
 #include "WallpaperEngine/WebBrowser/WebBrowserContext.h"
 
 #include "WallpaperEngine/Data/Model/Types.h"
+#include "WallpaperEngine/Media/MediaSource.h"
 
 #include <set>
 
@@ -35,15 +36,15 @@ public:
     /**
      * Prepares the application for rendering.
      */
-    void setup();
+    void setup ();
     /**
      * Renders a frame of the application.
      */
-    void render();
+    void render ();
     /**
      * Cleans up all the resources used by the application.
      */
-    static void cleanup();
+    static void cleanup ();
     /**
      * Shows the application until it's closed
      */
@@ -87,6 +88,11 @@ private:
      * @param bg
      */
     AssetLocatorUniquePtr setupAssetLocator (const std::string& bg) const;
+    /**
+     * Initializes subsystems required for application operation
+     */
+    void initializeSubsystems ();
+
     /**
      * Loads projects based off the settings
      */
@@ -169,6 +175,7 @@ private:
     std::unique_ptr<WallpaperEngine::Render::Drivers::VideoDriver> m_videoDriver = nullptr;
     std::unique_ptr<WallpaperEngine::Render::Drivers::Detectors::FullScreenDetector> m_fullScreenDetector = nullptr;
     std::unique_ptr<WallpaperEngine::WebBrowser::WebBrowserContext> m_browserContext = nullptr;
+    std::unique_ptr<WallpaperEngine::Media::MediaSource> m_mediaSource = nullptr;
     std::mt19937 m_playlistRng { std::random_device {}() };
     bool m_isPaused = false;
     bool m_screenShotTaken = false;

+ 69 - 0
src/WallpaperEngine/Data/Builders/ColorBuilder.cpp

@@ -0,0 +1,69 @@
+#include "ColorBuilder.h"
+#include "VectorBuilder.h"
+
+#include <algorithm>
+#include <format>
+#include <glm/vec3.hpp>
+
+const WallpaperEngine::Data::Model::Color WallpaperEngine::Data::Builders::ColorBuilder::White
+    = WallpaperEngine::Data::Model::Color (1.0f, 1.0f, 1.0f, 1.0f);
+const WallpaperEngine::Data::Model::Color WallpaperEngine::Data::Builders::ColorBuilder::Black
+    = WallpaperEngine::Data::Model::Color (0.0f, 0.0f, 0.0f, 1.0f);
+
+WallpaperEngine::Data::Model::Color
+WallpaperEngine::Data::Builders::ColorBuilder::parse (const std::string& value, float alpha) {
+    auto copy = value;
+
+    // replace the actual separators with spaces to normalize them
+    if (copy.find (',') != std::string::npos) {
+	// replace comma separator with spaces so it's
+	std::ranges::replace (copy, ',', ' ');
+    }
+
+    // hex colors should be converted to int colors
+    if (copy.find ('#') == 0) {
+	auto number = copy.substr (1);
+
+	// expand short css notation into the right one
+	// support for css notation
+	if (number.size () == 3) {
+	    number = std::format (
+		"{}{}{}{}{}{}{:02x}", number.at (0), number.at (0), number.at (1), number.at (1), number.at (2),
+		number.at (2), static_cast<int> (alpha * 255)
+	    );
+	} else if (number.size () == 4) {
+	    number = std::format (
+		"{}{}{}{}{}{}{}{}", number.at (0), number.at (0), number.at (1), number.at (1), number.at (2),
+		number.at (2), number.at (3), number.at (3)
+	    );
+	} else if (number.size () != 6 && number.size () != 8) {
+	    sLog.exception ("Invalid CSS color notation for ", value);
+	}
+
+	// parse hex color
+	const auto color = std::stoi (number, nullptr, 16);
+
+	return WallpaperEngine::Data::Model::Color (
+	    (color >> 24 & 0xFF) / 255.0f, (color >> 16 & 0xFF) / 255.0f, (color >> 8 & 0xFF) / 255.0f,
+	    (color & 0xFF) / 255.0f
+	);
+    }
+
+    int vectorSize = VectorBuilder::preparseSize (copy);
+
+    if (vectorSize != 3 && vectorSize != 4) {
+	throw std::invalid_argument ("Invalid color value");
+    }
+
+    if (copy.find ('.') == std::string::npos) {
+	const auto final = vectorSize == 3 ? glm::ivec4 (VectorBuilder::parse<glm::ivec3> (copy), alpha * 255)
+					   : VectorBuilder::parse<glm::ivec4> (copy);
+
+	return { final.r / 255.0f, final.g / 255.0f, final.b / 255.0f, final.a / 255.0f };
+    }
+
+    return Model::Color (
+	vectorSize == 3 ? glm::vec4 (VectorBuilder::parse<glm::vec3> (copy), alpha)
+			: VectorBuilder::parse<glm::vec4> (copy)
+    );
+}

+ 22 - 0
src/WallpaperEngine/Data/Builders/ColorBuilder.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include "WallpaperEngine/Data/Model/Color.h"
+
+#include <glm/vec4.hpp>
+#include <string>
+
+namespace WallpaperEngine::Data::Builders {
+class ColorBuilder {
+public:
+    /**
+     * White color constant
+     */
+    static const Model::Color White;
+    /**
+     * Black color constant
+     */
+    static const Model::Color Black;
+
+    static WallpaperEngine::Data::Model::Color parse (const std::string& value, float alpha = 1.0f);
+};
+}

+ 1 - 1
src/WallpaperEngine/Data/Builders/VectorBuilder.h

@@ -38,7 +38,7 @@ public:
 	const char* third = second ? strchr (second + 1, ' ') : nullptr;
 
 	if (first == nullptr) {
-	    return 0;
+	    return 1;
 	}
 
 	if (second == nullptr) {

+ 7 - 1
src/WallpaperEngine/Data/JSON.cpp

@@ -10,4 +10,10 @@ UserSettingUniquePtr JsonExtensions::user (const std::string& key, const Propert
     const auto value = this->require (key, "User setting without default value must be present");
 
     return UserSettingParser::parse (value, properties);
-}
+}
+
+UserSettingUniquePtr JsonExtensions::color (const std::string& key, const Properties& properties) const {
+    const auto value = this->require (key, "User setting without default value must be present");
+
+    return UserSettingParser::parse (value, properties, true);
+}

+ 17 - 0
src/WallpaperEngine/Data/JSON.h

@@ -1,5 +1,7 @@
 #pragma once
 
+#include "Builders/ColorBuilder.h"
+
 #include <glm/detail/qualifier.hpp>
 #include <glm/detail/type_vec1.hpp>
 #include <nlohmann/json.hpp>
@@ -45,6 +47,7 @@ public:
     [[nodiscard]] glm::vec<length, type, qualifier> get () const {
 	return VectorBuilder::parse<length, type, qualifier> (this->base ().get<std::string> ());
     }
+    [[nodiscard]] Model::Color get () const { return ColorBuilder::parse (this->base ().get<std::string> ()); }
     [[nodiscard]] base_type require (const std::string& key, const std::string& message) const {
 	auto base = this->base ();
 	const auto it = base.find (key);
@@ -100,6 +103,7 @@ public:
     template <typename T>
     [[nodiscard]] UserSettingUniquePtr
     user (const std::string& key, const Properties& properties, T defaultValue) const {
+	static_assert (std::is_same_v<T, Color> == false, "Use color() for color properties");
 	const auto value = this->optional (key);
 
 	if (!value.has_value ()) {
@@ -110,6 +114,19 @@ public:
 	// this resolving the include loop
 	return this->user (key, properties);
     }
+    [[nodiscard]] UserSettingUniquePtr color (const std::string& key, const Properties& properties) const;
+    [[nodiscard]] UserSettingUniquePtr
+    color (const std::string& key, const Properties& properties, Color defaultValue) const {
+	const auto value = this->optional (key);
+
+	if (!value.has_value ()) {
+	    return UserSettingBuilder::fromValue<Color> (defaultValue);
+	}
+
+	// performs a second lookup, but handles the actual call to UserSettingParser outside of this header
+	// this resolving the include loop
+	return this->color (key, properties);
+    }
 
     template <int length, typename type, glm::qualifier qualifier> operator glm::vec<length, type, qualifier> () const {
 	return get<length, type, qualifier> ();

+ 21 - 0
src/WallpaperEngine/Data/Model/Color.h

@@ -0,0 +1,21 @@
+#pragma once
+#include <glm/vec4.hpp>
+
+namespace WallpaperEngine::Data::Model {
+/**
+ * Representation of a color using RGBA components in the 0.0f - 1.0f range
+ */
+struct Color : private glm::vec4 {
+    using glm::vec4::vec4;
+    using glm::vec4::operator=;
+    using glm::vec4::operator+=;
+    using glm::vec4::operator-=;
+    using glm::vec4::operator*=;
+    using glm::vec4::operator/=;
+    explicit Color (const glm::vec4& v) : glm::vec4 (v) { }
+    using glm::vec4::a;
+    using glm::vec4::b;
+    using glm::vec4::g;
+    using glm::vec4::r;
+};
+}

+ 76 - 131
src/WallpaperEngine/Data/Model/DynamicValue.cpp

@@ -1,29 +1,44 @@
 #include "DynamicValue.h"
+#include "UserSetting.h"
+
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
 
 using namespace WallpaperEngine::Data::Model;
 
-DynamicValue::DynamicValue (const glm::ivec4& value) { this->DynamicValue::update (value); }
-
-DynamicValue::DynamicValue (const glm::ivec3& value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (const DynamicValue& other) {
+    this->DynamicValue::update (other, UpdateSource::Initialization);
+}
 
-DynamicValue::DynamicValue (const glm::ivec2& value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (const glm::vec4& value) {
+    this->DynamicValue::update (value, UpdateSource::Initialization);
+}
 
-DynamicValue::DynamicValue (const glm::vec4& value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (const glm::vec3& value) {
+    this->DynamicValue::update (value, UpdateSource::Initialization);
+}
 
-DynamicValue::DynamicValue (const glm::vec3& value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (const glm::vec2& value) {
+    this->DynamicValue::update (value, UpdateSource::Initialization);
+}
 
-DynamicValue::DynamicValue (const glm::vec2& value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (float value) { this->DynamicValue::update (value, UpdateSource::Initialization); }
 
-DynamicValue::DynamicValue (float value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (int value) { this->DynamicValue::update (value, UpdateSource::Initialization); }
 
-DynamicValue::DynamicValue (int value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (bool value) { this->DynamicValue::update (value, UpdateSource::Initialization); }
 
-DynamicValue::DynamicValue (bool value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (const std::string& value) {
+    this->DynamicValue::update (value, UpdateSource::Initialization);
+}
 
-DynamicValue::DynamicValue (const std::string& value) { this->DynamicValue::update (value); }
+DynamicValue::DynamicValue (const Model::Color& value) {
+    this->DynamicValue::update (value, UpdateSource::Initialization);
+}
 
 DynamicValue::~DynamicValue () {
+    // TODO: PROPERLY FIX THESE
     if (this->m_aliveFlag) {
 	*this->m_aliveFlag = false;
     }
@@ -31,12 +46,6 @@ DynamicValue::~DynamicValue () {
     this->m_listeners.clear ();
 }
 
-const glm::ivec4& DynamicValue::getIVec4 () const { return this->m_ivec4; }
-
-const glm::ivec3& DynamicValue::getIVec3 () const { return this->m_ivec3; }
-
-const glm::ivec2& DynamicValue::getIVec2 () const { return this->m_ivec2; }
-
 const glm::vec4& DynamicValue::getVec4 () const { return this->m_vec4; }
 
 const glm::vec3& DynamicValue::getVec3 () const { return this->m_vec3; }
@@ -69,14 +78,6 @@ std::string DynamicValue::toString () const {
 	case UnderlyingType::Vec4:
 	    return std::to_string (this->m_vec4.x) + ", " + std::to_string (this->m_vec4.y) + ", "
 		+ std::to_string (this->m_vec4.z) + ", " + std::to_string (this->m_vec4.w);
-	case UnderlyingType::IVec2:
-	    return std::to_string (this->m_ivec2.x) + ", " + std::to_string (this->m_ivec2.y);
-	case UnderlyingType::IVec3:
-	    return std::to_string (this->m_ivec3.x) + ", " + std::to_string (this->m_ivec3.y) + ", "
-		+ std::to_string (this->m_ivec3.z);
-	case UnderlyingType::IVec4:
-	    return std::to_string (this->m_ivec4.x) + ", " + std::to_string (this->m_ivec4.y) + ", "
-		+ std::to_string (this->m_ivec4.z) + ", " + std::to_string (this->m_ivec4.w);
 	case UnderlyingType::String:
 	    return this->m_string;
 	default:
@@ -84,10 +85,7 @@ std::string DynamicValue::toString () const {
     }
 }
 
-void DynamicValue::update (const float newValue) {
-    this->m_ivec4 = glm::ivec4 (static_cast<int> (newValue));
-    this->m_ivec3 = glm::ivec3 (static_cast<int> (newValue));
-    this->m_ivec2 = glm::ivec2 (static_cast<int> (newValue));
+void DynamicValue::update (const float newValue, UpdateSource source) {
     this->m_vec4 = glm::vec4 (newValue);
     this->m_vec3 = glm::vec3 (newValue);
     this->m_vec2 = glm::vec2 (newValue);
@@ -97,13 +95,10 @@ void DynamicValue::update (const float newValue) {
     this->m_string = "";
     this->m_type = UnderlyingType::Float;
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update (const int newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue);
-    this->m_ivec3 = glm::ivec3 (newValue);
-    this->m_ivec2 = glm::ivec2 (newValue);
+void DynamicValue::update (const int newValue, UpdateSource source) {
     this->m_vec4 = glm::vec4 (static_cast<float> (newValue));
     this->m_vec3 = glm::vec3 (static_cast<float> (newValue));
     this->m_vec2 = glm::vec2 (static_cast<float> (newValue));
@@ -113,13 +108,10 @@ void DynamicValue::update (const int newValue) {
     this->m_string = "";
     this->m_type = UnderlyingType::Int;
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update (const bool newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue);
-    this->m_ivec3 = glm::ivec3 (newValue);
-    this->m_ivec2 = glm::ivec2 (newValue);
+void DynamicValue::update (const bool newValue, UpdateSource source) {
     this->m_vec4 = glm::vec4 (newValue);
     this->m_vec3 = glm::vec3 (newValue);
     this->m_vec2 = glm::vec2 (newValue);
@@ -129,13 +121,10 @@ void DynamicValue::update (const bool newValue) {
     this->m_string = "";
     this->m_type = UnderlyingType::Boolean;
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update (const glm::vec2& newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue, 0, 0);
-    this->m_ivec3 = glm::ivec3 (newValue, 0);
-    this->m_ivec2 = glm::ivec2 (newValue);
+void DynamicValue::update (const glm::vec2& newValue, UpdateSource source) {
     this->m_vec2 = newValue;
     this->m_vec3 = glm::vec3 (newValue, 0.0f);
     this->m_vec4 = glm::vec4 (newValue, 0.0f, 0.0f);
@@ -145,13 +134,10 @@ void DynamicValue::update (const glm::vec2& newValue) {
     this->m_string = "";
     this->m_type = UnderlyingType::Vec2;
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update (const glm::vec3& newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue, 0);
-    this->m_ivec3 = glm::ivec3 (newValue);
-    this->m_ivec2 = glm::ivec2 (newValue);
+void DynamicValue::update (const glm::vec3& newValue, UpdateSource source) {
     this->m_vec2 = glm::vec2 (newValue);
     this->m_vec3 = newValue;
     this->m_vec4 = glm::vec4 (newValue, 0.0f);
@@ -161,13 +147,10 @@ void DynamicValue::update (const glm::vec3& newValue) {
     this->m_string = "";
     this->m_type = UnderlyingType::Vec3;
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update (const glm::vec4& newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue);
-    this->m_ivec3 = glm::ivec3 (newValue);
-    this->m_ivec2 = glm::ivec2 (newValue);
+void DynamicValue::update (const glm::vec4& newValue, UpdateSource source) {
     this->m_vec2 = glm::vec2 (newValue);
     this->m_vec3 = glm::vec3 (newValue);
     this->m_vec4 = newValue;
@@ -177,61 +160,10 @@ void DynamicValue::update (const glm::vec4& newValue) {
     this->m_string = "";
     this->m_type = UnderlyingType::Vec4;
 
-    this->propagate ();
-}
-
-void DynamicValue::update (const glm::ivec2& newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue, 0, 0);
-    this->m_ivec3 = glm::ivec3 (newValue, 0);
-    this->m_ivec2 = glm::ivec2 (newValue);
-    this->m_vec2 = newValue;
-    this->m_vec3 = glm::vec3 (newValue, 0.0f);
-    this->m_vec4 = glm::vec4 (newValue, 0.0f, 0.0f);
-    this->m_float = static_cast<float> (newValue.x);
-    this->m_int = static_cast<int> (newValue.x);
-    this->m_bool = newValue.x != 0;
-    this->m_string = "";
-    this->m_type = UnderlyingType::IVec2;
-
-    this->propagate ();
-}
-
-void DynamicValue::update (const glm::ivec3& newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue, 0);
-    this->m_ivec3 = glm::ivec3 (newValue);
-    this->m_ivec2 = glm::ivec2 (newValue);
-    this->m_vec2 = glm::vec2 (newValue);
-    this->m_vec3 = newValue;
-    this->m_vec4 = glm::vec4 (newValue, 0.0f);
-    this->m_float = static_cast<float> (newValue.x);
-    this->m_int = static_cast<int> (newValue.x);
-    this->m_bool = newValue.x != 0;
-    this->m_string = "";
-    this->m_type = UnderlyingType::IVec3;
-
-    this->propagate ();
-}
-
-void DynamicValue::update (const glm::ivec4& newValue) {
-    this->m_ivec4 = glm::ivec4 (newValue);
-    this->m_ivec3 = glm::ivec3 (newValue);
-    this->m_ivec2 = glm::ivec2 (newValue);
-    this->m_vec2 = glm::vec2 (newValue);
-    this->m_vec3 = glm::vec3 (newValue);
-    this->m_vec4 = newValue;
-    this->m_float = static_cast<float> (newValue.x);
-    this->m_int = static_cast<int> (newValue.x);
-    this->m_bool = newValue.x != 0;
-    this->m_string = "";
-    this->m_type = UnderlyingType::IVec4;
-
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update (const std::string& newValue) {
-    this->m_ivec4 = glm::ivec4 (0);
-    this->m_ivec3 = glm::ivec3 (0);
-    this->m_ivec2 = glm::ivec2 (0);
+void DynamicValue::update (const std::string& newValue, UpdateSource source) {
     this->m_vec2 = glm::vec2 (0.0f);
     this->m_vec3 = glm::vec3 (0.0f);
     this->m_vec4 = glm::vec4 (0.0f);
@@ -243,9 +175,6 @@ void DynamicValue::update (const std::string& newValue) {
 
     if (this->m_condition.has_value ()) {
 	const bool boolValue = this->m_condition.value ().condition == newValue;
-	this->m_ivec4 = glm::ivec4 (boolValue ? 1 : 0);
-	this->m_ivec3 = glm::ivec3 (boolValue ? 1 : 0);
-	this->m_ivec2 = glm::ivec2 (boolValue ? 1 : 0);
 	this->m_vec2 = glm::vec2 (boolValue ? 1.0f : 0.0f);
 	this->m_vec3 = glm::vec3 (boolValue ? 1.0f : 0.0f);
 	this->m_vec4 = glm::vec4 (boolValue ? 1.0f : 0.0f);
@@ -255,13 +184,23 @@ void DynamicValue::update (const std::string& newValue) {
 	this->m_type = UnderlyingType::Boolean;
     }
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update (const DynamicValue& other) {
-    this->m_ivec4 = other.getIVec4 ();
-    this->m_ivec3 = other.getIVec3 ();
-    this->m_ivec2 = other.getIVec2 ();
+void DynamicValue::update (const Model::Color& newValue, UpdateSource source) {
+    this->m_vec2 = glm::vec2 (newValue.r, newValue.g);
+    this->m_vec3 = glm::vec3 (newValue.r, newValue.g, newValue.b);
+    this->m_vec4 = glm::vec4 (newValue.r, newValue.g, newValue.b, newValue.a);
+    this->m_float = newValue.r;
+    this->m_int = 255 * newValue.r;
+    this->m_bool = newValue.a != 0.0f;
+    this->m_string = "";
+    this->m_type = UnderlyingType::Vec4;
+
+    this->propagate (source);
+}
+
+void DynamicValue::update (const DynamicValue& other, UpdateSource source) {
     this->m_vec2 = other.getVec2 ();
     this->m_vec3 = other.getVec3 ();
     this->m_vec4 = other.getVec4 ();
@@ -273,9 +212,6 @@ void DynamicValue::update (const DynamicValue& other) {
 
     if (this->m_condition.has_value () && other.getType () == UnderlyingType::String) {
 	const bool boolValue = this->m_condition.value ().condition == other.getString ();
-	this->m_ivec4 = glm::ivec4 (boolValue ? 1 : 0);
-	this->m_ivec3 = glm::ivec3 (boolValue ? 1 : 0);
-	this->m_ivec2 = glm::ivec2 (boolValue ? 1 : 0);
 	this->m_vec2 = glm::vec2 (boolValue ? 1.0f : 0.0f);
 	this->m_vec3 = glm::vec3 (boolValue ? 1.0f : 0.0f);
 	this->m_vec4 = glm::vec4 (boolValue ? 1.0f : 0.0f);
@@ -285,13 +221,10 @@ void DynamicValue::update (const DynamicValue& other) {
 	this->m_type = UnderlyingType::Boolean;
     }
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-void DynamicValue::update () {
-    this->m_ivec4 = glm::ivec4 (0);
-    this->m_ivec3 = glm::ivec3 (0);
-    this->m_ivec2 = glm::ivec2 (0);
+void DynamicValue::update (UpdateSource source) {
     this->m_vec2 = glm::vec2 (0.0f);
     this->m_vec3 = glm::vec3 (0.0f);
     this->m_vec4 = glm::vec4 (0.0f);
@@ -300,10 +233,10 @@ void DynamicValue::update () {
     this->m_bool = false;
     this->m_type = UnderlyingType::Null;
 
-    this->propagate ();
+    this->propagate (source);
 }
 
-std::function<void ()> DynamicValue::listen (const std::function<void (const DynamicValue&)>& callback) {
+std::function<void ()> DynamicValue::listen (const std::function<void (const DynamicValue&, UpdateSource)>& callback) {
     const auto it = this->m_listeners.insert (this->m_listeners.end (), callback);
     auto alive = this->m_aliveFlag;
 
@@ -316,20 +249,20 @@ std::function<void ()> DynamicValue::listen (const std::function<void (const Dyn
 }
 
 void DynamicValue::connect (DynamicValue* other) {
-    const auto lambda = [this] (const DynamicValue& other) {
+    const auto lambda = [this] (const DynamicValue& other, UpdateSource source) {
 	// null is a special case, copying everything to 0 is different,
 	// so calling the update without parameters is required
 	if (other.getType () == UnderlyingType::Null) {
-	    this->update ();
+	    this->update (source);
 	} else {
-	    this->update (other);
+	    this->update (other, source);
 	}
     };
 
     const auto deregisterFunction = other->listen (lambda);
 
     // same update cycle has to happen as in the lambda, so trigger it
-    lambda (*other);
+    lambda (*other, UpdateSource::Initialization);
 
     this->m_connections.push_back (deregisterFunction);
 }
@@ -351,8 +284,20 @@ void DynamicValue::disconnect () {
 
 void DynamicValue::attachCondition (const ConditionInfo& condition) { this->m_condition = condition; }
 
-void DynamicValue::propagate () const {
+void DynamicValue::setScriptSource (const std::string& source) { this->m_scriptSource = source; }
+
+void DynamicValue::clearScriptSource () { this->m_scriptSource = std::nullopt; }
+
+const std::optional<std::string>& DynamicValue::getScriptSource () const { return this->m_scriptSource; }
+
+std::map<std::string, UserSettingUniquePtr>& DynamicValue::getProperties () { return this->m_properties; }
+
+void DynamicValue::setProperties (std::map<std::string, UserSettingUniquePtr> properties) {
+    this->m_properties = std::move (properties);
+}
+
+void DynamicValue::propagate (UpdateSource source) const {
     for (const auto& callback : this->m_listeners) {
-	callback (*this);
+	callback (*this, source);
     }
 }

+ 61 - 29
src/WallpaperEngine/Data/Model/DynamicValue.h

@@ -1,18 +1,31 @@
 #pragma once
 
+#include "Color.h"
+#include "Types.h"
+
 #include <functional>
 #include <glm/glm.hpp>
 #include <list>
+#include <map>
 #include <memory>
 #include <optional>
 #include <string>
 
 namespace WallpaperEngine::Data::Model {
+class DynamicValue;
+
 struct ConditionInfo {
     std::string name;
     std::string condition;
 };
 
+struct ScriptContext {
+    struct Object {
+	int id;
+	std::string name;
+    } object;
+};
+
 /**
  * Class that represents different types of dynamic values
  */
@@ -20,22 +33,23 @@ class DynamicValue {
 public:
     enum UnderlyingType {
 	Null = 0,
-	IVec4 = 1,
-	IVec3 = 2,
-	IVec2 = 3,
 	Vec4 = 4,
 	Vec3 = 5,
 	Vec2 = 6,
 	Float = 7,
 	Int = 8,
 	Boolean = 9,
-	String = 10
+	String = 10,
+    };
+
+    enum UpdateSource {
+	Initialization = 0,
+	Script = 1,
+	User = 2,
     };
 
     DynamicValue () = default;
-    explicit DynamicValue (const glm::ivec4& value);
-    explicit DynamicValue (const glm::ivec3& value);
-    explicit DynamicValue (const glm::ivec2& value);
+    DynamicValue (const DynamicValue& other);
     explicit DynamicValue (const glm::vec4& value);
     explicit DynamicValue (const glm::vec3& value);
     explicit DynamicValue (const glm::vec2& value);
@@ -43,11 +57,9 @@ public:
     explicit DynamicValue (int value);
     explicit DynamicValue (bool value);
     explicit DynamicValue (const std::string& value);
+    explicit DynamicValue (const Model::Color& value);
     virtual ~DynamicValue ();
 
-    [[nodiscard]] const glm::ivec4& getIVec4 () const;
-    [[nodiscard]] const glm::ivec3& getIVec3 () const;
-    [[nodiscard]] const glm::ivec2& getIVec2 () const;
     [[nodiscard]] const glm::vec4& getVec4 () const;
     [[nodiscard]] const glm::vec3& getVec3 () const;
     [[nodiscard]] const glm::vec2& getVec2 () const;
@@ -58,21 +70,19 @@ public:
     [[nodiscard]] UnderlyingType getType () const;
     [[nodiscard]] virtual std::string toString () const;
 
-    virtual void update (float newValue);
-    virtual void update (int newValue);
-    virtual void update (bool newValue);
-    virtual void update (const glm::vec2& newValue);
-    virtual void update (const glm::vec3& newValue);
-    virtual void update (const glm::vec4& newValue);
-    virtual void update (const glm::ivec2& newValue);
-    virtual void update (const glm::ivec3& newValue);
-    virtual void update (const glm::ivec4& newValue);
-    virtual void update (const std::string& newValue);
-    virtual void update (const DynamicValue& other);
+    virtual void update (float newValue, UpdateSource source);
+    virtual void update (int newValue, UpdateSource source);
+    virtual void update (bool newValue, UpdateSource source);
+    virtual void update (const glm::vec2& newValue, UpdateSource source);
+    virtual void update (const glm::vec3& newValue, UpdateSource source);
+    virtual void update (const glm::vec4& newValue, UpdateSource source);
+    virtual void update (const std::string& newValue, UpdateSource source);
+    virtual void update (const Model::Color& newValue, UpdateSource source);
+    virtual void update (const DynamicValue& other, UpdateSource source);
     /**
      * Sets the current value to null
      */
-    virtual void update ();
+    virtual void update (UpdateSource source);
 
     /**
      * Registers the given callback to be called when the value changes
@@ -81,7 +91,7 @@ public:
      *
      * @return The de-register function to call when the listener is no longer needed
      */
-    std::function<void ()> listen (const std::function<void (const DynamicValue&)>& callback);
+    std::function<void ()> listen (const std::function<void (const DynamicValue&, UpdateSource)>& callback);
     /**
      * Connects the current instance to the given instance, updating this instance's value
      * based on the given instance's value
@@ -104,28 +114,50 @@ public:
      * @param condition
      */
     void attachCondition (const ConditionInfo& condition);
+    /**
+     * Associates a script source with the dynamic value to allow for dynamic updates
+     *
+     * @param source The script code to associate to this value
+     */
+    void setScriptSource (const std::string& source);
+    /**
+     * Clears the associated script to this dynamic value
+     */
+    void clearScriptSource ();
+    /**
+     * @return The current script source associated with this dynamic value
+     */
+    [[nodiscard]] const std::optional<std::string>& getScriptSource () const;
+    /**
+     * @return The script properties associated with this dynamic value (if any)
+     */
+    [[nodiscard]] std::map<std::string, UserSettingUniquePtr>& getProperties ();
+    /**
+     * Updates the script properties associated with this dynamic value
+     */
+    void setProperties (std::map<std::string, UserSettingUniquePtr> properties);
 
 private:
     /**
      * Notifies any listeners that the value has changed
      */
-    void propagate () const;
+    void propagate (UpdateSource source) const;
 
     std::shared_ptr<bool> m_aliveFlag = std::make_shared<bool> (true);
-    std::list<std::function<void (const DynamicValue&)>> m_listeners = {};
+    std::list<std::function<void (const DynamicValue&, UpdateSource)>> m_listeners = {};
     std::vector<std::function<void ()>> m_connections = {};
+    std::optional<std::string> m_scriptSource = std::nullopt;
 
-    glm::ivec4 m_ivec4 = {};
-    glm::ivec3 m_ivec3 = {};
-    glm::ivec2 m_ivec2 = {};
     glm::vec4 m_vec4 = {};
     glm::vec3 m_vec3 = {};
     glm::vec2 m_vec2 = {};
     float m_float = 0.0f;
     int m_int = 0;
     bool m_bool = false;
-    std::string m_string = "";
+    std::string m_string;
     UnderlyingType m_type = Null;
     std::optional<ConditionInfo> m_condition = std::nullopt;
+    /** All the properties this script takes in */
+    std::map<std::string, UserSettingUniquePtr> m_properties;
 };
 }

+ 4 - 5
src/WallpaperEngine/Data/Model/Object.h

@@ -8,6 +8,7 @@
 
 #include <glm/glm.hpp>
 
+#include "DynamicValue.h"
 #include "Effect.h"
 #include "Material.h"
 #include "Model.h"
@@ -58,6 +59,7 @@ struct ImageEffectPassOverride {
     ComboMap combos;
     ShaderConstantMap constants;
     TextureMap textures;
+    TextureMap usertextures;
     std::optional<std::string> shaderOverride; // Overrides MaterialPass::shader when set
 };
 
@@ -589,11 +591,7 @@ public:
  */
 struct TextData {
     /** Initial text content to render (for scripted text, this is the `value` placeholder) */
-    std::string text;
-    /** Scripted text source — full JS, resolved inline or loaded from a .js asset. Empty for static text. */
-    std::string script;
-    /** Typed initial values for the script's scriptProperties, keyed by property name */
-    std::map<std::string, UserSettingUniquePtr> scriptProperties;
+    UserSettingUniquePtr text;
     /** Font reference from scene (e.g. "fonts/VCR_OSD_MONO.ttf" or "systemfont_arial") */
     std::string font;
     /** Font size in points, optionally bound to a user setting or script */
@@ -614,6 +612,7 @@ struct TextData {
     std::string verticalalign;
     /** Padding inside the bounding box */
     int padding;
+    // TODO: PARSE LIMITS TOO!
 };
 
 class Text : public Object, public TextData {

+ 24 - 58
src/WallpaperEngine/Data/Model/Property.h

@@ -1,7 +1,10 @@
 #pragma once
 
+#include "../Builders/ColorBuilder.h"
 #include "../Utils/TypeCaster.h"
 #include "DynamicValue.h"
+#include "WallpaperEngine/Logging/Log.h"
+
 #include <map>
 #include <string>
 #include <utility>
@@ -30,19 +33,19 @@ public:
     explicit Property (PropertyData data) : DynamicValue (), TypeCaster (), PropertyData (std::move (data)) { }
 
     using DynamicValue::update;
-    virtual void update (const std::string& value) = 0;
+    virtual void update (const std::string& value, UpdateSource source) = 0;
     [[nodiscard]] virtual std::string dump () const = 0;
 };
 
 class PropertySlider final : public Property, SliderData {
 public:
     PropertySlider (PropertyData data, SliderData sliderData, const float value) :
-	Property (std::move (data)), SliderData (std::move (sliderData)) {
-	this->Property::update (value);
+	Property (std::move (data)), SliderData (sliderData) {
+	this->Property::update (value, UpdateSource::Initialization);
     }
 
     using Property::update;
-    void update (const std::string& value) override { this->update (std::stof (value)); }
+    void update (const std::string& value, UpdateSource source) override { this->update (std::stof (value), source); }
 
     [[nodiscard]] std::string dump () const override {
 	std::stringstream ss;
@@ -61,11 +64,13 @@ public:
 class PropertyBoolean final : public Property {
 public:
     explicit PropertyBoolean (PropertyData data, const bool value) : Property (std::move (data)) {
-	this->Property::update (value);
+	this->Property::update (value, UpdateSource::Initialization);
     }
 
     using Property::update;
-    void update (const std::string& value) override { this->update (value == "true" || value == "1"); }
+    void update (const std::string& value, UpdateSource source) override {
+	this->update (value == "true" || value == "1", source);
+    }
 
     [[nodiscard]] std::string dump () const override {
 	std::stringstream ss;
@@ -81,49 +86,12 @@ public:
 class PropertyColor final : public Property {
 public:
     explicit PropertyColor (PropertyData data, const std::string& value) : Property (std::move (data)) {
-	this->PropertyColor::update (value);
+	this->PropertyColor::update (value, UpdateSource::Initialization);
     }
 
     using Property::update;
-    void update (const std::string& value) override {
-	auto copy = value;
-
-	// TODO: ENSURE ALL THIS PARSING IS CORRECT
-	if (copy.find (',') != std::string::npos) {
-	    // replace comma separator with spaces so it's
-	    std::ranges::replace (copy, ',', ' ');
-	}
-
-	// hex colors should be converted to int colors
-	if (copy.find ('#') == 0) {
-	    auto number = copy.substr (1);
-
-	    // support for css notation
-	    if (number.size () == 3) {
-		number = number[0] + number[0] + number[1] + number[1] + number[2] + number[2];
-	    }
-
-	    // remove alpha if it's present, should look into it more closely
-	    if (number.size () > 6) {
-		sLog.error ("Color value has alpha channel, which is not supported");
-		number = number.substr (0, 6);
-	    }
-
-	    const auto color = std::stoi (number, nullptr, 16);
-
-	    // format the number as float vector
-	    copy = std::to_string (((color >> 16) & 0xFF) / 255.0) + " "
-		+ std::to_string (((color >> 8) & 0xFF) / 255.0) + " " + std::to_string ((color & 0xFF) / 255.0);
-	} else if (copy.find ('.') == std::string::npos) {
-	    // integer vector, convert it to float vector
-	    const auto intcolor = VectorBuilder::parse<glm::ivec3> (copy);
-
-	    copy = std::to_string (intcolor.r / 255.0) + " " + std::to_string (intcolor.g / 255.0) + " "
-		+ std::to_string (intcolor.b / 255.0);
-	}
-
-	// finally parse the string as a float vector
-	this->update (VectorBuilder::parse<glm::vec3> (copy));
+    void update (const std::string& value, UpdateSource source) override {
+	this->update (ColorBuilder::parse (value), source);
     }
 
     [[nodiscard]] std::string dump () const override {
@@ -141,18 +109,18 @@ class PropertyCombo final : public Property, ComboData {
 public:
     PropertyCombo (PropertyData data, ComboData comboData, const std::string& value) :
 	Property (std::move (data)), ComboData (std::move (comboData)) {
-	this->PropertyCombo::update (value);
+	this->PropertyCombo::update (value, UpdateSource::Initialization);
     }
 
     using Property::update;
-    void update (const std::string& value) override {
+    void update (const std::string& value, UpdateSource source) override {
 	if (this->values.contains (value) == false) {
 	    sLog.error ("Combo value not found in combo options: ", value);
 	    return;
 	}
 
 	// search for the value in the combo options or default to the textual value
-	this->DynamicValue::update (value);
+	this->DynamicValue::update (value, source);
     }
 
     [[nodiscard]] std::string dump () const override {
@@ -176,9 +144,7 @@ public:
     explicit PropertyText (PropertyData data) : Property (std::move (data)) { }
 
     using Property::update;
-    void update (const std::string& value) override {
-	throw std::runtime_error ("PropertyText::update() is not implemented");
-    }
+    void update (const std::string& value, UpdateSource source) override { this->DynamicValue::update (value, source); }
 
     [[nodiscard]] std::string toString () const override { return this->text; }
 
@@ -196,10 +162,10 @@ public:
 class PropertySceneTexture final : public Property {
 public:
     explicit PropertySceneTexture (PropertyData data, const std::string& value) : Property (std::move (data)) {
-	this->PropertySceneTexture::update (value);
+	this->PropertySceneTexture::update (value, UpdateSource::Initialization);
     }
 
-    void update (const std::string& value) override { this->m_value = value; }
+    void update (const std::string& value, UpdateSource source) override { this->DynamicValue::update (value, source); }
 
     [[nodiscard]] std::string dump () const override {
 	std::stringstream ss;
@@ -218,10 +184,10 @@ private:
 class PropertyFile final : public Property {
 public:
     explicit PropertyFile (PropertyData data, const std::string& value) : Property (std::move (data)) {
-	this->PropertyFile::update (value);
+	this->PropertyFile::update (value, UpdateSource::Initialization);
     }
 
-    void update (const std::string& value) override { this->m_value = value; }
+    void update (const std::string& value, UpdateSource source) override { this->DynamicValue::update (value, source); }
 
     [[nodiscard]] std::string dump () const override {
 	std::stringstream ss;
@@ -240,10 +206,10 @@ private:
 class PropertyTextInput final : public Property {
 public:
     explicit PropertyTextInput (PropertyData data, const std::string& value) : Property (std::move (data)) {
-	this->PropertyTextInput::update (value);
+	this->PropertyTextInput::update (value, UpdateSource::Initialization);
     }
 
-    void update (const std::string& value) override { this->m_value = value; }
+    void update (const std::string& value, UpdateSource source) override { this->DynamicValue::update (value, source); }
 
     [[nodiscard]] std::string dump () const override {
 	std::stringstream ss;

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

@@ -1,74 +0,0 @@
-#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 ();
-}
-
-ScriptedDynamicValue::~ScriptedDynamicValue () {
-    WallpaperEngine::Scripting::ScriptEngine::instance ().releaseBinding (this);
-}
-
-void ScriptedDynamicValue::setBindingContext (ScriptBindingContext context) {
-    this->m_bindingContext = std::move (context);
-}
-
-const std::optional<ScriptBindingContext>& ScriptedDynamicValue::getBindingContext () const {
-    return this->m_bindingContext;
-}
-
-void ScriptedDynamicValue::reevaluate () { this->reevaluate (nullptr); }
-
-void ScriptedDynamicValue::reevaluate (WallpaperEngine::Render::Wallpapers::CScene* scene) {
-    if (this->m_evaluating) {
-	return;
-    }
-
-    this->m_evaluating = true;
-    struct EvaluationGuard {
-	bool& evaluating;
-	~EvaluationGuard () { evaluating = false; }
-    } guard { this->m_evaluating };
-
-    // 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 ();
-    }
-
-    const DynamicValue& currentValue = this->getType () == DynamicValue::Null ? this->m_baseValue : *this;
-
-    auto result = WallpaperEngine::Scripting::ScriptEngine::instance ().evaluate (
-	this,
-	this->m_scriptSource,
-	propsMap,
-	currentValue,
-	scene,
-	this->m_bindingContext.has_value () ? &this->m_bindingContext.value () : nullptr
-    );
-
-    if (result) {
-	this->update (*result);
-    }
-}

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

@@ -1,52 +0,0 @@
-#pragma once
-
-#include <map>
-#include <optional>
-#include <string>
-
-#include "DynamicValue.h"
-#include "Types.h"
-
-namespace WallpaperEngine::Render::Wallpapers {
-class CScene;
-}
-
-namespace WallpaperEngine::Data::Model {
-
-struct ScriptBindingContext {
-    int objectId = -1;
-    std::string objectName;
-    std::string propertyName;
-};
-
-/**
- * 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;
-
-    void setBindingContext (ScriptBindingContext context);
-    [[nodiscard]] const std::optional<ScriptBindingContext>& getBindingContext () const;
-    void reevaluate (WallpaperEngine::Render::Wallpapers::CScene* scene);
-
-private:
-    void reevaluate ();
-
-    std::string m_scriptSource;
-    std::map<std::string, DynamicValueUniquePtr> m_scriptProps;
-    DynamicValue m_baseValue;
-    std::optional<ScriptBindingContext> m_bindingContext = std::nullopt;
-    bool m_evaluating = false;
-};
-} // namespace WallpaperEngine::Data::Model

+ 6 - 6
src/WallpaperEngine/Data/Model/Wallpaper.h

@@ -39,8 +39,8 @@ public:
 
 struct SceneData {
     struct {
-	glm::vec3 ambient;
-	glm::vec3 skylight;
+	UserSettingUniquePtr ambient;
+	UserSettingUniquePtr skylight;
 	UserSettingUniquePtr clear;
     } colors;
     /**
@@ -48,7 +48,7 @@ struct SceneData {
      */
     struct Camera {
 	/** Enable fade effect */
-	bool fade;
+	UserSettingUniquePtr fade;
 	/** Used by the software to allow the users to preview the background or not? */
 	bool preview;
 
@@ -99,9 +99,9 @@ struct SceneData {
 	    int width;
 	    int height;
 	    bool isAuto;
-	    float nearz;
-	    float farz;
-	    float fov;
+	    UserSettingUniquePtr nearz;
+	    UserSettingUniquePtr farz;
+	    UserSettingUniquePtr fov;
 	} projection;
     } camera;
 

+ 80 - 0
src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp

@@ -0,0 +1,80 @@
+#include "DynamicValueParser.h"
+
+#include "UserSettingParser.h"
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
+
+using namespace WallpaperEngine::Data::Parsers;
+
+DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Properties& properties, bool expectColor) {
+    auto value = std::make_unique<DynamicValue> ();
+    auto valueIt = data;
+    std::optional<std::string> scriptSource = std::nullopt;
+    std::optional<json> scriptPropsJson = std::nullopt;
+
+    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");
+
+	if (script.has_value () && !script->is_null ()) {
+	    scriptSource = script->get<std::string> ();
+	    scriptPropsJson = data.optional ("scriptproperties");
+	}
+    }
+
+    // actual value parsing
+    if (valueIt.is_string ()) {
+	if (expectColor) {
+	    value->update (Builders::ColorBuilder::parse (valueIt), DynamicValue::UpdateSource::Initialization);
+	} else {
+	    std::string str = valueIt;
+	    int size = Builders::VectorBuilder::preparseSize (str);
+
+	    if (size == 1) {
+		// scalar? text value?
+		std::size_t parsed = 0;
+		try {
+		    float f = std::stof (str, &parsed);
+
+		    if (parsed == str.size ()) {
+			value->update (f, DynamicValue::UpdateSource::Initialization);
+		    } else {
+			value->update (str, DynamicValue::UpdateSource::Initialization);
+		    }
+		} catch (const std::exception&) {
+		    value->update (str, DynamicValue::UpdateSource::Initialization);
+		}
+	    } else if (size == 2) {
+		value->update (static_cast<glm::vec2> (valueIt), DynamicValue::UpdateSource::Initialization);
+	    } else if (size == 3) {
+		value->update (static_cast<glm::vec3> (valueIt), DynamicValue::UpdateSource::Initialization);
+	    } else {
+		value->update (static_cast<glm::vec4> (valueIt), DynamicValue::UpdateSource::Initialization);
+	    }
+	}
+    } else if (valueIt.is_number_integer ()) {
+	value->update (valueIt.get<int> (), DynamicValue::UpdateSource::Initialization);
+    } else if (valueIt.is_number_float ()) {
+	value->update (valueIt.get<float> (), DynamicValue::UpdateSource::Initialization);
+    } else if (valueIt.is_boolean ()) {
+	value->update (valueIt.get<bool> (), DynamicValue::UpdateSource::Initialization);
+    } else if (valueIt.is_null ()) {
+	// null value with no connection to property
+	value->update (DynamicValue::UpdateSource::Initialization);
+    }
+
+    if (scriptSource.has_value ()) {
+	std::map<std::string, UserSettingUniquePtr> scriptProps;
+
+	if (scriptPropsJson.has_value () && scriptPropsJson->is_object ()) {
+	    for (const auto& [key, propData] : scriptPropsJson->items ()) {
+		scriptProps[key] = UserSettingParser::parse (propData, properties);
+	    }
+	}
+
+	value->setProperties (std::move (scriptProps));
+	value->setScriptSource (scriptSource.value ());
+    }
+
+    return value;
+}

+ 14 - 0
src/WallpaperEngine/Data/Parsers/DynamicValueParser.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include "WallpaperEngine/Data/JSON.h"
+#include "WallpaperEngine/Data/Model/Types.h"
+
+namespace WallpaperEngine::Data::Parsers {
+using json = WallpaperEngine::Data::JSON::JSON;
+using namespace WallpaperEngine::Data::Model;
+
+class DynamicValueParser {
+public:
+    static Model::DynamicValueUniquePtr parse (const json& data, const Properties& properties, bool expectColor);
+};
+}

+ 1 - 0
src/WallpaperEngine/Data/Parsers/EffectParser.cpp

@@ -1,6 +1,7 @@
 #include "EffectParser.h"
 #include "MaterialParser.h"
 
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Effect.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Project.h"

+ 3 - 27
src/WallpaperEngine/Data/Parsers/MaterialParser.cpp

@@ -1,5 +1,6 @@
 #include "MaterialParser.h"
 
+#include "TextureParser.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Parsers/ShaderConstantParser.h"
@@ -48,38 +49,13 @@ MaterialPassUniquePtr MaterialParser::parsePass (const JSON& it, const Project&
 	.depthtest = parseDepthtestMode (it.optional ("depthtest", std::string ("disabled"))),
 	.depthwrite = parseDepthwriteMode (it.optional ("depthwrite", std::string ("disabled"))),
 	.shader = it.require<std::string> ("shader", "Material pass must have a shader"),
-	.textures = textures.has_value () ? parseTextures (*textures) : TextureMap {},
-	.usertextures = usertextures.has_value () ? parseTextures (*usertextures) : TextureMap {},
+	.textures = textures.has_value () ? TextureParser::parseTextureMap (*textures) : TextureMap {},
+	.usertextures = usertextures.has_value () ? TextureParser::parseTextureMap (*usertextures) : TextureMap {},
 	.combos = combos.has_value () ? parseCombos (*combos) : ComboMap {},
 	.constants = constants.has_value () ? ShaderConstantParser::parse (*constants, project) : ShaderConstantMap {},
     });
 }
 
-std::map<int, std::string> MaterialParser::parseTextures (const JSON& it) {
-    std::map<int, std::string> result = {};
-
-    if (!it.is_array ()) {
-	return result;
-    }
-
-    int index = 0;
-
-    for (const auto& cur : it) {
-	if (!cur.is_null ()) {
-	    if (!cur.is_string ()) {
-		sLog.error ("Detected a non-string texture, most likely a special value: ", cur.dump ());
-		result.emplace (index, "");
-	    } else {
-		result.emplace (index, cur);
-	    }
-	}
-
-	index++;
-    }
-
-    return result;
-}
-
 std::map<std::string, int> MaterialParser::parseCombos (const JSON& it) {
     std::map<std::string, int> result = {};
 

+ 0 - 1
src/WallpaperEngine/Data/Parsers/MaterialParser.h

@@ -14,7 +14,6 @@ public:
     static MaterialUniquePtr parse (const JSON& it, const std::string& filename, const Project& project);
     static std::vector<MaterialPassUniquePtr> parsePasses (const JSON& it, const Project& project);
     static MaterialPassUniquePtr parsePass (const JSON& it, const Project& project);
-    static std::map<int, std::string> parseTextures (const JSON& it);
     static std::map<std::string, int> parseCombos (const JSON& it);
     static BlendingMode parseBlendMode (const std::string& mode);
     static CullingMode parseCullMode (const std::string& mode);

+ 1 - 0
src/WallpaperEngine/Data/Parsers/ModelParser.cpp

@@ -1,6 +1,7 @@
 #include "ModelParser.h"
 #include "MaterialParser.h"
 
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Model.h"
 #include "WallpaperEngine/Data/Model/Project.h"

+ 48 - 278
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -4,10 +4,11 @@
 #include "ModelParser.h"
 
 #include "ShaderConstantParser.h"
+#include "TextureParser.h"
 #include "UserSettingParser.h"
+#include "WallpaperEngine/Data/Builders/ColorBuilder.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Project.h"
-#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Logging/Log.h"
 
 #include <glm/gtc/constants.hpp>
@@ -16,100 +17,6 @@
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Model;
 
-namespace {
-// Wraps a string value in a UserSetting with a String-typed DynamicValue.
-// UserSettingParser would try to numeric-parse single-char strings like "-"/":",
-// so we bypass it for raw string script-property values.
-UserSettingUniquePtr makeStringSetting (const std::string& s) {
-    auto dv = std::make_unique<DynamicValue> ();
-    dv->update (s);
-    return std::make_unique<UserSetting> (UserSetting {
-	.value = std::move (dv),
-	.property = nullptr,
-	.condition = std::nullopt,
-    });
-}
-
-// Resolves the `script` field of a scripted text node: if it looks like a
-// single-line path ending in .js, read it through the asset locator.
-// Returns empty string on failure (caller treats empty script as static).
-std::string resolveScriptSource (std::string raw, const Project& project) {
-    const bool looksLikePath =
-	!raw.empty () && raw.find ('\n') == std::string::npos && raw.size () >= 3
-	&& raw.compare (raw.size () - 3, 3, ".js") == 0;
-
-    if (!looksLikePath || project.assetLocator == nullptr)
-	return raw;
-
-    try {
-	return project.assetLocator->readString (raw);
-    } catch (const std::exception& e) {
-	sLog.error ("CText: cannot load script asset '", raw, "': ", e.what ());
-	return {};
-    }
-}
-
-// Parses a text object's `scriptproperties` subtree into a map of UserSettings.
-// Plain string values bypass UserSettingParser (which stof-throws on "-"/":"),
-// object values with a string `value` field also go through the string path.
-std::map<std::string, UserSettingUniquePtr>
-parseScriptProperties (const JSON& propsObj, const Properties& properties) {
-    std::map<std::string, UserSettingUniquePtr> out;
-    for (const auto& [key, propData] : propsObj.items ()) {
-	try {
-	    if (propData.is_string ()) {
-		out.emplace (key, makeStringSetting (propData.template get<std::string> ()));
-		continue;
-	    }
-	    if (propData.is_object ()) {
-		if (const auto valueField = propData.find ("value");
-		    valueField != propData.end () && valueField->is_string ()) {
-		    out.emplace (key, makeStringSetting (valueField->template get<std::string> ()));
-		    continue;
-		}
-	    }
-	    out.emplace (key, UserSettingParser::parse (propData, properties));
-	} catch (const std::exception& e) {
-	    sLog.error ("CText: failed to parse scriptProperty '", key, "': ", e.what ());
-	}
-    }
-    return out;
-}
-
-// The `color` user setting may arrive as vec3 or ivec3; normalize to vec4 with
-// full alpha so downstream renderers can assume a uniform type.
-void widenColorToVec4 (DynamicValue& v) {
-    if (v.getType () == DynamicValue::UnderlyingType::Vec3) {
-	v.update (glm::vec4 (v.getVec3 (), 1.0f));
-    } else if (v.getType () == DynamicValue::UnderlyingType::IVec3) {
-	const glm::ivec3 icolor = v.getIVec3 ();
-	v.update (glm::vec4 (glm::vec3 (icolor), 255.0f) / 255.0f);
-    }
-}
-
-static void bindScriptContext (
-    const UserSettingUniquePtr& setting,
-    int objectId,
-    const std::string& objectName,
-    const std::string& propertyName
-) {
-    if (!setting || !setting->value) {
-	return;
-    }
-
-    auto* scripted = dynamic_cast<ScriptedDynamicValue*> (setting->value.get ());
-    if (!scripted) {
-	return;
-    }
-
-    scripted->setBindingContext (ScriptBindingContext {
-	.objectId = objectId,
-	.objectName = objectName,
-	.propertyName = propertyName,
-    });
-}
-} // namespace
-
 ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     const auto imageIt = it.find ("image");
     const auto soundIt = it.find ("sound");
@@ -158,11 +65,6 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	};
     }
 
-    bindScriptContext (basedata.origin, basedata.id, basedata.name, "origin");
-    bindScriptContext (basedata.groupScale, basedata.id, basedata.name, "scale");
-    bindScriptContext (basedata.groupAngles, basedata.id, basedata.name, "angles");
-    bindScriptContext (basedata.groupVisible, basedata.id, basedata.name, "visible");
-
     if (imageIt != it.end () && imageIt->is_string ()) {
 	return parseImage (it, project, std::move (basedata), *imageIt);
     } else if (soundIt != it.end () && soundIt->is_array ()) {
@@ -221,55 +123,22 @@ SoundUniquePtr ObjectParser::parseSound (const JSON& it, ObjectData base) {
 }
 
 TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, ObjectData base) {
-    const auto& properties = project.properties;
-    const auto textIt = it.require ("text", "Text object must have a text field");
-
-    std::string text;
-    std::string script;
-    std::map<std::string, UserSettingUniquePtr> scriptProps;
-
-    if (textIt.is_string ()) {
-	text = textIt.get<std::string> ();
-    } else if (textIt.is_object ()) {
-	// Scripted text: carries the JS source (either inline or as a .js asset path),
-	// an initial placeholder `value`, and typed initial values for scriptProperties.
-	if (const auto scriptIt = textIt.optional<std::string> ("script"); scriptIt.has_value ())
-	    script = resolveScriptSource (*scriptIt, project);
-
-	if (const auto valueIt = textIt.optional<std::string> ("value"); valueIt.has_value ())
-	    text = *valueIt;
-
-	if (const auto propsIt = textIt.find ("scriptproperties"); propsIt != textIt.end () && propsIt->is_object ())
-	    scriptProps = parseScriptProperties (*propsIt, properties);
-    }
-
-    auto result = std::make_unique<Text> (
+    return std::make_unique<Text> (
 	std::move (base),
 	TextData {
-	    .text = std::move (text),
-	    .script = std::move (script),
-	    .scriptProperties = std::move (scriptProps),
+	    .text = it.user ("text", project.properties),
 	    .font = it.optional ("font", std::string ()),
-	    .pointSize = it.user ("pointsize", properties, 32.0f),
+	    .pointSize = it.user ("pointsize", project.properties, 32.0f),
 	    .size = it.optional ("size", glm::vec2 (0.0f)),
-	    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
-	    .color = it.user ("color", properties, glm::vec4 (1.0f)),
-	    .alpha = it.user ("alpha", properties, 1.0f),
-	    .visible = it.user ("visible", properties, true),
+	    .scale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
+	    .color = it.color ("color", project.properties, Builders::ColorBuilder::White),
+	    .alpha = it.user ("alpha", project.properties, 1.0f),
+	    .visible = it.user ("visible", project.properties, true),
 	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
 	    .verticalalign = it.optional ("verticalalign", std::string ("center")),
 	    .padding = it.optional ("padding", 0),
 	}
     );
-
-    widenColorToVec4 (*result->color->value);
-    bindScriptContext (result->visible, result->id, result->name, "visible");
-    bindScriptContext (result->color, result->id, result->name, "color");
-    bindScriptContext (result->alpha, result->id, result->name, "alpha");
-    bindScriptContext (result->scale, result->id, result->name, "scale");
-    bindScriptContext (result->pointSize, result->id, result->name, "pointSize");
-
-    return result;
 }
 
 ImageUniquePtr
@@ -282,12 +151,12 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	std::move (base),
 	ImageData {
 	    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
-	    .angles = it.user ("angles", properties, glm::vec3 (0.0)),
+	    .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 	    .visible = it.user ("visible", properties, true),
 	    .alpha = it.user ("alpha", properties, 1.0f),
-	    .color = it.user ("color", properties, glm::vec4 (1.0f)),
+	    .color = it.color ("color", properties, Builders::ColorBuilder::White),
 	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
-	    .size = it.user ("size", project.properties, glm::vec2 (0.0f))->value->getVec2 (),
+	    .size = it.user ("size", properties, glm::vec2 (0.0f))->value->getVec2 (),
 	    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 	    .colorBlendMode = it.user ("colorBlendMode", properties, 0),
 	    .brightness = it.user ("brightness", properties, 1.0f),
@@ -298,43 +167,25 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	}
     );
 
-    // color should be a vec4 for alpha, but it's read as vec3
-    if (result->color->value->getType () == DynamicValue::UnderlyingType::Vec3) {
-	result->color->value->update (glm::vec4 (result->color->value->getVec3 (), 1.0f));
-    } else if (result->color->value->getType () == DynamicValue::UnderlyingType::IVec3) {
-	result->color->value->update (glm::vec4 (result->color->value->getIVec3 (), 255));
-    }
-
-    bindScriptContext (result->scale, result->id, result->name, "scale");
-    bindScriptContext (result->angles, result->id, result->name, "angles");
-    bindScriptContext (result->visible, result->id, result->name, "visible");
-    bindScriptContext (result->alpha, result->id, result->name, "alpha");
-    bindScriptContext (result->color, result->id, result->name, "color");
-    bindScriptContext (result->parallaxDepth, result->id, result->name, "parallaxDepth");
-
     const auto instance = it.optional ("instance");
+
     if (instance.has_value () && instance->is_object () && !result->model->material->passes.empty ()) {
 	auto& firstPass = **result->model->material->passes.begin ();
 	const auto instanceTextures = instance->optional ("textures");
+
 	if (instanceTextures.has_value ()) {
-	    const auto parsed = parseTextureMap (*instanceTextures);
+	    const auto parsed = TextureParser::parseTextureMap (*instanceTextures);
 	    firstPass.textures.insert (parsed.begin (), parsed.end ());
 	}
+
 	const auto instanceUserTextures = instance->optional ("usertextures");
+
 	if (instanceUserTextures.has_value ()) {
-	    const auto parsed = parseTextureMap (*instanceUserTextures);
+	    const auto parsed = TextureParser::parseTextureMap (*instanceUserTextures);
 	    firstPass.usertextures.insert (parsed.begin (), parsed.end ());
 	}
     }
 
-    for (const auto& effect : result->effects) {
-	for (const auto& pass : effect->passOverrides) {
-	    for (const auto& [name, constant] : pass->constants) {
-		bindScriptContext (constant, result->id, result->name, name);
-	    }
-	}
-    }
-
     return result;
 }
 
@@ -382,6 +233,7 @@ ImageEffectPassOverrideUniquePtr ObjectParser::parseEffectPass (const JSON& it,
     const auto& combos = it.optional ("combos");
     const auto& textures = it.optional ("textures");
     const auto& constants = it.optional ("constantshadervalues");
+    const auto& usertextures = it.optional ("usertextures");
 
     // TODO: PARSE CONSTANT SHADER VALUES AND FIND REFS?
     return std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
@@ -389,39 +241,12 @@ ImageEffectPassOverrideUniquePtr ObjectParser::parseEffectPass (const JSON& it,
 	.combos = combos.has_value () ? parseComboMap (combos.value ()) : ComboMap {},
 	.constants
 	= constants.has_value () ? ShaderConstantParser::parse (constants.value (), project) : ShaderConstantMap {},
-	.textures = textures.has_value () ? parseTextureMap (textures.value ()) : TextureMap {},
+	.textures = textures.has_value () ? TextureParser::parseTextureMap (textures.value ()) : TextureMap {},
+	.usertextures
+	= usertextures.has_value () ? TextureParser::parseTextureMap (usertextures.value ()) : TextureMap {},
     });
 }
 
-TextureMap ObjectParser::parseTextureMap (const JSON& it) {
-    if (!it.is_array ()) {
-	return {};
-    }
-
-    TextureMap result = {};
-    int textureIndex = -1;
-
-    for (const auto& cur : it) {
-	textureIndex++;
-
-	if (cur.is_null ()) {
-	    continue;
-	} else if (cur.is_object ()) {
-	    const auto nameIt = cur.find ("name");
-	    if (nameIt != cur.end () && nameIt->is_string ()) {
-		result.emplace (textureIndex, nameIt->get<std::string> ());
-	    }
-	} else {
-	    std::string texName = cur;
-	    if (!texName.empty ()) {
-		result.emplace (textureIndex, texName);
-	    }
-	}
-    }
-
-    return result;
-}
-
 ComboMap ObjectParser::parseComboMap (const JSON& it) {
     if (!it.is_object ()) {
 	return {};
@@ -489,43 +314,17 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		    .renderers = {},
 		    .controlPoints = {},
 		    .children = {},
-		    .instanceOverride
-		    = { .enabled
-			= std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (false),
-								       .property = nullptr,
-								       .condition = std::nullopt }),
-			.alpha
-			= std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (1.0f),
-								       .property = nullptr,
-								       .condition = std::nullopt }),
-			.size
-			= std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (1.0f),
-								       .property = nullptr,
-								       .condition = std::nullopt }),
-			.lifetime
-			= std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (1.0f),
-								       .property = nullptr,
-								       .condition = std::nullopt }),
-			.rate
-			= std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (1.0f),
-								       .property = nullptr,
-								       .condition = std::nullopt }),
-			.speed
-			= std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (1.0f),
-								       .property = nullptr,
-								       .condition = std::nullopt }),
-			.count
-			= std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (1.0f),
-								       .property = nullptr,
-								       .condition = std::nullopt }),
-			.color = std::make_unique<UserSetting> (UserSetting {
-			    .value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)),
-			    .property = nullptr,
-			    .condition = std::nullopt }),
-			.colorn = std::make_unique<UserSetting> (UserSetting {
-			    .value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)),
-			    .property = nullptr,
-			    .condition = std::nullopt }) },
+		    .instanceOverride = {
+		        .enabled = Builders::UserSettingBuilder::fromValue(false),
+			.alpha = Builders::UserSettingBuilder::fromValue(1.0f),
+			.size = Builders::UserSettingBuilder::fromValue(1.0f),
+			.lifetime = Builders::UserSettingBuilder::fromValue(1.0f),
+			.rate = Builders::UserSettingBuilder::fromValue(1.0f),
+			.speed = Builders::UserSettingBuilder::fromValue(1.0f),
+			.count = Builders::UserSettingBuilder::fromValue(1.0f),
+			.color = Builders::UserSettingBuilder::fromValue(1.0f),
+			.colorn = Builders::UserSettingBuilder::fromValue(1.0f),
+		    },
 		}
 	    );
 	}
@@ -629,28 +428,15 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 
 	// Parse instance override
 	ParticleInstanceOverride instanceOverride = {
-	    .enabled = std::make_unique<UserSetting> (UserSetting {
-		.value = std::make_unique<DynamicValue> (false), .property = nullptr, .condition = std::nullopt }),
-	    .alpha = std::make_unique<UserSetting> (UserSetting {
-		.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt }),
-	    .size = std::make_unique<UserSetting> (UserSetting {
-		.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt }),
-	    .lifetime = std::make_unique<UserSetting> (UserSetting {
-		.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt }),
-	    .rate = std::make_unique<UserSetting> (UserSetting {
-		.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt }),
-	    .speed = std::make_unique<UserSetting> (UserSetting {
-		.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt }),
-	    .count = std::make_unique<UserSetting> (UserSetting {
-		.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt }),
-	    .color
-	    = std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)),
-							   .property = nullptr,
-							   .condition = std::nullopt }),
-	    .colorn
-	    = std::make_unique<UserSetting> (UserSetting { .value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)),
-							   .property = nullptr,
-							   .condition = std::nullopt })
+	    .enabled = Builders::UserSettingBuilder::fromValue (false),
+	    .alpha = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .size = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .lifetime = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .rate = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .speed = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .count = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .color = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .colorn = Builders::UserSettingBuilder::fromValue (1.0f),
 	};
 	const auto instanceOverrideIt = it.optional ("instanceoverride");
 	if (instanceOverrideIt.has_value ()) {
@@ -718,7 +504,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    flags = flagsIt->get<uint32_t> ();
 	}
 
-	auto result = std::make_unique<Particle> (
+	return std::make_unique<Particle> (
 	    std::move (base),
 	    ParticleData {
 		.scale = it.user ("scale", properties, glm::vec3 (1.0f)),
@@ -741,11 +527,6 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		.instanceOverride = std::move (instanceOverride),
 	    }
 	);
-	bindScriptContext (result->scale, result->id, result->name, "scale");
-	bindScriptContext (result->angles, result->id, result->name, "angles");
-	bindScriptContext (result->visible, result->id, result->name, "visible");
-	bindScriptContext (result->parallaxDepth, result->id, result->name, "parallaxDepth");
-	return result;
     } catch (nlohmann::json::exception& e) {
 	sLog.error ("Error parsing particle '", base.name, "': ", e.what ());
 	sLog.error ("Particle JSON: ", it.dump ());
@@ -755,7 +536,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 
 ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
     // Parse string name safely
-    std::string name = "";
+    std::string name;
     const auto nameIt = it.find ("name");
     if (nameIt != it.end () && nameIt->is_string ()) {
 	name = nameIt->get<std::string> ();
@@ -845,21 +626,10 @@ ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON&
     std::string name = it.optional<std::string> ("name", "");
 
     if (name == "colorrandom") {
-	// Only normalize if there's no property connection or values are > 1.0
-	auto minSetting = it.user ("min", properties, glm::vec3 (0.0f));
-	auto maxSetting = it.user ("max", properties, glm::vec3 (255.0f));
-
-	auto minVec = minSetting->value->getVec3 ();
-	if (minSetting->property == nullptr && (minVec.x > 1.0f || minVec.y > 1.0f || minVec.z > 1.0f)) {
-	    minSetting->value->update (minVec / 255.0f);
-	}
-
-	auto maxVec = maxSetting->value->getVec3 ();
-	if (maxSetting->property == nullptr && (maxVec.x > 1.0f || maxVec.y > 1.0f || maxVec.z > 1.0f)) {
-	    maxSetting->value->update (maxVec / 255.0f);
-	}
-
-	return std::make_unique<ColorRandomInitializer> (std::move (minSetting), std::move (maxSetting));
+	return std::make_unique<ColorRandomInitializer> (
+	    it.color ("min", properties, Builders::ColorBuilder::Black),
+	    it.color ("max", properties, Builders::ColorBuilder::White)
+	);
     } else if (name == "sizerandom") {
 	return std::make_unique<SizeRandomInitializer> (
 	    it.user ("min", properties, 0.0f), it.user ("max", properties, 20.0f),

+ 0 - 1
src/WallpaperEngine/Data/Parsers/ObjectParser.h

@@ -29,7 +29,6 @@ private:
     static std::vector<ImageEffectPassOverrideUniquePtr>
     parseEffectPassOverrides (const JSON& it, const Project& project);
     static ImageEffectPassOverrideUniquePtr parseEffectPass (const JSON& it, const Project& project);
-    static TextureMap parseTextureMap (const JSON& it);
     static ComboMap parseComboMap (const JSON& it);
     static std::vector<ImageAnimationLayerUniquePtr> parseAnimationLayers (const JSON& it, const Project& project);
     static ImageAnimationLayerUniquePtr parseAnimationLayer (const JSON& it, const Project& project);

+ 31 - 0
src/WallpaperEngine/Data/Parsers/TextureParser.cpp

@@ -122,6 +122,37 @@ FrameSharedPtr TextureParser::parseFrame (const BinaryReader& file) {
     return result;
 }
 
+TextureMap TextureParser::parseTextureMap (const JSON& it) {
+    if (!it.is_array ()) {
+	return {};
+    }
+
+    TextureMap result = {};
+    int textureIndex = -1;
+
+    for (const auto& cur : it) {
+	textureIndex++;
+
+	if (cur.is_null ()) {
+	    continue;
+	}
+
+	if (cur.is_object ()) {
+	    const auto nameIt = cur.find ("name");
+	    if (nameIt != cur.end () && nameIt->is_string ()) {
+		result.emplace (textureIndex, nameIt->get<std::string> ());
+	    }
+	} else if (cur.is_string ()) {
+	    std::string texName = cur;
+	    if (!texName.empty ()) {
+		result.emplace (textureIndex, texName);
+	    }
+	}
+    }
+
+    return result;
+}
+
 TextureFormat TextureParser::parseTextureFormat (uint32_t value) {
     switch (value) {
 	case TextureFormat_UNKNOWN:

+ 4 - 0
src/WallpaperEngine/Data/Parsers/TextureParser.h

@@ -5,12 +5,15 @@
 #include <functional>
 #include <iostream>
 
+#include "WallpaperEngine/Data/JSON.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Data/Utils/BinaryReader.h"
 
 namespace WallpaperEngine::Data::Parsers {
+using JSON = WallpaperEngine::Data::JSON::JSON;
 using namespace WallpaperEngine::Data::Utils;
 using namespace WallpaperEngine::Data::Assets;
+using namespace WallpaperEngine::Data::Model;
 
 class TextureParser {
 public:
@@ -22,6 +25,7 @@ public:
     static MipmapSharedPtr parseMipmap (const BinaryReader& file, const Texture& header);
     static FrameSharedPtr parseFrame (const BinaryReader& file);
     static FrameSharedPtr parseFrameV1 (const BinaryReader& file);
+    static TextureMap parseTextureMap (const JSON& it);
 
 private:
     static void parseTextureHeader (Texture& header, const BinaryReader& file);

+ 4 - 79
src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp

@@ -1,32 +1,19 @@
 #include "UserSettingParser.h"
+#include "DynamicValueParser.h"
 
 #include "WallpaperEngine/Data/Model/Property.h"
-#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Data/Model/UserSetting.h"
-#include "WallpaperEngine/Logging/Log.h"
 
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Builders;
 
-UserSettingUniquePtr UserSettingParser::parse (const json& data, const Properties& properties) {
-    auto value = std::make_unique<DynamicValue> ();
+UserSettingUniquePtr UserSettingParser::parse (const json& data, const Properties& properties, bool expectColor) {
+    auto value = DynamicValueParser::parse (data, properties, expectColor);
     PropertySharedPtr property;
-    std::optional<ConditionInfo> condition;
-    auto valueIt = data;
-    std::string content = data.dump ();
-
-    std::optional<std::string> scriptSource;
-    std::optional<json> scriptPropsJson;
+    std::optional<ConditionInfo> condition = std::nullopt;
 
     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");
-
-	if (script.has_value () && !script->is_null ()) {
-	    scriptSource = script->get<std::string> ();
-	    scriptPropsJson = data.optional ("scriptproperties");
-	}
 
 	if (user.has_value () && !user->is_null ()) {
 	    std::string source;
@@ -49,68 +36,6 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	}
     }
 
-    // actual value parsing
-    if (valueIt.is_string ()) {
-	std::string str = valueIt;
-
-	// TODO: VALIDATE THIS IS RIGHT?
-	if (int size = VectorBuilder::preparseSize (str); size == 1) {
-	    size_t parsed = 0;
-	    const float scalar = std::stof (str, &parsed);
-	    if (parsed != str.size ()) {
-		sLog.exception ("Invalid scalar format: ", str);
-	    }
-	    value->update (scalar);
-	} else if (size == 2) {
-	    value->update (static_cast<glm::vec2> (valueIt));
-	} else if (size == 3) {
-	    value->update (static_cast<glm::vec3> (valueIt));
-	} else if (size == 4) {
-	    value->update (static_cast<glm::vec4> (valueIt));
-	} else {
-	    // preparseSize returned 0: no spaces found — try parsing as a scalar float,
-	    // fall back to a plain string for non-numeric values (e.g. "bottom", "center").
-	    std::size_t parsed = 0;
-	    try {
-		float f = std::stof (str, &parsed);
-		if (parsed == str.size ()) {
-		    value->update (f);
-		} else {
-		    value->update (str);
-		}
-	    } catch (const std::exception&) {
-		value->update (str);
-	    }
-	}
-    } else if (valueIt.is_number_integer ()) {
-	value->update (valueIt.get<int> ());
-    } else if (valueIt.is_number_float ()) {
-	value->update (valueIt.get<float> ());
-    } else if (valueIt.is_boolean ()) {
-	value->update (valueIt.get<bool> ());
-    } else if (valueIt.is_null ()) {
-	// null value with no connection to property
-	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) {

+ 2 - 1
src/WallpaperEngine/Data/Parsers/UserSettingParser.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "WallpaperEngine/Data/JSON.h"
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Data/Model/UserSetting.h"
 #include "WallpaperEngine/Logging/Log.h"
@@ -11,6 +12,6 @@ using namespace WallpaperEngine::Data::Model;
 
 class UserSettingParser {
 public:
-    static UserSettingUniquePtr parse (const json& data, const Properties& properties);
+    static UserSettingUniquePtr parse (const json& data, const Properties& properties, bool expectColor = false);
 };
 } // namespace WallpaperEngine::Data::Parsers

+ 6 - 6
src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp

@@ -39,12 +39,12 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
             .project = project
         }, SceneData {
             .colors = {
-                .ambient  = general.optional ("ambientcolor", glm::vec3 (0.0f)),
-                .skylight = general.optional ("skylightcolor", glm::vec3 (0.0f)),
+                .ambient  = general.user ("ambientcolor", properties, glm::vec3 (0.0f)),
+                .skylight = general.user ("skylightcolor", properties, glm::vec3 (0.0f)),
                 .clear = general.user ("clearcolor", properties, glm::vec3 (1.0f)),
             },
             .camera = {
-                .fade = general.optional ("camerafade", false),
+                .fade = general.user ("camerafade", properties, false),
                 .preview = general.optional ("camerapreview", false),
                 .bloom = {
                     .enabled = general.user ("bloom", properties, false),
@@ -72,9 +72,9 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
                     .width  = projection.optional ("auto", false) ? 0 : projection.require <int> ("width",  "Projection must have a width"),
                     .height = projection.optional ("auto", false) ? 0 : projection.require <int> ("height", "Projection must have a height"),
                     .isAuto = projection.optional ("auto", false),
-                    .nearz = camera.optional <float> ("nearz", 0.0f),
-                    .farz = camera.optional <float> ("farz", 1000.0f),
-                    .fov = camera.optional <float> ("fov", 50.0f)
+                    .nearz = camera.user ("nearz", properties, 0.0f),
+                    .farz = camera.user ("farz", properties, 1000.0f),
+                    .fov = camera.user ("fov", properties, 50.0f)
                 }
             },
             .objects = parseObjects (objects, project),

+ 35 - 0
src/WallpaperEngine/Data/Utils/ScopeGuard.h

@@ -0,0 +1,35 @@
+#pragma once
+#include <utility>
+
+namespace WallpaperEngine::Data::Utils {
+/**
+ * Simple basic class that runs code on scope exit
+ */
+template <typename F> struct ScopeGuard {
+    ScopeGuard (const ScopeGuard& other) = delete;
+    ScopeGuard& operator= (const ScopeGuard& other) = delete;
+
+    explicit ScopeGuard (F&& f) : func (std::forward<F> (f)), owner (true) { }
+    explicit ScopeGuard (ScopeGuard&& other) noexcept : func (std::move (other.func)), owner (other.owner) {
+	other.owner = false;
+    }
+    ~ScopeGuard () { this->execute (); }
+
+    /**
+     * Prevents the guard from being run on scope exit
+     */
+    void cancel () { this->owner = false; }
+
+protected:
+    void execute () {
+	if (this->owner) {
+	    this->func ();
+	}
+
+	this->owner = false;
+    }
+
+    F func;
+    bool owner;
+};
+};

+ 5 - 7
src/WallpaperEngine/Data/Utils/TypeCaster.h

@@ -6,29 +6,27 @@ namespace WallpaperEngine::Data::Utils {
 /**
  * A simple implementation of type and sub-types casting to be as explicit
  * as possible while being performant
- *
- * Uses typeid from C++17, so base types must have a virtual, default destructor
  */
 class TypeCaster {
 public:
     virtual ~TypeCaster () = default;
 
     template <class T> [[nodiscard]] const T* as () const {
-	if (is<T> ()) {
-	    return static_cast<const T*> (this);
+	if (auto* ptr = dynamic_cast<const T*> (this)) {
+	    return ptr;
 	}
 
 	throw std::bad_cast ();
     }
 
     template <class T> [[nodiscard]] T* as () {
-	if (is<T> ()) {
-	    return static_cast<T*> (this);
+	if (auto* ptr = dynamic_cast<T*> (this)) {
+	    return ptr;
 	}
 
 	throw std::bad_cast ();
     }
 
-    template <class T> [[nodiscard]] bool is () const { return typeid (*this) == typeid (T); }
+    template <class T> [[nodiscard]] bool is () const { return dynamic_cast<const T*> (this) != nullptr; }
 };
 } // namespace WallpaperEngine::Data::Utils

+ 7 - 3
src/WallpaperEngine/FileSystem/Adapters/Directory.cpp

@@ -64,10 +64,14 @@ std::filesystem::path DirectoryAdapter::physicalPath (const std::filesystem::pat
 }
 
 bool DirectoryFactory::handlesMountpoint (const std::filesystem::path& path) const {
-    const auto finalpath = std::filesystem::canonical (path);
-    const auto status = std::filesystem::status (finalpath);
+    try {
+	const auto finalpath = std::filesystem::canonical (path);
+	const auto status = std::filesystem::status (finalpath);
 
-    return std::filesystem::exists (finalpath) && std::filesystem::is_directory (status);
+	return std::filesystem::exists (finalpath) && std::filesystem::is_directory (status);
+    } catch (std::filesystem::filesystem_error&) {
+	return false;
+    }
 }
 
 AdapterSharedPtr DirectoryFactory::create (const std::filesystem::path& path) const {

+ 64 - 0
src/WallpaperEngine/FileSystem/Adapters/MediaCover.cpp

@@ -0,0 +1,64 @@
+#include <filesystem>
+#include <fstream>
+#include <memory>
+
+#include "MediaCover.h"
+
+#include "WallpaperEngine/Assets/AssetLoadException.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Media/MediaSource.h"
+
+using namespace WallpaperEngine::FileSystem;
+using namespace WallpaperEngine::FileSystem::Adapters;
+
+ReadStreamSharedPtr MediaCoverAdapter::open (const std::filesystem::path& path) const {
+    if (path != "$mediaThumbnail") {
+	throw std::filesystem::filesystem_error (
+	    "MediaCoverAdapter only supports $mediaThumbnail", path, std::error_code ()
+	);
+    }
+
+    if (!source.getMediaInfo ().url.has_value ()) {
+	throw std::filesystem::filesystem_error ("Media source does not have a valid URL", path, std::error_code ());
+    }
+
+    std::string album = *source.getMediaInfo ().url;
+
+    if (album.starts_with ("file://")) {
+	album = album.substr (7);
+    } else {
+	throw std::filesystem::filesystem_error (
+	    "Only file:// URLs are supported for media covers", album, std::error_code ()
+	);
+    }
+
+    std::filesystem::path file = std::filesystem::absolute (album);
+
+    if (std::filesystem::exists (file) == false) {
+	throw std::filesystem::filesystem_error ("Media file does not exist", file, std::error_code ());
+    }
+
+    if (std::filesystem::is_regular_file (file) == false) {
+	throw std::filesystem::filesystem_error ("Media file is not a regular file", file, std::error_code ());
+    }
+
+    return std::make_shared<std::ifstream> (file);
+}
+
+bool MediaCoverAdapter::exists (const std::filesystem::path& path) const { return path == ""; }
+
+std::filesystem::path MediaCoverAdapter::physicalPath (const std::filesystem::path& path) const {
+    sLog.exception ("MediaCoverAdapter does not support realpath");
+}
+
+bool MediaCoverFactory::handlesMountpoint (const std::filesystem::path& path) const {
+    return path == "$mediaThumbnail";
+}
+
+AdapterSharedPtr MediaCoverFactory::create (const std::filesystem::path& path) const {
+    if (path != "$mediaThumbnail") {
+	sLog.exception ("MediaCoveradapter only supports $mediaThumbnail");
+    }
+
+    return std::make_unique<MediaCoverAdapter> (source);
+}

+ 30 - 0
src/WallpaperEngine/FileSystem/Adapters/MediaCover.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <filesystem>
+#include <utility>
+
+#include "Types.h"
+
+namespace WallpaperEngine::Media {
+class MediaSource;
+}
+namespace WallpaperEngine::FileSystem::Adapters {
+struct MediaCoverFactory final : Factory {
+    MediaCoverFactory (Media::MediaSource& source) : source (source) { }
+
+    [[nodiscard]] bool handlesMountpoint (const std::filesystem::path& path) const override;
+    [[nodiscard]] AdapterSharedPtr create (const std::filesystem::path& path) const override;
+
+    Media::MediaSource& source;
+};
+
+struct MediaCoverAdapter final : Adapter {
+    explicit MediaCoverAdapter (Media::MediaSource& source) : source (source) { }
+
+    [[nodiscard]] ReadStreamSharedPtr open (const std::filesystem::path& path) const override;
+    [[nodiscard]] bool exists (const std::filesystem::path& path) const override;
+    [[nodiscard]] std::filesystem::path physicalPath (const std::filesystem::path& path) const override;
+
+    Media::MediaSource& source;
+};
+}

+ 9 - 5
src/WallpaperEngine/FileSystem/Adapters/Package.cpp

@@ -49,11 +49,15 @@ std::filesystem::path PackageAdapter::physicalPath (const std::filesystem::path&
 }
 
 bool PackageFactory::handlesMountpoint (const std::filesystem::path& path) const {
-    const auto finalpath = std::filesystem::canonical (path);
-    const auto status = std::filesystem::status (finalpath);
-
-    return std::filesystem::exists (finalpath) && std::filesystem::is_regular_file (status)
-	&& finalpath.extension () == ".pkg";
+    try {
+	const auto finalpath = std::filesystem::canonical (path);
+	const auto status = std::filesystem::status (finalpath);
+
+	return std::filesystem::exists (finalpath) && std::filesystem::is_regular_file (status)
+	    && finalpath.extension () == ".pkg";
+    } catch (std::filesystem::filesystem_error&) {
+	return false;
+    }
 }
 
 AdapterSharedPtr PackageFactory::create (const std::filesystem::path& path) const {

+ 9 - 1
src/WallpaperEngine/FileSystem/Container.cpp

@@ -36,7 +36,13 @@ Container::Container () {
 ReadStreamSharedPtr Container::read (const std::filesystem::path& path) const {
     const auto normalized = normalize_path (path);
 
-    return this->resolveAdapterForFile (path).open (normalized);
+    ReadStreamSharedPtr result = this->resolveAdapterForFile (path).open (normalized);
+
+    if (result->fail ()) {
+	throw std::runtime_error ("Failed to open file: " + normalized.string ());
+    }
+
+    return result;
 }
 
 std::string Container::readString (const std::filesystem::path& path) const {
@@ -94,3 +100,5 @@ Adapter& Container::resolveAdapterForFile (const std::filesystem::path& path) co
 	"Cannot find requested file in any of the mountpoints", path, std::error_code ()
     );
 }
+
+void Container::registerAdapterFactory (FactoryUniquePtr factory) { this->m_factories.push_back (std::move (factory)); }

+ 5 - 0
src/WallpaperEngine/FileSystem/Container.h

@@ -52,6 +52,11 @@ public:
      */
     VirtualAdapter& getVFS () const;
 
+    /**
+     * @param factory The factory to register for this container
+     */
+    void registerAdapterFactory (FactoryUniquePtr factory);
+
 private:
     /**
      * Searches for an adapter to handle the given file

+ 23 - 26
src/WallpaperEngine/Input/Drivers/WaylandMouseInput.cpp

@@ -1,9 +1,9 @@
 #include "WaylandMouseInput.h"
 #include "WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h"
-#include <glm/common.hpp>
 #include <chrono>
 #include <cstdlib>
 #include <cstring>
+#include <glm/common.hpp>
 #include <regex>
 #include <string>
 #include <sys/socket.h>
@@ -13,24 +13,6 @@
 
 using namespace WallpaperEngine::Input::Drivers;
 
-namespace {
-const WallpaperEngine::Render::Drivers::Output::WaylandOutputViewport* getActiveViewport (
-    const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& driver
-) {
-    if (driver.viewportInFocus && driver.viewportInFocus->rendering) {
-	return driver.viewportInFocus;
-    }
-
-    for (const auto* viewport : driver.m_screens) {
-	if (viewport && viewport->rendering) {
-	    return viewport;
-	}
-    }
-
-    return nullptr;
-}
-}
-
 WaylandMouseInput::WaylandMouseInput (const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& driver) :
     m_waylandDriver (driver) { }
 
@@ -80,7 +62,8 @@ glm::dvec2 WaylandMouseInput::position () const {
 	return { 0, 0 };
     }
 
-    const auto* viewport = getActiveViewport (m_waylandDriver);
+    const auto* viewport = this->getActiveOutputViewport ();
+
     if (!viewport) {
 	return { 0, 0 };
     }
@@ -100,7 +83,7 @@ glm::dvec2 WaylandMouseInput::position () const {
 }
 
 WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::leftClick () const {
-    const auto* viewport = getActiveViewport (m_waylandDriver);
+    const auto* viewport = this->getActiveOutputViewport ();
     if (viewport) {
 	return viewport->leftClick;
     }
@@ -108,6 +91,21 @@ WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::leftClick () const {
     return MouseClickStatus::Released;
 }
 
+const WallpaperEngine::Render::Drivers::Output::WaylandOutputViewport*
+WaylandMouseInput::getActiveOutputViewport () const {
+    if (this->m_waylandDriver.viewportInFocus && this->m_waylandDriver.viewportInFocus->rendering) {
+	return this->m_waylandDriver.viewportInFocus;
+    }
+
+    for (const auto* viewport : this->m_waylandDriver.m_screens) {
+	if (viewport && viewport->rendering) {
+	    return viewport;
+	}
+    }
+
+    return nullptr;
+}
+
 std::optional<glm::dvec2> WaylandMouseInput::queryHyprlandCursorPosition () const {
     const char* signature = std::getenv ("HYPRLAND_INSTANCE_SIGNATURE");
     const char* runtime = std::getenv ("XDG_RUNTIME_DIR");
@@ -124,10 +122,8 @@ std::optional<glm::dvec2> WaylandMouseInput::queryHyprlandCursorPosition () cons
 
     timeval timeout {};
     timeout.tv_usec = 50000;
-    if (
-	setsockopt (fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof (timeout)) != 0
-	|| setsockopt (fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof (timeout)) != 0
-    ) {
+    if (setsockopt (fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof (timeout)) != 0
+	|| setsockopt (fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof (timeout)) != 0) {
 	close (fd);
 	return std::nullopt;
     }
@@ -176,7 +172,8 @@ std::optional<glm::dvec2> WaylandMouseInput::queryHyprlandCursorPosition () cons
 }
 
 WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::rightClick () const {
-    const auto* viewport = getActiveViewport (m_waylandDriver);
+    const auto* viewport = this->getActiveOutputViewport ();
+
     if (viewport) {
 	return viewport->rightClick;
     }

+ 5 - 1
src/WallpaperEngine/Input/Drivers/WaylandMouseInput.h

@@ -4,11 +4,14 @@
 
 #include "WallpaperEngine/Input/MouseInput.h"
 
-#include <glm/vec2.hpp>
 #include <chrono>
+#include <glm/vec2.hpp>
 #include <optional>
 
 namespace WallpaperEngine::Render::Drivers {
+namespace Output {
+    class WaylandOutputViewport;
+}
 class WaylandOpenGLDriver;
 };
 
@@ -41,6 +44,7 @@ public:
     [[nodiscard]] MouseClickStatus rightClick () const override;
 
 private:
+    [[nodiscard]] const Render::Drivers::Output::WaylandOutputViewport* getActiveOutputViewport () const;
     [[nodiscard]] std::optional<glm::dvec2> queryHyprlandCursorPosition () const;
 
     /**

+ 1 - 1
src/WallpaperEngine/Logging/Log.cpp

@@ -9,7 +9,7 @@ Log::Log () { assert (this->sInstance == nullptr); }
 
 Log& Log::get () {
     if (sInstance == nullptr) {
-	sInstance = std::make_unique<Log> ();
+	sInstance = std::unique_ptr<Log> (new Log ());
     }
 
     return *sInstance;

+ 2 - 2
src/WallpaperEngine/Logging/Log.h

@@ -12,8 +12,6 @@ namespace WallpaperEngine::Logging {
  */
 class Log {
 public:
-    Log ();
-
     void addOutput (std::ostream* stream);
     void addError (std::ostream* stream);
 
@@ -75,6 +73,8 @@ public:
     static Log& get ();
 
 private:
+    Log ();
+
     template <typename... Data> std::string buildBuffer (Data... data) {
 	// buffer the string first
 	std::stringbuf buffer;

+ 32 - 0
src/WallpaperEngine/Maths.cpp

@@ -0,0 +1,32 @@
+#include "Maths.h"
+
+using namespace WallpaperEngine::Maths;
+
+float WallpaperEngine::Maths::randomFloat (std::mt19937& rng, float min, float max) {
+    if (max < min) {
+	std::swap (min, max);
+    }
+    std::uniform_real_distribution<float> dist (min, max);
+    return dist (rng);
+}
+
+glm::vec3 WallpaperEngine::Maths::randomVec3 (std::mt19937& rng, const glm::vec3& min, const glm::vec3& max) {
+    return glm::vec3 (
+	randomFloat (rng, min.x, max.x), randomFloat (rng, min.y, max.y), randomFloat (rng, min.z, max.z)
+    );
+}
+
+// Helper: Linear interpolation
+float WallpaperEngine::Maths::lerp (float t, float a, float b) { return a + t * (b - a); }
+
+// Helper: Fade value change over lifetime
+float WallpaperEngine::Maths::fadeValue (float life, float startTime, float endTime, float startValue, float endValue) {
+    if (life <= startTime) {
+	return startValue;
+    } else if (life >= endTime) {
+	return endValue;
+    } else {
+	float t = (life - startTime) / (endTime - startTime);
+	return lerp (t, startValue, endValue);
+    }
+}

+ 11 - 0
src/WallpaperEngine/Maths.h

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <glm/vec3.hpp>
+#include <random>
+
+namespace WallpaperEngine::Maths {
+float randomFloat (std::mt19937& rng, float min, float max);
+glm::vec3 randomVec3 (std::mt19937& rng, const glm::vec3& min, const glm::vec3& max);
+float lerp (float t, float a, float b);
+float fadeValue (float life, float startTime, float endTime, float startValue, float endValue);
+}

+ 370 - 0
src/WallpaperEngine/Media/DBusMediaSource.cpp

@@ -0,0 +1,370 @@
+#include "DBusMediaSource.h"
+
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Logging/Log.h"
+
+using namespace WallpaperEngine::Media;
+
+DBusHandlerResult dbus_message_filter (DBusConnection* connection, DBusMessage* message, void* user_data) {
+    const auto mediaSource = static_cast<DBusMediaSource*> (user_data);
+
+    if (!dbus_message_is_signal (message, "org.freedesktop.DBus.Properties", "PropertiesChanged")) {
+	return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+    }
+
+    const char* interface = nullptr;
+    DBusMessageIter iter;
+
+    dbus_message_iter_init (message, &iter);
+    dbus_message_iter_get_basic (&iter, &interface);
+
+    std::string iface = interface ?: "";
+
+    if (iface != "org.mpris.MediaPlayer2.Player") {
+	return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+    }
+
+    DBusMessageIter changed;
+
+    dbus_message_iter_next (&iter);
+    dbus_message_iter_recurse (&iter, &changed);
+
+    while (dbus_message_iter_get_arg_type (&changed) == DBUS_TYPE_DICT_ENTRY) {
+	DBusMessageIter entry;
+	dbus_message_iter_recurse (&changed, &entry);
+
+	const char* key = nullptr;
+	dbus_message_iter_get_basic (&entry, &key);
+
+	std::string keyStr = key ?: "";
+
+	DBusMessageIter value;
+	dbus_message_iter_next (&entry);
+	dbus_message_iter_recurse (&entry, &value);
+
+	if (keyStr == "Metadata") {
+	    mediaSource->parseMetadata (value);
+	} else if (keyStr == "PlaybackStatus") {
+	    mediaSource->parsePlaybackStatus (value, dbus_message_get_sender (message));
+	}
+
+	dbus_message_iter_next (&changed);
+    }
+
+    return DBUS_HANDLER_RESULT_HANDLED;
+}
+
+DBusMediaSource::DBusMediaSource (std::chrono::milliseconds updateInterval) : MediaSource (updateInterval) {
+    DBusError err;
+
+    dbus_error_init (&err);
+
+    this->m_connection = dbus_bus_get (DBUS_BUS_SESSION, &err);
+
+    if (!this->m_connection) {
+	sLog.exception ("Could not connect to DBus: ", err.message);
+    }
+
+    dbus_connection_add_filter (this->m_connection, dbus_message_filter, this, nullptr);
+
+    dbus_bus_add_match (
+	this->m_connection, "type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'",
+	nullptr
+    );
+
+    dbus_connection_flush (this->m_connection);
+
+    this->detectPlayer ();
+    this->initialStatusFetch ();
+}
+
+DBusMediaSource::~DBusMediaSource () {
+    dbus_connection_remove_filter (this->m_connection, dbus_message_filter, this);
+
+    dbus_connection_unref (this->m_connection);
+}
+
+void DBusMediaSource::parseMetadata (DBusMessageIter& variant) {
+    DBusMessageIter dict;
+    dbus_message_iter_recurse (&variant, &dict);
+
+    bool metadataUpdate = false;
+    bool albumUpdate = false;
+
+    while (dbus_message_iter_get_arg_type (&dict) == DBUS_TYPE_DICT_ENTRY) {
+	DBusMessageIter entry;
+	dbus_message_iter_recurse (&dict, &entry);
+
+	const char* key = nullptr;
+	dbus_message_iter_get_basic (&entry, &key);
+
+	std::string keyStr = key ?: "";
+
+	DBusMessageIter value;
+	dbus_message_iter_next (&entry);
+	dbus_message_iter_recurse (&entry, &value);
+
+	if (keyStr == "xesam:title") {
+	    const char* title = nullptr;
+	    dbus_message_iter_get_basic (&value, &title);
+
+	    std::string titleStr = title ?: "";
+
+	    if (this->m_mediaInfo.title != titleStr) {
+		this->m_mediaInfo.title = titleStr;
+		metadataUpdate = true;
+	    }
+	} else if (keyStr == "xesam:artist") {
+	    DBusMessageIter arr;
+
+	    dbus_message_iter_recurse (&value, &arr);
+
+	    if (dbus_message_iter_get_arg_type (&arr) == DBUS_TYPE_STRING) {
+		const char* artist = nullptr;
+		dbus_message_iter_get_basic (&arr, &artist);
+
+		std::string artistStr = artist ?: "";
+
+		if (this->m_mediaInfo.artist != artistStr) {
+		    this->m_mediaInfo.artist = artistStr;
+		    metadataUpdate = true;
+		}
+	    }
+	} else if (keyStr == "xesam:album") {
+	    const char* album = nullptr;
+	    dbus_message_iter_get_basic (&value, &album);
+
+	    std::string albumStr = album ?: "";
+
+	    if (this->m_mediaInfo.album != albumStr) {
+		this->m_mediaInfo.album = albumStr;
+		albumUpdate = true;
+	    }
+	} else if (keyStr == "mpris:artUrl") {
+	    const char* artUrl = nullptr;
+	    dbus_message_iter_get_basic (&value, &artUrl);
+
+	    std::string artUrlStr = artUrl ?: "";
+
+	    if (artUrlStr.empty () && this->m_mediaInfo.url.has_value ()) {
+		this->m_mediaInfo.url.reset ();
+		albumUpdate = true;
+	    } else if (this->m_mediaInfo.url.has_value () && artUrlStr != *this->m_mediaInfo.url) {
+		this->m_mediaInfo.url = artUrlStr;
+		albumUpdate = true;
+	    } else if (!this->m_mediaInfo.url.has_value ()) {
+		this->m_mediaInfo.url = artUrlStr;
+		albumUpdate = true;
+	    }
+	} else if (keyStr == "mpris:length") {
+	    int64_t length = 0;
+	    dbus_message_iter_get_basic (&value, &length);
+
+	    if (this->m_mediaInfo.duration != length) {
+		this->m_mediaInfo.duration = length;
+		metadataUpdate = true;
+	    }
+	}
+
+	dbus_message_iter_next (&dict);
+    }
+
+    sLog.debug (
+	"Player metadata received: title=", this->m_mediaInfo.title, ",artist=", this->m_mediaInfo.artist,
+	",album=", this->m_mediaInfo.album, ",url=", this->m_mediaInfo.url.has_value () ? *this->m_mediaInfo.url : ""
+    );
+
+    if (metadataUpdate) {
+	this->fireMetadataListeners ();
+    }
+
+    if (albumUpdate) {
+	this->fireAlbumArtListeners ();
+    }
+}
+
+void DBusMediaSource::parsePlaybackStatus (DBusMessageIter& variant, const char* sender) {
+    const char* status = nullptr;
+    dbus_message_iter_get_basic (&variant, &status);
+    std::string statusStr = status ?: "";
+    PlaybackState newState = this->m_mediaInfo.playbackState;
+
+    if (statusStr == "Playing") {
+	if (sender != nullptr) {
+	    this->m_currentPlayer = sender;
+	}
+
+	newState = PlaybackState::Playing;
+    } else if (statusStr == "Paused") {
+	newState = PlaybackState::Paused;
+    } else {
+	newState = PlaybackState::Stopped;
+    }
+
+    if (newState != this->m_mediaInfo.playbackState) {
+	this->m_mediaInfo.playbackState = newState;
+	this->fireMetadataListeners ();
+    }
+}
+
+void DBusMediaSource::parsePosition (DBusMessageIter& variant) {
+    int64_t position = 0;
+    dbus_message_iter_get_basic (&variant, &position);
+
+    if (this->m_mediaInfo.position != position) {
+	this->m_mediaInfo.position = position;
+	this->fireMetadataListeners ();
+    }
+}
+
+void DBusMediaSource::update () {
+    this->MediaSource::update ();
+
+    // drain any dbus events
+    dbus_connection_read_write (this->m_connection, 0);
+
+    while (dbus_connection_dispatch (this->m_connection) == DBUS_DISPATCH_DATA_REMAINS)
+	;
+}
+
+DBusMessage* DBusMediaSource::dbusMessage (
+    const char* bus_name, const char* path, const char* interface, const char* method, const char* iface,
+    const char* prop
+) {
+    DBusError err;
+    dbus_error_init (&err);
+
+    DBusMessage* msg = dbus_message_new_method_call (bus_name, path, interface, method);
+    Data::Utils::ScopeGuard guard ([msg] { dbus_message_unref (msg); });
+
+    if (iface != nullptr && prop != nullptr) {
+	dbus_message_append_args (msg, DBUS_TYPE_STRING, &iface, DBUS_TYPE_STRING, &prop, DBUS_TYPE_INVALID);
+    }
+
+    DBusMessage* reply = dbus_connection_send_with_reply_and_block (m_connection, msg, -1, &err);
+
+    if (reply == nullptr) {
+	sLog.error ("DBus error: ", err.message, " (", err.name, ")");
+	return nullptr;
+    }
+
+    return reply;
+}
+
+void DBusMediaSource::detectPlayer () {
+    DBusMessage* reply
+	= this->dbusMessage ("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames");
+
+    if (reply == nullptr) {
+	return;
+    }
+
+    Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); });
+    std::vector<std::string> players;
+    DBusMessageIter iter;
+    dbus_message_iter_init (reply, &iter);
+
+    if (dbus_message_iter_get_arg_type (&iter) != DBUS_TYPE_ARRAY) {
+	return;
+    }
+
+    DBusMessageIter array;
+    dbus_message_iter_recurse (&iter, &array);
+
+    while (dbus_message_iter_get_arg_type (&array) != DBUS_TYPE_INVALID) {
+	char* name;
+	dbus_message_iter_get_basic (&array, &name);
+
+	std::string service = name;
+
+	if (service.starts_with ("org.mpris.MediaPlayer2.")) {
+	    players.push_back (service);
+	}
+
+	dbus_message_iter_next (&array);
+    }
+
+    if (players.empty ()) {
+	return;
+    }
+
+    for (const auto& player : players) {
+	// also get playback status
+	reply = this->dbusMessage (
+	    player.c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get",
+	    "org.mpris.MediaPlayer2.Player", "PlaybackStatus"
+	);
+
+	if (reply == nullptr) {
+	    return;
+	}
+
+	Data::Utils::ScopeGuard guard2 ([reply] { dbus_message_unref (reply); });
+
+	DBusMessageIter outer;
+	dbus_message_iter_init (reply, &outer);
+	DBusMessageIter variant;
+	dbus_message_iter_recurse (&outer, &variant);
+
+	this->parsePlaybackStatus (variant, player.c_str ());
+
+	if (this->m_currentPlayer.has_value ()) {
+	    break;
+	}
+    }
+}
+
+void DBusMediaSource::initialStatusFetch () {
+    if (this->m_currentPlayer.has_value () == false) {
+	return;
+    }
+
+    DBusMessage* reply = this->dbusMessage (
+	this->m_currentPlayer.value ().c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get",
+	"org.mpris.MediaPlayer2.Player", "Metadata"
+    );
+
+    if (reply == nullptr) {
+	return;
+    }
+
+    Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); });
+    DBusMessageIter outer;
+    dbus_message_iter_init (reply, &outer);
+
+    DBusMessageIter variant;
+    dbus_message_iter_recurse (&outer, &variant);
+
+    this->parseMetadata (variant);
+}
+
+void DBusMediaSource::performUpdate () {
+    // nothing to do if no player is detected
+    if (!this->m_currentPlayer.has_value ()) {
+	return;
+    }
+
+    DBusMessage* reply = this->dbusMessage (
+	this->m_currentPlayer.value ().c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get",
+	"org.mpris.MediaPlayer2.Player", "Position"
+    );
+
+    if (reply == nullptr) {
+	return;
+    }
+
+    Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); });
+    DBusMessageIter outer;
+    dbus_message_iter_init (reply, &outer);
+
+    DBusMessageIter variant;
+    dbus_message_iter_recurse (&outer, &variant);
+
+    dbus_int64_t position = 0;
+    dbus_message_iter_get_basic (&variant, &position);
+
+    if (this->m_mediaInfo.position != position) {
+	this->m_mediaInfo.position = position;
+	this->fireMetadataListeners ();
+    }
+}

+ 32 - 0
src/WallpaperEngine/Media/DBusMediaSource.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include "MediaSource.h"
+#include <dbus/dbus.h>
+
+namespace WallpaperEngine::Media {
+class DBusMediaSource : public MediaSource {
+public:
+    explicit DBusMediaSource (std::chrono::milliseconds updateInterval);
+    ~DBusMediaSource () override;
+
+    void parseMetadata (DBusMessageIter& variant);
+    void parsePlaybackStatus (DBusMessageIter& variant, const char* sender);
+    void parsePosition (DBusMessageIter& variant);
+
+    void update () override;
+
+protected:
+    void performUpdate () override;
+    void initialStatusFetch ();
+    void detectPlayer ();
+
+    DBusMessage* dbusMessage (
+	const char* bus_name, const char* path, const char* interface, const char* method, const char* iface = nullptr,
+	const char* prop = nullptr
+    );
+
+    std::optional<std::string> m_currentPlayer = std::nullopt;
+
+    DBusConnection* m_connection;
+};
+}

+ 80 - 0
src/WallpaperEngine/Media/MediaSource.cpp

@@ -0,0 +1,80 @@
+#include "MediaSource.h"
+
+#include <ranges>
+
+using namespace WallpaperEngine::Media;
+
+MediaSource::MediaSource (std::chrono::milliseconds updateInterval) :
+    m_aliveFlag (std::make_unique<bool> (true)), m_mediaInfo (
+						     {
+							 .playbackState = PlaybackState::Stopped,
+							 .title = "",
+							 .artist = "",
+							 .url = std::nullopt,
+							 .duration = 0.0f,
+							 .position = 0.0f,
+							 .available = false,
+						     }
+						 ),
+    m_updateInterval (updateInterval) { }
+
+MediaSource::~MediaSource () {
+    if (this->m_aliveFlag) {
+	*m_aliveFlag = false;
+    }
+
+    m_albumArtListeners.clear ();
+    m_metadataListeners.clear ();
+}
+
+void MediaSource::update () {
+    if (std::chrono::steady_clock::now () <= m_nextUpdate) {
+	return;
+    }
+
+    this->performUpdate ();
+}
+
+std::function<void ()> MediaSource::addMetadataListener (std::function<void (const MediaInfo&)> listener) {
+    int listenerId = ++m_metadataListenerId;
+
+    m_metadataListeners.emplace (listenerId, listener);
+
+    auto alive = m_aliveFlag;
+
+    return [this, listenerId, alive] () {
+	if (!alive || !*alive) {
+	    return;
+	}
+
+	m_metadataListeners.erase (listenerId);
+    };
+}
+
+std::function<void ()> MediaSource::addAlbumArtListener (std::function<void (const MediaInfo&)> listener) {
+    int listenerId = ++m_albumArtListenerId;
+
+    m_albumArtListeners.emplace (listenerId, listener);
+
+    auto alive = m_aliveFlag;
+
+    return [this, listenerId, alive] () {
+	if (!alive || !*alive) {
+	    return;
+	}
+
+	m_albumArtListeners.erase (listenerId);
+    };
+}
+
+void MediaSource::fireMetadataListeners () {
+    for (auto& listener : m_metadataListeners | std::views::values) {
+	listener (m_mediaInfo);
+    }
+}
+
+void MediaSource::fireAlbumArtListeners () {
+    for (auto& listener : m_albumArtListeners | std::views::values) {
+	listener (m_mediaInfo);
+    }
+}

+ 62 - 0
src/WallpaperEngine/Media/MediaSource.h

@@ -0,0 +1,62 @@
+#pragma once
+
+#include <chrono>
+#include <functional>
+#include <map>
+#include <string>
+
+namespace WallpaperEngine::Media {
+class MediaSource {
+public:
+    enum PlaybackState {
+	Stopped = 0,
+	Playing = 1,
+	Paused = 2,
+    };
+
+    struct MediaInfo {
+	PlaybackState playbackState;
+	std::string title;
+	std::string artist;
+	std::string album;
+	std::optional<std::string> url;
+	double duration;
+	double position;
+	bool available;
+    };
+
+    explicit MediaSource (std::chrono::milliseconds updateInterval);
+    virtual ~MediaSource ();
+
+    virtual void update ();
+
+    /**
+     * @param listener The callback to call when media info changes
+     *
+     * @return A function that can be called to remove the listener
+     */
+    std::function<void ()> addMetadataListener (std::function<void (const MediaInfo&)> listener);
+    /**
+     * @param listener The callback to call when media info changes
+     *
+     * @return A function that can be called to remove the listener
+     */
+    std::function<void ()> addAlbumArtListener (std::function<void (const MediaInfo&)> listener);
+
+    [[nodiscard]] const MediaInfo& getMediaInfo () const { return m_mediaInfo; }
+
+protected:
+    void fireMetadataListeners ();
+    void fireAlbumArtListeners ();
+    virtual void performUpdate () = 0;
+
+    std::shared_ptr<bool> m_aliveFlag;
+    uint32_t m_metadataListenerId = 0;
+    std::map<uint32_t, std::function<void (MediaInfo&)>> m_metadataListeners;
+    uint32_t m_albumArtListenerId = 0;
+    std::map<uint32_t, std::function<void (MediaInfo&)>> m_albumArtListeners;
+    MediaInfo m_mediaInfo;
+    std::chrono::milliseconds m_updateInterval;
+    std::chrono::time_point<std::chrono::steady_clock> m_nextUpdate;
+};
+}

+ 136 - 0
src/WallpaperEngine/Render/AlbumTexture.cpp

@@ -0,0 +1,136 @@
+#include "AlbumTexture.h"
+
+#include "RenderContext.h"
+#include "WallpaperEngine/Assets/AssetLoadException.h"
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Media/MediaSource.h"
+#include "stb_image.h"
+
+using namespace WallpaperEngine::Render;
+
+int albumtexture_read (void* user, char* data, int size) {
+    auto* stream = static_cast<ReadStream*> (user);
+
+    stream->read (data, size);
+
+    return stream->gcount ();
+}
+
+void albumtexture_skip (void* user, int n) {
+    auto* stream = static_cast<ReadStream*> (user);
+    stream->seekg (n, std::ios::cur);
+}
+
+int albumtexture_eof (void* user) { return static_cast<ReadStream*> (user)->eof (); }
+
+stbi_io_callbacks album_texture_callbacks
+    = { .read = albumtexture_read, .skip = albumtexture_skip, .eof = albumtexture_eof };
+
+AlbumTexture::AlbumTexture (RenderContext& context) : Helpers::ContextAware (context) {
+    // setup a basic texture with clamping and no mipmaps
+    this->m_resolution = glm::vec4 (1.0f, 1.0f, 1.0f, 1.0f);
+
+    glGenTextures (1, &this->m_textureID);
+    glBindTexture (GL_TEXTURE_2D, this->m_textureID);
+
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+
+    glTexParameterf (GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 8.0f);
+}
+
+AlbumTexture::~AlbumTexture () { glDeleteTextures (1, &this->m_textureID); }
+
+GLuint AlbumTexture::getTextureID (uint32_t imageIndex) const { return this->m_textureID; }
+uint32_t AlbumTexture::getTextureWidth (uint32_t imageIndex) const { return this->m_width; }
+uint32_t AlbumTexture::getTextureHeight (uint32_t imageIndex) const { return this->m_height; }
+uint32_t AlbumTexture::getRealWidth () const { return this->m_width; }
+uint32_t AlbumTexture::getRealHeight () const { return this->m_height; }
+TextureFormat AlbumTexture::getFormat () const { return TextureFormat_ARGB8888; }
+uint32_t AlbumTexture::getFlags () const { return TextureFlags_NoFlags; }
+const std::vector<FrameSharedPtr>& AlbumTexture::getFrames () const { return this->m_frames; }
+const glm::vec4* AlbumTexture::getResolution () const { return &this->m_resolution; }
+bool AlbumTexture::isAnimated () const { return false; }
+uint32_t AlbumTexture::getSpritesheetCols () const { return 1; }
+uint32_t AlbumTexture::getSpritesheetRows () const { return 1; }
+uint32_t AlbumTexture::getSpritesheetFrames () const { return 1; }
+float AlbumTexture::getSpritesheetDuration () const { return 0.0f; }
+
+void AlbumTexture::incrementUsageCount () const { }
+void AlbumTexture::decrementUsageCount () const { }
+void AlbumTexture::update () const { }
+
+void AlbumTexture::copyContents (const TextureProvider& other) const noexcept {
+    // fallback to gpu -> cpu -> gpu copy
+    // RGBA8 texture: 4 bytes per pixel
+    size_t bufferSize = other.getTextureWidth (0) * other.getTextureHeight (0) * 4;
+
+    uint8_t* buffer = new uint8_t[bufferSize];
+
+    // Read the source texture
+    glBindTexture (GL_TEXTURE_2D, other.getTextureID (0));
+    glGetnTexImage (GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, bufferSize, buffer);
+
+    // Upload into another texture
+    glBindTexture (GL_TEXTURE_2D, this->m_textureID);
+    glTexImage2D (
+	GL_TEXTURE_2D, 0, GL_RGBA8, other.getTextureWidth (0), other.getTextureHeight (0), 0, GL_RGBA, GL_UNSIGNED_BYTE,
+	buffer
+    );
+
+    delete[] buffer;
+
+    // copy over the important metadata
+    this->m_width = other.getTextureWidth (0);
+    this->m_height = other.getTextureHeight (0);
+    this->m_resolution = *other.getResolution ();
+}
+
+void AlbumTexture::load () const {
+    this->m_width = 0;
+    this->m_height = 0;
+
+    for (const auto& project : this->getContext ().getApp ().getBackgrounds () | std::views::values) {
+	try {
+	    // try to open the file in any of the asset locators
+	    auto contents = project->assetLocator->read ("$mediaThumbnail");
+
+	    int width, height, channels;
+
+	    auto* dataptr
+		= stbi_load_from_callbacks (&album_texture_callbacks, contents.get (), &width, &height, &channels, 4);
+
+	    if (dataptr == nullptr) {
+		continue;
+	    }
+
+	    ScopeGuard guard ([dataptr] { stbi_image_free (dataptr); });
+
+	    if (width == 0 || height == 0) {
+		continue;
+	    }
+
+	    this->m_width = width;
+	    this->m_height = height;
+	    this->m_resolution = glm::vec4 (this->m_width, this->m_height, this->m_width, this->m_height);
+
+	    // setup texture contents
+	    glBindTexture (GL_TEXTURE_2D, this->m_textureID);
+	    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, dataptr);
+	    return;
+	} catch (AssetLoadException&) {
+	    // this is expected if the thumbnail is not available
+	}
+    }
+}
+
+bool AlbumTexture::isReady () const {
+    // these are only ready to be rendered if their content's are present
+    return this->m_width > 0 && this->m_height > 0;
+}

+ 43 - 0
src/WallpaperEngine/Render/AlbumTexture.h

@@ -0,0 +1,43 @@
+#pragma once
+
+#include "Helpers/ContextAware.h"
+#include "TextureProvider.h"
+#include "WallpaperEngine/Media/MediaSource.h"
+
+namespace WallpaperEngine::Render {
+class AlbumTexture : public TextureProvider, public Helpers::ContextAware {
+public:
+    explicit AlbumTexture (RenderContext& context);
+    ~AlbumTexture () override;
+
+    [[nodiscard]] GLuint getTextureID (uint32_t imageIndex) const override;
+    [[nodiscard]] uint32_t getTextureWidth (uint32_t imageIndex) const override;
+    [[nodiscard]] uint32_t getTextureHeight (uint32_t imageIndex) const override;
+    [[nodiscard]] uint32_t getRealWidth () const override;
+    [[nodiscard]] uint32_t getRealHeight () const override;
+    [[nodiscard]] TextureFormat getFormat () const override;
+    [[nodiscard]] uint32_t getFlags () const override;
+    [[nodiscard]] const std::vector<FrameSharedPtr>& getFrames () const override;
+    [[nodiscard]] const glm::vec4* getResolution () const override;
+    [[nodiscard]] bool isAnimated () const override;
+    [[nodiscard]] uint32_t getSpritesheetCols () const override;
+    [[nodiscard]] uint32_t getSpritesheetRows () const override;
+    [[nodiscard]] uint32_t getSpritesheetFrames () const override;
+    [[nodiscard]] float getSpritesheetDuration () const override;
+
+    void incrementUsageCount () const override;
+    void decrementUsageCount () const override;
+    void update () const override;
+
+    void copyContents (const TextureProvider& other) const noexcept;
+    void load () const;
+    bool isReady () const override;
+
+private:
+    std::vector<FrameSharedPtr> m_frames;
+    mutable glm::vec4 m_resolution;
+    mutable uint32_t m_width = 0;
+    mutable uint32_t m_height = 0;
+    GLuint m_textureID = GL_NONE;
+};
+}

+ 2 - 0
src/WallpaperEngine/Render/CFBO.cpp

@@ -132,3 +132,5 @@ float CFBO::getSpritesheetDuration () const {
 void CFBO::incrementUsageCount () const { }
 void CFBO::decrementUsageCount () const { }
 void CFBO::update () const { }
+// FBOs are always ready
+bool CFBO::isReady () const { return true; }

+ 1 - 0
src/WallpaperEngine/Render/CFBO.h

@@ -37,6 +37,7 @@ public:
     void incrementUsageCount () const override;
     void decrementUsageCount () const override;
     void update () const override;
+    bool isReady () const override;
 
 private:
     GLuint m_framebuffer = GL_NONE;

+ 1 - 0
src/WallpaperEngine/Render/CObject.cpp

@@ -9,6 +9,7 @@ using namespace WallpaperEngine::Render::Wallpapers;
 CObject::CObject (Wallpapers::CScene& scene, const Object& object) :
     Helpers::ContextAware (scene), m_scene (scene), m_object (object) { }
 
+void CObject::setup () { }
 void CObject::render () { }
 
 Wallpapers::CScene& CObject::getScene () const { return this->m_scene; }

+ 2 - 19
src/WallpaperEngine/Render/CObject.h

@@ -11,29 +11,12 @@ class CScene;
 }
 
 namespace WallpaperEngine::Render {
-class CObject : public Helpers::ContextAware {
+class CObject : public Helpers::ContextAware, public TypeCaster {
 public:
-    template <class T> [[nodiscard]] const T* as () const {
-	if (is<T> ()) {
-	    return static_cast<const T*> (this);
-	}
-
-	throw std::bad_cast ();
-    }
-
-    template <class T> [[nodiscard]] T* as () {
-	if (is<T> ()) {
-	    return static_cast<T*> (this);
-	}
-
-	throw std::bad_cast ();
-    }
-
-    template <class T> [[nodiscard]] bool is () const { return typeid (*this) == typeid (T); }
-
     CObject (Wallpapers::CScene& scene, const Object& object);
     virtual ~CObject () override = default;
 
+    virtual void setup ();
     virtual void render ();
 
     [[nodiscard]] Wallpapers::CScene& getScene () const;

+ 4 - 1
src/WallpaperEngine/Render/CTexture.cpp

@@ -262,4 +262,7 @@ void CTexture::update () const {
     if (this->m_player) {
 	this->m_player->render ();
     }
-}
+}
+
+// CTextures are always ready to be rendered at all times
+bool CTexture::isReady () const { return true; }

+ 1 - 0
src/WallpaperEngine/Render/CTexture.h

@@ -58,6 +58,7 @@ public:
      * Some textures need to be updated
      */
     void update () const override;
+    bool isReady () const override;
 
 private:
     /**

+ 10 - 8
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -196,8 +196,9 @@ void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
     }
 }
 
-void CWallpaper::render (const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition,
-    const glm::ivec2& logicalSize) {
+void CWallpaper::render (
+    const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition, const glm::ivec2& logicalSize
+) {
     // Get current frame counter from the driver to avoid redundant scene renders
     const uint32_t currentFrame = this->getContext ().getDriver ().getFrameCounter ();
     const bool needsSceneRender = (currentFrame != this->m_lastRenderedFrame);
@@ -250,12 +251,13 @@ void CWallpaper::render (const glm::ivec4& viewport, const bool vflip, const glm
 
 	// Log span debug info only on first few frames
 	if (this->m_lastRenderedFrame < 5) {
-	    sLog.debug ("SPAN DEBUG: viewport=", viewport.z, "x", viewport.w,
-		" globalPos=(", globalPosition.x, ",", globalPosition.y, ")",
-		" span=(", span.totalBounds.x, ",", span.totalBounds.y, ",", span.totalBounds.z, ",", span.totalBounds.w, ")",
-		" rel=[", relLeft, ",", relRight, "]x[", relTop, ",", relBottom, "]",
-		" baseUV=[", baseUstart, ",", baseUend, "]x[", baseVstart, ",", baseVend, "]",
-		" finalUV=[", ustart, ",", uend, "]x[", vstart, ",", vend, "]");
+	    sLog.debug (
+		"SPAN DEBUG: viewport=", viewport.z, "x", viewport.w, " globalPos=(", globalPosition.x, ",",
+		globalPosition.y, ")", " span=(", span.totalBounds.x, ",", span.totalBounds.y, ",", span.totalBounds.z,
+		",", span.totalBounds.w, ")", " rel=[", relLeft, ",", relRight, "]x[", relTop, ",", relBottom, "]",
+		" baseUV=[", baseUstart, ",", baseUend, "]x[", baseVstart, ",", baseVend, "]", " finalUV=[", ustart,
+		",", uend, "]x[", vstart, ",", vend, "]"
+	    );
 	}
     } else {
 	// Normal mode: compute UVs based on viewport dimensions and wallpaper resolution

+ 6 - 21
src/WallpaperEngine/Render/CWallpaper.h

@@ -11,6 +11,7 @@
 #include "WallpaperEngine/Render/RenderContext.h"
 
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
+#include "WallpaperEngine/Media/MediaSource.h"
 
 #include "FBOProvider.h"
 #include "WallpaperState.h"
@@ -33,7 +34,7 @@ using namespace WallpaperEngine::Audio;
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::FileSystem;
 
-class CWallpaper : public Helpers::ContextAware, public FBOProvider {
+class CWallpaper : public Helpers::ContextAware, public FBOProvider, public TypeCaster {
     friend class WallpaperEngine::Application::WallpaperApplication;
 
 public:
@@ -43,31 +44,15 @@ public:
 	glm::ivec4 totalBounds;
     };
 
-    template <class T> [[nodiscard]] const T* as () const {
-	if (is<T> ()) {
-	    return static_cast<const T*> (this);
-	}
-
-	throw std::bad_cast ();
-    }
-
-    template <class T> [[nodiscard]] T* as () {
-	if (is<T> ()) {
-	    return static_cast<T*> (this);
-	}
-
-	throw std::bad_cast ();
-    }
-
-    template <class T> [[nodiscard]] bool is () const { return typeid (*this) == typeid (T); }
-
     virtual ~CWallpaper () override;
 
     /**
      * Performs a render pass of the wallpaper
      */
-    void render (const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition = {0, 0},
-	const glm::ivec2& logicalSize = {0, 0});
+    void render (
+	const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition = { 0, 0 },
+	const glm::ivec2& logicalSize = { 0, 0 }
+    );
 
     /**
      * Pause the renderer

+ 5 - 5
src/WallpaperEngine/Render/Camera.cpp

@@ -33,18 +33,18 @@ float Camera::getWidth () const { return this->m_width; }
 
 float Camera::getHeight () const { return this->m_height; }
 
-float Camera::getFov () const { return this->m_camera.projection.fov; }
+float Camera::getFov () const { return this->m_camera.projection.fov->value->getFloat (); }
 
-float Camera::getNearZ () const { return this->m_camera.projection.nearz; }
+float Camera::getNearZ () const { return this->m_camera.projection.nearz->value->getFloat (); }
 
-float Camera::getFarZ () const { return this->m_camera.projection.farz; }
+float Camera::getFarZ () const { return this->m_camera.projection.farz->value->getFloat (); }
 
 void Camera::setOrthogonalProjection (const float width, const float height) {
     this->m_width = width;
     this->m_height = height;
 
-    float nearz = this->m_camera.projection.nearz;
-    float farz = this->m_camera.projection.farz;
+    float nearz = this->m_camera.projection.nearz->value->getFloat ();
+    float farz = this->m_camera.projection.farz->value->getFloat ();
 
     this->m_projection = glm::ortho<float> (-width / 2.0, width / 2.0, -height / 2.0, height / 2.0, nearz, farz);
     this->m_projection = glm::translate (this->m_projection, this->getEye ());

+ 2 - 2
src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h

@@ -13,9 +13,9 @@ public:
     glm::ivec4 viewport;
     std::string name;
     /** Global position of this viewport in the combined desktop coordinate space */
-    glm::ivec2 globalPosition = {0, 0};
+    glm::ivec2 globalPosition = { 0, 0 };
     /** Logical (unscaled) size in the same coordinate space as globalPosition */
-    glm::ivec2 logicalSize = {0, 0};
+    glm::ivec2 logicalSize = { 0, 0 };
 
     /** Whether this viewport is single in the framebuffer or shares space with more viewports */
     bool single;

+ 136 - 127
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -3,19 +3,24 @@
 #include "CRenderable.h"
 
 #include <algorithm>
-#include <cmath>
 #include <cstring>
 #include <iterator>
 #include <optional>
 #include <sstream>
 
+#include <glm/glm.hpp>
 #include <glm/gtc/matrix_transform.hpp>
+#define GLM_ENABLE_EXPERIMENTAL
+#include <glm/gtx/rotate_vector.hpp>
+#undef GLM_ENABLE_EXPERIMENTAL
 
 #include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/UserSetting.h"
 #include "WallpaperEngine/Data/Parsers/MaterialParser.h"
+#include "WallpaperEngine/Data/Utils/BinaryReader.h"
+#include "WallpaperEngine/Data/Utils/MemoryStream.h"
 #include "WallpaperEngine/Logging/Log.h"
 
 using namespace WallpaperEngine;
@@ -23,6 +28,7 @@ using namespace WallpaperEngine::Render::Objects;
 using namespace WallpaperEngine::Render::Objects::Effects;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Builders;
+using namespace WallpaperEngine::Data::Utils;
 
 namespace {
 glm::vec2 rotateVec2 (const glm::vec2& value, float angle) {
@@ -31,23 +37,7 @@ glm::vec2 rotateVec2 (const glm::vec2& value, float angle) {
     return { value.x * cosAngle - value.y * sinAngle, value.x * sinAngle + value.y * cosAngle };
 }
 
-UserSettingUniquePtr makeStaticSetting (const glm::vec3& value) {
-    auto dynamicValue = std::make_unique<DynamicValue> (value);
-    return std::make_unique<UserSetting> (
-	UserSetting { .value = std::move (dynamicValue), .property = nullptr, .condition = std::nullopt }
-    );
-}
-
-UserSettingUniquePtr makeStaticSetting (float value) {
-    auto dynamicValue = std::make_unique<DynamicValue> (value);
-    return std::make_unique<UserSetting> (
-	UserSetting { .value = std::move (dynamicValue), .property = nullptr, .condition = std::nullopt }
-    );
-}
-
-bool isMagentaNeonTint (const glm::vec3& color) {
-    return color.r > 0.55f && color.g < 0.25f && color.b > 0.45f;
-}
+bool isMagentaNeonTint (const glm::vec3& color) { return color.r > 0.55f && color.g < 0.25f && color.b > 0.45f; }
 
 std::optional<glm::vec3> findMagentaCompositeTint (const Image& image, const std::vector<int>& skippedEffectIds) {
     for (const auto& effect : image.effects) {
@@ -81,18 +71,6 @@ std::optional<glm::vec3> findMagentaCompositeTint (const Image& image, const std
     return std::nullopt;
 }
 
-uint32_t readU32 (const std::vector<char>& data, const size_t offset) {
-    uint32_t value = 0;
-    std::memcpy (&value, data.data () + offset, sizeof (value));
-    return value;
-}
-
-float readFloat (const std::vector<char>& data, const size_t offset) {
-    float value = 0.0f;
-    std::memcpy (&value, data.data () + offset, sizeof (value));
-    return value;
-}
-
 struct PuppetMeshBlock {
     size_t headerOffset = 0;
     uint32_t vertexBytes = 0;
@@ -100,10 +78,11 @@ struct PuppetMeshBlock {
 };
 
 std::optional<PuppetMeshBlock> findPuppetMeshBlock (
-    const std::vector<char>& data, size_t markerSize, size_t mdlsOffset, size_t meshHeaderSize, size_t vertexStride
+    const BinaryReader& reader, size_t markerSize, size_t mdlsOffset, size_t meshHeaderSize, size_t vertexStride
 ) {
     for (size_t offset = markerSize; offset + meshHeaderSize + sizeof (uint32_t) < mdlsOffset; offset++) {
-	const uint32_t candidateVertexBytes = readU32 (data, offset + sizeof (uint32_t));
+	reader.base ().seekg (static_cast<std::streamoff> (offset + sizeof (uint32_t)), std::ios::beg);
+	const uint32_t candidateVertexBytes = reader.nextUInt32 ();
 	const size_t verticesOffset = offset + meshHeaderSize;
 	const size_t indexLengthOffset = verticesOffset + candidateVertexBytes;
 
@@ -112,22 +91,24 @@ std::optional<PuppetMeshBlock> findPuppetMeshBlock (
 	    continue;
 	}
 
-	const uint32_t candidateIndexBytes = readU32 (data, indexLengthOffset);
+	reader.base ().seekg (static_cast<std::streamoff> (indexLengthOffset), std::ios::beg);
+	const uint32_t candidateIndexBytes = reader.nextUInt32 ();
 	const size_t indicesOffset = indexLengthOffset + sizeof (uint32_t);
 	if (candidateIndexBytes == 0 || candidateIndexBytes % (sizeof (uint16_t) * 3) != 0
 	    || indicesOffset + candidateIndexBytes > mdlsOffset) {
 	    continue;
 	}
 
-	return PuppetMeshBlock { .headerOffset = offset, .vertexBytes = candidateVertexBytes, .indexBytes = candidateIndexBytes };
+	return PuppetMeshBlock { .headerOffset = offset,
+				 .vertexBytes = candidateVertexBytes,
+				 .indexBytes = candidateIndexBytes };
     }
 
     return std::nullopt;
 }
 }
 
-CImage::ResolvedTransform CImage::resolveTransform (const Object& object, const int depth) const {
-    constexpr int kMaxParentDepth = 32;
+CImage::ResolvedTransform CImage::localTransform (const Object& object) {
     glm::vec3 origin = object.origin->value->getVec3 ();
     glm::vec3 scale = glm::vec3 (1.0f);
     float angle = 0.0f;
@@ -144,39 +125,64 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object, const
 	angle = object.groupAngles->value->getVec3 ().z;
     }
 
-    if (!object.parent.has_value ()) {
-	return { origin, scale, angle };
-    }
+    return { origin, scale, angle };
+}
 
-    if (depth >= kMaxParentDepth) {
-	sLog.error ("Parent transform chain is too deep; possible cycle at object id=", object.id);
-	return { origin, scale, angle };
-    }
+CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const {
+    constexpr int kMaxParentDepth = 32;
 
-    const auto* parentObject = this->getScene ().getObject (object.parent.value ());
-    if (parentObject == nullptr) {
-	return { origin, scale, angle };
+    // Walk up the parent chain leaf-first, bounded by kMaxParentDepth to guard
+    // against cycles. chain[0] is the requested object; the last entry is the root.
+    const Object* chain[kMaxParentDepth + 1];
+    int count = 0;
+    const Object* current = &object;
+    chain[count++] = current;
+
+    while (current->parent.has_value ()) {
+	if (count > kMaxParentDepth) {
+	    sLog.error ("Parent transform chain is too deep; possible cycle at object id=", current->id);
+	    break;
+	}
+	const auto* parentObject = this->getScene ().getObject (current->parent.value ());
+	if (parentObject == nullptr) {
+	    break;
+	}
+	current = &parentObject->getObject ();
+	chain[count++] = current;
     }
 
-    const auto& parent = parentObject->getObject ();
-    const auto parentTransform = this->resolveTransform (parent, depth + 1);
-    const glm::vec2 local = rotateVec2 ({ origin.x * parentTransform.scale.x, origin.y * parentTransform.scale.y }, parentTransform.angle);
-    origin.x = parentTransform.origin.x + local.x;
-    origin.y = parentTransform.origin.y + local.y;
-    origin.z = parentTransform.origin.z + origin.z * parentTransform.scale.z;
-    scale *= parentTransform.scale;
-    angle += parentTransform.angle;
+    // Accumulate top-down: the root's local transform is already its resolved
+    // transform, then fold each child onto its already-resolved parent.
+    ResolvedTransform resolved = localTransform (*chain[count - 1]);
+    for (int i = count - 2; i >= 0; --i) {
+	ResolvedTransform local = localTransform (*chain[i]);
+	const glm::vec2 offset
+	    = rotateVec2 ({ local.origin.x * resolved.scale.x, local.origin.y * resolved.scale.y }, resolved.angle);
+	local.origin.x = resolved.origin.x + offset.x;
+	local.origin.y = resolved.origin.y + offset.y;
+	local.origin.z = resolved.origin.z + local.origin.z * resolved.scale.z;
+	resolved = { local.origin, local.scale * resolved.scale, local.angle + resolved.angle };
+    }
 
-    return { origin, scale, angle };
+    return resolved;
 }
 
 CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
-    CRenderable (scene, image, *image.model->material), m_sceneSpacePosition (GL_NONE), m_copySpacePosition (GL_NONE),
-    m_passSpacePosition (GL_NONE), m_texcoordCopy (GL_NONE), m_texcoordPass (GL_NONE), m_modelViewProjectionScreen (),
+    CObject (scene, image), CRenderable (scene, image, *image.model->material), ScriptableObject (scene, image),
+    m_sceneSpacePosition (GL_NONE), m_copySpacePosition (GL_NONE), m_passSpacePosition (GL_NONE),
+    m_texcoordCopy (GL_NONE), m_texcoordPass (GL_NONE), m_modelViewProjectionScreen (),
     m_modelViewProjectionPass (glm::mat4 (1.0)), m_modelViewProjectionCopy (), m_modelViewProjectionScreenInverse (),
     m_modelViewProjectionPassInverse (glm::inverse (m_modelViewProjectionPass)), m_modelViewProjectionCopyInverse (),
-    m_modelMatrix (), m_viewProjectionMatrix (), m_image (image), m_material (nullptr), m_colorBlendMaterial (nullptr),
-    m_pos (), m_initialized (false) {
+    m_modelMatrix (), m_viewProjectionMatrix (), m_image (image), m_pos (), m_initialized (false) {
+    // register any properties in use on this object
+    this->registerProperty ("origin", *image.origin->value);
+    this->registerProperty ("scale", *image.scale->value);
+    this->registerProperty ("angles", *image.angles->value);
+    this->registerProperty ("visible", *image.visible->value);
+    this->registerProperty ("alpha", *image.alpha->value);
+    this->registerProperty ("color", *image.color->value);
+    this->registerProperty ("parallaxDepth", *image.parallaxDepth->value);
+
     // get scene width and height to calculate positions
     auto scene_width = static_cast<float> (scene.getWidth ());
     auto scene_height = static_cast<float> (scene.getHeight ());
@@ -207,8 +213,10 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     if ((size.x == 0.0f || size.y == 0.0f) && this->m_texture != nullptr) {
 	size.x = static_cast<float> (this->m_texture->getRealWidth ());
 	size.y = static_cast<float> (this->m_texture->getRealHeight ());
-    } else if ((size.x == 0.0f || size.y == 0.0f) && this->getImage ().model->width.has_value ()
-	       && this->getImage ().model->height.has_value ()) {
+    } else if (
+	(size.x == 0.0f || size.y == 0.0f) && this->getImage ().model->width.has_value ()
+	&& this->getImage ().model->height.has_value ()
+    ) {
 	size.x = static_cast<float> (this->getImage ().model->width.value ());
 	size.y = static_cast<float> (this->getImage ().model->height.value ());
     }
@@ -283,9 +291,11 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 	    / static_cast<float> (this->getTexture ()->getTextureHeight (0));
     }
     // calculate the correct texCoord limits for the texture based on the texture screen size and real size
-    else if (this->getTexture () != nullptr
-	     && (this->getTexture ()->getTextureWidth (0) != this->getTexture ()->getRealWidth ()
-		 || this->getTexture ()->getTextureHeight (0) != this->getTexture ()->getRealHeight ())) {
+    else if (
+	this->getTexture () != nullptr
+	&& (this->getTexture ()->getTextureWidth (0) != this->getTexture ()->getRealWidth ()
+	    || this->getTexture ()->getTextureHeight (0) != this->getTexture ()->getRealHeight ())
+    ) {
 	// Account for padding in non-power-of-two textures: clamp UVs to the real content
 	width = static_cast<float> (this->getTexture ()->getRealWidth ())
 	    / static_cast<float> (this->getTexture ()->getTextureWidth (0));
@@ -365,11 +375,8 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     this->m_hasPuppetMesh = this->loadPuppetMesh (size);
 
     // 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_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 ();
@@ -430,7 +437,8 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 	constexpr size_t positionOffset = 0;
 	constexpr size_t uvOffset = 72;
 
-	const std::string puppetVersion = data.size () >= markerSize ? std::string (data.data (), strlen ("MDLV0021")) : "";
+	const std::string puppetVersion
+	    = data.size () >= markerSize ? std::string (data.data (), strlen ("MDLV0021")) : "";
 	if (puppetVersion != "MDLV0021" && puppetVersion != "MDLV0023") {
 	    sLog.error ("Unsupported puppet model header ", puppetVersion, " in ", *this->getImage ().model->puppet);
 	    return false;
@@ -443,9 +451,12 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 		}
 	    }
 	    return data.size ();
-	} ();
+	}();
 
-	const auto meshBlock = findPuppetMeshBlock (data, markerSize, mdlsOffset, meshHeaderSize, vertexStride);
+	auto meshBuffer = std::make_unique<char[]> (data.size ());
+	std::copy (data.begin (), data.end (), meshBuffer.get ());
+	const BinaryReader reader (std::make_shared<MemoryStream> (std::move (meshBuffer), data.size ()));
+	const auto meshBlock = findPuppetMeshBlock (reader, markerSize, mdlsOffset, meshHeaderSize, vertexStride);
 	if (!meshBlock.has_value ()) {
 	    sLog.error ("Could not find a usable MDLV mesh block in ", *this->getImage ().model->puppet);
 	    return false;
@@ -465,11 +476,13 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 
 	for (size_t index = 0; index < vertexCount; index++) {
 	    const size_t vertexOffset = verticesOffset + index * vertexStride;
-	    const float x = readFloat (data, vertexOffset + positionOffset);
-	    const float y = readFloat (data, vertexOffset + positionOffset + sizeof (float));
-	    const float z = readFloat (data, vertexOffset + positionOffset + sizeof (float) * 2);
-	    const float u = readFloat (data, vertexOffset + uvOffset);
-	    const float v = readFloat (data, vertexOffset + uvOffset + sizeof (float));
+	    reader.base ().seekg (static_cast<std::streamoff> (vertexOffset + positionOffset), std::ios::beg);
+	    const float x = reader.nextFloat ();
+	    const float y = reader.nextFloat ();
+	    const float z = reader.nextFloat ();
+	    reader.base ().seekg (static_cast<std::streamoff> (vertexOffset + uvOffset), std::ios::beg);
+	    const float u = reader.nextFloat ();
+	    const float v = reader.nextFloat ();
 
 	    this->m_puppetRawPositions.push_back (x);
 	    this->m_puppetRawPositions.push_back (y);
@@ -478,9 +491,10 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 	    texcoords.push_back (v);
 	}
 
+	reader.base ().seekg (static_cast<std::streamoff> (indicesOffset), std::ios::beg);
 	for (size_t index = 0; index < indexCount; index++) {
 	    uint16_t value = 0;
-	    std::memcpy (&value, data.data () + indicesOffset + index * sizeof (uint16_t), sizeof (value));
+	    reader.next (reinterpret_cast<char*> (&value), sizeof (value));
 	    if (value >= vertexCount) {
 		sLog.error ("Invalid puppet mesh index ", value, " in ", *this->getImage ().model->puppet);
 		return false;
@@ -500,8 +514,8 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 
 	this->m_puppetIndexCount = static_cast<GLsizei> (indices.size ());
 	sLog.out (
-	    "Loaded puppet mesh ", *this->getImage ().model->puppet, " version=", puppetVersion, " vertices=", vertexCount,
-	    " indices=", this->m_puppetIndexCount
+	    "Loaded puppet mesh ", *this->getImage ().model->puppet, " version=", puppetVersion,
+	    " vertices=", vertexCount, " indices=", this->m_puppetIndexCount
 	);
 
 	return true;
@@ -557,7 +571,9 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
 		glClearColor (0.0f, 0.0f, 0.0f, 0.0f);
 		glClear (GL_COLOR_BUFFER_BIT);
-		glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
+		glClearColor (
+		    previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]
+		);
 	    }
 	    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
 	    glDrawElements (GL_TRIANGLES, this->m_puppetIndexCount, GL_UNSIGNED_SHORT, nullptr);
@@ -587,23 +603,23 @@ void CImage::setup () {
     // COMPOSITE_B
     // TODO: SUPPORT PASSTHROUGH (IT'S A SHADER)
     if (this->m_image.model->passthrough) {
-		// passthrough images without effects are bad, do not draw them
-		if(this->m_image.effects.empty ()) {
-			return;
-		}
+	// passthrough images without effects are bad, do not draw them
+	if (this->m_image.effects.empty ()) {
+	    return;
+	}
 
-		// Some have attempted to declare effects with visible set to false.
-		bool allEffectsInvisible = true;
-		for (const auto& cur : this->m_image.effects) {
-			if (cur->visible->value->getBool()) {
-				allEffectsInvisible = false;
-				break;
-			}
-		}
+	// Some have attempted to declare effects with visible set to false.
+	bool allEffectsInvisible = true;
+	for (const auto& cur : this->m_image.effects) {
+	    if (cur->visible->value->getBool ()) {
+		allEffectsInvisible = false;
+		break;
+	    }
+	}
 
-		if (allEffectsInvisible) {
-			return;
-		}
+	if (allEffectsInvisible) {
+	    return;
+	}
     }
 
     const auto& debug = this->getScene ().getContext ().getApp ().getContext ().settings.render.debug;
@@ -715,8 +731,8 @@ void CImage::setup () {
 		.constants = {},
 		.textures = {},
 	    });
-	    tintOverride->constants.emplace ("color", makeStaticSetting (magentaCompositeTint.value ()));
-	    tintOverride->constants.emplace ("alpha", makeStaticSetting (1.0f));
+	    tintOverride->constants.emplace ("color", UserSettingBuilder::fromValue (magentaCompositeTint.value ()));
+	    tintOverride->constants.emplace ("alpha", UserSettingBuilder::fromValue (1.0f));
 
 	    this->m_materials.compatibilityMaterials.emplace_back (
 		MaterialParser::load (this->getScene ().getScene ().project, "materials/effects/tint.json")
@@ -732,7 +748,7 @@ void CImage::setup () {
     }
 
     // extra render pass if there's any blending to be done
-    if (!debug.baseOnly && this->m_image.colorBlendMode->value->getInt() > 0) {
+    if (!debug.baseOnly && this->m_image.colorBlendMode->value->getInt () > 0) {
 	this->m_materials.colorBlending.material
 	    = MaterialParser::load (this->getScene ().getScene ().project, "materials/util/effectpassthrough.json");
 	this->m_materials.colorBlending.override = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
@@ -788,7 +804,8 @@ void CImage::setupPasses () {
 	GLuint spacePosition = (isFirstPass)
 	    ? (this->m_hasPuppetMesh ? this->m_puppetSpacePosition : this->getCopySpacePosition ())
 	    : this->getPassSpacePosition ();
-	const glm::mat4* projection = (isFirstPass) ? &this->m_modelViewProjectionCopy : &this->m_modelViewProjectionPass;
+	const glm::mat4* projection
+	    = (isFirstPass) ? &this->m_modelViewProjectionCopy : &this->m_modelViewProjectionPass;
 	const glm::mat4* inverseProjection
 	    = (isFirstPass) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
 	first = false;
@@ -844,11 +861,8 @@ bool CImage::shouldRenderFinalPass (bool isLastPass) const {
 }
 
 bool CImage::configurePassTarget (
-    Effects::CPass* pass,
-    std::shared_ptr<const CFBO>& drawTo,
-    const std::shared_ptr<const TextureProvider>& asInput,
-    std::shared_ptr<const TextureProvider>& effectInput,
-    bool& inTargetEffectSequence
+    Effects::CPass* pass, std::shared_ptr<const CFBO>& drawTo, const std::shared_ptr<const TextureProvider>& asInput,
+    std::shared_ptr<const TextureProvider>& effectInput, bool& inTargetEffectSequence
 ) {
     if (!pass->getTarget ().has_value ()) {
 	return false;
@@ -908,7 +922,7 @@ void CImage::render () {
     this->updateScreenSpacePosition ();
 
 #if !NDEBUG
-    std::string str = "Rendering ";
+    std::string str = "Image ";
 
     if (this->getScene ().getScene ().camera.bloom.enabled->value->getBool () && this->getId () == -1) {
 	str += "bloom";
@@ -935,7 +949,7 @@ void CImage::render () {
 #endif /* DEBUG */
 }
 
-const float& CImage::getBrightness () const { return this->m_image.brightness->value->getFloat(); }
+const float& CImage::getBrightness () const { return this->m_image.brightness->value->getFloat (); }
 
 const float& CImage::getUserAlpha () const { return this->m_image.alpha->value->getFloat (); }
 
@@ -953,8 +967,10 @@ glm::vec2 CImage::resolveGeometrySize (float sceneWidth, float sceneHeight, glm:
     if ((size.x == 0.0f || size.y == 0.0f) && this->m_texture != nullptr) {
 	size.x = static_cast<float> (this->m_texture->getRealWidth ());
 	size.y = static_cast<float> (this->m_texture->getRealHeight ());
-    } else if ((size.x == 0.0f || size.y == 0.0f) && this->getImage ().model->width.has_value ()
-	       && this->getImage ().model->height.has_value ()) {
+    } else if (
+	(size.x == 0.0f || size.y == 0.0f) && this->getImage ().model->width.has_value ()
+	&& this->getImage ().model->height.has_value ()
+    ) {
 	size.x = static_cast<float> (this->getImage ().model->width.value ());
 	size.y = static_cast<float> (this->getImage ().model->height.value ());
     }
@@ -1005,13 +1021,9 @@ void CImage::uploadGeometryBuffers (const glm::vec2& size) {
 
     float width = 1.0f;
     float height = 1.0f;
-    if (
-	this->getTexture () != nullptr && !this->getTexture ()->isAnimated ()
-	&& (
-	    this->getTexture ()->getTextureWidth (0) != this->getTexture ()->getRealWidth ()
-	    || this->getTexture ()->getTextureHeight (0) != this->getTexture ()->getRealHeight ()
-	)
-    ) {
+    if (this->getTexture () != nullptr && !this->getTexture ()->isAnimated ()
+	&& (this->getTexture ()->getTextureWidth (0) != this->getTexture ()->getRealWidth ()
+	    || this->getTexture ()->getTextureHeight (0) != this->getTexture ()->getRealHeight ())) {
 	width = static_cast<float> (this->getTexture ()->getRealWidth ())
 	    / static_cast<float> (this->getTexture ()->getTextureWidth (0));
 	height = static_cast<float> (this->getTexture ()->getRealHeight ())
@@ -1052,7 +1064,8 @@ void CImage::uploadGeometryBuffers (const glm::vec2& size) {
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texcoordCopy);
     glBufferData (GL_ARRAY_BUFFER, sizeof (texcoordCopy), texcoordCopy, GL_DYNAMIC_DRAW);
 
-    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_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_modelViewProjectionCopy = this->getImage ().model->passthrough
 	? this->m_modelViewProjectionScreen
 	: glm::ortho<float> (0.0, size.x, 0.0, size.y);
@@ -1060,7 +1073,7 @@ void CImage::uploadGeometryBuffers (const glm::vec2& size) {
     this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
 }
 
-void CImage::updateGeometryBuffers () {
+CImage::ResolvedTransform CImage::updateGeometryBuffers () {
     auto sceneWidth = static_cast<float> (this->getScene ().getWidth ());
     auto sceneHeight = static_cast<float> (this->getScene ().getHeight ());
     const auto transform = this->resolveTransform (this->getImage ());
@@ -1075,14 +1088,15 @@ void CImage::updateGeometryBuffers () {
 
     this->updateScenePosition (origin, size, scale, sceneWidth, sceneHeight);
     this->uploadGeometryBuffers (size);
+    return transform;
 }
 
 void CImage::updateScreenSpacePosition () {
-    this->updateGeometryBuffers ();
+    const ResolvedTransform transform = this->updateGeometryBuffers ();
 
     // 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)
-    const float angle = this->resolveTransform (this->getImage ()).angle;
+    const float angle = transform.angle;
     glm::mat4 rotModel = glm::mat4 (1.0f);
     if (angle != 0.0f) {
 	rotModel = glm::translate (rotModel, this->m_sceneCenter);
@@ -1090,9 +1104,8 @@ void CImage::updateScreenSpacePosition () {
 	rotModel = glm::translate (rotModel, -this->m_sceneCenter);
     }
 
-    glm::mat4 mvp = this->getScene ().getCamera ().getProjection ()
-		   * this->getScene ().getCamera ().getLookAt ()
-		   * rotModel;
+    glm::mat4 mvp
+	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt () * rotModel;
 
     // Apply parallax displacement if enabled
     if (this->getScene ().getScene ().camera.parallax.enabled
@@ -1116,10 +1129,6 @@ void CImage::updateScreenSpacePosition () {
 
 const Image& CImage::getImage () const { return this->m_image; }
 
-const std::vector<CEffect*>& CImage::getEffects () const { return this->m_effects; }
-
-const Effects::CMaterial* CImage::getMaterial () const { return this->m_material; }
-
 glm::vec2 CImage::getSize () const {
     if (this->m_texture == nullptr) {
 	return this->getImage ().size;

+ 16 - 16
src/WallpaperEngine/Render/Objects/CImage.h

@@ -8,22 +8,21 @@
 #include "WallpaperEngine/Render/Shaders/Shader.h"
 
 #include "../TextureProvider.h"
+#include "WallpaperEngine/Scripting/ScriptableObject.h"
 
 #include <glm/vec3.hpp>
 #include <vector>
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
-
+using namespace WallpaperEngine::Scripting;
 namespace WallpaperEngine::Render::Objects::Effects {
 class CMaterial;
 class CPass;
 } // namespace WallpaperEngine::Render::Objects::Effects
 
 namespace WallpaperEngine::Render::Objects {
-class CEffect;
-
-class CImage final : public CRenderable {
+class CImage final : public CRenderable, public ScriptableObject {
     friend CObject;
 
 public:
@@ -34,8 +33,6 @@ public:
     void render () override;
 
     [[nodiscard]] const Image& getImage () const;
-    [[nodiscard]] const std::vector<CEffect*>& getEffects () const;
-    [[nodiscard]] const Effects::CMaterial* getMaterial () const;
     [[nodiscard]] glm::vec2 getSize () const;
 
     [[nodiscard]] GLuint getSceneSpacePosition () const;
@@ -70,22 +67,28 @@ protected:
 	float angle;
     };
 
-    [[nodiscard]] ResolvedTransform resolveTransform (const WallpaperEngine::Data::Model::Object& object, int depth = 0) const;
+    [[nodiscard]] ResolvedTransform resolveTransform (const WallpaperEngine::Data::Model::Object& object) const;
+
+    /**
+     * Computes the object's own transform (origin/scale/angle) without walking the
+     * parent chain. Used as the per-node step of resolveTransform.
+     */
+    [[nodiscard]] static ResolvedTransform localTransform (const WallpaperEngine::Data::Model::Object& object);
 
 private:
     bool loadPuppetMesh (const glm::vec2& size);
     void updatePuppetPositionBuffer (const glm::vec2& size);
     void setupPuppetGeometryCallback (Effects::CPass* pass) const;
-    void updateGeometryBuffers ();
+    ResolvedTransform updateGeometryBuffers ();
     [[nodiscard]] glm::vec2 resolveGeometrySize (float sceneWidth, float sceneHeight, glm::vec3& origin) const;
-    void updateScenePosition (const glm::vec3& origin, const glm::vec2& size, const glm::vec3& scale, float sceneWidth, float sceneHeight);
+    void updateScenePosition (
+	const glm::vec3& origin, const glm::vec2& size, const glm::vec3& scale, float sceneWidth, float sceneHeight
+    );
     void uploadGeometryBuffers (const glm::vec2& size);
     [[nodiscard]] bool shouldRenderFinalPass (bool isLastPass) const;
     bool configurePassTarget (
-	Effects::CPass* pass,
-	std::shared_ptr<const CFBO>& drawTo,
-	const std::shared_ptr<const TextureProvider>& asInput,
-	std::shared_ptr<const TextureProvider>& effectInput,
+	Effects::CPass* pass, std::shared_ptr<const CFBO>& drawTo,
+	const std::shared_ptr<const TextureProvider>& asInput, std::shared_ptr<const TextureProvider>& effectInput,
 	bool& inTargetEffectSequence
     );
 
@@ -118,9 +121,6 @@ private:
 
     const Image& m_image;
 
-    std::vector<CEffect*> m_effects = {};
-    Effects::CMaterial* m_material = nullptr;
-    Effects::CMaterial* m_colorBlendMaterial = nullptr;
     std::vector<Effects::CPass*> m_passes = {};
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
 

+ 164 - 175
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -1,6 +1,8 @@
 #include "CParticle.h"
+
 #include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Maths.h"
 #include "WallpaperEngine/Render/Utils/NoiseUtils.h"
 
 #include <GL/glew.h>
@@ -15,41 +17,14 @@ using namespace WallpaperEngine::Render::Objects;
 using namespace WallpaperEngine::Render::Utils;
 using namespace WallpaperEngine::Data::Model;
 
-namespace {
-// Helper: Random float in range
-inline float randomFloat (std::mt19937& rng, float min, float max) {
-    if (max < min) {
-	std::swap (min, max);
-    }
-    std::uniform_real_distribution<float> dist (min, max);
-    return dist (rng);
-}
-
-// Helper: Random vec3 in range
-inline glm::vec3 randomVec3 (std::mt19937& rng, const glm::vec3& min, const glm::vec3& max) {
-    return glm::vec3 (
-	randomFloat (rng, min.x, max.x), randomFloat (rng, min.y, max.y), randomFloat (rng, min.z, max.z)
-    );
-}
-
-// Helper: Linear interpolation
-inline float lerp (float t, float a, float b) { return a + t * (b - a); }
-
-// Helper: Fade value change over lifetime
-inline float fadeValue (float life, float startTime, float endTime, float startValue, float endValue) {
-    if (life <= startTime) {
-	return startValue;
-    } else if (life >= endTime) {
-	return endValue;
-    } else {
-	float t = (life - startTime) / (endTime - startTime);
-	return lerp (t, startValue, endValue);
-    }
-}
-}
-
 CParticle::CParticle (Wallpapers::CScene& scene, const Particle& particle) :
-    CRenderable (scene, particle, *particle.material->material), m_particle (particle) {
+    CObject (scene, particle), CRenderable (scene, particle, *particle.material->material),
+    ScriptableObject (scene, particle), m_particle (particle) {
+    this->registerProperty ("scale", *particle.scale->value);
+    this->registerProperty ("angles", *particle.angles->value);
+    this->registerProperty ("visible", *particle.visible->value);
+    this->registerProperty ("parallaxDepth", *particle.parallaxDepth->value);
+
     this->detectTexture ();
     // Initialize random number generator with time-based seed
     std::random_device rd;
@@ -412,132 +387,136 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
     bool limitOnePerFrame = (emitter.flags & 2) != 0;
     bool randomPeriodicEmission = (emitter.flags & 4) != 0;
 
-    return [this, emitter, transformedEmitterOrigin, controlPointIndex, rate, flippedDirections, limitOnePerFrame,
-	    randomPeriodicEmission, emissionTimer = 0.0f, delayTimer = emitter.delay, durationTimer = 0.0f,
-	    periodicTimer = 0.0f, periodicDuration = 0.0f, periodicDelay = 0.0f, emitting = false,
-	    instantaneousEmitted
-	    = false] (std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
-	if (count >= particles.size ()) {
-	    return;
-	}
-
-	// Handle delay
-	if (delayTimer > 0.0f) {
-	    delayTimer -= dt;
-	    return;
-	}
-
-	// Handle duration
-	if (emitter.duration > 0.0f) {
-	    durationTimer += dt;
-	    if (durationTimer >= emitter.duration) {
+    return
+	[this, emitter, transformedEmitterOrigin, controlPointIndex, rate, flippedDirections, limitOnePerFrame,
+	 randomPeriodicEmission, emissionTimer = 0.0f, delayTimer = emitter.delay, durationTimer = 0.0f,
+	 periodicTimer = 0.0f, periodicDuration = 0.0f, periodicDelay = 0.0f, emitting = false,
+	 instantaneousEmitted = false] (std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
+	    if (count >= particles.size ()) {
 		return;
 	    }
-	}
 
-	// Handle random periodic emission
-	if (randomPeriodicEmission) {
-	    periodicTimer += dt;
+	    // Handle delay
+	    if (delayTimer > 0.0f) {
+		delayTimer -= dt;
+		return;
+	    }
 
-	    if (!emitting) {
-		if (periodicTimer >= periodicDelay) {
-		    emitting = true;
-		    periodicTimer = 0.0f;
-		    periodicDuration = randomFloat (m_rng, emitter.minPeriodicDuration, emitter.maxPeriodicDuration);
-		} else {
-		    return;
-		}
-	    } else {
-		if (periodicTimer >= periodicDuration) {
-		    emitting = false;
-		    periodicTimer = 0.0f;
-		    periodicDelay = randomFloat (m_rng, emitter.minPeriodicDelay, emitter.maxPeriodicDelay);
+	    // Handle duration
+	    if (emitter.duration > 0.0f) {
+		durationTimer += dt;
+		if (durationTimer >= emitter.duration) {
 		    return;
 		}
 	    }
-	}
 
-	// TODO: Audio processing (audioProcessingMode, audioProcessingBounds, etc.)
-
-	// Handle instantaneous emission
-	uint32_t toEmit = 0;
-	if (emitter.instantaneous > 0 && !instantaneousEmitted) {
-	    toEmit = emitter.instantaneous;
-	    instantaneousEmitted = true;
-	}
-
-	// Rate-based emission with optional cap at 1 per frame
-	if (emitter.rate > 0.0f) {
-	    emissionTimer += dt * rate;
-	    uint32_t rateEmit = static_cast<uint32_t> (emissionTimer);
-	    emissionTimer -= static_cast<float> (rateEmit);
-	    // limitOnePerFrame (flags bit 1): cap at 1 to prevent rope artifacts
-	    if (limitOnePerFrame && rateEmit > 1) {
-		rateEmit = 1;
+	    // Handle random periodic emission
+	    if (randomPeriodicEmission) {
+		periodicTimer += dt;
+
+		if (!emitting) {
+		    if (periodicTimer >= periodicDelay) {
+			emitting = true;
+			periodicTimer = 0.0f;
+			periodicDuration = WallpaperEngine::Maths::randomFloat (
+			    m_rng, emitter.minPeriodicDuration, emitter.maxPeriodicDuration
+			);
+		    } else {
+			return;
+		    }
+		} else {
+		    if (periodicTimer >= periodicDuration) {
+			emitting = false;
+			periodicTimer = 0.0f;
+			periodicDelay = WallpaperEngine::Maths::randomFloat (
+			    m_rng, emitter.minPeriodicDelay, emitter.maxPeriodicDelay
+			);
+			return;
+		    }
+		}
 	    }
-	    toEmit += rateEmit;
-	}
 
-	// Emit particles
-	for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) {
-	    auto& p = particles[count];
+	    // TODO: Audio processing (audioProcessingMode, audioProcessingBounds, etc.)
 
-	    glm::vec3 spawnOrigin = transformedEmitterOrigin;
-	    if (controlPointIndex >= 0 && controlPointIndex < static_cast<int> (m_controlPoints.size ())) {
-		spawnOrigin += m_controlPoints[controlPointIndex].position;
+	    // Handle instantaneous emission
+	    uint32_t toEmit = 0;
+	    if (emitter.instantaneous > 0 && !instantaneousEmitted) {
+		toEmit = emitter.instantaneous;
+		instantaneousEmitted = true;
 	    }
 
-	    // Generate random position within box volume centered on origin
-	    // This creates a centered box (or hollow box if distanceMin > 0)
-	    glm::vec3 randomPos;
-	    for (int axis = 0; axis < 3; axis++) {
-		float minDist = emitter.distanceMin[axis];
-		float maxDist = emitter.distanceMax[axis];
-		// Generate value in [minDist, maxDist]
-		float dist = randomFloat (m_rng, minDist, maxDist);
-		// Randomly flip sign to center the distribution
-		if (randomFloat (m_rng, 0.0f, 1.0f) < 0.5f) {
-		    dist = -dist;
+	    // Rate-based emission with optional cap at 1 per frame
+	    if (emitter.rate > 0.0f) {
+		emissionTimer += dt * rate;
+		uint32_t rateEmit = static_cast<uint32_t> (emissionTimer);
+		emissionTimer -= static_cast<float> (rateEmit);
+		// limitOnePerFrame (flags bit 1): cap at 1 to prevent rope artifacts
+		if (limitOnePerFrame && rateEmit > 1) {
+		    rateEmit = 1;
 		}
-		randomPos[axis] = dist;
+		toEmit += rateEmit;
 	    }
-	    randomPos *= flippedDirections;
 
-	    p.position = spawnOrigin + randomPos;
+	    // Emit particles
+	    for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) {
+		auto& p = particles[count];
 
-	    // Emitter does not set velocity - initializers handle that
-	    p.velocity = glm::vec3 (0.0f);
-	    p.acceleration = glm::vec3 (0.0f);
-	    p.rotation = glm::vec3 (0.0f);
-	    p.angularVelocity = glm::vec3 (0.0f);
-	    p.angularAcceleration = glm::vec3 (0.0f);
+		glm::vec3 spawnOrigin = transformedEmitterOrigin;
+		if (controlPointIndex >= 0 && controlPointIndex < static_cast<int> (m_controlPoints.size ())) {
+		    spawnOrigin += m_controlPoints[controlPointIndex].position;
+		}
 
-	    p.color = glm::vec3 (1.0f) * m_particle.instanceOverride.colorn->value->getVec3 ();
-	    p.alpha = 1.0f * m_particle.instanceOverride.alpha->value->getFloat ();
-	    p.size = 20.0f * m_particle.instanceOverride.size->value->getFloat ();
-	    p.lifetime = 1.0f * m_particle.instanceOverride.lifetime->value->getFloat ();
-	    p.age = 0.0f;
-	    p.alive = true;
-	    p.frame = -1.0f;
+		// Generate random position within box volume centered on origin
+		// This creates a centered box (or hollow box if distanceMin > 0)
+		glm::vec3 randomPos;
+		for (int axis = 0; axis < 3; axis++) {
+		    float minDist = emitter.distanceMin[axis];
+		    float maxDist = emitter.distanceMax[axis];
+		    // Generate value in [minDist, maxDist]
+		    float dist = WallpaperEngine::Maths::randomFloat (m_rng, minDist, maxDist);
+		    // Randomly flip sign to center the distribution
+		    if (WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f) < 0.5f) {
+			dist = -dist;
+		    }
+		    randomPos[axis] = dist;
+		}
+		randomPos *= flippedDirections;
 
-	    p.initial.color = p.color;
-	    p.initial.alpha = p.alpha;
-	    p.initial.size = p.size;
-	    p.initial.lifetime = p.lifetime;
+		p.position = spawnOrigin + randomPos;
 
-	    // Reset oscillator state for reused particles
-	    p.oscillateAlpha = {};
-	    p.oscillateSize = {};
-	    p.oscillatePosition = {};
+		// Emitter does not set velocity - initializers handle that
+		p.velocity = glm::vec3 (0.0f);
+		p.acceleration = glm::vec3 (0.0f);
+		p.rotation = glm::vec3 (0.0f);
+		p.angularVelocity = glm::vec3 (0.0f);
+		p.angularAcceleration = glm::vec3 (0.0f);
+
+		p.color = glm::vec3 (1.0f) * m_particle.instanceOverride.colorn->value->getVec3 ();
+		p.alpha = 1.0f * m_particle.instanceOverride.alpha->value->getFloat ();
+		p.size = 20.0f * m_particle.instanceOverride.size->value->getFloat ();
+		p.lifetime = 1.0f * m_particle.instanceOverride.lifetime->value->getFloat ();
+		p.age = 0.0f;
+		p.alive = true;
+		p.frame = -1.0f;
+
+		p.initial.color = p.color;
+		p.initial.alpha = p.alpha;
+		p.initial.size = p.size;
+		p.initial.lifetime = p.lifetime;
+
+		// Reset oscillator state for reused particles
+		p.oscillateAlpha = {};
+		p.oscillateSize = {};
+		p.oscillatePosition = {};
+
+		// Apply initializers
+		for (auto& init : m_initializers) {
+		    init (p);
+		}
 
-	    // Apply initializers
-	    for (auto& init : m_initializers) {
-		init (p);
+		count++;
 	    }
-
-	    count++;
-	}
-    };
+	};
 }
 
 EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
@@ -598,24 +577,25 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 	    // Perspective particles (flags & 4 != 0): use 3D spherical shell distribution
 	    if ((m_particle.flags & 4) == 0) {
 		// 2D disk distribution with random Z offset
-		float angle = randomFloat (m_rng, 0.0f, glm::two_pi<float> ());
+		float angle = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, glm::two_pi<float> ());
 		float minRadius = emitter.distanceMin.x;
 		float maxRadius = emitter.distanceMax.x;
 
 		// Use sqrt for uniform area distribution in annulus
 		float minRadiusSq = minRadius * minRadius;
 		float maxRadiusSq = maxRadius * maxRadius;
-		float radiusXY = std::sqrt (randomFloat (m_rng, minRadiusSq, maxRadiusSq));
+		float radiusXY = std::sqrt (WallpaperEngine::Maths::randomFloat (m_rng, minRadiusSq, maxRadiusSq));
 
 		randomPos = glm::vec3 (
-		    radiusXY * std::cos (angle), radiusXY * std::sin (angle), randomFloat (m_rng, -maxRadius, maxRadius)
+		    radiusXY * std::cos (angle), radiusXY * std::sin (angle),
+		    WallpaperEngine::Maths::randomFloat (m_rng, -maxRadius, maxRadius)
 		);
 
 		randomPos *= emitter.directions;
 	    } else {
 		// 3D spherical shell distribution
-		float theta = randomFloat (m_rng, 0.0f, glm::two_pi<float> ());
-		float cosTheta = randomFloat (m_rng, -1.0f, 1.0f);
+		float theta = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, glm::two_pi<float> ());
+		float cosTheta = WallpaperEngine::Maths::randomFloat (m_rng, -1.0f, 1.0f);
 		float sinTheta = std::sqrt (1.0f - cosTheta * cosTheta);
 
 		randomPos = glm::vec3 (sinTheta * std::cos (theta), sinTheta * std::sin (theta), cosTheta);
@@ -625,7 +605,7 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 		float maxRadius = emitter.distanceMax.x;
 		float minRadiusCubed = minRadius * minRadius * minRadius;
 		float maxRadiusCubed = maxRadius * maxRadius * maxRadius;
-		float radius = std::cbrt (randomFloat (m_rng, minRadiusCubed, maxRadiusCubed));
+		float radius = std::cbrt (WallpaperEngine::Maths::randomFloat (m_rng, minRadiusCubed, maxRadiusCubed));
 
 		randomPos *= radius;
 		randomPos *= emitter.directions;
@@ -648,7 +628,7 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 		// Velocity pointing outward from ellipsoid (randomPos already includes directions scaling)
 		glm::vec3 direction
 		    = glm::length (randomPos) > 0.0f ? glm::normalize (randomPos) : glm::vec3 (0.0f, 1.0f, 0.0f);
-		float speed = randomFloat (m_rng, emitter.speedMin, emitter.speedMax);
+		float speed = WallpaperEngine::Maths::randomFloat (m_rng, emitter.speedMin, emitter.speedMax);
 		p.velocity = direction * speed;
 	    } else {
 		// No emitter speed specified, velocity will be set by initializers
@@ -735,7 +715,8 @@ InitializerFunc CParticle::createColorRandomInitializer (const ColorRandomInitia
     DynamicValue* colorOverride = m_particle.instanceOverride.colorn->value.get ();
 
     return [this, minValue, maxValue, colorOverride] (ParticleInstance& p) {
-	p.color = randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ()) * colorOverride->getVec3 ();
+	p.color = WallpaperEngine::Maths::randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ())
+	    * colorOverride->getVec3 ();
 	p.initial.color = p.color;
     };
 }
@@ -747,7 +728,7 @@ InitializerFunc CParticle::createSizeRandomInitializer (const SizeRandomInitiali
     DynamicValue* sizeOverride = m_particle.instanceOverride.size->value.get ();
 
     return [this, minValue, maxValue, exponentValue, sizeOverride] (ParticleInstance& p) {
-	float t = randomFloat (m_rng, 0.0f, 1.0f);
+	float t = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f);
 	float exponent = exponentValue->getFloat ();
 	float min = minValue->getFloat ();
 	float max = maxValue->getFloat ();
@@ -765,7 +746,8 @@ InitializerFunc CParticle::createAlphaRandomInitializer (const AlphaRandomInitia
     DynamicValue* alphaOverride = m_particle.instanceOverride.alpha->value.get ();
 
     return [this, minValue, maxValue, alphaOverride] (ParticleInstance& p) {
-	p.alpha = randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ()) * alphaOverride->getFloat ();
+	p.alpha = WallpaperEngine::Maths::randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ())
+	    * alphaOverride->getFloat ();
 	p.initial.alpha = p.alpha;
     };
 }
@@ -776,7 +758,8 @@ InitializerFunc CParticle::createLifetimeRandomInitializer (const LifetimeRandom
     DynamicValue* lifetimeOverride = m_particle.instanceOverride.lifetime->value.get ();
 
     return [this, minValue, maxValue, lifetimeOverride] (ParticleInstance& p) {
-	p.lifetime = randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ()) * lifetimeOverride->getFloat ();
+	p.lifetime = WallpaperEngine::Maths::randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ())
+	    * lifetimeOverride->getFloat ();
 	p.initial.lifetime = p.lifetime;
     };
 }
@@ -787,7 +770,8 @@ InitializerFunc CParticle::createVelocityRandomInitializer (const VelocityRandom
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
 
     return [this, minValue, maxValue, speedOverride] (ParticleInstance& p) {
-	glm::vec3 vel = randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ()) * speedOverride->getFloat ();
+	glm::vec3 vel = WallpaperEngine::Maths::randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ())
+	    * speedOverride->getFloat ();
 	vel.y = -vel.y;
 	p.velocity += vel;
     };
@@ -799,7 +783,8 @@ InitializerFunc CParticle::createRotationRandomInitializer (const RotationRandom
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
 
     return [this, minValue, maxValue, speedOverride] (ParticleInstance& p) {
-	p.rotation = randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ()) * speedOverride->getFloat ();
+	p.rotation = WallpaperEngine::Maths::randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ())
+	    * speedOverride->getFloat ();
     };
 }
 
@@ -820,7 +805,7 @@ InitializerFunc CParticle::createAngularVelocityRandomInitializer (const Angular
 	// exponent >= 2: bias towards min
 	glm::vec3 result;
 	for (int i = 0; i < 3; i++) {
-	    float t = randomFloat (m_rng, 0.0f, 1.0f);
+	    float t = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f);
 	    t = std::pow (t, exponent);
 	    result[i] = minVec[i] + t * (maxVec[i] - minVec[i]);
 	}
@@ -862,7 +847,7 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 	    right = glm::vec3 (1.0f, 0.0f, 0.0f);
 	}
 
-	float speed = randomFloat (m_rng, speedMin->getFloat (), speedMax->getFloat ());
+	float speed = WallpaperEngine::Maths::randomFloat (m_rng, speedMin->getFloat (), speedMax->getFloat ());
 	float scale = scaleVal->getFloat ();
 	float offset = offsetVal->getFloat ();
 	float timeScale = timeScaleVal->getFloat ();
@@ -877,7 +862,7 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 	noisePos += glm::vec3 (static_cast<float> (m_time) * timeScale);
 
 	// Phase adds per-particle randomization to noise position
-	float phase = randomFloat (m_rng, phaseMin, phaseMax);
+	float phase = WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax);
 	glm::vec3 samplePos = noisePos + glm::vec3 (phase, phase * 0.7f, phase * 1.3f);
 
 	// Sample curl noise for direction and normalize
@@ -965,7 +950,7 @@ CParticle::createMapSequenceAroundControlPointInitializer (const MapSequenceArou
 	// Set velocity based on angle and speed range
 	glm::vec3 speedMin = speedMinValue->getVec3 ();
 	glm::vec3 speedMax = speedMaxValue->getVec3 ();
-	glm::vec3 speed = randomVec3 (m_rng, speedMin, speedMax);
+	glm::vec3 speed = WallpaperEngine::Maths::randomVec3 (m_rng, speedMin, speedMax);
 
 	// Flip Y before rotation to convert to centered space
 	speed.y = -speed.y;
@@ -1134,10 +1119,10 @@ OperatorFunc CParticle::createAlphaFadeOperator (const AlphaFadeOperator& op) {
 		float life = p.getLifetimePos ();
 
 		if (life <= fadeInTime) {
-		    float fade = fadeValue (life, 0.0f, fadeInTime, 0.0f, 1.0f);
+		    float fade = WallpaperEngine::Maths::fadeValue (life, 0.0f, fadeInTime, 0.0f, 1.0f);
 		    p.alpha = p.initial.alpha * fade;
 		} else if (life > fadeOutTime) {
-		    float fade = 1.0f - fadeValue (life, fadeOutTime, 1.0f, 0.0f, 1.0f);
+		    float fade = 1.0f - WallpaperEngine::Maths::fadeValue (life, fadeOutTime, 1.0f, 0.0f, 1.0f);
 		    p.alpha = p.initial.alpha * fade;
 		} else {
 		    p.alpha = p.initial.alpha;
@@ -1171,7 +1156,7 @@ OperatorFunc CParticle::createSizeChangeOperator (const SizeChangeOperator& op)
 		}
 
 		float life = p.getLifetimePos ();
-		float multiplier = fadeValue (life, startTime, endTime, startValue, endValue);
+		float multiplier = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue, endValue);
 		p.size = p.initial.size * multiplier;
 
 		// Update oscillator base so oscillateSize combines properly
@@ -1202,7 +1187,7 @@ OperatorFunc CParticle::createAlphaChangeOperator (const AlphaChangeOperator& op
 		}
 
 		float life = p.getLifetimePos ();
-		float multiplier = fadeValue (life, startTime, endTime, startValue, endValue);
+		float multiplier = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue, endValue);
 		p.alpha = p.initial.alpha * multiplier;
 
 		// Update oscillator base so oscillateAlpha combines properly
@@ -1235,9 +1220,9 @@ OperatorFunc CParticle::createColorChangeOperator (const ColorChangeOperator& op
 		float life = p.getLifetimePos ();
 
 		glm::vec3 color;
-		color.r = fadeValue (life, startTime, endTime, startValue.r, endValue.r);
-		color.g = fadeValue (life, startTime, endTime, startValue.g, endValue.g);
-		color.b = fadeValue (life, startTime, endTime, startValue.b, endValue.b);
+		color.r = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue.r, endValue.r);
+		color.g = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue.g, endValue.g);
+		color.b = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue.b, endValue.b);
 
 		p.color = p.initial.color * color;
 	    }
@@ -1262,8 +1247,10 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
     // DynamicValue* audioFreqEndValue = op.audioProcessingFrequencyEnd->value.get ();
 
     // Phase and speed are randomized once per operator instance, not per particle
-    const float phase = randomFloat (m_rng, phaseMinValue->getFloat (), phaseMaxValue->getFloat ());
-    const float turbSpeed = randomFloat (m_rng, speedMinValue->getFloat (), speedMaxValue->getFloat ());
+    const float phase
+	= WallpaperEngine::Maths::randomFloat (m_rng, phaseMinValue->getFloat (), phaseMaxValue->getFloat ());
+    const float turbSpeed
+	= WallpaperEngine::Maths::randomFloat (m_rng, speedMinValue->getFloat (), speedMaxValue->getFloat ());
 
     return [scaleValue, timeScaleValue, maskValue, speedOverride, phase, turbSpeed] (
 	       std::vector<ParticleInstance>& particles, uint32_t count, const std::vector<ControlPointData>&,
@@ -1530,9 +1517,10 @@ OperatorFunc CParticle::createOscillateAlphaOperator (const OscillateAlphaOperat
 
 		// Initialize per-particle oscillator values on first use
 		if (!p.oscillateAlpha.initialized) {
-		    p.oscillateAlpha.frequency = randomFloat (m_rng, freqMin, freqMax);
-		    p.oscillateAlpha.scale = randomFloat (m_rng, scaleMin, scaleMax);
-		    p.oscillateAlpha.phase = randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
+		    p.oscillateAlpha.frequency = WallpaperEngine::Maths::randomFloat (m_rng, freqMin, freqMax);
+		    p.oscillateAlpha.scale = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax);
+		    p.oscillateAlpha.phase
+			= WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
 		    p.oscillateAlpha.base = p.alpha; // Capture initial base
 		    p.oscillateAlpha.initialized = true;
 		}
@@ -1573,9 +1561,10 @@ OperatorFunc CParticle::createOscillateSizeOperator (const OscillateSizeOperator
 
 		// Initialize per-particle oscillator values on first use
 		if (!p.oscillateSize.initialized) {
-		    p.oscillateSize.frequency = randomFloat (m_rng, freqMin, freqMax);
-		    p.oscillateSize.scale = randomFloat (m_rng, scaleMin, scaleMax);
-		    p.oscillateSize.phase = randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
+		    p.oscillateSize.frequency = WallpaperEngine::Maths::randomFloat (m_rng, freqMin, freqMax);
+		    p.oscillateSize.scale = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax);
+		    p.oscillateSize.phase
+			= WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
 		    p.oscillateSize.base = p.size; // Capture initial base
 		    p.oscillateSize.initialized = true;
 		}
@@ -1621,10 +1610,10 @@ OperatorFunc CParticle::createOscillatePositionOperator (const OscillatePosition
 	    // Initialize per-particle oscillator values on first use (per axis)
 	    if (!p.oscillatePosition.initialized) {
 		for (int axis = 0; axis < 3; axis++) {
-		    p.oscillatePosition.frequency[axis] = randomFloat (m_rng, freqMin, freqMax);
-		    p.oscillatePosition.scale[axis] = randomFloat (m_rng, scaleMin, scaleMax);
+		    p.oscillatePosition.frequency[axis] = WallpaperEngine::Maths::randomFloat (m_rng, freqMin, freqMax);
+		    p.oscillatePosition.scale[axis] = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax);
 		    p.oscillatePosition.phase[axis]
-			= randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
+			= WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi<float> ());
 		}
 		p.oscillatePosition.initialized = true;
 	    }
@@ -2055,7 +2044,7 @@ void CParticle::renderSprites () {
     }
 
 #if !NDEBUG
-    std::string str = "Rendering particles ";
+    std::string str = "Particles ";
     str += this->getParticle ().name + " (" + std::to_string (this->getId ()) + ", " + this->getParticle ().particleFile
 	+ ")";
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
@@ -2292,7 +2281,7 @@ void CParticle::renderRope () {
     }
 
 #if !NDEBUG
-    std::string str = "Rendering rope particles ";
+    std::string str = "Rope particles ";
     str += this->getParticle ().name + " (" + std::to_string (this->getId ()) + ", " + this->getParticle ().particleFile
 	+ ")";
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());

+ 2 - 1
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -4,6 +4,7 @@
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Render/Objects/Effects/CPass.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
+#include "WallpaperEngine/Scripting/ScriptableObject.h"
 
 #include <functional>
 #include <glm/mat4x4.hpp>
@@ -104,7 +105,7 @@ using InitializerFunc = std::function<void (ParticleInstance&)>;
 using OperatorFunc = std::function<
     void (std::vector<ParticleInstance>&, uint32_t, const std::vector<ControlPointData>&, float, float)>;
 
-class CParticle final : public CRenderable {
+class CParticle final : public CRenderable, public Scripting::ScriptableObject {
     friend CObject;
 
 public:

+ 2 - 0
src/WallpaperEngine/Render/Objects/CRenderable.cpp

@@ -26,6 +26,8 @@ void CRenderable::detectTexture () {
 }
 
 void CRenderable::setup () {
+    CObject::setup ();
+
     // calculate full animation time (if any)
     this->m_animationTime = 0.0f;
 

+ 2 - 2
src/WallpaperEngine/Render/Objects/CRenderable.h

@@ -10,7 +10,7 @@ using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
 
 namespace WallpaperEngine::Render::Objects {
-class CRenderable : public CObject, public FBOProvider {
+class CRenderable : virtual public CObject, public FBOProvider {
     friend CObject;
 
 public:
@@ -20,7 +20,7 @@ public:
 
     [[nodiscard]] double getAnimationTime () const;
 
-    virtual void setup ();
+    void setup () override;
 
     [[nodiscard]] virtual const float& getBrightness () const = 0;
     [[nodiscard]] virtual const float& getUserAlpha () const = 0;

+ 95 - 52
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -73,33 +73,59 @@ GLuint compileShader (GLenum type, const char* source) {
 }
 } // namespace
 
-CText::CText (Wallpapers::CScene& scene, const Text& text) : CObject (scene, text), m_text (text) {}
+CText::CText (Wallpapers::CScene& scene, const Text& text) :
+    CObject (scene, text), ScriptableObject (scene, text), m_text (text) {
+    this->registerProperty ("color", *text.color->value);
+    this->registerProperty ("alpha", *text.alpha->value);
+    this->registerProperty ("origin", *text.origin->value);
+    this->registerProperty ("scale", *text.scale->value);
+    this->registerProperty ("visible", *text.visible->value);
+    this->registerProperty ("pointSize", *text.pointSize->value);
+    this->registerProperty ("text", *text.text->value);
+    this->registerProperty ("pointSize", *text.pointSize->value);
+}
 
 CText::~CText () {
     if (m_layerHandle != Scripting::kInvalidLayerHandle) {
-	Scripting::ScriptEngine::instance ().destroyLayer (m_layerHandle);
+	this->getScene ().getScriptEngine ().destroyLayer (m_layerHandle);
 	m_layerHandle = Scripting::kInvalidLayerHandle;
     }
-    if (m_vbo != 0) glDeleteBuffers (1, &m_vbo);
-    if (m_vao != 0) glDeleteVertexArrays (1, &m_vao);
-    if (m_program != 0) glDeleteProgram (m_program);
-    if (m_texture != 0) glDeleteTextures (1, &m_texture);
-    if (m_ftFace != nullptr) FT_Done_Face (m_ftFace);
-    if (m_ftLibrary != nullptr) FT_Done_FreeType (m_ftLibrary);
+    if (m_vbo != 0) {
+	glDeleteBuffers (1, &m_vbo);
+    }
+    if (m_vao != 0) {
+	glDeleteVertexArrays (1, &m_vao);
+    }
+    if (m_program != 0) {
+	glDeleteProgram (m_program);
+    }
+    if (m_texture != 0) {
+	glDeleteTextures (1, &m_texture);
+    }
+    if (m_ftFace != nullptr) {
+	FT_Done_Face (m_ftFace);
+    }
+    if (m_ftLibrary != nullptr) {
+	FT_Done_FreeType (m_ftLibrary);
+    }
 }
 
 void CText::setup () {
-    const bool scripted = !m_text.script.empty ();
+    const bool scripted = m_text.text->value->getScriptSource ().has_value ();
+    const auto& text = m_text.text->value->getString ();
 
     // Nothing to render and no script to produce text later → bail.
-    if (m_text.text.empty () && !scripted)
+    if (text.empty () && !scripted) {
 	return;
+    }
 
-    if (!initFreeType ())
+    if (!initFreeType ()) {
 	return;
+    }
 
-    if (!loadEmbeddedFont () && !loadSystemFont ())
+    if (!loadEmbeddedFont () && !loadSystemFont ()) {
 	return;
+    }
 
     m_lastPixelSize = computeEffectivePixelSize ();
     FT_Set_Pixel_Sizes (m_ftFace, 0, static_cast<FT_UInt> (m_lastPixelSize));
@@ -107,17 +133,19 @@ void CText::setup () {
     buildShader ();
     // Scripted text may have an empty placeholder; use a single space so the
     // glyph texture has non-zero dimensions until the script produces a value.
-    rebuildTextureFrom (m_text.text.empty () ? std::string (" ") : m_text.text);
+    rebuildTextureFrom (text.empty () ? std::string (" ") : text);
 
-    if (scripted)
+    if (scripted) {
 	initScriptLayer ();
+    }
 
     m_valid = m_texture != 0 && m_program != 0 && m_vao != 0;
 }
 
 bool CText::initFreeType () {
-    if (FT_Init_FreeType (&m_ftLibrary) == 0)
+    if (FT_Init_FreeType (&m_ftLibrary) == 0) {
 	return true;
+    }
     sLog.error ("CText: FT_Init_FreeType failed for object ", m_text.name);
     return false;
 }
@@ -126,8 +154,9 @@ bool CText::loadEmbeddedFont () {
     // Wallpapers packed in .pkg don't expose physical paths, so we read the font
     // into memory and use FT_New_Memory_Face. m_fontData must outlive the face.
     // `systemfont_*` references signal "use a system font"; let the fallback handle them.
-    if (m_text.font.empty () || m_text.font.rfind ("systemfont_", 0) == 0)
+    if (m_text.font.empty () || m_text.font.rfind ("systemfont_", 0) == 0) {
 	return false;
+    }
 
     try {
 	auto stream = getAssetLocator ().read (m_text.font);
@@ -137,9 +166,12 @@ bool CText::loadEmbeddedFont () {
 	m_fontData.resize (static_cast<size_t> (size));
 	stream->read (reinterpret_cast<char*> (m_fontData.data ()), size);
 
-	if (FT_New_Memory_Face (m_ftLibrary, m_fontData.data (),
-		static_cast<FT_Long> (m_fontData.size ()), 0, &m_ftFace) == 0)
+	if (FT_New_Memory_Face (
+		m_ftLibrary, m_fontData.data (), static_cast<FT_Long> (m_fontData.size ()), 0, &m_ftFace
+	    )
+	    == 0) {
 	    return true;
+	}
 
 	sLog.error ("CText: FT_New_Memory_Face failed for '", m_text.font, "', falling back to system font");
     } catch (const std::exception& e) {
@@ -176,24 +208,24 @@ unsigned int CText::computeEffectivePixelSize () const {
     // the on-screen size matches the intended pointsize.
     const glm::vec3 initialScale = m_text.scale->value->getVec3 ();
     const float avgScale = (initialScale.x + initialScale.y) * 0.5f;
-    const float compensate = (avgScale > 0.0f && avgScale < 1.0f)
-	? std::min (1.0f / avgScale, 32.0f)
-	: 1.0f;
-    return std::max<unsigned int> (
-	1u, static_cast<unsigned int> (m_text.pointSize->value->getFloat () * compensate));
+    const float compensate = (avgScale > 0.0f && avgScale < 1.0f) ? std::min (1.0f / avgScale, 32.0f) : 1.0f;
+    return std::max<unsigned int> (1u, static_cast<unsigned int> (m_text.pointSize->value->getFloat () * compensate));
 }
 
 void CText::initScriptLayer () {
-    std::map<std::string, DynamicValue*> initialProps;
-    for (const auto& [k, setting] : m_text.scriptProperties) {
-	if (setting && setting->value)
-	    initialProps.emplace (k, setting->value.get ());
+    const auto& script = m_text.text->value->getScriptSource ();
+
+    if (!script.has_value ()) {
+	return;
     }
-    m_layerHandle = Scripting::ScriptEngine::instance ().createLayerScript (
-	m_text.script, initialProps, m_text.text
+
+    m_layerHandle = this->getScene ().getScriptEngine ().createLayerScript (
+	*script, m_text.text->value->getProperties (), m_text.text->value->getString ()
     );
-    if (m_layerHandle == Scripting::kInvalidLayerHandle)
+
+    if (m_layerHandle == Scripting::kInvalidLayerHandle) {
 	sLog.error ("CText: createLayerScript failed for '", m_text.name, "'");
+    }
 }
 
 void CText::rebuildTextureFrom (const std::string& text) {
@@ -237,7 +269,9 @@ void CText::rebuildTextureFrom (const std::string& text) {
 	    for (unsigned int col = 0; col < bmp.width; ++col) {
 		const int dstX = originX + static_cast<int> (col);
 		const int dstY = originY + static_cast<int> (row);
-		if (dstX < 0 || dstX >= width || dstY < 0 || dstY >= height) continue;
+		if (dstX < 0 || dstX >= width || dstY < 0 || dstY >= height) {
+		    continue;
+		}
 		pixels[static_cast<size_t> (dstY) * width + dstX] = bmp.buffer[row * bmp.pitch + col];
 	    }
 	}
@@ -259,8 +293,8 @@ void CText::rebuildTextureFrom (const std::string& text) {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
     }
 
-    m_textureSize = {width, height};
-    m_quadSize = {static_cast<float> (width), static_cast<float> (height)};
+    m_textureSize = { width, height };
+    m_quadSize = { static_cast<float> (width), static_cast<float> (height) };
     m_lastRenderedText = text;
 
     uploadQuadVertices ();
@@ -270,8 +304,12 @@ void CText::buildShader () {
     GLuint vs = compileShader (GL_VERTEX_SHADER, kVertexShader);
     GLuint fs = compileShader (GL_FRAGMENT_SHADER, kFragmentShader);
     if (vs == 0 || fs == 0) {
-	if (vs) glDeleteShader (vs);
-	if (fs) glDeleteShader (fs);
+	if (vs) {
+	    glDeleteShader (vs);
+	}
+	if (fs) {
+	    glDeleteShader (fs);
+	}
 	return;
     }
 
@@ -309,12 +347,8 @@ void CText::uploadQuadVertices () {
     // lower GL y) appears at screen top. UV.v=0 here = FT glyph top → shows at screen top ✓
     const float verts[] = {
 	// pos        // uv
-	-hx, -hy, 0.0f, 0.0f,
-	 hx, -hy, 1.0f, 0.0f,
-	 hx,  hy, 1.0f, 1.0f,
-	-hx, -hy, 0.0f, 0.0f,
-	 hx,  hy, 1.0f, 1.0f,
-	-hx,  hy, 0.0f, 1.0f,
+	-hx, -hy, 0.0f, 0.0f, hx, -hy, 1.0f, 0.0f, hx,  hy, 1.0f, 1.0f,
+	-hx, -hy, 0.0f, 0.0f, hx, hy,  1.0f, 1.0f, -hx, hy, 0.0f, 1.0f,
     };
 
     const bool firstUpload = (m_vao == 0);
@@ -329,23 +363,31 @@ void CText::uploadQuadVertices () {
 	glEnableVertexAttribArray (0);
 	glVertexAttribPointer (0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof (float), reinterpret_cast<void*> (0));
 	glEnableVertexAttribArray (1);
-	glVertexAttribPointer (1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof (float), reinterpret_cast<void*> (2 * sizeof (float)));
+	glVertexAttribPointer (
+	    1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof (float), reinterpret_cast<void*> (2 * sizeof (float))
+	);
     }
     glBindVertexArray (0);
 }
 
 void CText::render () {
-    if (!m_valid) return;
-    if (!m_text.visible->value->getBool ()) return;
+    if (!m_valid) {
+	return;
+    }
+    if (!m_text.visible->value->getBool ()) {
+	return;
+    }
 
+#if !NDEBUG
+    std::string str = "Text " + this->getObject ().name + " (" + std::to_string (this->getObject ().id) + ")";
+    glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
+#endif /* DEBUG */
     std::string renderedText = m_lastRenderedText;
     if (m_layerHandle != Scripting::kInvalidLayerHandle) {
-	auto& se = Scripting::ScriptEngine::instance ();
+	auto& se = this->getScene ().getScriptEngine ();
 	se.tickLayer (
-	    m_layerHandle,
-	    static_cast<double> (getScene ().getTime ()),
-	    static_cast<double> (getScene ().getDeltaTime ()),
-	    static_cast<double> (getScene ().getFps ())
+	    m_layerHandle, static_cast<double> (getScene ().getTime ()),
+	    static_cast<double> (getScene ().getDeltaTime ()), static_cast<double> (getScene ().getFps ())
 	);
 	const std::string current = se.layerText (m_layerHandle);
 	renderedText = current.empty () ? std::string (" ") : current;
@@ -382,9 +424,7 @@ void CText::render () {
     glm::mat4 model = glm::translate (glm::mat4 (1.0f), gl_origin);
     model = glm::scale (model, scale);
 
-    const glm::mat4 mvp = getScene ().getCamera ().getProjection ()
-			  * getScene ().getCamera ().getLookAt ()
-			  * model;
+    const glm::mat4 mvp = getScene ().getCamera ().getProjection () * getScene ().getCamera ().getLookAt () * model;
 
     glEnable (GL_BLEND);
     glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
@@ -400,4 +440,7 @@ void CText::render () {
     glBindVertexArray (m_vao);
     glDrawArrays (GL_TRIANGLES, 0, 6);
     glBindVertexArray (0);
+#if !NDEBUG
+    glPopDebugGroup ();
+#endif /* DEBUG */
 }

+ 5 - 4
src/WallpaperEngine/Render/Objects/CText.h

@@ -9,6 +9,7 @@
 
 #include "WallpaperEngine/Render/CObject.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
+#include "WallpaperEngine/Scripting/ScriptableObject.h"
 
 // Forward-declare FreeType types to avoid leaking the header into users.
 struct FT_LibraryRec_;
@@ -36,12 +37,12 @@ using namespace WallpaperEngine::Data::Model;
  * the data model but renders an empty string — the Wallpaper Engine JS
  * runtime required to evaluate it is out of scope for Phase 1.
  */
-class CText final : public CObject {
+class CText final : virtual public CObject, public Scripting::ScriptableObject {
 public:
     CText (Wallpapers::CScene& scene, const Text& text);
     ~CText () override;
 
-    void setup ();
+    void setup () override;
     void render () override;
 
 private:
@@ -77,8 +78,8 @@ private:
     GLint m_uColor = -1;
     GLint m_uTexture = -1;
 
-    glm::ivec2 m_textureSize = {0, 0};
-    glm::vec2 m_quadSize = {0.0f, 0.0f};
+    glm::ivec2 m_textureSize = { 0, 0 };
+    glm::vec2 m_quadSize = { 0.0f, 0.0f };
 
     bool m_valid = false;
 };

+ 186 - 78
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -51,27 +51,35 @@ CPass::CPass (
     Helpers::ContextAware (renderable), m_renderable (renderable), m_fboProvider (std::move (fboProvider)),
     m_pass (pass), m_binds (binds.has_value () ? binds.value ().get () : DEFAULT_BINDS),
     m_override (override.has_value () ? override.value ().get () : DEFAULT_OVERRIDE), m_target (target),
-    m_blendingmode (pass.blending) {
+    m_blendingmode (pass.blending), m_vao (GL_NONE) {
     this->setupShaders ();
+    glGenVertexArrays (1, &m_vao);
 }
 
 CPass::~CPass () {
+    glDeleteVertexArrays (1, &m_vao);
+    this->m_vao = GL_NONE;
+
     // destroy shader programs
-    if (!glIsProgram(this->m_programID)) return; // program already invalid or deleted
+    if (!glIsProgram (this->m_programID)) {
+	return; // program already invalid or deleted
+    }
 
     GLint shaderCount = 0;
-    glGetProgramiv(this->m_programID, GL_ATTACHED_SHADERS, &shaderCount);
+    glGetProgramiv (this->m_programID, GL_ATTACHED_SHADERS, &shaderCount);
 
     if (shaderCount > 0) {
-        std::vector<GLuint> attachedShaders(shaderCount);
-        glGetAttachedShaders(this->m_programID, shaderCount, nullptr, attachedShaders.data());
+	std::vector<GLuint> attachedShaders (shaderCount);
+	glGetAttachedShaders (this->m_programID, shaderCount, nullptr, attachedShaders.data ());
 
-        for (GLuint s : attachedShaders) {
-            if (glIsShader(s)) glDeleteShader(s);
-        }
+	for (GLuint s : attachedShaders) {
+	    if (glIsShader (s)) {
+		glDeleteShader (s);
+	    }
+	}
     }
 
-    glDeleteProgram(this->m_programID);
+    glDeleteProgram (this->m_programID);
     this->m_programID = 0;
 }
 
@@ -200,16 +208,43 @@ void CPass::setupRenderTexture () {
 
 std::shared_ptr<const TextureProvider> CPass::resolveTexture0 () {
     auto texture0 = this->resolveTexture (this->m_input, 0, this->m_input);
-    if (const auto texture0Override = this->m_textures.find (0); texture0Override != this->m_textures.end ()) {
-	texture0 = texture0Override->second == nullptr ? (this->m_previousInput ?: this->m_input) : texture0Override->second;
+    const auto it = this->m_textures.find (0);
+
+    if (it == this->m_textures.end ()) {
+	return texture0;
+    }
+
+    auto& chain = it->second;
+
+    do {
+	texture0 = chain->texture;
+
+	if (texture0 == nullptr) {
+	    if (this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+		return this->m_previousInput;
+	    }
+
+	    if (this->m_input != nullptr && this->m_input->isReady ()) {
+		return this->m_input;
+	    }
+	} else if (texture0->isReady ()) {
+	    return texture0;
+	}
+
+	chain = chain->next;
+    } while (chain != nullptr);
+
+    // got to the end of the chain, use previous input or current input if available
+    if (this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+	return this->m_previousInput;
     }
 
-    return texture0;
+    // last resort, doesn't matter if the input is ready or not
+    return this->m_input;
 }
 
-CPass::TextureAnimationState CPass::resolveTextureAnimationState (
-    const std::shared_ptr<const TextureProvider>& texture
-) const {
+CPass::TextureAnimationState
+CPass::resolveTextureAnimationState (const std::shared_ptr<const TextureProvider>& texture) const {
     TextureAnimationState state;
 
     if (texture == nullptr || !texture->isAnimated ()) {
@@ -217,8 +252,7 @@ CPass::TextureAnimationState CPass::resolveTextureAnimationState (
     }
 
     double currentRenderTime = fmod (
-	static_cast<double> (this->getContext ().getDriver ().getRenderTime ()),
-	this->m_renderable.getAnimationTime ()
+	static_cast<double> (this->getContext ().getDriver ().getRenderTime ()), this->m_renderable.getAnimationTime ()
     );
 
     for (const auto& frameCur : texture->getFrames ()) {
@@ -242,9 +276,7 @@ CPass::TextureAnimationState CPass::resolveTextureAnimationState (
     return state;
 }
 
-void CPass::bindTextureUnit (
-    int index, const std::shared_ptr<const TextureProvider>& texture, uint32_t frame
-) const {
+void CPass::bindTextureUnit (int index, const std::shared_ptr<const TextureProvider>& texture, uint32_t frame) const {
     if (texture == nullptr) {
 	return;
     }
@@ -253,18 +285,42 @@ void CPass::bindTextureUnit (
     glBindTexture (GL_TEXTURE_2D, texture->getTextureID (frame));
 }
 
-void CPass::bindTextureOverrides (
-    uint32_t currentTexture, std::shared_ptr<const TextureProvider>& texture0
-) const {
-    for (const auto& [index, expectedTexture] : this->m_textures) {
-	auto texture = expectedTexture == nullptr ? (this->m_previousInput ?: this->m_input) : expectedTexture;
-	if (texture == nullptr) {
-	    continue;
+void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const TextureProvider>& texture0) const {
+    for (auto [index, chain] : this->m_textures) {
+	// find the expected texture
+	auto expectedTexture = chain->texture;
+
+	do {
+	    if (expectedTexture == nullptr) {
+		if (this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+		    expectedTexture = this->m_previousInput;
+		    break;
+		}
+
+		if (this->m_input != nullptr && this->m_input->isReady ()) {
+		    expectedTexture = this->m_input;
+		    break;
+		}
+	    } else if (expectedTexture->isReady ()) {
+		break;
+	    }
+
+	    chain = chain->next;
+	    expectedTexture = chain == nullptr ? nullptr : chain->texture;
+	} while (chain != nullptr);
+
+	if (expectedTexture == nullptr && this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+	    expectedTexture = this->m_previousInput;
+	}
+
+	if (expectedTexture == nullptr) {
+	    expectedTexture = this->m_input;
 	}
 
-	this->bindTextureUnit (index, texture, index == 0 ? currentTexture : 0);
+	this->bindTextureUnit (index, expectedTexture, index == 0 ? currentTexture : 0);
+
 	if (index == 0) {
-	    texture0 = texture;
+	    texture0 = expectedTexture;
 	}
     }
 }
@@ -396,11 +452,14 @@ void CPass::cleanupRenderSetup () {
 }
 
 void CPass::render () {
+    // set the VAO for now
+    glBindVertexArray (this->m_vao);
+
     const auto& debug = this->getContext ().getApp ().getContext ().settings.render.debug;
     if (debug.passLog) {
 	sLog.out (
-	    "Render pass object=", this->m_renderable.getId (), " shader=", this->m_pass.shader, " target=",
-	    this->m_target.has_value () ? this->m_target.value ().get () : std::string ("<screen/local>"),
+	    "Render pass object=", this->m_renderable.getId (), " shader=", this->m_pass.shader,
+	    " target=", this->m_target.has_value () ? this->m_target.value ().get () : std::string ("<screen/local>"),
 	    " drawTo=", this->m_drawTo ? this->m_drawTo->getName () : std::string ("<null>"),
 	    " drawSize=", textureSizeLabel (this->m_drawTo), " inputSize=", textureSizeLabel (this->m_input)
 	);
@@ -411,16 +470,18 @@ void CPass::render () {
 	    }
 
 	    switch (uniform->second->type) {
-		case Vector3: {
-		    const auto* v = static_cast<const glm::vec3*> (uniform->second->value);
-		    sLog.out ("  uniform ", uniformName, "=", v->x, " ", v->y, " ", v->z);
-		    break;
-		}
-		case Float: {
-		    const auto* v = static_cast<const float*> (uniform->second->value);
-		    sLog.out ("  uniform ", uniformName, "=", *v);
-		    break;
-		}
+		case Vector3:
+		    {
+			const auto* v = static_cast<const glm::vec3*> (uniform->second->value);
+			sLog.out ("  uniform ", uniformName, "=", v->x, " ", v->y, " ", v->z);
+			break;
+		    }
+		case Float:
+		    {
+			const auto* v = static_cast<const float*> (uniform->second->value);
+			sLog.out ("  uniform ", uniformName, "=", *v);
+			break;
+		    }
 		default:
 		    break;
 	    }
@@ -561,8 +622,8 @@ void CPass::setupShaders () {
     }
 
     this->m_shader = new Render::Shaders::Shader (
-	this->m_renderable.getAssetLocator (), shaderName, this->m_combos, this->m_override.combos,
-	passTextures, this->m_override.textures, this->m_override.constants
+	this->m_renderable.getAssetLocator (), shaderName, this->m_combos, this->m_override.combos, passTextures,
+	this->m_override.textures, this->m_override.constants
     );
 
     const auto [vertex, fragment]
@@ -640,11 +701,15 @@ void CPass::setupTextureUniforms () {
     // and then try with fragment's and override any existing
     for (const auto& [index, textureName] : this->m_shader->getVertex ().getTextures ()) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    // create chain entry
+	    this->m_textures[index] = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = nullptr,
+	    });
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for fragment shader ", ex.what ());
 	}
@@ -652,11 +717,17 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_shader->getFragment ().getTextures ()) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for fragment shader ", ex.what ());
 	}
@@ -664,11 +735,17 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_pass.textures) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for pass ", ex.what ());
 	}
@@ -676,11 +753,17 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_pass.usertextures) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve user texture ", textureName, " for pass ", ex.what ());
 	}
@@ -689,25 +772,50 @@ void CPass::setupTextureUniforms () {
     // override any texture
     for (const auto& [index, textureName] : this->m_override.textures) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for override ", ex.what ());
 	}
     }
 
+    for (const auto& [index, textureName] : this->m_override.usertextures) {
+	try {
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
+	} catch (std::runtime_error& ex) {
+	    sLog.error ("Cannot resolve user texture ", textureName, " for override ", ex.what ());
+	}
+    }
+
     // binds are set last as they're the most important to be set
     for (const auto& [index, bind] : this->m_binds) {
-	if (bind == "previous") {
-	    // use nullptr as indication for "previous" texture
-	    this->m_textures[index] = nullptr;
-	} else if (!bind.empty ()) {
-	    // a normal bind, search for the corresponding FBO and set it
-	    this->m_textures[index] = this->resolveFBO (bind);
-	}
+	const auto texture = bind == "previous" ? nullptr : this->resolveFBO (bind);
+	const auto it = this->m_textures.find (index);
+	const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+	    .texture = texture,
+	    .next = it != this->m_textures.end () ? it->second : nullptr,
+	});
+
+	this->m_textures[index] = chain;
     }
 
     // resolve the main texture
@@ -730,7 +838,7 @@ void CPass::setupTextureUniforms () {
 
 	namestream << "g_Texture" << textureIndex << "Resolution";
 
-	texture = this->resolveTexture (expectedTexture, textureIndex, texture);
+	texture = this->resolveTexture (expectedTexture->texture, textureIndex, texture);
 	this->addUniform (namestream.str (), texture->getResolution ());
     }
 
@@ -746,8 +854,8 @@ void CPass::setupUniforms () {
     const auto& recorder = this->m_renderable.getScene ().getAudioContext ().getRecorder ();
 
     // lighting variables
-    this->addUniform ("g_LightAmbientColor", sceneData.colors.ambient);
-    this->addUniform ("g_LightSkylightColor", sceneData.colors.skylight);
+    this->addUniform ("g_LightAmbientColor", sceneData.colors.ambient->value->getVec3 ());
+    this->addUniform ("g_LightSkylightColor", sceneData.colors.skylight->value->getVec3 ());
     // register variables like brightness and alpha with some default value
     this->addUniform ("g_Brightness", renderable.getBrightness ());
     this->addUniform ("g_UserAlpha", renderable.getUserAlpha ());

+ 15 - 9
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -31,11 +31,11 @@ public:
     );
     ~CPass ();
 
-	    void render ();
+    void render ();
 
-	    void setDestination (std::shared_ptr<const CFBO> drawTo);
-	    void setInput (std::shared_ptr<const TextureProvider> input);
-	    void setPreviousInput (std::shared_ptr<const TextureProvider> input);
+    void setDestination (std::shared_ptr<const CFBO> drawTo);
+    void setInput (std::shared_ptr<const TextureProvider> input);
+    void setPreviousInput (std::shared_ptr<const TextureProvider> input);
     void setTexCoord (GLuint texcoord);
     void setPosition (GLuint position);
     void setModelViewProjectionMatrix (const glm::mat4* projection);
@@ -65,6 +65,11 @@ public:
     void addUniform (const std::string& name, const glm::mat4* value);
 
 private:
+    struct TextureChainEntry {
+	std::shared_ptr<const TextureProvider> texture;
+	std::shared_ptr<TextureChainEntry> next;
+    };
+
     enum UniformType {
 	Float = 0,
 	Matrix3 = 1,
@@ -188,14 +193,14 @@ private:
     /**
      * Contains the final map of textures to be used
      */
-    std::map<int, std::shared_ptr<const TextureProvider>> m_textures = {};
+    std::map<int, std::shared_ptr<TextureChainEntry>> m_textures = {};
 
     Render::Shaders::Shader* m_shader = nullptr;
 
-	    std::shared_ptr<const CFBO> m_drawTo = nullptr;
-	    std::shared_ptr<const TextureProvider> m_input = nullptr;
-	    std::shared_ptr<const TextureProvider> m_previousInput = nullptr;
-	    glm::vec4 m_texture0Resolution = {};
+    std::shared_ptr<const CFBO> m_drawTo = nullptr;
+    std::shared_ptr<const TextureProvider> m_input = nullptr;
+    std::shared_ptr<const TextureProvider> m_previousInput = nullptr;
+    glm::vec4 m_texture0Resolution = {};
 
     GLuint m_programID;
 
@@ -204,6 +209,7 @@ private:
     GLint g_Texture0Translation;
     GLuint a_TexCoord;
     GLuint a_Position;
+    GLuint m_vao;
 
     // Custom geometry callbacks (for particles, etc.)
     GeometryCallback m_setupAttribsCallback;

+ 10 - 4
src/WallpaperEngine/Render/RenderContext.cpp

@@ -8,8 +8,11 @@
 #include "WallpaperEngine/Data/Model/Project.h"
 
 namespace WallpaperEngine::Render {
-RenderContext::RenderContext (Drivers::VideoDriver& driver, WallpaperApplication& app) :
-    m_driver (driver), m_app (app), m_textureCache (new TextureCache (*this)) { }
+RenderContext::RenderContext (
+    Drivers::VideoDriver& driver, WallpaperApplication& app, Media::MediaSource& mediaSource
+) :
+    m_driver (driver), m_app (app), m_mediaSource (mediaSource),
+    m_textureCache (std::make_unique<TextureCache> (*this)) { }
 
 void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
     viewport->makeCurrent ();
@@ -24,8 +27,9 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
 
     // render the background
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
-	ref->second->render (viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition,
-	    viewport->logicalSize);
+	ref->second->render (
+	    viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition, viewport->logicalSize
+	);
     }
 
 #if !NDEBUG
@@ -62,4 +66,6 @@ const std::map<std::string, std::shared_ptr<CWallpaper>>& RenderContext::getWall
     return this->m_wallpapers;
 }
 
+Media::MediaSource& RenderContext::getMediaSource () const { return this->m_mediaSource; }
+
 } // namespace WallpaperEngine::Render

+ 8 - 2
src/WallpaperEngine/Render/RenderContext.h

@@ -16,6 +16,9 @@ namespace WallpaperEngine {
 namespace Application {
     class WallpaperApplication;
 }
+namespace Media {
+    class MediaSource;
+}
 
 namespace Render {
     namespace Drivers {
@@ -32,7 +35,7 @@ namespace Render {
 
     class RenderContext {
     public:
-	RenderContext (Drivers::VideoDriver& driver, WallpaperApplication& app);
+	RenderContext (Drivers::VideoDriver& driver, WallpaperApplication& app, Media::MediaSource& mediaSource);
 
 	void render (Drivers::Output::OutputViewport* viewport);
 	void setWallpaper (const std::string& display, std::shared_ptr<CWallpaper> wallpaper);
@@ -43,6 +46,7 @@ namespace Render {
 	[[nodiscard]] const Drivers::Output::Output& getOutput () const;
 	[[nodiscard]] std::shared_ptr<const TextureProvider> resolveTexture (const std::string& name) const;
 	[[nodiscard]] const std::map<std::string, std::shared_ptr<CWallpaper>>& getWallpapers () const;
+	[[nodiscard]] Media::MediaSource& getMediaSource () const;
 
     private:
 	/** Video driver in use */
@@ -51,8 +55,10 @@ namespace Render {
 	std::map<std::string, std::shared_ptr<CWallpaper>> m_wallpapers = {};
 	/** App that holds the render context */
 	WallpaperApplication& m_app;
+	/** Source for the media playback information */
+	Media::MediaSource& m_mediaSource;
 	/** Texture cache for the render */
-	TextureCache* m_textureCache = nullptr;
+	std::unique_ptr<TextureCache> m_textureCache = nullptr;
     };
 } // namespace Render
 } // namespace WallpaperEngine

+ 7 - 7
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -35,16 +35,16 @@
 	  "#define CAST3(x) (vec3(x))\n"                                                                               \
 	  "#define CAST4(x) (vec4(x))\n"                                                                               \
 	  "#define CAST3X3(x) (mat3(x))\n"                                                                             \
-	  "#define float2 vec2\n"                                                                                     \
-	  "#define float3 vec3\n"                                                                                     \
-	  "#define float4 vec4\n"                                                                                     \
-	  "#define int2 ivec2\n"                                                                                      \
-	  "#define int3 ivec3\n"                                                                                      \
-	  "#define int4 ivec4\n"                                                                                      \
+	  "#define float2 vec2\n"                                                                                      \
+	  "#define float3 vec3\n"                                                                                      \
+	  "#define float4 vec4\n"                                                                                      \
+	  "#define int2 ivec2\n"                                                                                       \
+	  "#define int3 ivec3\n"                                                                                       \
+	  "#define int4 ivec4\n"                                                                                       \
 	  "#define saturate(x) (clamp(x, 0.0, 1.0))\n"                                                                 \
 	  "#define texSample2D texture\n"                                                                              \
 	  "#define texSample2DLod textureLod\n"                                                                        \
-	  "#define log10(x) (log2(x) * 0.301029995663981)\n"                                                             \
+	  "#define log10(x) (log2(x) * 0.301029995663981)\n"                                                           \
 	  "#define atan2 atan\n"                                                                                       \
 	  "#define fmod(x, y) ((x)-(y)*trunc((x)/(y)))\n"                                                              \
 	  "#define ddx dFdx\n"                                                                                         \

+ 30 - 376
src/WallpaperEngine/Render/TextureCache.cpp

@@ -1,5 +1,6 @@
 #include "TextureCache.h"
 
+#include "AlbumTexture.h"
 #include "WallpaperEngine/FileSystem/Container.h"
 
 #include "CTexture.h"
@@ -10,404 +11,52 @@
 #include "WallpaperEngine/Data/Parsers/TextureParser.h"
 
 #include <algorithm>
-#include <array>
 #include <chrono>
-#include <cstring>
 #include <filesystem>
 #include <fstream>
-#include <iterator>
-#include <optional>
-#include <poll.h>
-#include <signal.h>
-#include <spawn.h>
-#include <stb_image.h>
-#include <sys/wait.h>
-#include <unistd.h>
 
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Assets;
 
-extern char** environ;
+TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (context) {
+    // these textures are special cases, so make sure they're created only upon request
+    this->m_currentThumbnail = std::make_shared<AlbumTexture> (this->getContext ());
 
-namespace {
-constexpr size_t kMaxThumbnailBytes = 16 * 1024 * 1024;
+#if !NDEBUG
+    glObjectLabel (GL_TEXTURE, this->m_currentThumbnail->getTextureID (0), -1, "$mediaThumbnail");
+#endif
 
-std::string trimTrailingNewlines (std::string value) {
-    while (!value.empty () && (value.back () == '\n' || value.back () == '\r')) {
-	value.pop_back ();
-    }
-    return value;
-}
-
-std::vector<char*> makeArgv (const std::string& program, const std::vector<std::string>& args) {
-    std::vector<char*> argv;
-    argv.reserve (args.size () + 2);
-    argv.push_back (const_cast<char*> (program.c_str ()));
-    for (const auto& arg : args) {
-	argv.push_back (const_cast<char*> (arg.c_str ()));
-    }
-    argv.push_back (nullptr);
-    return argv;
-}
-
-std::optional<pid_t> spawnProcessWithStdout (
-    const std::string& program, const std::vector<std::string>& args, int& readFd
-) {
-    int outputPipe[2] {};
-    if (pipe (outputPipe) != 0) {
-	return std::nullopt;
-    }
+    this->m_previousThumbnail = std::make_shared<AlbumTexture> (this->getContext ());
 
-    posix_spawn_file_actions_t actions;
-    posix_spawn_file_actions_init (&actions);
-    posix_spawn_file_actions_adddup2 (&actions, outputPipe[1], STDOUT_FILENO);
-    posix_spawn_file_actions_addclose (&actions, outputPipe[0]);
-    posix_spawn_file_actions_addclose (&actions, outputPipe[1]);
+#if !NDEBUG
+    glObjectLabel (GL_TEXTURE, this->m_previousThumbnail->getTextureID (0), -1, "$mediaPreviousThumbnail");
+#endif
 
-    auto argv = makeArgv (program, args);
-    pid_t pid = 0;
-    const int spawnResult = posix_spawnp (&pid, program.c_str (), &actions, nullptr, argv.data (), environ);
-    posix_spawn_file_actions_destroy (&actions);
-    close (outputPipe[1]);
+    // load the latest texture (if available)
+    this->m_currentThumbnail->load ();
 
-    if (spawnResult != 0) {
-	close (outputPipe[0]);
-	return std::nullopt;
-    }
+    // add these to the cache and return the right one
+    this->store ("$mediaThumbnail", this->m_currentThumbnail);
+    this->store ("$mediaPreviousThumbnail", this->m_previousThumbnail);
 
-    readFd = outputPipe[0];
-    return pid;
-}
+    this->m_mediaCallback = this->getContext ().getMediaSource ().addAlbumArtListener (
+	[this] (const Media::MediaSource::MediaInfo& data) {
+	    if (this->m_currentThumbnail->isReady ()) {
+		// copy over pixel data and setup the new texture with the new data
+		this->m_previousThumbnail->copyContents (*this->m_currentThumbnail);
+	    }
 
-bool waitForProcessUntil (pid_t pid, const std::chrono::steady_clock::time_point& deadline, int& status) {
-    while (std::chrono::steady_clock::now () < deadline) {
-	const pid_t waitResult = waitpid (pid, &status, WNOHANG);
-	if (waitResult == pid) {
-	    return true;
+	    // load the next image
+	    this->m_currentThumbnail->load ();
 	}
-	if (waitResult < 0) {
-	    return false;
-	}
-	usleep (1000);
-    }
-
-    kill (pid, SIGKILL);
-    waitpid (pid, &status, 0);
-    return false;
-}
-
-bool pollReadable (int readFd, const std::chrono::steady_clock::time_point& deadline) {
-    const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds> (
-	deadline - std::chrono::steady_clock::now ()
     );
-    if (remaining.count () <= 0) {
-	return false;
-    }
-
-    pollfd readPoll {
-	.fd = readFd,
-	.events = POLLIN,
-	.revents = 0,
-    };
-    const int pollResult = poll (&readPoll, 1, static_cast<int> (std::max (remaining.count (), int64_t { 1 })));
-    return pollResult > 0 && !(readPoll.revents & (POLLERR | POLLNVAL)) && (readPoll.revents & (POLLIN | POLLHUP));
-}
-
-std::string processReadFirstLine (const std::string& program, const std::vector<std::string>& args) {
-    constexpr auto timeout = std::chrono::milliseconds (300);
-    const auto deadline = std::chrono::steady_clock::now () + timeout;
-
-    int outputFd = -1;
-    const auto pid = spawnProcessWithStdout (program, args, outputFd);
-    if (!pid.has_value ()) {
-	return {};
-    }
-
-    std::array<char, 512> buffer {};
-    std::string output;
-    while (std::chrono::steady_clock::now () < deadline && pollReadable (outputFd, deadline)) {
-	const ssize_t bytesRead = read (outputFd, buffer.data (), buffer.size ());
-	if (bytesRead <= 0) {
-	    break;
-	}
-	output.append (buffer.data (), static_cast<size_t> (bytesRead));
-	const auto newline = output.find ('\n');
-	if (newline != std::string::npos) {
-	    output.erase (newline + 1);
-	    break;
-	}
-    }
-    close (outputFd);
-
-    int status = 0;
-    if (!waitForProcessUntil (*pid, deadline, status)) {
-	return {};
-    }
-    if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) {
-	return {};
-    }
-
-    return trimTrailingNewlines (output);
 }
 
-std::vector<char> processReadBytes (
-    const std::string& program, const std::vector<std::string>& args, std::chrono::milliseconds timeout
-) {
-    const auto deadline = std::chrono::steady_clock::now () + timeout;
-
-    int outputFd = -1;
-    const auto pid = spawnProcessWithStdout (program, args, outputFd);
-    if (!pid.has_value ()) {
-	return {};
-    }
-
-    std::vector<char> output;
-    std::array<char, 4096> buffer {};
-    bool tooLarge = false;
-    while (std::chrono::steady_clock::now () < deadline && pollReadable (outputFd, deadline)) {
-	const ssize_t bytesRead = read (outputFd, buffer.data (), buffer.size ());
-	if (bytesRead <= 0) {
-	    break;
-	}
-	if (output.size () + static_cast<size_t> (bytesRead) > kMaxThumbnailBytes) {
-	    tooLarge = true;
-	    break;
-	}
-	output.insert (output.end (), buffer.begin (), buffer.begin () + bytesRead);
-    }
-    close (outputFd);
-
-    if (tooLarge) {
-	kill (*pid, SIGKILL);
-	waitpid (*pid, nullptr, 0);
-	return {};
-    }
-
-    int status = 0;
-    if (!waitForProcessUntil (*pid, deadline, status)) {
-	return {};
-    }
-    if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) {
-	return {};
-    }
-
-    return output;
-}
-
-std::vector<char> readFileBytes (const std::filesystem::path& path) {
-    std::error_code sizeError;
-    const auto size = std::filesystem::file_size (path, sizeError);
-    if (!sizeError && size > kMaxThumbnailBytes) {
-	return {};
-    }
-
-    std::ifstream stream (path, std::ios::binary);
-    if (!stream) {
-	return {};
-    }
-
-    std::vector<char> output;
-    output.reserve (!sizeError ? static_cast<size_t> (std::min<uintmax_t> (size, kMaxThumbnailBytes)) : 0);
-    std::array<char, 4096> buffer {};
-    while (stream && output.size () < kMaxThumbnailBytes) {
-	const auto remaining = kMaxThumbnailBytes - output.size ();
-	stream.read (buffer.data (), static_cast<std::streamsize> (std::min (buffer.size (), remaining)));
-	const auto bytesRead = stream.gcount ();
-	if (bytesRead <= 0) {
-	    break;
-	}
-	output.insert (output.end (), buffer.begin (), buffer.begin () + bytesRead);
-    }
-    if (stream && output.size () >= kMaxThumbnailBytes) {
-	return {};
-    }
-    return output;
-}
-
-FIF detectImageFormat (const std::vector<char>& bytes) {
-    const auto* data = reinterpret_cast<const unsigned char*> (bytes.data ());
-    if (bytes.size () >= 4 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G') {
-	return FIF_PNG;
-    }
-    if (bytes.size () >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) {
-	return FIF_JPEG;
-    }
-    if (bytes.size () >= 12 && std::memcmp (data, "RIFF", 4) == 0 && std::memcmp (data + 8, "WEBP", 4) == 0) {
-	return FIF_WEBP;
-    }
-    return FIF_UNKNOWN;
-}
-
-int hexValue (char value) {
-    if (value >= '0' && value <= '9') {
-	return value - '0';
-    }
-    if (value >= 'a' && value <= 'f') {
-	return value - 'a' + 10;
-    }
-    if (value >= 'A' && value <= 'F') {
-	return value - 'A' + 10;
-    }
-    return -1;
-}
-
-std::optional<std::string> percentDecode (const std::string& value) {
-    std::string result;
-    result.reserve (value.size ());
-    for (size_t index = 0; index < value.size (); index++) {
-	if (value[index] != '%') {
-	    result.push_back (value[index]);
-	    continue;
-	}
-	if (index + 2 >= value.size ()) {
-	    return std::nullopt;
-	}
-	const int high = hexValue (value[index + 1]);
-	const int low = hexValue (value[index + 2]);
-	if (high < 0 || low < 0) {
-	    return std::nullopt;
-	}
-	result.push_back (static_cast<char> ((high << 4) | low));
-	index += 2;
-    }
-    return result;
-}
-
-std::optional<std::filesystem::path> parseLocalFileUri (const std::string& uri) {
-    if (uri.rfind ("file:", 0) != 0) {
-	return std::nullopt;
-    }
-
-    std::string rest = uri.substr (5);
-    std::string pathPart;
-    if (rest.rfind ("//", 0) == 0) {
-	rest = rest.substr (2);
-	const auto slash = rest.find ('/');
-	const std::string authority = slash == std::string::npos ? rest : rest.substr (0, slash);
-	if (!authority.empty () && authority != "localhost") {
-	    return std::nullopt;
-	}
-	pathPart = slash == std::string::npos ? std::string () : rest.substr (slash);
-    } else {
-	pathPart = rest;
-    }
-
-    auto decoded = percentDecode (pathPart);
-    if (!decoded.has_value () || decoded->empty ()) {
-	return std::nullopt;
-    }
-    return std::filesystem::path (*decoded);
-}
-
-std::shared_ptr<const TextureProvider> tryCreateImageTexture (RenderContext& context, const std::vector<char>& bytes) {
-    if (bytes.empty ()) {
-	return nullptr;
-    }
-
-    int width = 0;
-    int height = 0;
-    int channels = 0;
-    if (!stbi_info_from_memory (
-	    reinterpret_cast<const stbi_uc*> (bytes.data ()), static_cast<int> (bytes.size ()), &width, &height, &channels
-	)) {
-	return nullptr;
-    }
-
-    auto mipmap = std::make_shared<Mipmap> ();
-    mipmap->width = static_cast<uint32_t> (width);
-    mipmap->height = static_cast<uint32_t> (height);
-    mipmap->uncompressedSize = static_cast<int> (bytes.size ());
-    mipmap->uncompressedData = std::make_unique<char[]> (bytes.size ());
-    std::copy (bytes.begin (), bytes.end (), mipmap->uncompressedData.get ());
-
-    auto texture = std::make_unique<Texture> ();
-    texture->containerVersion = ContainerVersion_TEXB0001;
-    texture->flags = TextureFlags_ClampUVs;
-    texture->width = static_cast<uint32_t> (width);
-    texture->height = static_cast<uint32_t> (height);
-    texture->textureWidth = static_cast<uint32_t> (width);
-    texture->textureHeight = static_cast<uint32_t> (height);
-    texture->format = TextureFormat_ARGB8888;
-    texture->freeImageFormat = detectImageFormat (bytes);
-    texture->imageCount = 1;
-    texture->images.emplace (0, MipmapList { mipmap });
-
-    return std::make_shared<CTexture> (context, std::move (texture));
-}
-
-std::shared_ptr<const TextureProvider> tryLoadMediaThumbnailUrl (RenderContext& context, const std::string& artUrl) {
-    std::vector<char> bytes;
-    if (artUrl.rfind ("file:", 0) == 0) {
-	if (const auto path = parseLocalFileUri (artUrl); path.has_value ()) {
-	    bytes = readFileBytes (*path);
-	}
-    } else if (artUrl.rfind ("http://", 0) == 0 || artUrl.rfind ("https://", 0) == 0) {
-	bytes = processReadBytes ("curl", { "-L", "-s", "--max-time", "3", "--output", "-", artUrl }, std::chrono::milliseconds (3500));
-    }
-
-    return tryCreateImageTexture (context, bytes);
-}
-
-struct MediaThumbnailState {
-    RenderContext* context = nullptr;
-    std::string currentArtUrl;
-    std::shared_ptr<const TextureProvider> currentTexture = nullptr;
-    std::shared_ptr<const TextureProvider> previousTexture = nullptr;
-    std::chrono::steady_clock::time_point lastMetadataPoll = {};
-    bool hasMetadataPoll = false;
-};
-
-MediaThumbnailState& mediaThumbnailState () {
-    static MediaThumbnailState state;
-    return state;
-}
-
-std::shared_ptr<const TextureProvider> tryLoadMediaThumbnail (RenderContext& context, bool previous) {
-    auto& state = mediaThumbnailState ();
-    if (state.context != &context) {
-	state = MediaThumbnailState { .context = &context };
-    }
-
-    constexpr auto metadataPollInterval = std::chrono::milliseconds (750);
-    const auto now = std::chrono::steady_clock::now ();
-    if (state.hasMetadataPoll && now - state.lastMetadataPoll < metadataPollInterval) {
-	return previous ? state.previousTexture : state.currentTexture;
-    }
-    state.lastMetadataPoll = now;
-    state.hasMetadataPoll = true;
-
-    const std::string artUrl = processReadFirstLine ("playerctl", { "metadata", "mpris:artUrl" });
-    if (artUrl.empty ()) {
-	return previous ? state.previousTexture : nullptr;
-    }
-
-    if (artUrl != state.currentArtUrl) {
-	auto texture = tryLoadMediaThumbnailUrl (context, artUrl);
-	if (texture != nullptr) {
-	    state.previousTexture = state.currentTexture;
-	    state.currentTexture = texture;
-	    state.currentArtUrl = artUrl;
-	}
-    }
-
-    return previous ? state.previousTexture : state.currentTexture;
-}
-}
-
-TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (context) { }
+TextureCache::~TextureCache () { this->m_mediaCallback (); }
 
 std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string& filename) {
-    // Media thumbnails are dynamic, so avoid caching the texture by name.
-    if (filename == "$mediaThumbnail" || filename == "$mediaPreviousThumbnail") {
-	auto texture = tryLoadMediaThumbnail (this->getContext (), filename == "$mediaPreviousThumbnail");
-	if (texture == nullptr) {
-	    texture = this->resolve ("util/white");
-	}
-	return texture;
-    }
-
     if (const auto found = this->m_textureCache.find (filename); found != this->m_textureCache.end ()) {
 	return found->second;
     }
@@ -428,6 +77,10 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	    auto parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
 	    auto texture = std::make_shared<CTexture> (this->getContext (), std::move (parsedTexture));
 
+#if !NDEBUG
+	    glObjectLabel (GL_TEXTURE, texture->getTextureID (0), -1, filename.c_str ());
+#endif
+
 	    this->store (filename, texture);
 
 	    return texture;
@@ -436,6 +89,7 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	}
     }
 
+    // TODO: FILL IN WITH A CHECKERED PATTERN TEXTURE INSTEAD?
     throw AssetLoadException ("Cannot find file", filename, std::error_code ());
 }
 

+ 9 - 0
src/WallpaperEngine/Render/TextureCache.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <functional>
 #include <map>
 #include <memory>
 #include <string>
@@ -11,6 +12,7 @@
 using namespace WallpaperEngine::Render;
 
 namespace WallpaperEngine::Render {
+class AlbumTexture;
 namespace Helpers {
     class ContextAware;
 }
@@ -20,6 +22,7 @@ class RenderContext;
 class TextureCache final : Helpers::ContextAware {
 public:
     explicit TextureCache (RenderContext& context);
+    ~TextureCache () override;
 
     /**
      * Checks if the given texture was already loaded and returns it
@@ -39,7 +42,13 @@ public:
     void store (const std::string& name, std::shared_ptr<const TextureProvider> texture);
 
 private:
+    /** The previous album thumbnail texture */
+    std::shared_ptr<const AlbumTexture> m_previousThumbnail = nullptr;
+    /** The current album thumbnail texture */
+    std::shared_ptr<const AlbumTexture> m_currentThumbnail = nullptr;
     /** Cached textures */
     std::map<std::string, std::shared_ptr<const TextureProvider>> m_textureCache = {};
+    /** The callback to de-register media events */
+    std::function<void ()> m_mediaCallback;
 };
 } // namespace WallpaperEngine::Render

+ 6 - 2
src/WallpaperEngine/Render/TextureProvider.h

@@ -12,8 +12,8 @@
 namespace WallpaperEngine::Render {
 using namespace WallpaperEngine::Data::Assets;
 /**
- * Base interface that describes the minimum information required for a texture
- * to be displayed by the engine
+ * Base interface that describes the minimum information required for the engine
+ * to display a texture
  */
 class TextureProvider {
 public:
@@ -78,6 +78,10 @@ public:
      * @return Duration of spritesheet animation in seconds
      */
     [[nodiscard]] virtual float getSpritesheetDuration () const = 0;
+    /**
+     * @return If this texture is ready to be used or not
+     */
+    virtual bool isReady () const = 0;
 
     /**
      * Increments the usage count of the texture

+ 24 - 111
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -21,38 +21,6 @@ using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Render::Wallpapers;
-using JSON = WallpaperEngine::Data::JSON::JSON;
-
-namespace {
-Render::CObject* createImageObject (CScene& scene, const Image& imageData) {
-    auto* image = new Objects::CImage (scene, imageData);
-    try {
-	image->setup ();
-    } catch (std::runtime_error&) {
-	sLog.error ("Cannot setup image ", image->getImage ().name);
-	delete image;
-	return nullptr;
-    }
-    return image;
-}
-
-Render::CObject* createParticleObject (CScene& scene, const Particle& particleData) {
-    if (scene.getContext ().getApp ().getContext ().settings.general.disableParticles == true) {
-	sLog.debug ("Ignoring particle system (disabled in settings): ", particleData.name);
-	return nullptr;
-    }
-
-    auto* particle = new Objects::CParticle (scene, particleData);
-    try {
-	particle->setup ();
-    } catch (std::runtime_error&) {
-	sLog.error ("Cannot setup particle ", particle->getParticle ().name);
-	delete particle;
-	return nullptr;
-    }
-    return particle;
-}
-}
 
 CScene::CScene (
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
@@ -61,6 +29,8 @@ CScene::CScene (
     // caller should check this, if not a std::bad_cast is good to throw
     auto scene = wallpaper.as<Scene> ();
 
+    // setup scripting engine
+    this->m_scriptEngine = std::make_unique<Scripting::ScriptEngine> (*this, context.getMediaSource ());
     // setup the scene camera
     this->m_camera = std::make_unique<Camera> (*this, scene->camera);
 
@@ -130,8 +100,6 @@ CScene::CScene (
 	this->addObjectToRenderOrder (*object);
     }
 
-    this->collectScriptedValues ();
-
     // create extra framebuffers for the bloom effect
     this->_rt_4FrameBuffer = this->create (
 	"_rt_4FrameBuffer", TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0, { sceneWidth / 4, sceneHeight / 4 },
@@ -261,26 +229,33 @@ Render::CObject* CScene::dispatchObjectType (const Object& object) {
     Render::CObject* renderObject = nullptr;
 
     if (object.is<Image> ()) {
-	renderObject = createImageObject (*this, *object.as<Image> ());
+	renderObject = new Objects::CImage (*this, *object.as<Image> ());
     } else if (object.is<Sound> ()) {
 	renderObject = new Objects::CSound (*this, *object.as<Sound> ());
     } else if (object.is<Text> ()) {
-	auto* text = new Objects::CText (*this, *object.as<Text> ());
-	try {
-	    text->setup ();
-	} catch (std::runtime_error&) {
-	    sLog.error ("Cannot setup text ", text->getObject ().name);
-	    delete text;
+	renderObject = new Objects::CText (*this, *object.as<Text> ());
+    } else if (object.is<Particle> ()) {
+	const auto& particleData = *object.as<Particle> ();
+
+	if (this->getContext ().getApp ().getContext ().settings.general.disableParticles == true) {
+	    sLog.debug ("Ignoring particle system (disabled in settings): ", particleData.name);
 	    return nullptr;
 	}
-	renderObject = text;
-    } else if (object.is<Particle> ()) {
-	renderObject = createParticleObject (*this, *object.as<Particle> ());
+
+	renderObject = new Objects::CParticle (*this, particleData);
     } else {
-	sLog.debug ("Unknown object type, creating placeholder, empty object: ", object.id);
+	sLog.error ("Unknown object type, creating placeholder, empty object: ", object.id);
 	renderObject = new CObject (*this, object);
     }
 
+    try {
+	renderObject->setup ();
+    } catch (const std::exception& e) {
+	sLog.error ("Failed to setup object ", object.id, ": ", e.what ());
+	delete renderObject;
+	renderObject = nullptr;
+    }
+
     return renderObject;
 }
 
@@ -319,78 +294,13 @@ void CScene::addObjectToRenderOrder (const Object& object) {
     }
 }
 
-void CScene::registerScriptedValue (const UserSettingUniquePtr& setting) {
-    if (!setting || !setting->value) {
-	return;
-    }
-
-    auto* scripted = dynamic_cast<ScriptedDynamicValue*> (setting->value.get ());
-    if (!scripted) {
-	return;
-    }
-
-    if (std::ranges::find (this->m_scriptedValues, scripted) == this->m_scriptedValues.end ()) {
-	this->m_scriptedValues.emplace_back (scripted);
-    }
-}
-
-void CScene::collectScriptedValues () {
-    this->m_scriptedValues.clear ();
-
-    for (const auto& object : this->getScene ().objects) {
-	this->registerScriptedValue (object->origin);
-	this->registerScriptedValue (object->groupScale);
-	this->registerScriptedValue (object->groupAngles);
-	this->registerScriptedValue (object->groupVisible);
-
-	if (object->is<Image> ()) {
-	    const auto* image = object->as<Image> ();
-	    this->registerScriptedValue (image->scale);
-	    this->registerScriptedValue (image->angles);
-	    this->registerScriptedValue (image->visible);
-	    this->registerScriptedValue (image->alpha);
-	    this->registerScriptedValue (image->color);
-	    this->registerScriptedValue (image->parallaxDepth);
-
-	    for (const auto& effect : image->effects) {
-		this->registerScriptedValue (effect->visible);
-		for (const auto& pass : effect->passOverrides) {
-		    for (const auto& constant : pass->constants | std::views::values) {
-			this->registerScriptedValue (constant);
-		    }
-		}
-	    }
-	} else if (object->is<Particle> ()) {
-	    const auto* particle = object->as<Particle> ();
-	    this->registerScriptedValue (particle->scale);
-	    this->registerScriptedValue (particle->angles);
-	    this->registerScriptedValue (particle->visible);
-	    this->registerScriptedValue (particle->parallaxDepth);
-	} else if (object->is<Text> ()) {
-	    const auto* text = object->as<Text> ();
-	    this->registerScriptedValue (text->visible);
-	    this->registerScriptedValue (text->color);
-	    this->registerScriptedValue (text->alpha);
-	    this->registerScriptedValue (text->scale);
-	    this->registerScriptedValue (text->pointSize);
-	}
-    }
-}
-
-void CScene::updateScriptedValues () {
-    for (const auto& scripted : this->m_scriptedValues) {
-	scripted->reevaluate (this);
-    }
-}
-
+ScriptEngine& CScene::getScriptEngine () const { return *this->m_scriptEngine; }
 Camera& CScene::getCamera () const { return *this->m_camera; }
 
 void CScene::renderFrame (const glm::ivec4& viewport) {
     // ensure the virtual mouse position is up to date
     this->updateMouse (viewport);
 
-    this->updateScriptedValues ();
-
     // update the parallax position if required
     if (this->getScene ().camera.parallax.enabled->value->getBool ()
 	&& !this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
@@ -405,6 +315,9 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 	    = glm::mix (this->m_parallaxDisplacement, (centeredMouse * amount) * influence, delay);
     }
 
+    // run a tick in the javascript logic
+    this->getScriptEngine ().tick ();
+
     // update main textures for images
     for (const auto& cur : this->m_objectsByRenderOrder) {
 	if (!cur->is<Objects::CImage> ()) {

+ 4 - 5
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -2,8 +2,8 @@
 
 #include "WallpaperEngine/Render/Camera.h"
 
-#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
 
 namespace WallpaperEngine::Render {
 class Camera;
@@ -22,6 +22,7 @@ public:
 
     ~CScene () override;
 
+    [[nodiscard]] Scripting::ScriptEngine& getScriptEngine () const;
     [[nodiscard]] Camera& getCamera () const;
 
     [[nodiscard]] const Scene& getScene () const;
@@ -54,16 +55,14 @@ private:
     Render::CObject* createObject (const Object& object);
     Render::CObject* dispatchObjectType (const Object& object);
     void addObjectToRenderOrder (const Object& object);
-    void collectScriptedValues ();
-    void registerScriptedValue (const UserSettingUniquePtr& setting);
-    void updateScriptedValues ();
 
+    std::unique_ptr<Scripting::ScriptEngine> m_scriptEngine;
     std::unique_ptr<Camera> m_camera;
     ObjectUniquePtr m_bloomObjectData;
     CObject* m_bloomObject = nullptr;
     std::map<int, CObject*> m_objects = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
-    std::vector<ScriptedDynamicValue*> m_scriptedValues = {};
+    std::vector<DynamicValue*> m_scriptedValues = {};
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
     glm::vec2 m_mousePositionNormalized = {};

+ 4 - 1
src/WallpaperEngine/Render/Wallpapers/CVideo.cpp

@@ -25,7 +25,10 @@ CVideo::CVideo (
 	this->getContext (), this->CWallpaper::getWallpaperTexture (), videopath, 64, 64,
 	this->CWallpaper::getWallpaperFramebuffer ()
     );
-    this->m_player->setVolume (this->getContext ().getApp ().getContext ().settings.audio.volume * 100.0 / 128.0);
+    // --silent disables audio (audio.enabled = false) and must mute video wallpapers too;
+    // a volume of 0 mutes the mpv backend (matching what --volume 0 already does).
+    const auto& audioSettings = this->getContext ().getApp ().getContext ().settings.audio;
+    this->m_player->setVolume (audioSettings.enabled ? audioSettings.volume * 100.0 / 128.0 : 0.0);
     // make sure the video has at least one usage marked, this ensures the video plays
     this->m_player->incrementUsageCount ();
 }

+ 30 - 0
src/WallpaperEngine/Scripting/Adapters/ObjectAdapter.cpp

@@ -0,0 +1,30 @@
+#include "ObjectAdapter.h"
+
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+using namespace WallpaperEngine::Data::Model;
+using namespace WallpaperEngine::Scripting::Adapters;
+
+ObjectAdapter::ObjectAdapter (ScriptEngine& engine) :
+    m_engine (engine), m_classId (JS_INVALID_CLASS_ID), m_definition () { }
+
+JSValue ObjectAdapter::instantiate (ScriptableObject& object) {
+    JSValue result = JS_NewObjectClass (this->m_engine.getContext (), this->m_classId);
+    JS_SetOpaque (result, &object);
+
+    return result;
+}
+
+JSValue ObjectAdapter::instantiate (DynamicValue& value) {
+    JSValue result = JS_NewObjectClass (this->m_engine.getContext (), this->m_classId);
+    JS_SetOpaque (result, &value);
+
+    return result;
+}
+
+void ObjectAdapter::registerType (const JSClassDef& definition) {
+    this->m_definition = definition;
+
+    JS_NewClassID (this->m_engine.getRuntime (), &this->m_classId);
+    JS_NewClass (this->m_engine.getRuntime (), this->m_classId, &definition);
+}

+ 32 - 0
src/WallpaperEngine/Scripting/Adapters/ObjectAdapter.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include "WallpaperEngine/Data/Utils/TypeCaster.h"
+#include "quickjs.h"
+
+namespace WallpaperEngine::Data::Model {
+class DynamicValue;
+}
+namespace WallpaperEngine::Scripting {
+class ScriptEngine;
+class ScriptableObject;
+}
+
+namespace WallpaperEngine::Scripting::Adapters {
+class ObjectAdapter : public Data::Utils::TypeCaster {
+public:
+    explicit ObjectAdapter (ScriptEngine& engine);
+    ~ObjectAdapter () override = default;
+
+    virtual JSValue instantiate (ScriptableObject& object);
+    virtual JSValue instantiate (Data::Model::DynamicValue& value);
+
+    ScriptEngine& getEngine () const { return m_engine; }
+
+protected:
+    void registerType (const JSClassDef& definition);
+
+    ScriptEngine& m_engine;
+    JSClassID m_classId;
+    JSClassDef m_definition;
+};
+}

+ 90 - 0
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp

@@ -0,0 +1,90 @@
+#include "ScriptableObjectAdapter.h"
+
+#include <utility>
+
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+#include "WallpaperEngine/Scripting/ScriptableObject.h"
+
+using namespace WallpaperEngine::Data::Model;
+using namespace WallpaperEngine::Data::Utils;
+using namespace WallpaperEngine::Scripting::Adapters;
+
+#define SCRIPTABLE_OPAQUE_MAGIC 0xdeadbeef
+
+struct OpaqueScriptableObjectAdapter {
+    unsigned int magic;
+    ScriptableObjectAdapter& adapter;
+    WallpaperEngine::Scripting::ScriptableObject& object;
+};
+
+JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver) {
+    JSClassID classId = 0;
+
+    auto* container = static_cast<OpaqueScriptableObjectAdapter*> (JS_GetAnyOpaque (obj_val, &classId));
+
+    if (!container || container->magic != SCRIPTABLE_OPAQUE_MAGIC) {
+	return JS_EXCEPTION;
+    }
+
+    const char* name = JS_AtomToCString (ctx, atom);
+
+    if (name == nullptr) {
+	return JS_EXCEPTION;
+    }
+
+    ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
+
+    try {
+	// find the property inside, otherwise return undefined
+	auto& property = container->object.getProperty (name);
+
+	return container->adapter.getEngine ().dynamicToJs (property);
+    } catch (const std::exception& e) {
+	return JS_UNDEFINED;
+    }
+}
+
+int scriptableobject_property_set (
+    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
+) {
+    JSClassID classId = 0;
+
+    auto* container = static_cast<OpaqueScriptableObjectAdapter*> (JS_GetAnyOpaque (obj_val, &classId));
+
+    if (!container || container->magic != SCRIPTABLE_OPAQUE_MAGIC) {
+	return -1;
+    }
+
+    const char* name = JS_AtomToCString (ctx, atom);
+
+    if (name == nullptr) {
+	return -1;
+    }
+
+    return 0;
+}
+
+ScriptableObjectAdapter::ScriptableObjectAdapter (ScriptEngine& engine, std::string name) :
+    ObjectAdapter (engine), m_exoticMethods (), m_name (std::move (name)) {
+    this->registerType (
+	{
+	    .class_name = m_name.c_str (),
+	    .exotic = &m_exoticMethods,
+	}
+    );
+}
+
+JSValue ScriptableObjectAdapter::instantiate (ScriptableObject& object) {
+    JSValue result = this->ObjectAdapter::instantiate (object);
+    JS_SetOpaque (
+	result,
+	new OpaqueScriptableObjectAdapter { .magic = SCRIPTABLE_OPAQUE_MAGIC, .adapter = *this, .object = object }
+    );
+
+    return result;
+}
+
+JSValue ScriptableObjectAdapter::instantiate (DynamicValue& value) {
+    throw std::runtime_error ("Cannot create a ScriptableObject instance from a DynamicValue");
+}

+ 17 - 0
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include "ObjectAdapter.h"
+
+namespace WallpaperEngine::Scripting::Adapters {
+class ScriptableObjectAdapter : public ObjectAdapter {
+public:
+    explicit ScriptableObjectAdapter (ScriptEngine& engine, std::string name);
+
+    JSValue instantiate (ScriptableObject& object) override;
+    JSValue instantiate (Data::Model::DynamicValue& value) override;
+
+private:
+    JSClassExoticMethods m_exoticMethods;
+    std::string m_name;
+};
+}

+ 1005 - 0
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -0,0 +1,1005 @@
+#include "VectorAdapter.h"
+
+#include "../ScriptEngine.h"
+#include "WallpaperEngine/Data/Utils/SFINAE.h"
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+
+#include <variant>
+
+using namespace WallpaperEngine::Data::Utils;
+using namespace WallpaperEngine::Data::Model;
+using namespace WallpaperEngine::Scripting::Adapters;
+
+static uint32_t VectorInstanceId = 0;
+static uint32_t VectorAdapterInstanceId = 0;
+static constexpr int InvalidVectorInstanceId = 0;
+
+// magic value used to ensure the assigned opaque value we got back is valid
+#define VEC_OPAQUE_MAGIC 0xdeadbee0
+#define VEC_MAGIC_CHECK_EXCEPTION(container, components)                                                               \
+    do {                                                                                                               \
+	if (!container || container->magic != (int)(VEC_OPAQUE_MAGIC + components)) {                                  \
+	    return JS_EXCEPTION;                                                                                       \
+	}                                                                                                              \
+    } while (0)
+#define VEC_MAGIC_CHECK_ERROR(container, components)                                                                   \
+    do {                                                                                                               \
+	if (!container || container->magic != (int)(VEC_OPAQUE_MAGIC + components)) {                                  \
+	    return -1;                                                                                                 \
+	}                                                                                                              \
+    } while (0)
+
+template <int components> std::map<uint32_t, VectorAdapter<components>&> vectorAdapterInstances;
+
+template <int components> struct VectorOpaqueContainer {
+    int magic;
+    VectorAdapter<components>& adapter;
+    DynamicValue& value;
+    uint32_t id;
+};
+
+template <int components> auto vector_new () -> decltype (auto) {
+    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
+
+    if constexpr (components == 2) {
+	return glm::vec2 {};
+    } else if constexpr (components == 3) {
+	return glm::vec3 {};
+    } else if constexpr (components == 4) {
+	return glm::vec4 {};
+    }
+}
+
+template auto vector_new<2> () -> decltype (auto);
+template auto vector_new<3> () -> decltype (auto);
+template auto vector_new<4> () -> decltype (auto);
+
+template <int components> auto vector_new (float value) -> decltype (auto) {
+    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
+
+    if constexpr (components == 2) {
+	return glm::vec2 (value);
+    } else if constexpr (components == 3) {
+	return glm::vec3 (value);
+    } else if constexpr (components == 4) {
+	return glm::vec4 (value);
+    }
+}
+
+template auto vector_new<2> (float value) -> decltype (auto);
+template auto vector_new<3> (float value) -> decltype (auto);
+template auto vector_new<4> (float value) -> decltype (auto);
+
+template <int components> auto vector_get (DynamicValue& value) -> decltype (auto) {
+    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
+
+    if constexpr (components == 2) {
+	return value.getVec2 ();
+    } else if constexpr (components == 3) {
+	return value.getVec3 ();
+    } else if constexpr (components == 4) {
+	return value.getVec4 ();
+    }
+}
+
+template <int components> auto vector_get (JSContext* ctx, JSValue source) -> decltype (auto) {
+    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
+
+    int tag = JS_VALUE_GET_TAG (source);
+
+    if (tag == JS_TAG_INT) {
+	int32_t value = 0;
+
+	JS_ToInt32 (ctx, &value, source);
+
+	return vector_new<components> (value);
+    }
+
+    if (JS_TAG_IS_FLOAT64 (tag)) {
+	double value = 0.0f;
+
+	JS_ToFloat64 (ctx, &value, source);
+
+	return vector_new<components> (static_cast<float> (value));
+    }
+
+    if (tag == JS_TAG_OBJECT) {
+	// check components, extract x, y, z and w and create the appropriate vector
+	JSValue x = JS_GetPropertyStr (ctx, source, "x");
+	JSValue y = JS_GetPropertyStr (ctx, source, "y");
+	JSValue z = JS_GetPropertyStr (ctx, source, "z");
+	JSValue w = JS_GetPropertyStr (ctx, source, "w");
+
+	if (!JS_IsNumber (x) || !JS_IsNumber (y)) {
+	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
+	}
+
+	// do not accept bigger vectors
+	if (components <= 2 && JS_IsNumber (z)) {
+	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
+	}
+
+	if (components <= 3 && JS_IsNumber (w)) {
+	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
+	}
+
+	double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f, wVal = 0.0f;
+
+	JS_ToFloat64 (ctx, &xVal, x);
+	JS_ToFloat64 (ctx, &yVal, y);
+
+	if (JS_IsNumber (z)) {
+	    JS_ToFloat64 (ctx, &zVal, z);
+	}
+
+	if (JS_IsNumber (w)) {
+	    JS_ToFloat64 (ctx, &wVal, w);
+	}
+
+	if constexpr (components == 2) {
+	    return glm::vec2 (xVal, yVal);
+	} else if constexpr (components == 3) {
+	    return glm::vec3 (xVal, yVal, zVal);
+	} else if constexpr (components == 4) {
+	    return glm::vec4 (xVal, yVal, zVal, wVal);
+	}
+    }
+
+    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
+}
+
+template auto vector_get<2> (JSContext* ctx, JSValue source) -> decltype (auto);
+template auto vector_get<3> (JSContext* ctx, JSValue source) -> decltype (auto);
+template auto vector_get<4> (JSContext* ctx, JSValue source) -> decltype (auto);
+template auto vector_get<2> (DynamicValue& value) -> decltype (auto);
+template auto vector_get<3> (DynamicValue& value) -> decltype (auto);
+template auto vector_get<4> (DynamicValue& value) -> decltype (auto);
+
+template <int components>
+JSValue vector_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver) {
+    JSClassID classId = 0;
+
+    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (obj_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    const char* name = JS_AtomToCString (ctx, atom);
+
+    if (name == nullptr) {
+	return JS_EXCEPTION;
+    }
+
+    ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
+    const auto value = vector_get<components> (container->value);
+
+    if (strcmp (name, "x") == 0) {
+	return JS_NewFloat64 (ctx, value.x);
+    }
+    if (strcmp (name, "y") == 0) {
+	return JS_NewFloat64 (ctx, value.y);
+    }
+    if constexpr (components >= 3) {
+	if (strcmp (name, "z") == 0) {
+	    return JS_NewFloat64 (ctx, value.z);
+	}
+
+	if constexpr (components >= 4) {
+	    if (strcmp (name, "w") == 0) {
+		return JS_NewFloat64 (ctx, value.w);
+	    }
+	}
+    }
+
+    return JS_EXCEPTION;
+}
+
+template JSValue vector_property_get<2> (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver);
+template JSValue vector_property_get<3> (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver);
+template JSValue vector_property_get<4> (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver);
+
+template <int components>
+int vector_property_set (
+    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
+) {
+    JSClassID classId = 0;
+    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (obj_val, &classId));
+
+    VEC_MAGIC_CHECK_ERROR (container, components);
+
+    int tag = JS_VALUE_GET_TAG (val);
+
+    if (tag != JS_TAG_INT && !JS_TAG_IS_FLOAT64 (tag)) {
+	return -1;
+    }
+
+    const char* name = JS_AtomToCString (ctx, atom);
+
+    if (name == nullptr) {
+	return -1;
+    }
+
+    ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
+    auto vec = vector_get<components> (container->value);
+    void* into = nullptr;
+
+    if (strcmp (name, "x") == 0) {
+	if constexpr (
+	    std::is_same_v<decltype (vec), glm::vec2> || std::is_same_v<decltype (vec), glm::vec3>
+	    || std::is_same_v<decltype (vec), glm::vec4>
+	) {
+	    into = &vec.x;
+	} else if constexpr (std::is_same_v<decltype (vec), Color>) {
+	    into = &vec.r;
+	}
+    } else if (strcmp (name, "y") == 0) {
+	if constexpr (
+	    std::is_same_v<decltype (vec), glm::vec2> || std::is_same_v<decltype (vec), glm::vec3>
+	    || std::is_same_v<decltype (vec), glm::vec4>
+	) {
+	    into = &vec.y;
+	} else if constexpr (std::is_same_v<decltype (vec), Color>) {
+	    into = &vec.g;
+	}
+    } else if constexpr (components >= 3) {
+	if (strcmp (name, "z") == 0) {
+	    if constexpr (std::is_same_v<decltype (vec), glm::vec3> || std::is_same_v<decltype (vec), glm::vec4>) {
+		into = &vec.z;
+	    } else if constexpr (std::is_same_v<decltype (vec), Color>) {
+		into = &vec.b;
+	    }
+	} else if constexpr (components >= 4) {
+	    if (strcmp (name, "w") == 0) {
+		if constexpr (std::is_same_v<decltype (vec), glm::vec4>) {
+		    into = &vec.w;
+		} else if constexpr (std::is_same_v<decltype (vec), Color>) {
+		    into = &vec.a;
+		}
+	    }
+	}
+    }
+
+    if (into == nullptr) {
+	return -1;
+    }
+
+    double value = 0;
+
+    JS_ToFloat64 (ctx, &value, val);
+
+    *static_cast<float*> (into) = static_cast<float> (value);
+
+    container->value.update (vec, DynamicValue::UpdateSource::Script);
+
+    return 0;
+}
+
+template int vector_property_set<2> (
+    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
+);
+template int vector_property_set<3> (
+    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
+);
+template int vector_property_set<4> (
+    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
+);
+
+template <int components> JSValue vector_copy (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+
+    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    // create a new DynamicValue
+    return container->adapter.instantiate (container->value, true);
+}
+
+template JSValue vector_copy<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_copy<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_copy<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+bool vector_value_equals (JSContext* ctx, JSValue left, JSValue right) {
+    if (JS_TAG_IS_FLOAT64 (JS_VALUE_GET_TAG (left)) || JS_TAG_IS_FLOAT64 (JS_VALUE_GET_TAG (right))) {
+	double x1Val = 0.0f, x2Val = 0.0f;
+
+	JS_ToFloat64 (ctx, &x1Val, left);
+	JS_ToFloat64 (ctx, &x2Val, right);
+
+	if (std::abs (x1Val - x2Val) > 0.00001) {
+	    return false;
+	}
+    } else {
+	if (!JS_IsEqual (ctx, left, right)) {
+	    return false;
+	}
+    }
+
+    return true;
+}
+
+template <int components> JSValue vector_equals (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc == 0) {
+	return JS_FALSE;
+    }
+
+    JSClassID classId = 0;
+
+    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue other = argv[0];
+    auto* otherContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (other, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (otherContainer, components);
+
+    const auto vector = vector_get<components> (container->value);
+    const auto otherVector = vector_get<components> (otherContainer->value);
+
+    if constexpr (components == 2) {
+	return vector.x == otherVector.x && vector.y == otherVector.y ? JS_TRUE : JS_FALSE;
+    } else if constexpr (components == 3) {
+	return vector.x == otherVector.x && vector.y == otherVector.y && vector.z == otherVector.z ? JS_TRUE : JS_FALSE;
+    } else if constexpr (components == 4) {
+	return vector.x == otherVector.x && vector.y == otherVector.y && vector.z == otherVector.z
+		&& vector.w == otherVector.w
+	    ? JS_TRUE
+	    : JS_FALSE;
+    }
+
+    return JS_FALSE;
+}
+
+template JSValue vector_equals<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_equals<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_equals<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_length (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+
+    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    return JS_NewFloat64 (ctx, glm::length (vector_get<components> (container->value)));
+}
+
+template JSValue vector_length<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_length<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_length<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components>
+JSValue vector_constructor (JSContext* ctx, JSValueConst new_target, int argc, JSValueConst* argv, int magic) {
+    if (argc == 0) {
+	return JS_EXCEPTION;
+    }
+
+    auto it = vectorAdapterInstances<components>.find (magic);
+
+    if (it == vectorAdapterInstances<components>.end ()) {
+	return JS_EXCEPTION;
+    }
+
+    JSValue result = it->second.instantiate ();
+    JSClassID classId = 0;
+    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (result, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    container->value.update (vector_get<components> (ctx, argv[0]), DynamicValue::UpdateSource::Initialization);
+
+    return result;
+}
+
+template JSValue vector_constructor<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
+template JSValue vector_constructor<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
+template JSValue vector_constructor<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
+
+template <int components> void vector_finalizer (JSRuntime* rt, JSValueConst val) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (val, &classId));
+
+    if (!container || container->magic != (int)(VEC_OPAQUE_MAGIC + components)) {
+	return;
+    }
+
+    // free container and the associated DynamicValue if temporal
+    if (container->id != InvalidVectorInstanceId) {
+	container->adapter.free (container->id);
+    }
+
+    delete container;
+}
+
+template void vector_finalizer<2> (JSRuntime* rt, JSValueConst val);
+template void vector_finalizer<3> (JSRuntime* rt, JSValueConst val);
+template void vector_finalizer<4> (JSRuntime* rt, JSValueConst val);
+
+template <int components>
+JSValue vector_normalize (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate (container->value, true);
+
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    newContainer->value.update (
+	glm::normalize (vector_get<components> (container->value)), DynamicValue::UpdateSource::Script
+    );
+
+    return newVector;
+}
+
+template JSValue vector_normalize<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_normalize<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_normalize<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_add (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	vector_get<components> (ctx, argv[0]) + vector_get<components> (container->value),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_add<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_add<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_add<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components>
+JSValue vector_subtract (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	vector_get<components> (ctx, argv[0]) - vector_get<components> (container->value),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_subtract<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_subtract<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_subtract<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components>
+JSValue vector_multiply (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	vector_get<components> (ctx, argv[0]) * vector_get<components> (container->value),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_multiply<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_multiply<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_multiply<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_divide (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	vector_get<components> (ctx, argv[0]) / vector_get<components> (container->value),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_divide<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_divide<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_divide<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_dot (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::dot (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_dot<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_dot<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_dot<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_cross (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::cross (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_cross<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 2) {
+	return JS_EXCEPTION;
+    }
+
+    if (!JS_IsNumber (argv[1])) {
+	return JS_EXCEPTION;
+    }
+
+    double amount = 0.0f;
+
+    JS_ToFloat64 (ctx, &amount, argv[1]);
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::mix (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value), amount),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_mix<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_mix<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_mix<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_min (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::min (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_min<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_min<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_min<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_max (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_UNDEFINED;
+    }
+
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::max (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
+	DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_max<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_max<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_max<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_abs (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::abs (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_abs<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_abs<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_abs<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_sign (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::sign (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_sign<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_sign<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_sign<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_round (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::round (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_round<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_round<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_round<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_floor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (
+	glm::floor (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
+    );
+
+    return newVector;
+}
+
+template JSValue vector_floor<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_floor<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_floor<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components> JSValue vector_ceil (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    JSValue newVector = container->adapter.instantiate ();
+    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
+    const auto vector = vector_get<components> (container->value);
+
+    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
+
+    newContainer->value.update (glm::ceil (vector), DynamicValue::UpdateSource::Initialization);
+
+    return newVector;
+}
+
+template JSValue vector_ceil<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_ceil<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_ceil<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components>
+JSValue vector_toString (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId = 0;
+    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+
+    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+
+    return JS_NewString (ctx, container->value.toString ().c_str ());
+}
+
+template JSValue vector_toString<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_toString<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+template JSValue vector_toString<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+
+template <int components>
+VectorAdapter<components>::VectorAdapter (ScriptEngine& engine) :
+    ObjectAdapter (engine), m_instanceId (++VectorAdapterInstanceId), m_name ("Vec" + std::to_string (components)),
+    m_exoticMethods (
+	{
+	    .get_property = vector_property_get<components>,
+	    .set_property = vector_property_set<components>,
+	}
+    ) {
+    vectorAdapterInstances<components>.emplace (this->m_instanceId, *this);
+    this->registerType (
+	{
+	    .class_name = this->m_name.c_str (),
+	    .finalizer = vector_finalizer<components>,
+	    .exotic = &this->m_exoticMethods,
+	}
+    );
+
+    // build the prototype for the Vector and assign the required methods
+    m_prototype = JS_NewObject (this->m_engine.getContext ());
+
+    JS_DupValue (this->m_engine.getContext (), m_prototype);
+
+    JSValue ctor = JS_NewCFunctionMagic (
+	this->m_engine.getContext (), vector_constructor<components>, this->m_name.c_str (), 1,
+	JS_CFUNC_constructor_magic, this->m_instanceId
+    );
+
+    JS_SetConstructor (this->m_engine.getContext (), ctor, m_prototype);
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "copy",
+	JS_NewCFunction (this->m_engine.getContext (), vector_copy<components>, "copy", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "equals",
+	JS_NewCFunction (this->m_engine.getContext (), vector_equals<components>, "equals", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "length",
+	JS_NewCFunction (this->m_engine.getContext (), vector_length<components>, "length", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "lengthSqr",
+	JS_NewCFunction (this->m_engine.getContext (), vector_length<components>, "lengthSqr", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "normalize",
+	JS_NewCFunction (this->m_engine.getContext (), vector_normalize<components>, "normalize", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "add",
+	JS_NewCFunction (this->m_engine.getContext (), vector_add<components>, "add", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "subtract",
+	JS_NewCFunction (this->m_engine.getContext (), vector_subtract<components>, "subtract", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "multiply",
+	JS_NewCFunction (this->m_engine.getContext (), vector_multiply<components>, "multiply", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "divide",
+	JS_NewCFunction (this->m_engine.getContext (), vector_divide<components>, "divide", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "dot",
+	JS_NewCFunction (this->m_engine.getContext (), vector_dot<components>, "dot", 1), JS_PROP_ENUMERABLE
+    );
+    if constexpr (components == 3) {
+	JS_DefinePropertyValueStr (
+	    this->m_engine.getContext (), m_prototype, "cross",
+	    JS_NewCFunction (this->m_engine.getContext (), vector_cross<components>, "cross", 1), JS_PROP_ENUMERABLE
+	);
+    }
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "mix",
+	JS_NewCFunction (this->m_engine.getContext (), vector_mix<components>, "mix", 2), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "min",
+	JS_NewCFunction (this->m_engine.getContext (), vector_min<components>, "min", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "max",
+	JS_NewCFunction (this->m_engine.getContext (), vector_max<components>, "max", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "abs",
+	JS_NewCFunction (this->m_engine.getContext (), vector_abs<components>, "abs", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "sign",
+	JS_NewCFunction (this->m_engine.getContext (), vector_sign<components>, "sign", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "round",
+	JS_NewCFunction (this->m_engine.getContext (), vector_round<components>, "round", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "floor",
+	JS_NewCFunction (this->m_engine.getContext (), vector_floor<components>, "floor", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "ceil",
+	JS_NewCFunction (this->m_engine.getContext (), vector_ceil<components>, "ceil", 0), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), m_prototype, "toString",
+	JS_NewCFunction (this->m_engine.getContext (), vector_toString<components>, "toString", 0), JS_PROP_ENUMERABLE
+    );
+
+    JS_SetClassProto (this->m_engine.getContext (), this->m_classId, m_prototype);
+    JS_FreeValue (this->m_engine.getContext (), ctor);
+}
+
+template <int components> VectorAdapter<components>::~VectorAdapter () {
+    vectorAdapterInstances<components>.erase (this->m_instanceId);
+
+    JS_FreeValue (this->m_engine.getContext (), m_prototype);
+}
+
+template <int components> JSValue VectorAdapter<components>::instantiate (ScriptableObject& object) {
+    throw new std::runtime_error ("Cannot create a Vector4 instance from a ScriptableObject");
+}
+
+template <int components> JSValue VectorAdapter<components>::instantiate (DynamicValue& value) {
+    JSValue result = this->ObjectAdapter::instantiate (value);
+    JS_SetOpaque (
+	result,
+	new VectorOpaqueContainer<components> {
+	    .magic = VEC_OPAQUE_MAGIC + components,
+	    .adapter = *this,
+	    .value = value,
+	    .id = InvalidVectorInstanceId,
+	}
+    );
+
+    return result;
+}
+
+template <int components> JSValue VectorAdapter<components>::instantiate (DynamicValue& source, bool temporal) {
+    auto value = std::make_unique<DynamicValue> (source);
+    uint32_t id = ++VectorInstanceId;
+    JSValue result = this->ObjectAdapter::instantiate (*value);
+    JS_SetOpaque (
+	result,
+	new VectorOpaqueContainer<components> {
+	    .magic = VEC_OPAQUE_MAGIC + components,
+	    .adapter = *this,
+	    .value = *value,
+	    .id = id,
+	}
+    );
+
+    this->m_values.emplace (id, std::move (value));
+
+    return result;
+}
+
+template <int components> JSValue VectorAdapter<components>::instantiate () {
+    auto value = std::make_unique<DynamicValue> (vector_new<components> ());
+    uint32_t id = ++VectorInstanceId;
+    JSValue result = this->ObjectAdapter::instantiate (*value);
+    JS_SetOpaque (
+	result,
+	new VectorOpaqueContainer<components> {
+	    .magic = VEC_OPAQUE_MAGIC + components,
+	    .adapter = *this,
+	    .value = *value,
+	    .id = id,
+	}
+    );
+
+    this->m_values.emplace (id, std::move (value));
+
+    return result;
+}
+
+template <int components> void VectorAdapter<components>::free (uint32_t vectorId) {
+    auto it = this->m_values.find (vectorId);
+
+    if (it != this->m_values.end ()) {
+	this->m_values.erase (it);
+    }
+}
+
+namespace WallpaperEngine::Scripting::Adapters {
+template class VectorAdapter<2>;
+template class VectorAdapter<3>;
+template class VectorAdapter<4>;
+} // namespace WallpaperEngine::Scripting::Adapters

+ 34 - 0
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include "ObjectAdapter.h"
+#include "WallpaperEngine/Data/Model/Types.h"
+
+namespace WallpaperEngine::Scripting::Adapters {
+template <int components> class VectorAdapter : public ObjectAdapter {
+public:
+    explicit VectorAdapter (ScriptEngine& engine);
+    ~VectorAdapter () override;
+
+    int length () { return components; }
+    JSValue instantiate (Data::Model::DynamicValue& value) override;
+    JSValue instantiate (ScriptableObject& object) override;
+    /**
+     * @return A new, anonymous JSValue representing the vector
+     */
+    JSValue instantiate (Data::Model::DynamicValue& source, bool temporal);
+    JSValue instantiate ();
+
+    void free (uint32_t vectorId);
+
+private:
+    JSValue m_prototype;
+    uint32_t m_instanceId;
+    std::string m_name;
+    JSClassExoticMethods m_exoticMethods;
+    std::map<uint32_t, Data::Model::DynamicValueUniquePtr> m_values;
+};
+
+extern template class VectorAdapter<2>;
+extern template class VectorAdapter<3>;
+extern template class VectorAdapter<4>;
+}

+ 69 - 0
src/WallpaperEngine/Scripting/ConsoleObject.cpp

@@ -0,0 +1,69 @@
+#include "ConsoleObject.h"
+
+#include "EngineObject.h"
+#include "ScriptEngine.h"
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+
+using namespace WallpaperEngine::Scripting;
+
+JSValue console_log (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc < 1) {
+	return JS_UNDEFINED;
+    }
+
+    std::stringstream stream;
+
+    for (int i = 0; i < argc; i++) {
+	const char* str = JS_ToCString (ctx, argv[i]);
+	ScopeGuard guard ([ctx, str] { JS_FreeCString (ctx, str); });
+
+	stream << str;
+    }
+
+    sLog.out (stream.str ());
+
+    return JS_UNDEFINED;
+}
+
+JSValue console_error (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc < 1) {
+	return JS_UNDEFINED;
+    }
+
+    std::stringstream stream;
+
+    for (int i = 0; i < argc; i++) {
+	const char* str = JS_ToCString (ctx, argv[i]);
+	ScopeGuard guard ([ctx, str] { JS_FreeCString (ctx, str); });
+
+	stream << str;
+    }
+
+    sLog.error (stream.str ());
+
+    return JS_UNDEFINED;
+}
+
+ConsoleObject::ConsoleObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
+    m_scene (scene), m_engine (engine), m_classId (0) {
+    this->m_definition = { .class_name = "IConsole" };
+    JS_NewClassID (this->m_engine.getRuntime (), &this->m_classId);
+    JS_NewClass (this->m_engine.getRuntime (), this->m_classId, &this->m_definition);
+    this->m_instance = JS_NewObjectClass (this->m_engine.getContext (), this->m_classId);
+
+    JS_DupValue (this->m_engine.getContext (), this->m_instance);
+
+    // set properties
+    JS_SetOpaque (this->m_instance, this);
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "log",
+	JS_NewCFunction (this->m_engine.getContext (), console_log, "log", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "error",
+	JS_NewCFunction (this->m_engine.getContext (), console_error, "error", 1), JS_PROP_ENUMERABLE
+    );
+}
+
+ConsoleObject::~ConsoleObject () { JS_FreeValue (this->m_engine.getContext (), this->m_instance); }

+ 25 - 0
src/WallpaperEngine/Scripting/ConsoleObject.h

@@ -0,0 +1,25 @@
+#pragma once
+#include "quickjs.h"
+
+namespace WallpaperEngine::Render::Wallpapers {
+class CScene;
+}
+namespace WallpaperEngine::Scripting {
+class ScriptEngine;
+class ConsoleObject {
+public:
+    ConsoleObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene);
+    ~ConsoleObject ();
+
+    const Render::Wallpapers::CScene& getScene () const { return m_scene; }
+    JSValue getInstance () const { return m_instance; }
+
+protected:
+    Render::Wallpapers::CScene& m_scene;
+    ScriptEngine& m_engine;
+
+    JSClassID m_classId;
+    JSClassDef m_definition;
+    JSValue m_instance;
+};
+}

+ 291 - 0
src/WallpaperEngine/Scripting/EngineObject.cpp

@@ -0,0 +1,291 @@
+#include "EngineObject.h"
+#include "ScriptEngine.h"
+#include "WallpaperEngine/Logging/Log.h"
+
+#include <ranges>
+
+using namespace WallpaperEngine::Scripting;
+
+extern float g_Time;
+extern float g_TimeLast;
+extern float g_Daytime;
+
+static uint32_t EngineInstanceId = 0;
+std::map<uint32_t, EngineObject&> engineInstances;
+
+JSValue engine_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
+
+JSValue engine_open_user_shortcut (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    return JS_UNDEFINED;
+}
+
+JSValue engine_get_frametime (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    return JS_NewFloat64 (ctx, g_Time - g_TimeLast);
+}
+
+JSValue engine_get_runtime (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    return JS_NewFloat64 (ctx, g_Time);
+}
+
+JSValue engine_get_daytime (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    return JS_NewFloat64 (ctx, g_Daytime);
+}
+
+JSValue engine_stop_interval (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    if (argc != 1) {
+	return JS_EXCEPTION;
+    }
+
+    const auto it = engineInstances.find (magic);
+
+    if (it == engineInstances.end ()) {
+	return JS_EXCEPTION;
+    }
+
+    int id = 0;
+
+    JS_ToInt32 (ctx, &id, argv[0]);
+
+    it->second.clearInterval (id);
+
+    return JS_UNDEFINED;
+}
+
+JSValue engine_stop_timeout (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    if (argc != 1) {
+	return JS_EXCEPTION;
+    }
+
+    const auto it = engineInstances.find (magic);
+
+    if (it == engineInstances.end ()) {
+	return JS_EXCEPTION;
+    }
+
+    int id = 0;
+
+    JS_ToInt32 (ctx, &id, argv[0]);
+
+    it->second.clearTimeout (id);
+
+    return JS_UNDEFINED;
+}
+
+JSValue engine_set_interval (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc < 1) {
+	return JS_EXCEPTION;
+    }
+
+    int delay = 0;
+
+    if (argc > 1) {
+	JS_ToInt32 (ctx, &delay, argv[1]);
+    }
+
+    JSValue function = argv[0];
+
+    if (!JS_IsFunction (ctx, function)) {
+	return JS_EXCEPTION;
+    }
+
+    const auto it = engineInstances.find (magic);
+
+    if (it == engineInstances.end ()) {
+	return JS_EXCEPTION;
+    }
+
+    int id = it->second.reserveNextIntervalId (function, delay);
+
+    JSValue args[] = { JS_NewInt32 (ctx, id) };
+
+    return JS_NewCFunctionData (ctx, engine_stop_interval, 2, magic, 1, args);
+}
+
+JSValue engine_set_timeout (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc < 1) {
+	return JS_EXCEPTION;
+    }
+
+    int delay = 0;
+
+    if (argc > 1) {
+	JS_ToInt32 (ctx, &delay, argv[1]);
+    }
+
+    JSValue function = argv[0];
+
+    if (!JS_IsFunction (ctx, function)) {
+	return JS_EXCEPTION;
+    }
+
+    const auto it = engineInstances.find (magic);
+
+    if (it == engineInstances.end ()) {
+	return JS_EXCEPTION;
+    }
+
+    int id = it->second.reserveNextTimeoutId (function, delay);
+
+    JSValue args[] = { JS_NewInt32 (ctx, id) };
+
+    return JS_NewCFunctionData (ctx, engine_stop_timeout, 2, magic, 1, args);
+}
+
+EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
+    m_scene (scene), m_engine (engine), m_instanceId (++EngineInstanceId), m_classId (0) {
+    this->m_definition = { .class_name = "IEngine" };
+    JS_NewClassID (this->m_engine.getRuntime (), &this->m_classId);
+    JS_NewClass (this->m_engine.getRuntime (), this->m_classId, &this->m_definition);
+    this->m_instance = JS_NewObjectClass (this->m_engine.getContext (), this->m_classId);
+
+    JS_DupValue (this->m_engine.getContext (), this->m_instance);
+
+    // set properties
+    JS_SetOpaque (this->m_instance, this);
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "frametime"),
+	JS_NewCFunction (this->m_engine.getContext (), engine_get_frametime, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "runtime"),
+	JS_NewCFunction (this->m_engine.getContext (), engine_get_runtime, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "timeOfDay"),
+	JS_NewCFunction (this->m_engine.getContext (), engine_get_daytime, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "AUDIO_RESOLUTION_16",
+	JS_NewInt32 (this->m_engine.getContext (), 16), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "AUDIO_RESOLUTION_32",
+	JS_NewInt32 (this->m_engine.getContext (), 32), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "AUDIO_RESOLUTION_64",
+	JS_NewInt32 (this->m_engine.getContext (), 64), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "setInterval",
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_set_interval, "setInterval", 2, JS_CFUNC_generic, this->m_instanceId
+	),
+	JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "setTimeout",
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_set_timeout, "setTimeout", 2, JS_CFUNC_generic, this->m_instanceId
+	),
+	JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "openUserShortcut",
+	JS_NewCFunction (this->m_engine.getContext (), engine_open_user_shortcut, "openUserShortcut", 0),
+	JS_PROP_ENUMERABLE
+    );
+    // TODO: ADD THE REST OF THE DEFINITION!
+}
+
+EngineObject::~EngineObject () {
+    // clear all the timeouts and intervals
+    for (const auto& [id, timeout] : this->m_timeouts) {
+	JS_FreeValue (this->m_engine.getContext (), timeout.callback);
+    }
+    for (const auto& [id, interval] : this->m_intervals) {
+	JS_FreeValue (this->m_engine.getContext (), interval.callback);
+    }
+
+    engineInstances.erase (this->m_instanceId);
+    this->m_intervals.clear ();
+    this->m_timeouts.clear ();
+
+    JS_FreeValue (this->m_engine.getContext (), this->m_instance);
+}
+
+uint32_t EngineObject::reserveNextTimeoutId (JSValue function, uint64_t duration) {
+    const auto id = ++this->m_nextTimeoutId;
+
+    this->m_timeouts[id] = Timeout { .callback = function,
+				     .duration = std::chrono::milliseconds (duration),
+				     .next = std::chrono::steady_clock::now () + std::chrono::milliseconds (duration) };
+
+    return id;
+}
+
+uint32_t EngineObject::reserveNextIntervalId (JSValue function, uint64_t duration) {
+    const auto id = ++this->m_nextIntervalId;
+
+    this->m_intervals[id]
+	= Timeout { .callback = function,
+		    .duration = std::chrono::milliseconds (duration),
+		    .next = std::chrono::steady_clock::now () + std::chrono::milliseconds (duration) };
+
+    return id;
+}
+
+void EngineObject::clearInterval (uint32_t id) {
+    const auto it = this->m_intervals.find (id);
+
+    if (it == this->m_intervals.end ()) {
+	return;
+    }
+
+    JS_FreeValue (this->getEngine ().getContext (), it->second.callback);
+
+    this->m_intervals.erase (id);
+}
+
+void EngineObject::clearTimeout (uint32_t id) {
+    const auto it = this->m_timeouts.find (id);
+
+    if (it == this->m_timeouts.end ()) {
+	return;
+    }
+
+    JS_FreeValue (this->getEngine ().getContext (), it->second.callback);
+
+    this->m_timeouts.erase (id);
+}
+
+void EngineObject::tick () {
+    const auto now = std::chrono::steady_clock::now ();
+
+    // check any interval and run them if needed
+    for (auto& timeout : this->m_intervals | std::views::values) {
+	if (timeout.next > now) {
+	    continue;
+	}
+
+	timeout.next = now + timeout.duration;
+
+	JS_Call (this->m_engine.getContext (), timeout.callback, JS_NULL, 0, nullptr);
+    }
+
+    std::vector<uint32_t> removeTimeouts;
+
+    // check any timeout and run them if needed
+    for (auto& [id, timeout] : this->m_timeouts) {
+	if (timeout.next > now) {
+	    continue;
+	}
+
+	JS_Call (this->m_engine.getContext (), timeout.callback, JS_NULL, 0, nullptr);
+
+	JS_FreeValue (this->m_engine.getContext (), timeout.callback);
+
+	removeTimeouts.push_back (id);
+    }
+
+    for (auto id : removeTimeouts) {
+	this->m_timeouts.erase (id);
+    }
+}

+ 47 - 0
src/WallpaperEngine/Scripting/EngineObject.h

@@ -0,0 +1,47 @@
+#pragma once
+#include "quickjs.h"
+
+#include <chrono>
+#include <map>
+
+namespace WallpaperEngine::Render::Wallpapers {
+class CScene;
+}
+namespace WallpaperEngine::Scripting {
+class ScriptEngine;
+class EngineObject {
+public:
+    EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene);
+    ~EngineObject ();
+
+    const Render::Wallpapers::CScene& getScene () const { return m_scene; }
+    JSValue getInstance () const { return m_instance; }
+    ScriptEngine& getEngine () const { return m_engine; }
+    uint32_t getInstanceId () const { return m_instanceId; }
+    uint32_t reserveNextTimeoutId (JSValue function, uint64_t duration);
+    uint32_t reserveNextIntervalId (JSValue function, uint64_t duration);
+    void clearTimeout (uint32_t id);
+    void clearInterval (uint32_t id);
+
+    void tick ();
+
+protected:
+    struct Timeout {
+	JSValue callback;
+	std::chrono::milliseconds duration;
+	std::chrono::steady_clock::time_point next;
+    };
+
+    uint32_t m_nextTimeoutId = 0;
+    uint32_t m_nextIntervalId = 0;
+    std::map<uint32_t, Timeout> m_intervals;
+    std::map<uint32_t, Timeout> m_timeouts;
+    Render::Wallpapers::CScene& m_scene;
+    ScriptEngine& m_engine;
+
+    uint32_t m_instanceId;
+    JSClassID m_classId;
+    JSClassDef m_definition;
+    JSValue m_instance;
+};
+}

+ 66 - 0
src/WallpaperEngine/Scripting/InputObject.cpp

@@ -0,0 +1,66 @@
+#include "InputObject.h"
+
+#include "EngineObject.h"
+#include "ScriptEngine.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+
+using namespace WallpaperEngine::Scripting;
+
+JSValue get_cursor_world_position (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId;
+    auto* input = static_cast<InputObject*> (JS_GetAnyOpaque (this_val, &classId));
+
+    // TODO: PROPERLY IMPLEMENT THIS
+    return input->getScene ().getScriptEngine ().getAdapters ().vec3->instantiate ();
+}
+
+JSValue get_cursor_screen_position (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    JSClassID classId;
+    auto* input = static_cast<InputObject*> (JS_GetAnyOpaque (this_val, &classId));
+    auto position = input->getScene ().getMousePositionNormalized ();
+
+    JSValue result = input->getScene ().getScriptEngine ().getAdapters ().vec2->instantiate ();
+
+    JS_SetPropertyStr (ctx, result, "x", JS_NewFloat64 (ctx, position->x));
+    JS_SetPropertyStr (ctx, result, "y", JS_NewFloat64 (ctx, position->y));
+
+    return result;
+}
+
+JSValue get_cursor_left_down (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    // TODO: IMPLEMENT THIS
+    return JS_NewBool (ctx, false);
+}
+
+JSValue input_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
+
+InputObject::InputObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
+    m_scene (scene), m_engine (engine), m_classId (0) {
+    this->m_definition = { .class_name = "IInput" };
+    JS_NewClassID (this->m_engine.getRuntime (), &this->m_classId);
+    JS_NewClass (this->m_engine.getRuntime (), this->m_classId, &this->m_definition);
+    this->m_instance = JS_NewObjectClass (this->m_engine.getContext (), this->m_classId);
+
+    JS_DupValue (this->m_engine.getContext (), this->m_instance);
+
+    // set properties
+    JS_SetOpaque (this->m_instance, this);
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance,
+	JS_NewAtom (this->m_engine.getContext (), "cursorWorldPosition"),
+	JS_NewCFunction (this->m_engine.getContext (), get_cursor_world_position, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), input_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance,
+	JS_NewAtom (this->m_engine.getContext (), "cursorScreenPosition"),
+	JS_NewCFunction (this->m_engine.getContext (), get_cursor_screen_position, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), input_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "cursorLeftDown"),
+	JS_NewCFunction (this->m_engine.getContext (), get_cursor_left_down, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), input_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+}
+InputObject::~InputObject () { JS_FreeValue (this->m_engine.getContext (), this->m_instance); }

+ 25 - 0
src/WallpaperEngine/Scripting/InputObject.h

@@ -0,0 +1,25 @@
+#pragma once
+#include "quickjs.h"
+
+namespace WallpaperEngine::Render::Wallpapers {
+class CScene;
+}
+namespace WallpaperEngine::Scripting {
+class ScriptEngine;
+class InputObject {
+public:
+    InputObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene);
+    ~InputObject ();
+
+    const Render::Wallpapers::CScene& getScene () const { return m_scene; }
+    JSValue getInstance () const { return m_instance; }
+
+protected:
+    Render::Wallpapers::CScene& m_scene;
+    ScriptEngine& m_engine;
+
+    JSClassID m_classId;
+    JSClassDef m_definition;
+    JSValue m_instance;
+};
+}

+ 266 - 0
src/WallpaperEngine/Scripting/Modules/ColorModule.cpp

@@ -0,0 +1,266 @@
+#include "ColorModule.h"
+
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+using namespace WallpaperEngine::Scripting::Modules;
+
+#define min_f(a, b, c) (fminf (a, fminf (b, c)))
+#define max_f(a, b, c) (fmaxf (a, fmaxf (b, c)))
+
+static uint32_t ColorModuleInstanceId = 0;
+std::map<uint32_t, ColorModule&> colorModules;
+
+int wecolor_init (JSContext* ctx, JSModuleDef* m) {
+
+    JS_AddModuleExport (ctx, m, "rgb2hsv");
+    JS_AddModuleExport (ctx, m, "hsv2rgb");
+    JS_AddModuleExport (ctx, m, "normalizeColor");
+    JS_AddModuleExport (ctx, m, "expandColor");
+
+    return 0;
+}
+
+JSValue wecolor_rgb2hsv (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc != 1) {
+	return JS_EXCEPTION;
+    }
+
+    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
+	return JS_EXCEPTION;
+    }
+
+    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
+    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
+    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
+
+    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
+
+    JS_ToFloat64 (ctx, &xVal, x);
+    JS_ToFloat64 (ctx, &yVal, y);
+    JS_ToFloat64 (ctx, &zVal, z);
+
+    // conversion code from https://gist.github.com/yoggy/8999625
+    float h, s, v; // h:0-360.0, s:0.0-1.0, v:0.0-1.0
+
+    float max = max_f (xVal, yVal, zVal);
+    float min = min_f (xVal, yVal, zVal);
+
+    v = max;
+
+    if (max == 0.0f) {
+	s = 0;
+	h = 0;
+    } else if (max - min == 0.0f) {
+	s = 0;
+	h = 0;
+    } else {
+	s = (max - min) / max;
+
+	if (max == xVal) {
+	    h = 60 * ((yVal - zVal) / (max - min)) + 0;
+	} else if (max == yVal) {
+	    h = 60 * ((zVal - xVal) / (max - min)) + 120;
+	} else {
+	    h = 60 * ((xVal - yVal) / (max - min)) + 240;
+	}
+    }
+
+    if (h < 0) {
+	h += 360.0f;
+    }
+
+    const auto it = colorModules.find (magic);
+
+    if (it == colorModules.end ()) {
+	return JS_UNDEFINED;
+    }
+
+    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
+
+    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, h));
+    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, s));
+    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, v));
+
+    return value;
+}
+
+JSValue wecolor_hsv2rgb (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc != 1) {
+	return JS_EXCEPTION;
+    }
+
+    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
+	return JS_EXCEPTION;
+    }
+
+    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
+    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
+    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
+
+    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
+
+    JS_ToFloat64 (ctx, &xVal, x);
+    JS_ToFloat64 (ctx, &yVal, y);
+    JS_ToFloat64 (ctx, &zVal, z);
+
+    // conversion code from https://gist.github.com/yoggy/8999625
+    float r, g, b; // 0.0-1.0
+
+    int hi = (int)(xVal / 60.0f) % 6;
+    float f = (xVal / 60.0f) - hi;
+    float p = zVal * (1.0f - yVal);
+    float q = zVal * (1.0f - yVal * f);
+    float t = zVal * (1.0f - yVal * (1.0f - f));
+
+    switch (hi) {
+	case 0:
+	    r = zVal, g = t, b = p;
+	    break;
+	case 1:
+	    r = q, g = zVal, b = p;
+	    break;
+	case 2:
+	    r = p, g = zVal, b = t;
+	    break;
+	case 3:
+	    r = p, g = q, b = zVal;
+	    break;
+	case 4:
+	    r = t, g = p, b = zVal;
+	    break;
+	case 5:
+	    r = zVal, g = p, b = q;
+	    break;
+	default:
+	    r = 0, g = 0, b = 0;
+	    break;
+    }
+
+    const auto it = colorModules.find (magic);
+
+    if (it == colorModules.end ()) {
+	return JS_UNDEFINED;
+    }
+
+    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
+
+    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, r));
+    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, g));
+    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, b));
+
+    return value;
+}
+
+JSValue wecolor_normalizecolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc != 1) {
+	return JS_EXCEPTION;
+    }
+
+    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
+	return JS_EXCEPTION;
+    }
+
+    const auto it = colorModules.find (magic);
+
+    if (it == colorModules.end ()) {
+	return JS_UNDEFINED;
+    }
+
+    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
+    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
+    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
+
+    if (!JS_IsNumber (x) || !JS_IsNumber (y) || !JS_IsNumber (z)) {
+	return JS_EXCEPTION;
+    }
+
+    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
+
+    JS_ToFloat64 (ctx, &xVal, x);
+    JS_ToFloat64 (ctx, &yVal, y);
+    JS_ToFloat64 (ctx, &zVal, z);
+
+    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
+
+    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, xVal / 255.0f));
+    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, yVal / 255.0f));
+    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, zVal / 255.0f));
+
+    return value;
+}
+
+JSValue wecolor_expandcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc != 1) {
+	return JS_EXCEPTION;
+    }
+
+    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
+	return JS_EXCEPTION;
+    }
+
+    const auto it = colorModules.find (magic);
+
+    if (it == colorModules.end ()) {
+	return JS_UNDEFINED;
+    }
+
+    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
+    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
+    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
+
+    if (!JS_IsNumber (x) || !JS_IsNumber (y) || !JS_IsNumber (z)) {
+	return JS_EXCEPTION;
+    }
+
+    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
+
+    JS_ToFloat64 (ctx, &xVal, x);
+    JS_ToFloat64 (ctx, &yVal, y);
+    JS_ToFloat64 (ctx, &zVal, z);
+
+    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
+
+    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, xVal * 255.0f));
+    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, yVal * 255.0f));
+    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, zVal * 255.0f));
+
+    return value;
+}
+
+ColorModule::ColorModule (ScriptEngine& engine) : ScriptModule (engine, "WEColor", wecolor_init) {
+    this->m_instanceId = ++ColorModuleInstanceId;
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "rgb2hsv",
+	JS_NewCFunctionMagic (
+	    this->getEngine ().getContext (), wecolor_rgb2hsv, "rgb2hsv", 1, JS_CFUNC_generic_magic, this->m_instanceId
+	)
+    );
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "hsv2rgb",
+	JS_NewCFunctionMagic (
+	    this->getEngine ().getContext (), wecolor_hsv2rgb, "hsv2rgb", 1, JS_CFUNC_generic_magic, this->m_instanceId
+	)
+    );
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "normalizeColor",
+	JS_NewCFunctionMagic (
+	    this->getEngine ().getContext (), wecolor_normalizecolor, "normalizeColor", 1, JS_CFUNC_generic_magic,
+	    this->m_instanceId
+	)
+    );
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "expandColor",
+	JS_NewCFunctionMagic (
+	    this->getEngine ().getContext (), wecolor_expandcolor, "expandColor", 1, JS_CFUNC_generic_magic,
+	    this->m_instanceId
+	)
+    );
+
+    colorModules.emplace (this->m_instanceId, *this);
+}
+
+ColorModule::~ColorModule () { colorModules.erase (this->m_instanceId); }

+ 16 - 0
src/WallpaperEngine/Scripting/Modules/ColorModule.h

@@ -0,0 +1,16 @@
+#pragma once
+#include "ScriptModule.h"
+
+namespace WallpaperEngine::Scripting {
+class ScriptEngine;
+}
+namespace WallpaperEngine::Scripting::Modules {
+class ColorModule : public ScriptModule {
+public:
+    ColorModule (ScriptEngine& engine);
+    ~ColorModule () override;
+
+protected:
+    uint32_t m_instanceId;
+};
+}

+ 94 - 0
src/WallpaperEngine/Scripting/Modules/MathModule.cpp

@@ -0,0 +1,94 @@
+#include "MathModule.h"
+
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+using namespace WallpaperEngine::Scripting::Modules;
+
+#define min_f(a, b, c) (fminf (a, fminf (b, c)))
+#define max_f(a, b, c) (fmaxf (a, fmaxf (b, c)))
+
+static uint32_t MathModuleInstanceId = 0;
+std::map<uint32_t, MathModule&> mathModules;
+
+int wemath_init (JSContext* ctx, JSModuleDef* m) {
+
+    JS_AddModuleExport (ctx, m, "smoothStep");
+    JS_AddModuleExport (ctx, m, "mix");
+    JS_AddModuleExport (ctx, m, "deg2rad");
+    JS_AddModuleExport (ctx, m, "rad2deg");
+
+    return 0;
+}
+
+JSValue wemath_smoothstep (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc != 3) {
+	return JS_EXCEPTION;
+    }
+
+    if (!JS_IsNumber (argv[0]) || !JS_IsNumber (argv[1]) || !JS_IsNumber (argv[2])) {
+	return JS_EXCEPTION;
+    }
+
+    double edge0 = 0.0f;
+    double edge1 = 1.0f;
+    double x = 0.0f;
+
+    JS_ToFloat64 (ctx, &edge0, argv[0]);
+    JS_ToFloat64 (ctx, &edge1, argv[1]);
+    JS_ToFloat64 (ctx, &x, argv[2]);
+
+    return JS_NewFloat64 (ctx, glm::smoothstep (edge0, edge1, x));
+}
+
+JSValue wemath_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    if (argc != 3) {
+	return JS_EXCEPTION;
+    }
+
+    if (!JS_IsNumber (argv[0]) || !JS_IsNumber (argv[1]) || !JS_IsNumber (argv[2])) {
+	return JS_EXCEPTION;
+    }
+
+    double a = 0.0f;
+    double b = 1.0f;
+    double value = 0.0f;
+
+    JS_ToFloat64 (ctx, &a, argv[0]);
+    JS_ToFloat64 (ctx, &b, argv[1]);
+    JS_ToFloat64 (ctx, &value, argv[2]);
+
+    return JS_NewFloat64 (ctx, glm::mix (a, b, value));
+}
+
+MathModule::MathModule (ScriptEngine& engine) : ScriptModule (engine, "WEMath", wemath_init) {
+    this->m_instanceId = ++MathModuleInstanceId;
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "smoothStep",
+	JS_NewCFunctionMagic (
+	    this->getEngine ().getContext (), wemath_smoothstep, "smoothStep", 3, JS_CFUNC_generic_magic,
+	    this->m_instanceId
+	)
+    );
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "mix",
+	JS_NewCFunctionMagic (
+	    this->getEngine ().getContext (), wemath_mix, "mix", 1, JS_CFUNC_generic_magic, this->m_instanceId
+	)
+    );
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "deg2rad",
+	JS_NewFloat64 (this->getEngine ().getContext (), 0.01745329251994329576923690768489)
+    );
+
+    JS_SetModuleExport (
+	this->getEngine ().getContext (), this->getDefinition (), "rad2deg",
+	JS_NewFloat64 (this->getEngine ().getContext (), 57.295779513082320876798154814105)
+    );
+
+    mathModules.emplace (this->m_instanceId, *this);
+}
+
+MathModule::~MathModule () { mathModules.erase (this->m_instanceId); }

+ 16 - 0
src/WallpaperEngine/Scripting/Modules/MathModule.h

@@ -0,0 +1,16 @@
+#pragma once
+#include "ScriptModule.h"
+
+namespace WallpaperEngine::Scripting {
+class ScriptEngine;
+}
+namespace WallpaperEngine::Scripting::Modules {
+class MathModule : public ScriptModule {
+public:
+    MathModule (ScriptEngine& engine);
+    ~MathModule () override;
+
+protected:
+    uint32_t m_instanceId;
+};
+}

+ 12 - 0
src/WallpaperEngine/Scripting/Modules/ScriptModule.cpp

@@ -0,0 +1,12 @@
+#include "ScriptModule.h"
+
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+using namespace WallpaperEngine::Scripting::Modules;
+
+ScriptModule::ScriptModule (ScriptEngine& engine, const std::string& name, JSModuleInitFunc* init) :
+    m_name (name), m_engine (engine) {
+    this->m_definition = JS_NewCModule (this->m_engine.getContext (), this->m_name.c_str (), init);
+}
+
+ScriptModule::~ScriptModule () { }

+ 26 - 0
src/WallpaperEngine/Scripting/Modules/ScriptModule.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include "quickjs.h"
+
+#include <string>
+
+namespace WallpaperEngine::Scripting {
+class ScriptEngine;
+}
+namespace WallpaperEngine::Scripting::Modules {
+class ScriptModule {
+public:
+    virtual ~ScriptModule ();
+
+    ScriptEngine& getEngine () const { return m_engine; }
+    const std::string& getName () const { return m_name; }
+    JSModuleDef* getDefinition () const { return m_definition; }
+
+protected:
+    ScriptModule (ScriptEngine& engine, const std::string& name, JSModuleInitFunc* init);
+
+    const std::string m_name;
+    ScriptEngine& m_engine;
+    JSModuleDef* m_definition;
+};
+}

+ 310 - 0
src/WallpaperEngine/Scripting/SceneObject.cpp

@@ -0,0 +1,310 @@
+#include "SceneObject.h"
+
+#include "Adapters/ScriptableObjectAdapter.h"
+#include "ScriptEngine.h"
+#include "ScriptableObject.h"
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+
+using namespace WallpaperEngine::Scripting;
+
+SceneObject* get_opaque (JSValueConst this_val) {
+    JSClassID classId;
+    return static_cast<SceneObject*> (JS_GetAnyOpaque (this_val, &classId));
+}
+
+JSValue get_bloom (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewBool (ctx, container->getScene ().getScene ().camera.bloom.enabled->value->getBool ());
+}
+
+JSValue get_bloomstrength (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewInt32 (ctx, container->getScene ().getScene ().camera.bloom.strength->value->getInt ());
+}
+
+JSValue get_bloomthreshold (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewInt32 (ctx, container->getScene ().getScene ().camera.bloom.threshold->value->getInt ());
+}
+
+JSValue get_clearenabled (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewBool (ctx, container->getScene ().getScene ().camera.bloom.enabled->value->getBool ());
+}
+
+JSValue get_clearcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return container->getEngine ().getAdapters ().vec3->instantiate (
+	*container->getScene ().getScene ().colors.clear->value
+    );
+}
+
+JSValue get_ambientcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return container->getEngine ().getAdapters ().vec3->instantiate (
+	*container->getScene ().getScene ().colors.ambient->value
+    );
+}
+
+JSValue get_skylightcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return container->getEngine ().getAdapters ().vec3->instantiate (
+	*container->getScene ().getScene ().colors.ambient->value
+    );
+}
+
+JSValue get_fov (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.projection.fov->value->getFloat ());
+}
+
+JSValue get_nearz (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.projection.nearz->value->getFloat ());
+}
+
+JSValue get_farz (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.projection.farz->value->getFloat ());
+}
+
+JSValue get_camerafade (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewBool (ctx, container->getScene ().getScene ().camera.fade->value->getBool ());
+}
+
+JSValue get_camerashake (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewBool (ctx, container->getScene ().getScene ().camera.shake.enabled->value->getBool ());
+}
+
+JSValue get_camerashakespeed (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.shake.speed->value->getFloat ());
+}
+
+JSValue get_camerashakeamplitude (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.shake.amplitude->value->getFloat ());
+}
+
+JSValue get_camerashakeroughness (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.shake.roughness->value->getFloat ());
+}
+
+JSValue get_cameraparallax (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewBool (ctx, container->getScene ().getScene ().camera.parallax.enabled->value->getBool ());
+}
+
+JSValue get_cameraparallaxamount (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.parallax.amount->value->getFloat ());
+}
+
+JSValue get_cameraparallaxdelay (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.parallax.delay->value->getFloat ());
+}
+
+JSValue get_cameraparallaxmouseinfluence (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    auto* container = get_opaque (this_val);
+
+    return JS_NewFloat64 (ctx, container->getScene ().getScene ().camera.parallax.mouseInfluence->value->getFloat ());
+}
+
+JSValue get_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc != 1) {
+	return JS_EXCEPTION;
+    }
+
+    auto* container = get_opaque (this_val);
+
+    JSValue layer = argv[0];
+
+    if (JS_IsNumber (layer)) {
+	int id = 0;
+
+	JS_ToInt32 (ctx, &id, layer);
+
+	auto* object = container->getScene ().getObject (id);
+
+	if (object == nullptr) {
+	    return JS_UNDEFINED;
+	}
+
+	if (!object->is<ScriptableObject> ()) {
+	    return JS_UNDEFINED;
+	}
+
+	// TODO: REMOVE THIS CONST_CAST?
+	return container->getEngine ().getAdapters ().object->instantiate (
+	    const_cast<ScriptableObject&> (*object->as<ScriptableObject> ())
+	);
+    } else if (JS_IsString (layer)) {
+	// find by name, this is harder
+	const char* result = JS_ToCString (ctx, layer);
+
+	if (result == nullptr) {
+	    return JS_UNDEFINED;
+	}
+
+	ScopeGuard guard ([=] { JS_FreeCString (ctx, result); });
+
+	for (auto object : container->getScene ().getObjectsByRenderOrder ()) {
+	    if (object->getObject ().name != result) {
+		continue;
+	    }
+
+	    if (!object->is<ScriptableObject> ()) {
+		continue;
+	    }
+
+	    return container->getEngine ().getAdapters ().object->instantiate (*object->as<ScriptableObject> ());
+	}
+    }
+
+    return JS_EXCEPTION;
+}
+
+JSValue scene_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }
+
+SceneObject::SceneObject (ScriptEngine& engine, Render::Wallpapers::CScene& scene) :
+    m_scene (scene), m_engine (engine), m_classId (0) {
+    this->m_definition = { .class_name = "IScene" };
+    JS_NewClassID (this->m_engine.getRuntime (), &this->m_classId);
+    JS_NewClass (this->m_engine.getRuntime (), this->m_classId, &this->m_definition);
+    this->m_instance = JS_NewObjectClass (this->m_engine.getContext (), this->m_classId);
+
+    JS_DupValue (this->m_engine.getContext (), this->m_instance);
+
+    // set properties
+    JS_SetOpaque (this->m_instance, this);
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "bloom"),
+	JS_NewCFunction (this->m_engine.getContext (), get_bloom, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "bloomstrength"),
+	JS_NewCFunction (this->m_engine.getContext (), get_bloomstrength, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "bloomthreshold"),
+	JS_NewCFunction (this->m_engine.getContext (), get_bloomthreshold, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "clearenabled"),
+	JS_NewCFunction (this->m_engine.getContext (), get_clearenabled, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "clearcolor"),
+	JS_NewCFunction (this->m_engine.getContext (), get_clearcolor, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "ambientcolor"),
+	JS_NewCFunction (this->m_engine.getContext (), get_ambientcolor, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "skylightcolor"),
+	JS_NewCFunction (this->m_engine.getContext (), get_skylightcolor, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "fov"),
+	JS_NewCFunction (this->m_engine.getContext (), get_fov, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "nearz"),
+	JS_NewCFunction (this->m_engine.getContext (), get_nearz, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "farz"),
+	JS_NewCFunction (this->m_engine.getContext (), get_farz, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "camerafade"),
+	JS_NewCFunction (this->m_engine.getContext (), get_camerafade, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "camerashake"),
+	JS_NewCFunction (this->m_engine.getContext (), get_camerashake, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "camerashakespeed"),
+	JS_NewCFunction (this->m_engine.getContext (), get_camerashakespeed, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance,
+	JS_NewAtom (this->m_engine.getContext (), "camerashakeamplitude"),
+	JS_NewCFunction (this->m_engine.getContext (), get_camerashakeamplitude, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance,
+	JS_NewAtom (this->m_engine.getContext (), "camerashakeroughness"),
+	JS_NewCFunction (this->m_engine.getContext (), get_camerashakeroughness, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "cameraparallax"),
+	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallax, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance,
+	JS_NewAtom (this->m_engine.getContext (), "cameraparallaxamount"),
+	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallaxamount, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance,
+	JS_NewAtom (this->m_engine.getContext (), "cameraparallaxdelay"),
+	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallaxdelay, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance,
+	JS_NewAtom (this->m_engine.getContext (), "cameraparallaxmouseinfluence"),
+	JS_NewCFunction (this->m_engine.getContext (), get_cameraparallaxmouseinfluence, "get", 0),
+	JS_NewCFunction (this->m_engine.getContext (), scene_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "getLayer",
+	JS_NewCFunction (this->m_engine.getContext (), get_layer, "getLayer", 1), JS_PROP_ENUMERABLE
+    );
+    // TODO: ADD REST OF THE METHODS
+}
+
+SceneObject::~SceneObject () { JS_FreeValue (this->m_engine.getContext (), this->m_instance); }

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor