Pārlūkot izejas kodu

Merge pull request #1 from Almamu/main

master merge
WallpaperEngineLover 4 mēneši atpakaļ
vecāks
revīzija
360eb8b24a
76 mainītis faili ar 6553 papildinājumiem un 639 dzēšanām
  1. 7 7
      .github/workflows/cmake.yml
  2. 4 1
      .gitignore
  3. 4 1
      .gitmodules
  4. 101 27
      CMakeLists.txt
  5. 21 9
      README.md
  6. 130 0
      docs/OS-Waves-rendering-issues.md
  7. 1 4
      packaging/archlinux/PKGBUILD
  8. 1 0
      src/External/quickjs
  9. 137 5
      src/WallpaperEngine/Application/ApplicationContext.cpp
  10. 48 0
      src/WallpaperEngine/Application/ApplicationContext.h
  11. 201 85
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  12. 23 0
      src/WallpaperEngine/Application/WallpaperApplication.h
  13. 0 2
      src/WallpaperEngine/Audio/AudioStream.h
  14. 14 2
      src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp
  15. 1 0
      src/WallpaperEngine/Data/Assets/Texture.h
  16. 1 1
      src/WallpaperEngine/Data/Builders/VectorBuilder.h
  17. 25 3
      src/WallpaperEngine/Data/Model/DynamicValue.cpp
  18. 1 0
      src/WallpaperEngine/Data/Model/DynamicValue.h
  19. 57 11
      src/WallpaperEngine/Data/Model/Object.h
  20. 74 0
      src/WallpaperEngine/Data/Model/ScriptedDynamicValue.cpp
  21. 52 0
      src/WallpaperEngine/Data/Model/ScriptedDynamicValue.h
  22. 2 0
      src/WallpaperEngine/Data/Model/Types.h
  23. 244 27
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  24. 1 0
      src/WallpaperEngine/Data/Parsers/ObjectParser.h
  25. 6 3
      src/WallpaperEngine/Data/Parsers/PropertyParser.cpp
  26. 25 4
      src/WallpaperEngine/Data/Parsers/TextureParser.cpp
  27. 1 0
      src/WallpaperEngine/Data/Parsers/TextureParser.h
  28. 49 5
      src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp
  29. 2 2
      src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp
  30. 2 0
      src/WallpaperEngine/Data/Utils/MemoryStream.h
  31. 156 9
      src/WallpaperEngine/Input/Drivers/WaylandMouseInput.cpp
  32. 6 1
      src/WallpaperEngine/Input/Drivers/WaylandMouseInput.h
  33. 11 2
      src/WallpaperEngine/Render/CFBO.cpp
  34. 4 0
      src/WallpaperEngine/Render/CFBO.h
  35. 5 1
      src/WallpaperEngine/Render/CObject.cpp
  36. 5 6
      src/WallpaperEngine/Render/CObject.h
  37. 57 10
      src/WallpaperEngine/Render/CTexture.cpp
  38. 31 4
      src/WallpaperEngine/Render/CTexture.h
  39. 69 8
      src/WallpaperEngine/Render/CWallpaper.cpp
  40. 25 2
      src/WallpaperEngine/Render/CWallpaper.h
  41. 1 1
      src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.cpp
  42. 4 5
      src/WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.cpp
  43. 5 0
      src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h
  44. 81 5
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp
  45. 7 1
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h
  46. 54 15
      src/WallpaperEngine/Render/Drivers/Output/X11Output.cpp
  47. 4 0
      src/WallpaperEngine/Render/Drivers/Output/X11Output.h
  48. 1 0
      src/WallpaperEngine/Render/Drivers/VideoFactories.h
  49. 73 5
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
  50. 5 0
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h
  51. 649 54
      src/WallpaperEngine/Render/Objects/CImage.cpp
  52. 35 0
      src/WallpaperEngine/Render/Objects/CImage.h
  53. 53 27
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  54. 3 0
      src/WallpaperEngine/Render/Objects/CParticle.h
  55. 403 0
      src/WallpaperEngine/Render/Objects/CText.cpp
  56. 85 0
      src/WallpaperEngine/Render/Objects/CText.h
  57. 178 69
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  58. 19 5
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  59. 4 1
      src/WallpaperEngine/Render/RenderContext.cpp
  60. 1 2
      src/WallpaperEngine/Render/Shaders/Shader.cpp
  61. 0 8
      src/WallpaperEngine/Render/Shaders/Shader.h
  62. 153 22
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  63. 22 1
      src/WallpaperEngine/Render/Shaders/ShaderUnit.h
  64. 394 3
      src/WallpaperEngine/Render/TextureCache.cpp
  65. 19 0
      src/WallpaperEngine/Render/TextureProvider.h
  66. 231 30
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  67. 16 0
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  68. 26 134
      src/WallpaperEngine/Render/Wallpapers/CVideo.cpp
  69. 5 9
      src/WallpaperEngine/Render/Wallpapers/CVideo.h
  70. 1754 0
      src/WallpaperEngine/Scripting/ScriptEngine.cpp
  71. 176 0
      src/WallpaperEngine/Scripting/ScriptEngine.h
  72. 33 0
      src/WallpaperEngine/Testing/Cases/PropertyParser.cpp
  73. 296 0
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp
  74. 81 0
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
  75. 54 0
      src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.cpp
  76. 24 0
      src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.h

+ 7 - 7
.github/workflows/cmake.yml

@@ -58,7 +58,7 @@ jobs:
 
       - 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
+        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
         # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
@@ -99,10 +99,10 @@ jobs:
 
     - 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
+      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
+      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.
@@ -148,11 +148,11 @@ jobs:
 
       - 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 wayland-scanner++ wayland-protocols libwayland-dev
+        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 wayland-scanner++ wayland-protocols libwayland-dev
+        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.
@@ -190,11 +190,11 @@ jobs:
 
     - 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
+      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
+      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.

+ 4 - 1
.gitignore

@@ -1,10 +1,13 @@
 cmake-build-*
 .idea
+.cache/
 build/
 .vscode/
+.zed/
 third_party/
 packaging/archlinux/*
 !packaging/archlinux/PKGBUILD
 *-protocol.o
 *-protocol.c
-*-protocol.h
+*-protocol.h
+AGENTS.md

+ 4 - 1
.gitmodules

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

+ 101 - 27
CMakeLists.txt

@@ -27,6 +27,7 @@ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-narrowing")
 find_package(X11)
 find_package(OpenGL REQUIRED)
 find_package(GLEW REQUIRED)
+
 find_package(GLUT REQUIRED)
 find_package(ZLIB REQUIRED)
 find_package(SDL2 REQUIRED)
@@ -34,9 +35,11 @@ find_package(MPV REQUIRED)
 find_package(LZ4 REQUIRED)
 find_package(FFMPEG REQUIRED)
 find_package(PulseAudio REQUIRED)
+find_package(Freetype REQUIRED)
 
 set(ENABLE_OPT OFF)
 set(SPIRV_CROSS_FORCE_PIC ON)
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
 set(KISSFFT_TEST OFF)
 set(KISSFFT_TOOLS OFF)
 
@@ -121,7 +124,6 @@ list(REMOVE_ITEM CEF_CXX_COMPILER_FLAGS -std=c++17)
 list(REMOVE_ITEM CEF_COMPILER_FLAGS -fvisibility=hidden)
 list(REMOVE_ITEM CEF_COMPILER_FLAGS -fvisibility-inlines-hidden)
 list(APPEND CEF_CXX_COMPILER_FLAGS -std=c++20)
-list(APPEND CEF_COMPILER_FLAGS -rdynamic)
 list(APPEND CEF_EXE_LINKER_FLAGS -rdynamic)
 # remove the vulkan library from the files to copy so it doesn't fail after removing it
 list(REMOVE_ITEM CEF_BINARY_FILES libvulkan.so.1)
@@ -130,11 +132,20 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/output)
 set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
 set(TARGET_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/output)
 
+# RPATH configuration: use build directories during development, but $ORIGIN after installation
+# NOTE: \$ORIGIN must be escaped because CMake treats $VAR as variable expansion.
+# IMPORTANT: This must be set BEFORE any add_executable()/add_library() calls in this directory.
+set(CMAKE_BUILD_WITH_INSTALL_RPATH FALSE)
+set(CMAKE_INSTALL_RPATH "\$ORIGIN;\$ORIGIN/lib;\$ORIGIN/lib64")
+set(CMAKE_INSTALL_RPATH_USE_LINK_PATH FALSE)
+
 add_subdirectory(${CEF_LIBCEF_DLL_WRAPPER_PATH} libcef_dll_wrapper)
 add_subdirectory(src/External/glslang-WallpaperEngine glslang)
 add_subdirectory(src/External/SPIRV-Cross-WallpaperEngine spirv-cross)
 add_subdirectory(src/External/kissfft kissfft)
 add_subdirectory(src/External/argparse argparse)
+set(QJS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
+add_subdirectory(src/External/quickjs quickjs)
 if(BUILD_TESTING)
     add_subdirectory(src/External/Catch2)
 endif()
@@ -156,17 +167,21 @@ if(WAYLAND_SUPPORT_FOUND)
 
     message(STATUS "Building protocols...")
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.h
-        COMMAND ${WaylandScanner} client-header ${CMAKE_SOURCE_DIR}/protocols/wlr-foreign-toplevel-management-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.h)
+        COMMAND ${WaylandScanner} client-header ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-foreign-toplevel-management-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.h)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.c
-        COMMAND ${WaylandScanner} private-code ${CMAKE_SOURCE_DIR}/protocols/wlr-foreign-toplevel-management-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.c)
+        COMMAND ${WaylandScanner} private-code ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-foreign-toplevel-management-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.c)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h
-        COMMAND ${WaylandScanner} client-header ${CMAKE_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h)
+        COMMAND ${WaylandScanner} client-header ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c
-        COMMAND ${WaylandScanner} private-code ${CMAKE_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c)
+        COMMAND ${WaylandScanner} private-code ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.h
         COMMAND ${WaylandScanner} client-header ${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.h)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c
         COMMAND ${WaylandScanner} private-code ${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c)
+    add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h
+        COMMAND ${WaylandScanner} client-header ${WAYLAND_PROTOCOLS_DIR}/unstable/xdg-output/xdg-output-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h)
+    add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c
+        COMMAND ${WaylandScanner} private-code ${WAYLAND_PROTOCOLS_DIR}/unstable/xdg-output/xdg-output-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c)
 
     include_directories(${WAYLAND_SUPPORT_INCLUDE_DIRS})
     include_directories(${WAYLAND_OUTPUT_DIR})
@@ -192,7 +207,9 @@ if(WAYLAND_SUPPORT_FOUND)
         ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.c
         ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.h
         ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c
-        ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h)
+        ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h
+        ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c
+        ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h)
 endif()
 
 if(X11_FOUND)
@@ -395,6 +412,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Objects/CSound.cpp
     src/WallpaperEngine/Render/Objects/CParticle.h
     src/WallpaperEngine/Render/Objects/CParticle.cpp
+    src/WallpaperEngine/Render/Objects/CText.h
+    src/WallpaperEngine/Render/Objects/CText.cpp
 
     src/WallpaperEngine/Render/CFBO.h
     src/WallpaperEngine/Render/CFBO.cpp
@@ -462,7 +481,16 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Builders/VectorBuilder.h
     src/WallpaperEngine/Data/Dumpers/StringPrinter.cpp
     src/WallpaperEngine/Data/Dumpers/StringPrinter.h
-    src/WallpaperEngine/Data/JSON.cpp)
+    src/WallpaperEngine/Data/JSON.cpp
+
+    src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp
+    src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
+    src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.cpp
+    src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.h
+    src/WallpaperEngine/Scripting/ScriptEngine.h
+    src/WallpaperEngine/Scripting/ScriptEngine.cpp
+    src/WallpaperEngine/Data/Model/ScriptedDynamicValue.h
+    src/WallpaperEngine/Data/Model/ScriptedDynamicValue.cpp)
 
 if(BUILD_TESTING)
     add_executable(
@@ -477,19 +505,19 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Input/TestingMouseInput.h
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.cpp
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.h
-        src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp)
+        src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp
+        src/WallpaperEngine/Testing/Cases/PropertyParser.cpp)
 endif()
 
-add_executable(
-    linux-wallpaperengine
-    src/main.cpp
+add_library(
+    linux-wallpaperengine-lib SHARED
 
     ${COMMON_SOURCES}
     ${WAYLAND_SOURCES}
     ${X11_SOURCES}
     ${DEMOMODE_SOURCES})
 
-target_link_libraries (linux-wallpaperengine PUBLIC
+target_link_libraries (linux-wallpaperengine-lib PUBLIC
     ${OPENGL_LIBRARIES}
     GLEW::GLEW
     ${GLUT_LIBRARIES}
@@ -499,6 +527,7 @@ target_link_libraries (linux-wallpaperengine PUBLIC
     ${FFMPEG_LIBRARIES}
     ${MPV_LIBRARY}
     ${PULSEAUDIO_LIBRARY}
+    Freetype::Freetype
     ${WAYLAND_LIBRARIES}
     ${X11_LIBRARIES}
     kissfft
@@ -508,7 +537,8 @@ target_link_libraries (linux-wallpaperengine PUBLIC
     glfw
     libcef_lib
     libcef_dll_wrapper
-    argparse)
+    argparse
+    qjs)
 
 if (BUILD_TESTING)
     target_link_libraries (tests PRIVATE
@@ -522,6 +552,7 @@ if (BUILD_TESTING)
         ${FFMPEG_LIBRARIES}
         ${MPV_LIBRARY}
         ${PULSEAUDIO_LIBRARY}
+        Freetype::Freetype
         ${WAYLAND_LIBRARIES}
         ${X11_LIBRARIES}
         kissfft
@@ -531,11 +562,12 @@ if (BUILD_TESTING)
         glfw
         libcef_lib
         libcef_dll_wrapper
-        argparse)
+        argparse
+        qjs)
 endif()
 
-target_compile_definitions(linux-wallpaperengine PUBLIC ERRORONLY=${ERRORONLY})
-target_compile_definitions(linux-wallpaperengine PUBLIC DEMOMODE=${DEMOMODE})
+target_compile_definitions(linux-wallpaperengine-lib PUBLIC ERRORONLY=${ERRORONLY})
+target_compile_definitions(linux-wallpaperengine-lib PUBLIC DEMOMODE=${DEMOMODE})
 
 if (BUILD_TESTING)
     # tests should give as much output as possible
@@ -544,29 +576,71 @@ if (BUILD_TESTING)
 endif()
 
 if(X11_SUPPORT_FOUND)
-    target_compile_definitions(linux-wallpaperengine PUBLIC ENABLE_X11)
+    target_compile_definitions(linux-wallpaperengine-lib PUBLIC ENABLE_X11)
 
     # make sure some of the X11 functions we'll use are available
     check_function_exists(XSetIOErrorExitHandler HAVE_XSETIOERROREXITHANDLER)
 
     if(HAVE_XSETIOERROREXITHANDLER)
-        target_compile_definitions(linux-wallpaperengine PUBLIC HAVE_XSETIOERROREXITHANDLER=1)
+        target_compile_definitions(linux-wallpaperengine-lib PUBLIC HAVE_XSETIOERROREXITHANDLER=1)
     endif()
 endif()
 
 if(WAYLAND_SUPPORT_FOUND)
-    target_compile_definitions(linux-wallpaperengine PUBLIC ENABLE_WAYLAND)
+    target_compile_definitions(linux-wallpaperengine-lib PUBLIC ENABLE_WAYLAND)
 endif()
 
-COPY_FILES(linux-wallpaperengine "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR}" "${TARGET_OUTPUT_DIRECTORY}")
-COPY_FILES(linux-wallpaperengine "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}" "${TARGET_OUTPUT_DIRECTORY}")
+if(WAYLAND_SUPPORT_FOUND AND NOT X11_SUPPORT_FOUND)
+    target_compile_definitions(linux-wallpaperengine-lib PUBLIC GLEW_EGL)
+endif()
+
+target_include_directories(linux-wallpaperengine-lib PUBLIC
+    src
+    src/External/glslang-WallpaperEngine
+    src/External/SPIRV-Cross-WallpaperEngine
+    src/External/MimeTypes
+    src/External/json/include
+    src/External/stb
+    src/External/kissfft
+    ${MPV_INCLUDE_DIR}
+    ${GLEW_INCLUDE_DIR}
+    ${LZ4_INCLUDE_DIR}
+    ${SDL2_INCLUDE_DIRS}
+    ${FFMPEG_INCLUDE_DIR}
+    ${PULSEAUDIO_INCLUDE_DIR}
+    ${CEF_INCLUDE_PATH}
+    ${CMAKE_SOURCE_DIR}
+    ${X11_INCLUDES}
+    ${WAYLAND_OUTPUT_DIR})
+
+COPY_FILES(linux-wallpaperengine-lib "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR}" "${TARGET_OUTPUT_DIRECTORY}")
+COPY_FILES(linux-wallpaperengine-lib "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}" "${TARGET_OUTPUT_DIRECTORY}")
 # remove the vulkan lib as chromium includes a broken libvulkan.so.1 with it
 file(REMOVE "${CEF_BINARY_DIR}/libvulkan.so.1")
+set_target_properties(linux-wallpaperengine-lib PROPERTIES
+    LIBRARY_OUTPUT_DIRECTORY ${TARGET_OUTPUT_DIRECTORY})
 
-add_dependencies(linux-wallpaperengine libcef_dll_wrapper glslang spirv-cross-core spirv-cross-glsl)
+add_dependencies(linux-wallpaperengine-lib libcef_dll_wrapper glslang spirv-cross-core spirv-cross-glsl)
 # custom version of cef's SET_EXECUTABLE_TARGET_PROPERTIES that fixes C files being compiled with the wrong parameters
-REPLACED_SET_EXECUTABLE_TARGET_PROPERTIES(linux-wallpaperengine)
-
-# set some install parameters if not in debug mode
-install(DIRECTORY ${TARGET_OUTPUT_DIRECTORY}/ DESTINATION .)
-install(FILES ${TARGET_OUTPUT_DIRECTORY}/${PROJECT_NAME} PERMISSIONS OWNER_READ OWNER_WRITE WORLD_EXECUTE WORLD_READ GROUP_READ DESTINATION .)
+REPLACED_SET_EXECUTABLE_TARGET_PROPERTIES(linux-wallpaperengine-lib)
+
+add_executable(linux-wallpaperengine
+    src/main.cpp)
+
+target_link_libraries (linux-wallpaperengine PUBLIC linux-wallpaperengine-lib)
+
+# Install the main executable and shared library via install(TARGETS)
+# so that CMake automatically strips/replaces the build-time RPATH.
+install(TARGETS linux-wallpaperengine
+    RUNTIME DESTINATION .
+)
+install(TARGETS linux-wallpaperengine-lib
+    LIBRARY DESTINATION .
+)
+
+# Install everything else from the output directory (CEF resources, helper binaries, etc.)
+# but exclude the files already handled by install(TARGETS) above.
+install(DIRECTORY ${TARGET_OUTPUT_DIRECTORY}/ DESTINATION .
+    PATTERN "linux-wallpaperengine" EXCLUDE
+    PATTERN "liblinux-wallpaperengine-lib.so" EXCLUDE
+)

+ 21 - 9
README.md

@@ -2,13 +2,14 @@
 	<a href="https://github.com/Almamu/linux-wallpaperengine/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Almamu/linux-wallpaperengine" /></a>
     <a href="https://github.com/Almamu/linux-wallpaperengine/actions?query=branch%3Amain"><img src="https://img.shields.io/github/actions/workflow/status/Almamu/linux-wallpaperengine/cmake.yml?branch=main" /></a>
     <img src="https://img.shields.io/coderabbit/prs/github/Almamu/linux-wallpaperengine?utm_source=oss&utm_medium=github&utm_campaign=Almamu%2Flinux-wallpaperengine&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews" />
-    <a href="https://github.com/Almamu/linux-wallpaperengine/pulse"><img src="https://img.shields.io/tokei/lines/github/Almamu/linux-wallpaperengine" /></a>
+    <a href="https://github.com/Almamu/linux-wallpaperengine/pulse"><img src="https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/Almamu/linux-wallpaperengine/badge?filter=.cpp$,.h$&style=flat&logoColor=white&label=Lines of Code" /></a>
 	<a href="https://www.codefactor.io/repository/github/almamu/linux-wallpaperengine"><img src="https://img.shields.io/codefactor/grade/github/Almamu/linux-wallpaperengine" /></a>
 	<a href="https://github.com/Almamu/linux-wallpaperengine/graphs/commit-activity"><img src="https://img.shields.io/github/commit-activity/m/Almamu/linux-wallpaperengine" /></a>
 	<a href="https://github.com/Almamu/linux-wallpaperengine/graphs/contributors"><img src="https://img.shields.io/github/contributors/Almamu/linux-wallpaperengine" /></a>
 	<a href="https://github.com/Almamu/linux-wallpaperengine/issues"><img src="https://img.shields.io/github/issues-raw/Almamu/linux-wallpaperengine" /></a>
 	<a href="https://github.com/Almamu/linux-wallpaperengine/issues?q=is%3Aissue+is%3Aopen+label%3A%22help%20wanted%22"><img src="https://img.shields.io/github/issues/Almamu/linux-wallpaperengine/help%20wanted?color=green" alt="help wanted"></a>
-    <a href="https://wpengine.alma.mu/"><img src="https://img.shields.io/badge/showcase_gallery-blue" alt="Compatibility gallery" /></a>
+    <a href="https://wpengine.alma.mu/"><img src="https://img.shields.io/badge/showcase_gallery-blue" alt="Showcase gallery" /></a>
+    <a href="https://deepwiki.com/Almamu/linux-wallpaperengine"><img src="https://img.shields.io/badge/Deepwiki-Almamu%2Flinux--wallpaperengine-blue?logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK%2FAIi%2BQuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06%2Fuv1saEDv4O3n3dV60RfP947Mm9%2FSQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH%2F%2FPB8mnKqScAhsD0kYP3j%2FYt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY%2F56ebRWeraTjMt%2F00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ%2BfXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB%2FimwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE%2BgO0SsWmPiXB%2Bjikdf6SizrT5qKasx5j8ABbHpFTx%2BvFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa%2BAx283gghmj%2Bvj7feE2KBBRMW3FzOpLOADl0Isb5587h%2FU4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5%2FXFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb%2FvA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU%2B3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26%2FHfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr%2FFGaKiG%2BT%2Bv%2BTQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r%2FcKaoqr%2B27%2FXcrS5UwSMbQAAAABJRU5ErkJggg%3D%3D" alt="DeepWiki documentation" /></a>
 </p>
 
 # 🖼️ Linux Wallpaper Engine
@@ -40,13 +41,13 @@ Install the required dependencies on Ubuntu/Debian-based systems:
 ### Ubuntu 22.04
 ```bash
 sudo apt-get update
-sudo apt-get install build-essential cmake 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
+sudo apt-get install build-essential cmake 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
 ```
 
 ### Ubuntu 24.04
 ```bash
 sudo apt-get update
-sudo apt-get install build-essential cmake 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
+sudo apt-get install build-essential cmake 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
 ```
 
 ### Alt linux
@@ -60,7 +61,7 @@ Install the required dependencies on RHEL/Fedora-based systems:
 ### Fedora 42
 ```bash
 sudo dnf update
-sudo dnf install gcc g++ cmake libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel mesa-libGL-devel glew-devel freeglut-devel SDL2-devel lz4-devel ffmpeg ffmpeg-free-devel libXxf86vm-devel glm-devel glfw-devel mpv mpv-devel pulseaudio-libs-devel fftw-devel
+sudo dnf install gcc g++ cmake libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel mesa-libGL-devel glew-devel freeglut-devel SDL2-devel lz4-devel ffmpeg ffmpeg-free-devel libXxf86vm-devel glm-devel glfw-devel mpv mpv-devel pulseaudio-libs-devel fftw-devel gmp-devel
 ```
 
 ---
@@ -162,8 +163,12 @@ Implementing a GUI is out of scope for now.
 There's a few developers that decided to focus on this and created their own.
 If you're one of those developers, feel free to open an issue to get your project included here!
 
-- @Maxnights' GUI: https://github.com/Maxnights/simple-linux-wallpaperengine-gui
-- @jagrat7's GUI: https://github.com/jagrat7/linux-wallpaper-engine
+- [simple-linux-wallpaperengine-gui](https://github.com/Maxnights/simple-linux-wallpaperengine-gui) by @Maxnights
+- [linux-wallpaper-engine](https://github.com/jagrat7/linux-wallpaper-engine) by @jagrat7
+- [wallpaperengine-gui](https://github.com/MikiDevLog/wallpaperengine-gui) by @MikiDevLog
+- [linux-wallpaperengine-controllfer for Noctalia Shell](https://noctalia.dev/plugins/linux-wallpaperengine-controller/) by @PaloMiku
+- [waypaper](https://github.com/anufrievroman/waypaper) by @anufrievroman
+
 ### 🔧 Common Options
 
 | Option | Description |
@@ -175,7 +180,8 @@ If you're one of those developers, feel free to open an issue to get your projec
 | `--fps <val>` | Limit frame rate |
 | `--window <XxYxWxH>` | Run in windowed mode with custom size/position |
 | `--screen-root <screen>` | Set as background for specific screen |
-| `--bg <id/path>` | Assign a background to a specific screen (use after `--screen-root`) |
+| `--screen-span <screen-1>,<screen-2>,...` | Stretch a single wallpaper across multiple screens |
+| `--bg <id/path>` | Assign a background to a specific screen (use after `--screen-root`/`--screen-span`) |
 | `--scaling <mode>` | Wallpaper scaling: `stretch`, `fit`, `fill`, or `default` |
 | `--clamping <mode>` | Set texture clamping: `clamp`, `border`, `repeat` |
 | `--assets-dir <path>` | Set custom path for assets |
@@ -209,6 +215,12 @@ linux-wallpaperengine \
   --scaling fill --screen-root HDMI-1 --bg 2667198602
 ```
 
+#### Stretch one wallpaper across multiple monitors
+```bash
+linux-wallpaperengine \
+  --scaling fill --screen-span HDMI-A-1,DP-2,DP-3 --bg 1845706469
+```
+
 #### Run in a window
 ```bash
 linux-wallpaperengine --window 0x0x1280x720 1845706469
@@ -291,7 +303,7 @@ linux-wallpaperengine --set-property bloom=1 2370927443
 
 ## 🧪 Wayland & X11 Support
 
-- **Wayland**: Works with compositors that support `wlr-layer-shell-unstable`.
+- **Wayland**: Works with compositors that support `wlr-layer-shell-unstable`. Uses `xdg-output-unstable-v1` for accurate monitor positioning (required for `--screen-span`).
 - **X11**: Requires XRandr. Use `--screen-root <screen_name>` (as shown in `xrandr`).
 
 > ⚠ For X11 users: Currently doesn't work if a compositor or desktop environment (e.g. GNOME, KDE, Nautilus) is drawing the background.

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

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

+ 1 - 4
packaging/archlinux/PKGBUILD

@@ -8,13 +8,10 @@ arch=('x86_64')
 url="https://github.com/Almamu/linux-wallpaperengine"
 license=('GPL-3.0-only')
 depends=('lz4' 'ffmpeg' 'mpv' 'glfw' 'glew' 'freeglut' 'libpulse' 'libcups' 'at-spi2-core' 'nss' 'libxcomposite' 'libxdamage' 'nspr')
-makedepends=('git' 'cmake' 'sdl2' 'glm')
+makedepends=('git' 'cmake' 'sdl2' 'glm' 'wayland-protocols' 'xorg-xrandr')
 provides=("linux-wallpaperengine")
 source=("${pkgname}::git+https://github.com/Almamu/linux-wallpaperengine.git#branch=main")
 sha512sums=('SKIP')
-optdepends=(
-    'xorg-xrandr: support for X11'
-    'wayland-protocols: support for wayland')
 
 pkgver() {
     cd "$pkgname"

+ 1 - 0
src/External/quickjs

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

+ 137 - 5
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -9,6 +9,9 @@
 #include <cstring>
 #include <fstream>
 #include <iostream>
+#include <sstream>
+#include <optional>
+#include <stdexcept>
 #include <string_view>
 
 #include <argparse/argparse.hpp>
@@ -296,6 +299,11 @@ void ApplicationContext::loadSettingsFromArgv () {
 		!= this->settings.general.screenBackgrounds.end ()) {
 		sLog.exception ("Cannot specify the same screen more than once: ", value);
 	    }
+	    for (const auto& group : this->settings.general.spanGroups) {
+		if (std::find (group.screens.begin (), group.screens.end (), value) != group.screens.end ()) {
+		    sLog.exception ("--screen-root: screen '", value, "' already belongs to a span group");
+		}
+	    }
 	    if (this->settings.render.mode == EXPLICIT_WINDOW) {
 		sLog.exception ("Cannot run in both background and window mode");
 	    }
@@ -307,12 +315,64 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    this->settings.general.screenClamps[lastScreen] = this->settings.render.window.clamp;
 	})
 	.append ();
+    backgroundGroup.add_argument ("--screen-span")
+	.help ("Comma-separated list of screens to span a single wallpaper across")
+	.action ([this, &lastScreen] (const std::string& value) -> void {
+	    if (this->settings.render.mode == EXPLICIT_WINDOW) {
+		sLog.exception ("Cannot run in both background and window mode");
+	    }
+
+	    this->settings.render.mode = DESKTOP_BACKGROUND;
+
+	    SpanGroup group;
+	    std::string screen;
+	    std::istringstream ss (value);
+
+	    while (std::getline (ss, screen, ',')) {
+		if (screen.empty ()) {
+		    continue;
+		}
+		if (this->settings.general.screenBackgrounds.find (screen)
+		    != this->settings.general.screenBackgrounds.end ()) {
+		    sLog.exception ("--screen-span: screen '", screen, "' is already configured individually");
+		}
+		// reject duplicates within this group
+		if (std::find (group.screens.begin (), group.screens.end (), screen) != group.screens.end ()) {
+		    sLog.exception ("--screen-span: duplicate screen name '", screen, "'");
+		}
+		// reject screens already claimed by another span group
+		for (const auto& existing : this->settings.general.spanGroups) {
+		    if (std::find (existing.screens.begin (), existing.screens.end (), screen)
+			!= existing.screens.end ()) {
+			sLog.exception ("--screen-span: screen '", screen, "' already belongs to another span group");
+		    }
+		}
+		group.screens.push_back (screen);
+	    }
+
+	    if (group.screens.size () < 2) {
+		sLog.exception ("--screen-span requires at least two comma-separated screen names");
+	    }
+
+	    group.scaling = this->settings.render.window.scalingMode;
+	    group.clamp = this->settings.render.window.clamp;
+	    this->settings.general.spanGroups.push_back (std::move (group));
+	    // set lastScreen to a synthetic name so --bg/--scaling/--clamp can target this group
+	    lastScreen = "span:" + value;
+	    // register the synthetic name in screenBackgrounds so the rest of the pipeline sees it
+	    this->settings.general.screenBackgrounds[lastScreen] = "";
+	})
+	.append ();
     backgroundGroup.add_argument ("-b", "--bg")
-	.help ("After --screen-root, specifies the background to use for the given screen")
+	.help ("After --screen-root or --screen-span, specifies the background to use")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
 	    this->settings.general.screenBackgrounds[lastScreen] = translateBackground (value);
 	    // set the default background to the last one used
 	    this->settings.general.defaultBackground = translateBackground (value);
+	    // if this targets a span group, update the group's background too
+	    if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		this->settings.general.spanGroups.back ().background = translateBackground (value);
+	    }
 	})
 	.append ();
     backgroundGroup.add_argument ("--playlist")
@@ -342,8 +402,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.append ();
     backgroundGroup.add_argument ("--scaling")
 	.help (
-	    "Scaling mode to use when rendering the background, this applies to the previous --window or --screen-root "
-	    "output, or the default background if no other background is specified"
+	    "Scaling mode to use when rendering the background, this applies to the previous --window, --screen-root, "
+	    "or --screen-span output, or the default background if no other background is specified"
 	)
 	.choices ("stretch", "fit", "fill", "default")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
@@ -363,6 +423,10 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenScalings[lastScreen] = mode;
+		// also update span group if targeting one
+		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		    this->settings.general.spanGroups.back ().scaling = mode;
+		}
 	    } else {
 		this->settings.render.window.scalingMode = mode;
 	    }
@@ -370,8 +434,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.append ();
     backgroundGroup.add_argument ("--clamp")
 	.help (
-	    "Clamp mode to use when rendering the background, this applies to the previous --window or --screen-root "
-	    "output, or the default background if no other background is specified"
+	    "Clamp mode to use when rendering the background, this applies to the previous --window, --screen-root, "
+	    "or --screen-span output, or the default background if no other background is specified"
 	)
 	.choices ("clamp", "border", "repeat")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
@@ -389,9 +453,37 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenClamps[lastScreen] = flags;
+		// also update span group if targeting one
+		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		    this->settings.general.spanGroups.back ().clamp = flags;
+		}
 	    } else {
 		this->settings.render.window.clamp = flags;
 	    }
+	})
+	.append ();
+
+    backgroundGroup.add_argument ("--layer")
+	.help (
+	    "Wayland-only: which wlr-layer-shell layer to anchor the wallpaper to "
+	    "(background, bottom, top, overlay). Default: bottom. "
+	    "Use 'background' on niri to pair with the `place-within-backdrop` layer-rule, "
+	    "otherwise the wallpaper will be cloned to every workspace in the overview."
+	)
+	.choices ("background", "bottom", "top", "overlay")
+	.default_value (std::string ("bottom"))
+	.action ([this] (const std::string& value) -> void {
+	    if (value == "background") {
+		this->settings.render.wayland.layer = WAYLAND_LAYER_BACKGROUND;
+	    } else if (value == "bottom") {
+		this->settings.render.wayland.layer = WAYLAND_LAYER_BOTTOM;
+	    } else if (value == "top") {
+		this->settings.render.wayland.layer = WAYLAND_LAYER_TOP;
+	    } else if (value == "overlay") {
+		this->settings.render.wayland.layer = WAYLAND_LAYER_OVERLAY;
+	    } else {
+		sLog.exception ("Invalid wlr-layer-shell layer: ", value);
+	    }
 	});
 
     auto& performanceGroup = program.add_group ("Performance options");
@@ -509,6 +601,44 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.store_into (this->settings.general.dumpStructure);
 
+    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."
+	)
+	.action ([this] (const std::string& value) -> void {
+	    const auto parseDebugId = [&value] (const std::string& prefix) -> std::optional<int> {
+		try {
+		    return std::stoi (value.substr (prefix.length ()));
+		} catch (const std::invalid_argument&) {
+		    sLog.exception ("Invalid numeric value for --render-debug ", value);
+		} catch (const std::out_of_range&) {
+		    sLog.exception ("Out-of-range numeric value for --render-debug ", value);
+		}
+		return std::nullopt;
+	    };
+
+	    if (value == "base-only") {
+		this->settings.render.debug.baseOnly = true;
+	    } else if (value == "no-solid-final") {
+		this->settings.render.debug.noSolidFinal = true;
+	    } else if (value == "pass-log") {
+		this->settings.render.debug.passLog = true;
+	    } else if (value.rfind ("object=", 0) == 0) {
+		this->settings.render.debug.objectFilter = parseDebugId ("object=");
+	    } else if (value.rfind ("skip-object=", 0) == 0) {
+		if (const auto id = parseDebugId ("skip-object="); id.has_value ()) {
+		    this->settings.render.debug.skipObjects.emplace_back (*id);
+		}
+	    } else if (value.rfind ("skip-effect=", 0) == 0) {
+		if (const auto id = parseDebugId ("skip-effect="); id.has_value ()) {
+		    this->settings.render.debug.skipEffects.emplace_back (*id);
+		}
+	    } else {
+		sLog.exception ("Invalid render debug mode: ", value);
+	    }
+	})
+	.append ();
+
     program.add_epilog (
 	"Usage examples:\n"
 	"  linux-wallpaperengine --screen-root HDMI-1 --bg 2317494988 --scaling fill --clamp border\n"
@@ -520,6 +650,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	"    Runs two backgrounds on two screens, one on HDMI-1 and the other on HDMI-2\n\n"
 	"  linux-wallpaperengine --screen-root HDMI-1 --screen-root HDMI-2 2317494988\n"
 	"    Runs the background 2317494988 on two screens, one on HDMI-1 and the other on HDMI-2\n\n"
+	"  linux-wallpaperengine --screen-span HDMI-1,HDMI-2 --bg 2317494988 --scaling fill\n"
+	"    Spans the background 2317494988 across HDMI-1 and HDMI-2 as a single stretched wallpaper\n\n"
     );
 
     try {

+ 48 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -40,6 +40,18 @@ public:
 	EXPLICIT_WINDOW = 2,
     };
 
+    /**
+     * Wayland-only: which wlr-layer-shell layer to anchor the wallpaper surface to.
+     * Different compositors treat layers differently; e.g. niri's
+     * `place-within-backdrop` layer-rule only applies to BACKGROUND surfaces.
+     */
+    enum WAYLAND_LAYER {
+	WAYLAND_LAYER_BACKGROUND = 0,
+	WAYLAND_LAYER_BOTTOM = 1,
+	WAYLAND_LAYER_TOP = 2,
+	WAYLAND_LAYER_OVERLAY = 3,
+    };
+
     struct PlaylistSettings {
 	uint32_t delayMinutes = 60;
 	std::string mode = "timer";
@@ -54,6 +66,14 @@ public:
 	PlaylistSettings settings;
     };
 
+    struct SpanGroup {
+	std::vector<std::string> screens;
+	std::filesystem::path background;
+	WallpaperEngine::Render::WallpaperState::TextureUVsScaling scaling
+	    = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs;
+	TextureFlags clamp = TextureFlags_ClampUVs;
+    };
+
     struct {
 	/**
 	 * General settings
@@ -81,6 +101,8 @@ public:
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
 	    /** Playlist used in window mode */
 	    std::optional<PlaylistDefinition> defaultPlaylist;
+	    /** Span groups: multiple monitors sharing one stretched wallpaper */
+	    std::vector<SpanGroup> spanGroups;
 	} general;
 
 	/**
@@ -103,6 +125,15 @@ public:
 	     * Example: "firefox" will match "org.mozilla.firefox".
 	     */
 	    std::vector<std::string> fullscreenPauseIgnoreAppIds;
+	    /** Render debugging switches for scene compatibility work */
+	    struct {
+		bool baseOnly;
+		bool noSolidFinal;
+		bool passLog;
+		std::optional<int> objectFilter;
+		std::vector<int> skipObjects;
+		std::vector<int> skipEffects;
+	    } debug;
 
 	    struct {
 		/** The window size used in explicit window */
@@ -110,6 +141,11 @@ public:
 		TextureFlags clamp;
 		WallpaperEngine::Render::WallpaperState::TextureUVsScaling scalingMode;
 	    } window;
+
+	    struct {
+		/** Which wlr-layer-shell layer to use for desktop backgrounds */
+		WAYLAND_LAYER layer;
+	    } wayland;
 	} render;
 
 	/**
@@ -159,6 +195,7 @@ public:
             .screenClamps = {},
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
+            .spanGroups = {},
         },
         .render = {
             .mode = NORMAL_WINDOW,
@@ -166,11 +203,22 @@ public:
             .pauseOnFullscreen = true,
             .pauseOnFullscreenOnlyWhenActive = false,
             .fullscreenPauseIgnoreAppIds = {},
+            .debug = {
+                .baseOnly = false,
+                .noSolidFinal = false,
+	                .passLog = false,
+	                .objectFilter = std::nullopt,
+	                .skipObjects = {},
+	                .skipEffects = {},
+	            },
             .window = {
                 .geometry = {},
                 .clamp = TextureFlags_ClampUVs,
                 .scalingMode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs,
             },
+            .wayland = {
+                .layer = WAYLAND_LAYER_BOTTOM,
+            },
         },
         .audio = {
             .enabled = true,

+ 201 - 85
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -21,6 +21,7 @@
 #endif /* DEMOMODE */
 
 #include <algorithm>
+#include <climits>
 #include <numeric>
 #include <unistd.h>
 #define STB_IMAGE_WRITE_IMPLEMENTATION
@@ -182,13 +183,33 @@ void WallpaperApplication::loadBackgrounds () {
     }
 
     for (const auto& [screen, path] : this->m_context.settings.general.screenBackgrounds) {
-	// screens with no screen should use the default
+	// skip span group synthetic keys here, they're handled below
+	if (screen.rfind ("span:", 0) == 0) {
+	    continue;
+	}
+	// screens with no path should use the default
 	if (path.empty ()) {
 	    this->m_backgrounds[screen] = this->loadBackground (this->m_context.settings.general.defaultBackground);
 	} else {
 	    this->m_backgrounds[screen] = this->loadBackground (path);
 	}
     }
+
+    // Load one background per span group
+    for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	if (spanGroup.screens.empty ()) {
+	    continue;
+	}
+
+	std::filesystem::path bgPath = spanGroup.background;
+	if (bgPath.empty ()) {
+	    bgPath = this->m_context.settings.general.defaultBackground;
+	}
+
+	// use the first screen's name as the group key for the loaded project
+	const std::string groupKey = "span:" + spanGroup.screens.front ();
+	this->m_backgrounds[groupKey] = this->loadBackground (bgPath);
+    }
 }
 
 ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
@@ -199,13 +220,13 @@ ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
     // this allows taking screenshots after a background changes
     // useful for playlists
     if (this->m_context.settings.screenshot.take) {
-        this->m_nextFrameScreenshot = this->m_context.settings.screenshot.delay;
+	this->m_nextFrameScreenshot = this->m_context.settings.screenshot.delay;
 
-        if (this->m_videoDriver != nullptr) {
-            this->m_nextFrameScreenshot += this->m_videoDriver->getFrameCounter ();
-        }
+	if (this->m_videoDriver != nullptr) {
+	    this->m_nextFrameScreenshot += this->m_videoDriver->getFrameCounter ();
+	}
 
-        this->m_screenShotTaken = false;
+	this->m_screenShotTaken = false;
     }
 
     return WallpaperEngine::Data::Parsers::ProjectParser::parse (json, std::move (container));
@@ -587,7 +608,7 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
     const std::string extStr = extension.string ();
 
     // Offload pixel processing and saving to a background thread to avoid hitches
-    std::thread ([captures, width, height, vflip, extStr, filename]() {
+    std::thread ([captures, width, height, vflip, extStr, filename] () {
 	auto* bitmap = new uint8_t[width * height * 3] { 0 };
 
 	for (const auto& capture : captures) {
@@ -595,8 +616,10 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 	    for (int y = 0; y < capture.vpHeight; y++) {
 		for (int x = 0; x < capture.vpWidth; x++) {
 		    // interpolate within the UV range to get source coordinates
-		    const float u = capture.ustart + (static_cast<float> (x) / capture.vpWidth) * (capture.uend - capture.ustart);
-		    const float v = capture.vstart + (static_cast<float> (y) / capture.vpHeight) * (capture.vend - capture.vstart);
+		    const float u
+			= capture.ustart + (static_cast<float> (x) / capture.vpWidth) * (capture.uend - capture.ustart);
+		    const float v = capture.vstart
+			+ (static_cast<float> (y) / capture.vpHeight) * (capture.vend - capture.vstart);
 
 		    // convert UV to pixel coordinates in the source buffer
 		    const int srcX = std::clamp (static_cast<int> (u * capture.readWidth), 0, capture.readWidth - 1);
@@ -690,8 +713,11 @@ void WallpaperApplication::prepareOutputs () {
     m_renderContext = std::make_unique<WallpaperEngine::Render::RenderContext> (*m_videoDriver, *this);
     // create a new background for each screen
 
-    // set all the specific wallpapers required
+    // set all the specific wallpapers required (skip span group synthetic keys)
     for (const auto& [background, info] : this->m_backgrounds) {
+	if (background.rfind ("span:", 0) == 0) {
+	    continue;
+	}
 	const auto scalingIt = this->m_context.settings.general.screenScalings.find (background);
 	const auto clampIt = this->m_context.settings.general.screenClamps.find (background);
 	const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
@@ -708,6 +734,69 @@ void WallpaperApplication::prepareOutputs () {
 	    )
 	);
     }
+
+    // Set up span groups: one shared wallpaper per group, registered for each viewport
+    for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	if (spanGroup.screens.empty ()) {
+	    continue;
+	}
+
+	const std::string groupKey = "span:" + spanGroup.screens.front ();
+	const auto bgIt = this->m_backgrounds.find (groupKey);
+	if (bgIt == this->m_backgrounds.end ()) {
+	    continue;
+	}
+
+	// Compute the bounding box of all viewports in this span group
+	const auto& viewports = m_renderContext->getOutput ().getViewports ();
+	int minX = INT_MAX, minY = INT_MAX, maxX = INT_MIN, maxY = INT_MIN;
+	bool anyFound = false;
+
+	for (const auto& screenName : spanGroup.screens) {
+	    const auto vpIt = viewports.find (screenName);
+	    if (vpIt == viewports.end ()) {
+		sLog.error ("Span group screen not found: ", screenName);
+		continue;
+	    }
+	    anyFound = true;
+	    const auto& vp = vpIt->second;
+	    const int x = vp->globalPosition.x;
+	    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);
+	    minX = std::min (minX, x);
+	    minY = std::min (minY, y);
+	    maxX = std::max (maxX, x + w);
+	    maxY = std::max (maxY, y + h);
+	}
+
+	if (!anyFound) {
+	    sLog.error ("No viewports found for span group, skipping");
+	    continue;
+	}
+
+	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 };
+
+	// Create one shared wallpaper with the span group's scaling mode
+	auto sharedWallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
+	    *bgIt->second->wallpaper, *m_renderContext, *m_audioContext, m_browserContext.get (), spanGroup.scaling,
+	    spanGroup.clamp
+	);
+
+	// Convert to shared_ptr so it can be registered for multiple viewports
+	std::shared_ptr<WallpaperEngine::Render::CWallpaper> shared (std::move (sharedWallpaper));
+	shared->setSpanInfo (spanInfo);
+
+	// Register the same wallpaper for each screen in the span group
+	for (const auto& screenName : spanGroup.screens) {
+	    m_renderContext->setWallpaper (screenName, shared);
+	}
+    }
 }
 
 void WallpaperApplication::setupOpenGLDebugging () {
@@ -717,15 +806,12 @@ void WallpaperApplication::setupOpenGLDebugging () {
 #endif
 }
 
-void WallpaperApplication::show () {
+void WallpaperApplication::setup () {
     this->setupOutput ();
     this->setupAudio ();
     this->prepareOutputs ();
     this->setupOpenGLDebugging ();
 
-    static time_t seconds;
-    static struct tm* timeinfo;
-
     if (this->m_context.settings.general.dumpStructure) {
 	auto prettyPrinter = Data::Dumpers::StringPrinter ();
 
@@ -748,66 +834,17 @@ void WallpaperApplication::show () {
     bool initialized = false;
     int frame = 0;
 #endif /* DEMOMODE */
+}
 
-    while (this->m_context.state.general.keepRunning) {
-	// update g_Daytime
-	time (&seconds);
-	timeinfo = localtime (&seconds);
-	g_Daytime = ((timeinfo->tm_hour * 60.0f) + 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;
-	}
-
-#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 ();
+void WallpaperApplication::render () {
+    static time_t seconds;
+    static struct tm* timeinfo;
 
-	    m_renderContext->setPause (true);
-	    while (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning) {
+	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
@@ -822,29 +859,96 @@ void WallpaperApplication::show () {
 	    }
 
 	    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;
+		}
+
+#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 ();
+
+			m_renderContext->setPause (true);
+			return;
+		}
 	}
 
 	this->updatePlaylists ();
 
-        if (!this->m_context.settings.screenshot.take || this->m_screenShotTaken == true) {
-            continue;
-        }
+	if (!this->m_context.settings.screenshot.take || this->m_screenShotTaken == true) {
+	    return;
+	}
 
-        if (this->m_videoDriver->getFrameCounter () < this->m_nextFrameScreenshot) {
-            continue;
-        }
+	if (this->m_videoDriver->getFrameCounter () < this->m_nextFrameScreenshot) {
+	    return;
+	}
 
-        this->takeScreenshot (this->m_context.settings.screenshot.path);
-        this->m_screenShotTaken = true;
-    }
+	this->takeScreenshot (this->m_context.settings.screenshot.path);
+	this->m_screenShotTaken = true;
+}
 
-    sLog.out ("Stopping");
+void WallpaperApplication::cleanup () {
+	sLog.out ("Stopping");
 
-#if DEMOMODE
-    close_encoder ();
-#endif /* DEMOMODE */
+	#if DEMOMODE
+		close_encoder ();
+	#endif /* DEMOMODE */
 
-    SDL_Quit ();
+		SDL_Quit ();
+}
+
+void WallpaperApplication::show () {
+	setup();
+    while (this->m_context.state.general.keepRunning) {
+		render();
+    }
+    cleanup();
 }
 
 void WallpaperApplication::update (Render::Drivers::Output::OutputViewport* viewport) {
@@ -866,3 +970,15 @@ ApplicationContext& WallpaperApplication::getContext () const { return this->m_c
 const WallpaperEngine::Render::Drivers::Output::Output& WallpaperApplication::getOutput () const {
     return this->m_renderContext->getOutput ();
 }
+
+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);
+	};
+}
+
+GLuint WallpaperApplication::getDestinationFramebuffer () const { 
+	return this->m_destinationFramebuffer;
+}

+ 23 - 0
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -22,6 +22,7 @@
 #include <set>
 
 namespace WallpaperEngine::Application {
+
 using namespace WallpaperEngine::Assets;
 using namespace WallpaperEngine::Data::Model;
 /**
@@ -31,6 +32,18 @@ class WallpaperApplication {
 public:
     explicit WallpaperApplication (ApplicationContext& context);
 
+    /**
+     * Prepares the application for rendering.
+     */
+    void setup();
+    /**
+     * Renders a frame of the application.
+     */
+    void render();
+    /**
+     * Cleans up all the resources used by the application.
+     */
+    static void cleanup();
     /**
      * Shows the application until it's closed
      */
@@ -57,6 +70,15 @@ public:
      * Gets the output
      */
     [[nodiscard]] const WallpaperEngine::Render::Drivers::Output::Output& getOutput () const;
+    /**
+     * Sets the destination framebuffer for rendering. If not called, the default framebuffer will be used.
+     */
+    void setDestinationFramebuffer (GLuint framebuffer);
+
+    /**
+     * Gets the currently set destination framebuffer for rendering. If not set, returns 0 (the default framebuffer).
+     */
+    [[nodiscard]] GLuint getDestinationFramebuffer () const;
 
 private:
     /**
@@ -152,5 +174,6 @@ private:
     bool m_screenShotTaken = false;
     uint32_t m_nextFrameScreenshot = 0;
     std::chrono::steady_clock::time_point m_pauseStart {};
+    GLuint m_destinationFramebuffer = 0;
 };
 } // namespace WallpaperEngine::Application

+ 0 - 2
src/WallpaperEngine/Audio/AudioStream.h

@@ -173,8 +173,6 @@ private:
     int m_audioStream = NO_AUDIO_STREAM;
     /** File data pointer */
     ReadStreamSharedPtr m_buffer = nullptr;
-    /** The length of the file data pointer */
-    uint32_t m_length = 0;
 
     struct MyAVPacketList {
 	AVPacket* packet;

+ 14 - 2
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp

@@ -96,6 +96,10 @@ void pa_stream_read_cb (pa_stream* stream, const size_t /*nbytes*/, void* userda
 }
 
 void pa_server_info_cb (pa_context* ctx, const pa_server_info* info, void* userdata) {
+    if (info == nullptr) {
+	return;
+    }
+
     auto* recorder = static_cast<PulseAudioPlaybackRecorder::PulseAudioData*> (userdata);
 
     pa_sample_spec spec;
@@ -131,7 +135,10 @@ void pa_server_info_cb (pa_context* ctx, const pa_server_info* info, void* userd
 
 void pa_context_subscribe_cb (pa_context* ctx, pa_subscription_event_type_t t, uint32_t idx, void* userdata) {
     // sink changes mean re-take the stream
-    pa_context_get_server_info (ctx, &pa_server_info_cb, userdata);
+    pa_operation* o = pa_context_get_server_info (ctx, &pa_server_info_cb, userdata);
+    if (o) {
+	pa_operation_unref (o);
+    }
 }
 
 void pa_context_notify_cb (pa_context* ctx, void* userdata) {
@@ -151,7 +158,11 @@ void pa_context_notify_cb (pa_context* ctx, void* userdata) {
 		}
 
 		// context being ready means to fetch the sink too
-		pa_context_get_server_info (ctx, &pa_server_info_cb, userdata);
+		pa_operation* o2 = pa_context_get_server_info (ctx, &pa_server_info_cb, userdata);
+
+		if (o2) {
+		    pa_operation_unref (o2);
+		}
 
 		break;
 	    }
@@ -196,6 +207,7 @@ PulseAudioPlaybackRecorder::~PulseAudioPlaybackRecorder () {
     free (this->m_captureData.kisscfg);
 
     pa_context_disconnect (this->m_context);
+    pa_context_unref (this->m_context);
     pa_mainloop_free (this->m_mainloop);
 }
 

+ 1 - 0
src/WallpaperEngine/Data/Assets/Texture.h

@@ -19,6 +19,7 @@ enum ContainerVersion {
 
 enum AnimatedVersion {
     AnimatedVersion_UNKNOWN = 0,
+    AnimatedVersion_TEXS0001 = 1,
     AnimatedVersion_TEXS0002 = 2,
     AnimatedVersion_TEXS0003 = 3,
 };

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

@@ -38,7 +38,7 @@ public:
 	const char* third = second ? strchr (second + 1, ' ') : nullptr;
 
 	if (first == nullptr) {
-	    sLog.exception ("Invalid vector format: " + str + " (too few values, expected: 2, 3 or 4)");
+	    return 0;
 	}
 
 	if (second == nullptr) {

+ 25 - 3
src/WallpaperEngine/Data/Model/DynamicValue.cpp

@@ -21,6 +21,8 @@ DynamicValue::DynamicValue (int value) { this->DynamicValue::update (value); }
 
 DynamicValue::DynamicValue (bool value) { this->DynamicValue::update (value); }
 
+DynamicValue::DynamicValue (const std::string& value) { this->DynamicValue::update (value); }
+
 DynamicValue::~DynamicValue () {
     if (this->m_aliveFlag) {
 	*this->m_aliveFlag = false;
@@ -240,7 +242,17 @@ void DynamicValue::update (const std::string& newValue) {
     this->m_type = UnderlyingType::String;
 
     if (this->m_condition.has_value ()) {
-	this->m_bool = this->m_condition.value ().condition == newValue;
+	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);
+	this->m_float = boolValue ? 1.0f : 0.0f;
+	this->m_int = boolValue ? 1 : 0;
+	this->m_bool = boolValue;
+	this->m_type = UnderlyingType::Boolean;
     }
 
     this->propagate ();
@@ -256,11 +268,21 @@ void DynamicValue::update (const DynamicValue& other) {
     this->m_float = other.getFloat ();
     this->m_int = other.getInt ();
     this->m_bool = other.getBool ();
+    this->m_string = other.getString ();
     this->m_type = other.getType ();
 
     if (this->m_condition.has_value () && other.getType () == UnderlyingType::String) {
-	// TODO: DOES THIS NEED TO HAPPEN WITH OTHER TYPES TOO?
-	this->m_bool = this->m_condition.value ().condition == other.getString ();
+	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);
+	this->m_float = boolValue ? 1.0f : 0.0f;
+	this->m_int = boolValue ? 1 : 0;
+	this->m_bool = boolValue;
+	this->m_type = UnderlyingType::Boolean;
     }
 
     this->propagate ();

+ 1 - 0
src/WallpaperEngine/Data/Model/DynamicValue.h

@@ -42,6 +42,7 @@ public:
     explicit DynamicValue (float value);
     explicit DynamicValue (int value);
     explicit DynamicValue (bool value);
+    explicit DynamicValue (const std::string& value);
     virtual ~DynamicValue ();
 
     [[nodiscard]] const glm::ivec4& getIVec4 () const;

+ 57 - 11
src/WallpaperEngine/Data/Model/Object.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <map>
 #include <optional>
 #include <string>
 #include <utility>
@@ -22,6 +23,13 @@ struct ObjectData {
     int id;
     std::string name;
     std::vector<int> dependencies;
+    std::optional<int> parent;
+    /** The point of origin of the object */
+    UserSettingUniquePtr origin;
+    /** Transform fields for generic scene/group objects. Typed objects keep their own transform fields. */
+    UserSettingUniquePtr groupScale;
+    UserSettingUniquePtr groupAngles;
+    UserSettingUniquePtr groupVisible;
 };
 
 /**
@@ -79,15 +87,13 @@ struct ImageEffect {
  */
 struct ImageAnimationLayer {
     int id;
-    float rate;
+    UserSettingUniquePtr rate;
     UserSettingUniquePtr visible;
-    float blend;
-    int animation;
+    UserSettingUniquePtr blend;
+    UserSettingUniquePtr animation;
 };
 
 struct ImageData {
-    /** The point of origin of the image */
-    UserSettingUniquePtr origin;
     /** The scale of the image */
     UserSettingUniquePtr scale;
     /** The rotation of the image */
@@ -104,11 +110,11 @@ struct ImageData {
     /** The size of the image in pixels */
     glm::vec2 size;
     /** Parallax depth used for parallax scrolling */
-    glm::vec2 parallaxDepth;
+    UserSettingUniquePtr parallaxDepth;
     /** The color blending mode for this image */
-    int colorBlendMode;
+    UserSettingUniquePtr colorBlendMode;
     /** The brightness of the image */
-    float brightness;
+    UserSettingUniquePtr brightness;
     /** The material in use for this image */
     ModelUniquePtr model;
     /** The effects applied to this image after the material is rendered */
@@ -537,13 +543,12 @@ struct ParticleInstanceOverride {
 
 struct ParticleData {
     /** Position and transformation */
-    UserSettingUniquePtr origin;
     UserSettingUniquePtr scale;
     UserSettingUniquePtr angles;
     UserSettingUniquePtr visible;
 
     /** Parallax depth */
-    glm::vec2 parallaxDepth;
+    UserSettingUniquePtr parallaxDepth;
 
     /** Reference to particle definition file */
     std::string particleFile;
@@ -576,4 +581,45 @@ public:
 	Object (std::move (data)), ParticleData (std::move (particleData)) { };
     ~Particle () override = default;
 };
-} // namespace WallpaperEngine::Data::Model
+
+/**
+ * Text object data. Phase 1 of text support covers only static text;
+ * dynamic (script-driven) text captures the script source for a future
+ * pass but renders whatever initial value the scene provides.
+ */
+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;
+    /** 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 */
+    UserSettingUniquePtr pointSize;
+    /** Bounding box size */
+    glm::vec2 size;
+    /** Scale (x, y, z) */
+    UserSettingUniquePtr scale;
+    /** Text color as linear-space RGB */
+    UserSettingUniquePtr color;
+    /** Alpha multiplier */
+    UserSettingUniquePtr alpha;
+    /** Whether the text is visible */
+    UserSettingUniquePtr visible;
+    /** Horizontal alignment: "left", "center", "right" */
+    std::string alignment;
+    /** Vertical alignment: "top", "center", "bottom" */
+    std::string verticalalign;
+    /** Padding inside the bounding box */
+    int padding;
+};
+
+class Text : public Object, public TextData {
+public:
+    explicit Text (ObjectData data, TextData textData) noexcept :
+	Object (std::move (data)), TextData (std::move (textData)) { };
+    ~Text () override = default;
+};
+} // namespace WallpaperEngine::Data::Model

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

@@ -0,0 +1,74 @@
+#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);
+    }
+}

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

@@ -0,0 +1,52 @@
+#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

+ 2 - 0
src/WallpaperEngine/Data/Model/Types.h

@@ -19,6 +19,7 @@ class Image;
 struct ImageEffect;
 struct ImageEffectPassOverride;
 class Particle;
+class Text;
 class DynamicValue;
 class Property;
 class PropertySlider;
@@ -49,6 +50,7 @@ using ObjectUniquePtr = std::unique_ptr<Object>;
 using SoundUniquePtr = std::unique_ptr<Sound>;
 using ImageUniquePtr = std::unique_ptr<Image>;
 using ParticleUniquePtr = std::unique_ptr<Particle>;
+using TextUniquePtr = std::unique_ptr<Text>;
 using MaterialUniquePtr = std::unique_ptr<Material>;
 using MaterialPassUniquePtr = std::unique_ptr<MaterialPass>;
 using EffectUniquePtr = std::unique_ptr<Effect>;

+ 244 - 27
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -4,8 +4,10 @@
 #include "ModelParser.h"
 
 #include "ShaderConstantParser.h"
+#include "UserSettingParser.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>
@@ -14,12 +16,108 @@
 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");
     const auto particleIt = it.find ("particle");
     const auto textIt = it.find ("text");
     const auto lightIt = it.find ("light");
+    // use shape to refer to VolumeLight
+    const auto shapeIt = it.find ("shape");
 
     // Parse base object data
     // Some particle objects have numeric 'name' fields, so handle type mismatches gracefully
@@ -29,6 +127,11 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .id = it.require<int> ("id", "Object must have an id"),
 	    .name = it.require<std::string> ("name", "Object must have a name"),
 	    .dependencies = parseDependencies (it),
+	    .parent = it.optional<int> ("parent"),
+	    .origin = it.user ("origin", project.properties, glm::vec3 (0.0f)),
+	    .groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
+	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
+	    .groupVisible = it.user ("visible", project.properties, true),
 	};
     } catch (const std::exception& e) {
 	sLog.error ("Error parsing object base data: ", e.what ());
@@ -43,25 +146,45 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 		name = std::to_string (nameIt->get<int> ());
 	    }
 	}
-	basedata = ObjectData { .id = id, .name = name, .dependencies = {} };
+	basedata = ObjectData {
+	    .id = id,
+	    .name = name,
+	    .dependencies = parseDependencies (it),
+	    .parent = it.optional<int> ("parent"),
+	    .origin = it.user ("origin", project.properties, glm::vec3 (0.0f)),
+	    .groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
+	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
+	    .groupVisible = it.user ("visible", project.properties, true),
+	};
     }
 
+    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, basedata, *imageIt);
+	return parseImage (it, project, std::move (basedata), *imageIt);
     } else if (soundIt != it.end () && soundIt->is_array ()) {
-	return parseSound (it, basedata);
+	return parseSound (it, std::move (basedata));
     } else if (particleIt != it.end ()) {
-	return parseParticle (it, project, basedata);
+	return parseParticle (it, project, std::move (basedata));
     } else if (textIt != it.end ()) {
-	sLog.error ("Text objects are not supported yet");
+	return parseText (it, project, std::move (basedata));
     } else if (lightIt != it.end ()) {
 	sLog.error ("Light objects are not supported yet");
+    } else if (shapeIt != it.end ()) {
+	sLog.error ("VolumeLight objects are not supported yet");
     } else {
-	// dump the object for now, might want to change later
-	sLog.error ("Unknown object type found: ", it.dump ());
+	if (!it.optional ("solid", false)) {
+	    // dump the object for now, might want to change later
+	    // TODO: RE-EVALUATE IF THIS MAKES SENSE, THERE'S OBJECTS THAT CONTAIN OTHER OBJECTS AND THUS AREN'T REALLY
+	    // ANYTHING SPECIAL
+	    sLog.error ("Unknown object type found: ", it.dump ());
+	}
     }
 
-    return std::make_unique<Object> (basedata);
+    return std::make_unique<Object> (std::move (basedata));
 }
 
 std::vector<int> ObjectParser::parseDependencies (const JSON& it) {
@@ -97,6 +220,58 @@ 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> (
+	std::move (base),
+	TextData {
+	    .text = std::move (text),
+	    .script = std::move (script),
+	    .scriptProperties = std::move (scriptProps),
+	    .font = it.optional ("font", std::string ()),
+	    .pointSize = it.user ("pointsize", 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),
+	    .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
 ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image) {
     const auto& properties = project.properties;
@@ -106,17 +281,16 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
     auto result = std::make_unique<Image> (
 	std::move (base),
 	ImageData {
-	    .origin = it.user ("origin", properties, glm::vec3 (0.0f)),
 	    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 	    .angles = it.user ("angles", properties, glm::vec3 (0.0)),
 	    .visible = it.user ("visible", properties, true),
 	    .alpha = it.user ("alpha", properties, 1.0f),
 	    .color = it.user ("color", properties, glm::vec4 (1.0f)),
-	    .alignment = it.optional ("alignment", std::string ("center")),
-	    .size = it.optional ("size", glm::vec2 (0.0f)),
-	    .parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
-	    .colorBlendMode = it.optional ("colorBlendMode", 0),
-	    .brightness = it.optional ("brightness", 1.0f),
+	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
+	    .size = it.user ("size", project.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),
 	    .model = ModelParser::load (project, image),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .animationLayers = animationLayers.has_value () ? parseAnimationLayers (*animationLayers, project)
@@ -131,6 +305,36 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	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);
+	    firstPass.textures.insert (parsed.begin (), parsed.end ());
+	}
+	const auto instanceUserTextures = instance->optional ("usertextures");
+	if (instanceUserTextures.has_value ()) {
+	    const auto parsed = 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;
 }
 
@@ -201,9 +405,17 @@ TextureMap ObjectParser::parseTextureMap (const JSON& it) {
 	textureIndex++;
 
 	if (cur.is_null ()) {
-	    result.emplace (textureIndex, "");
+	    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 {
-	    result.emplace (textureIndex, cur);
+	    std::string texName = cur;
+	    if (!texName.empty ()) {
+		result.emplace (textureIndex, texName);
+	    }
 	}
     }
 
@@ -239,12 +451,14 @@ std::vector<ImageAnimationLayerUniquePtr> ObjectParser::parseAnimationLayers (co
 }
 
 ImageAnimationLayerUniquePtr ObjectParser::parseAnimationLayer (const JSON& it, const Project& project) {
+    const auto& properties = project.properties;
+
     return std::make_unique<ImageAnimationLayer> (ImageAnimationLayer {
-	.id = it.require ("id", "Animation layer must have an id"),
-	.rate = it.require ("rate", "Animation layer must have a rate"),
-	.visible = it.user ("visible", project.properties, false),
-	.blend = it.require ("blend", "Animation layer must include blend"),
-	.animation = it.require ("animation", "Animation layer must include an animation"),
+	.id = it.require<int> ("id", "Animation layer must have an id"),
+	.rate = it.user ("rate", properties, 1.0f),
+	.visible = it.user ("visible", properties, false),
+	.blend = it.user ("blend", properties, 1.0f),
+	.animation = it.user ("animation", properties, 0),
     });
 }
 
@@ -258,11 +472,10 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    return std::make_unique<Particle> (
 		std::move (base),
 		ParticleData {
-		    .origin = it.user ("origin", properties, glm::vec3 (0.0f)),
 		    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		    .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		    .visible = it.user ("visible", properties, true),
-		    .parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
+		    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 		    .particleFile = "",
 		    .animationMode = "sequence",
 		    .sequenceMultiplier = 1.0f,
@@ -505,14 +718,13 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    flags = flagsIt->get<uint32_t> ();
 	}
 
-	return std::make_unique<Particle> (
+	auto result = std::make_unique<Particle> (
 	    std::move (base),
 	    ParticleData {
-		.origin = it.user ("origin", properties, glm::vec3 (0.0f)),
 		.scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		.angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		.visible = it.user ("visible", properties, true),
-		.parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
+		.parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
 		.particleFile = particleFile,
 		.animationMode = animationMode,
 		.sequenceMultiplier = sequenceMultiplier,
@@ -529,6 +741,11 @@ 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 ());
@@ -889,4 +1106,4 @@ ParticleInstanceOverride ObjectParser::parseParticleInstanceOverride (const JSON
 	.color = it.user ("color", properties, glm::vec3 (1.0f)),
 	.colorn = it.user ("colorn", properties, glm::vec3 (1.0f)),
     };
-}
+}

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

@@ -23,6 +23,7 @@ private:
     static ImageUniquePtr
     parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
     static ParticleUniquePtr parseParticle (const JSON& it, const Project& project, ObjectData base);
+    static TextUniquePtr parseText (const JSON& it, const Project& project, ObjectData base);
     static std::vector<ImageEffectUniquePtr> parseEffects (const JSON& it, const Project& project);
     static ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static std::vector<ImageEffectPassOverrideUniquePtr>

+ 6 - 3
src/WallpaperEngine/Data/Parsers/PropertyParser.cpp

@@ -26,12 +26,15 @@ PropertySharedPtr PropertyParser::parse (const JSON& it, const std::string& name
     if (type == "scenetexture") {
 	return parseSceneTexture (it, name);
     }
-    if (type == "file") {
+    if (type == "file" || type == "directory") {
 	return parseFile (it, name);
     }
     if (type == "textinput") {
 	return parseTextInput (it, name);
     }
+    if (type == "usershortcut") {
+	return parseTextInput (it, name);
+    }
 
     if (type.has_value () && type != "group") {
 	// show the error and ignore this property
@@ -92,7 +95,7 @@ PropertySharedPtr PropertyParser::parseBoolean (const JSON& it, const std::strin
 	    .name = name,
 	    .text = it.optional<std::string> ("text", ""),
 	},
-	it.require ("value", "Property must have a value")
+	it.optional ("value", false)
     );
 }
 
@@ -146,4 +149,4 @@ PropertySharedPtr PropertyParser::parseTextInput (const JSON& it, const std::str
 	},
 	it.require ("value", "Property must have a value").dump ()
     );
-}
+}

+ 25 - 4
src/WallpaperEngine/Data/Parsers/TextureParser.cpp

@@ -92,6 +92,21 @@ MipmapSharedPtr TextureParser::parseMipmap (const BinaryReader& file, const Text
     return result;
 }
 
+FrameSharedPtr TextureParser::parseFrameV1 (const BinaryReader& file) {
+    auto result = std::make_shared<Frame> ();
+
+    result->frameNumber = file.nextUInt32 ();
+    result->frametime = file.nextFloat ();
+    result->x = static_cast<float> (file.nextUInt32 ());
+    result->y = static_cast<float> (file.nextUInt32 ());
+    result->width1 = static_cast<float> (file.nextUInt32 ());
+    std::ignore = file.nextUInt32 (); // unknown
+    std::ignore = file.nextUInt32 (); // unknown
+    result->height1 = static_cast<float> (file.nextUInt32 ());
+
+    return result;
+}
+
 FrameSharedPtr TextureParser::parseFrame (const BinaryReader& file) {
     auto result = std::make_shared<Frame> ();
 
@@ -195,7 +210,9 @@ void TextureParser::parseAnimations (Texture& header, const BinaryReader& file)
     // image is animated, keep parsing the rest of the image info
     file.next (magic, 9);
 
-    if (strncmp (magic, "TEXS0002", 9) == 0) {
+    if (strncmp (magic, "TEXS0001", 9) == 0) {
+	header.animatedVersion = AnimatedVersion_TEXS0001;
+    } else if (strncmp (magic, "TEXS0002", 9) == 0) {
 	header.animatedVersion = AnimatedVersion_TEXS0002;
     } else if (strncmp (magic, "TEXS0003", 9) == 0) {
 	header.animatedVersion = AnimatedVersion_TEXS0003;
@@ -211,11 +228,15 @@ void TextureParser::parseAnimations (Texture& header, const BinaryReader& file)
     }
 
     while (frameCount-- > 0) {
-	header.frames.push_back (parseFrame (file));
+	if (header.animatedVersion == AnimatedVersion_TEXS0001) {
+	    header.frames.push_back (parseFrameV1 (file));
+	} else {
+	    header.frames.push_back (parseFrame (file));
+	}
     }
 
-    // ensure gif width and height is right for TEXS0002
-    if (header.animatedVersion == AnimatedVersion_TEXS0002) {
+    // ensure gif width and height is right for TEXS0001, TEXS0002
+    if (header.animatedVersion == AnimatedVersion_TEXS0001 || header.animatedVersion == AnimatedVersion_TEXS0002) {
 	header.gifWidth = (*header.frames.begin ())->width1;
 	header.gifHeight = (*header.frames.begin ())->height1;
     }

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

@@ -21,6 +21,7 @@ public:
     );
     static MipmapSharedPtr parseMipmap (const BinaryReader& file, const Texture& header);
     static FrameSharedPtr parseFrame (const BinaryReader& file);
+    static FrameSharedPtr parseFrameV1 (const BinaryReader& file);
 
 private:
     static void parseTextureHeader (Texture& header, const BinaryReader& file);

+ 49 - 5
src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp

@@ -1,7 +1,9 @@
 #include "UserSettingParser.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;
@@ -13,14 +15,17 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
     auto valueIt = data;
     std::string content = data.dump ();
 
+    std::optional<std::string> scriptSource;
+    std::optional<json> scriptPropsJson;
+
     if (data.is_object ()) {
 	const auto user = data.optional ("user");
 	const auto script = data.optional ("script");
 	valueIt = data.require ("value", "User setting must have a value");
 
-	// TODO: PARSE SCRIPT VALUES
 	if (script.has_value () && !script->is_null ()) {
-	    sLog.error ("Found user setting with script value: ", script.value ().dump ());
+	    scriptSource = script->get<std::string> ();
+	    scriptPropsJson = data.optional ("scriptproperties");
 	}
 
 	if (user.has_value () && !user->is_null ()) {
@@ -49,12 +54,33 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	std::string str = valueIt;
 
 	// TODO: VALIDATE THIS IS RIGHT?
-	if (int size = VectorBuilder::preparseSize (str); size == 2) {
+	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 {
+	} 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> ());
@@ -67,6 +93,24 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	value->update ();
     }
 
+    // If the setting has a script, wrap the base value in a ScriptedDynamicValue
+    if (scriptSource.has_value ()) {
+	std::map<std::string, DynamicValueUniquePtr> scriptProps;
+
+	if (scriptPropsJson.has_value () && scriptPropsJson->is_object ()) {
+	    for (const auto& [key, propData] : scriptPropsJson->items ()) {
+		auto propSetting = UserSettingParser::parse (propData, properties);
+		scriptProps[key] = std::move (propSetting->value);
+	    }
+	}
+
+	value = std::make_unique<ScriptedDynamicValue> (
+	    std::move (scriptSource.value ()),
+	    std::move (scriptProps),
+	    std::move (*value)
+	);
+    }
+
     // TODO: This might need to be removed if it causes issues with default values
     // Connect to property if one is specified (this allows property overrides to propagate)
     if (property != nullptr) {
@@ -82,4 +126,4 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	.property = property,
 	.condition = condition,
     });
-}
+}

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

@@ -69,8 +69,8 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
                     .up = camera.require <glm::vec3> ("up", "Camera must have an up position"),
                 },
                 .projection = {
-                    .width = projection.require <int> ("width", "Projection must have a width"),
-                    .height = projection.require <int> ("height", "Projection must have a height"),
+                    .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),

+ 2 - 0
src/WallpaperEngine/Data/Utils/MemoryStream.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <iostream>
+#include <memory>
 
 namespace WallpaperEngine::Data::Utils {
 struct MemoryStream : std::istream, private std::streambuf {
@@ -25,4 +26,5 @@ struct MemoryStream : std::istream, private std::streambuf {
 };
 
 using MemoryStreamSharedPtr = std::shared_ptr<MemoryStream>;
+using MemoryStreamUniquePtr = std::unique_ptr<MemoryStream>;
 }

+ 156 - 9
src/WallpaperEngine/Input/Drivers/WaylandMouseInput.cpp

@@ -1,38 +1,185 @@
 #include "WaylandMouseInput.h"
 #include "WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h"
 #include <glm/common.hpp>
+#include <chrono>
+#include <cstdlib>
+#include <cstring>
+#include <regex>
+#include <string>
+#include <sys/socket.h>
+#include <sys/time.h>
+#include <sys/un.h>
+#include <unistd.h>
 
 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) { }
 
-void WaylandMouseInput::update () { }
+void WaylandMouseInput::update () {
+    if (!this->m_waylandDriver.getApp ().getContext ().settings.mouse.enabled) {
+	this->m_pos = { 0, 0 };
+	return;
+    }
+
+    if (m_waylandDriver.viewportInFocus && m_waylandDriver.viewportInFocus->rendering) {
+	this->m_pos = m_waylandDriver.viewportInFocus->mousePos;
+	return;
+    }
+
+    const auto now = std::chrono::steady_clock::now ();
+    if (now - this->m_lastHyprlandQuery < std::chrono::milliseconds (16)) {
+	return;
+    }
+    this->m_lastHyprlandQuery = now;
+
+    const auto globalCursor = this->queryHyprlandCursorPosition ();
+    if (!globalCursor.has_value ()) {
+	this->m_pos = { 0, 0 };
+	return;
+    }
+
+    for (const auto* viewport : this->m_waylandDriver.m_screens) {
+	if (!viewport || viewport->size.x <= 0 || viewport->size.y <= 0) {
+	    continue;
+	}
+
+	const double localX = globalCursor->x - viewport->position.x;
+	const double localY = globalCursor->y - viewport->position.y;
+	if (localX < 0.0 || localY < 0.0 || localX > viewport->size.x || localY > viewport->size.y) {
+	    continue;
+	}
+
+	this->m_pos = { localX * viewport->scale, (viewport->size.y - localY) * viewport->scale };
+	return;
+    }
+
+    this->m_pos = { 0, 0 };
+}
 
 glm::dvec2 WaylandMouseInput::position () const {
     if (!this->m_waylandDriver.getApp ().getContext ().settings.mouse.enabled) {
 	return { 0, 0 };
     }
 
-    if (m_waylandDriver.viewportInFocus && m_waylandDriver.viewportInFocus->rendering) {
-	return m_waylandDriver.viewportInFocus->mousePos;
+    const auto* viewport = getActiveViewport (m_waylandDriver);
+    if (!viewport) {
+	return { 0, 0 };
+    }
+
+    if (viewport == m_waylandDriver.viewportInFocus) {
+	return viewport->mousePos;
     }
 
-    return { 0, 0 };
+    if (viewport->mousePos.x != 0 || viewport->mousePos.y != 0) {
+	return viewport->mousePos;
+    }
+
+    return {
+	static_cast<double> (viewport->size.x * viewport->scale) / 2.0,
+	static_cast<double> (viewport->size.y * viewport->scale) / 2.0,
+    };
 }
 
 WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::leftClick () const {
-    if (m_waylandDriver.viewportInFocus && m_waylandDriver.viewportInFocus->rendering) {
-	return m_waylandDriver.viewportInFocus->leftClick;
+    const auto* viewport = getActiveViewport (m_waylandDriver);
+    if (viewport) {
+	return viewport->leftClick;
     }
 
     return MouseClickStatus::Released;
 }
 
+std::optional<glm::dvec2> WaylandMouseInput::queryHyprlandCursorPosition () const {
+    const char* signature = std::getenv ("HYPRLAND_INSTANCE_SIGNATURE");
+    const char* runtime = std::getenv ("XDG_RUNTIME_DIR");
+    if (!signature || !runtime) {
+	return std::nullopt;
+    }
+
+    const std::string socketPath = std::string (runtime) + "/hypr/" + signature + "/.socket.sock";
+
+    int fd = socket (AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
+    if (fd < 0) {
+	return std::nullopt;
+    }
+
+    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
+    ) {
+	close (fd);
+	return std::nullopt;
+    }
+
+    sockaddr_un addr {};
+    addr.sun_family = AF_UNIX;
+    if (socketPath.size () >= sizeof (addr.sun_path)) {
+	close (fd);
+	return std::nullopt;
+    }
+    std::strncpy (addr.sun_path, socketPath.c_str (), sizeof (addr.sun_path) - 1);
+
+    if (connect (fd, reinterpret_cast<sockaddr*> (&addr), sizeof (addr)) != 0) {
+	close (fd);
+	return std::nullopt;
+    }
+
+    constexpr const char* request = "j/cursorpos";
+    if (send (fd, request, std::strlen (request), MSG_NOSIGNAL) < 0) {
+	close (fd);
+	return std::nullopt;
+    }
+    shutdown (fd, SHUT_WR);
+
+    std::string response;
+    char buffer[256];
+    ssize_t readBytes = 0;
+    while ((readBytes = recv (fd, buffer, sizeof (buffer), 0)) > 0) {
+	response.append (buffer, static_cast<std::size_t> (readBytes));
+    }
+    close (fd);
+
+    static const std::regex xRegex (R"("x"\s*:\s*(-?\d+(?:\.\d+)?))");
+    static const std::regex yRegex (R"("y"\s*:\s*(-?\d+(?:\.\d+)?))");
+    std::smatch xMatch;
+    std::smatch yMatch;
+    if (!std::regex_search (response, xMatch, xRegex) || !std::regex_search (response, yMatch, yRegex)) {
+	return std::nullopt;
+    }
+
+    try {
+	return glm::dvec2 { std::stod (xMatch[1].str ()), std::stod (yMatch[1].str ()) };
+    } catch (const std::exception&) {
+	return std::nullopt;
+    }
+}
+
 WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::rightClick () const {
-    if (m_waylandDriver.viewportInFocus && m_waylandDriver.viewportInFocus->rendering) {
-	return m_waylandDriver.viewportInFocus->rightClick;
+    const auto* viewport = getActiveViewport (m_waylandDriver);
+    if (viewport) {
+	return viewport->rightClick;
     }
 
     return MouseClickStatus::Released;
-}
+}

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

@@ -5,6 +5,8 @@
 #include "WallpaperEngine/Input/MouseInput.h"
 
 #include <glm/vec2.hpp>
+#include <chrono>
+#include <optional>
 
 namespace WallpaperEngine::Render::Drivers {
 class WaylandOpenGLDriver;
@@ -39,13 +41,16 @@ public:
     [[nodiscard]] MouseClickStatus rightClick () const override;
 
 private:
+    [[nodiscard]] std::optional<glm::dvec2> queryHyprlandCursorPosition () const;
+
     /**
      * Wayland: Driver
      */
     const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& m_waylandDriver;
 
     glm::dvec2 m_pos = {};
+    std::chrono::steady_clock::time_point m_lastHyprlandQuery = {};
 };
 } // namespace WallpaperEngine::Input::Drivers
 
-#endif /* ENABLE_WAYLAND */
+#endif /* ENABLE_WAYLAND */

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

@@ -54,8 +54,13 @@ CFBO::CFBO (
 	sLog.exception ("Framebuffers are not properly set");
     }
 
-    // clear the framebuffer
+    // Layer framebuffers must start transparent. The scene clear color is often opaque,
+    // and using it here makes empty layer areas render as solid rectangles.
+    GLfloat previousClearColor[4] = {};
+    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]);
 
     this->m_resolution = { textureWidth, textureHeight, realWidth, realHeight };
 
@@ -122,4 +127,8 @@ uint32_t CFBO::getSpritesheetFrames () const {
 
 float CFBO::getSpritesheetDuration () const {
     return 0.0f; // FBOs don't have spritesheets
-}
+}
+
+void CFBO::incrementUsageCount () const { }
+void CFBO::decrementUsageCount () const { }
+void CFBO::update () const { }

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

@@ -34,6 +34,10 @@ public:
     [[nodiscard]] uint32_t getSpritesheetFrames () const override;
     [[nodiscard]] float getSpritesheetDuration () const override;
 
+    void incrementUsageCount () const override;
+    void decrementUsageCount () const override;
+    void update () const override;
+
 private:
     GLuint m_framebuffer = GL_NONE;
     GLuint m_depthbuffer = GL_NONE;

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

@@ -9,8 +9,12 @@ using namespace WallpaperEngine::Render::Wallpapers;
 CObject::CObject (Wallpapers::CScene& scene, const Object& object) :
     Helpers::ContextAware (scene), m_scene (scene), m_object (object) { }
 
+void CObject::render () { }
+
 Wallpapers::CScene& CObject::getScene () const { return this->m_scene; }
 
 const AssetLocator& CObject::getAssetLocator () const { return this->getScene ().getAssetLocator (); }
 
-int CObject::getId () const { return this->m_object.id; }
+int CObject::getId () const { return this->m_object.id; }
+
+const Object& CObject::getObject () const { return this->m_object; }

+ 5 - 6
src/WallpaperEngine/Render/CObject.h

@@ -31,16 +31,15 @@ public:
 
     template <class T> [[nodiscard]] bool is () const { return typeid (*this) == typeid (T); }
 
-    virtual void render () = 0;
+    CObject (Wallpapers::CScene& scene, const Object& object);
+    virtual ~CObject () override = default;
+
+    virtual void render ();
 
     [[nodiscard]] Wallpapers::CScene& getScene () const;
     [[nodiscard]] const AssetLocator& getAssetLocator () const;
     [[nodiscard]] int getId () const;
-
-    virtual ~CObject () override = default;
-
-protected:
-    CObject (Wallpapers::CScene& scene, const Object& object);
+    [[nodiscard]] const Object& getObject () const;
 
 private:
     Wallpapers::CScene& m_scene;

+ 57 - 10
src/WallpaperEngine/Render/CTexture.cpp

@@ -1,23 +1,50 @@
 #include "CTexture.h"
 #include "WallpaperEngine/Logging/Log.h"
 
-#include <cstring>
 #include <lz4.h>
-#include <string>
 
 #define STB_IMAGE_IMPLEMENTATION
+#include "RenderContext.h"
+
 #include <stb_image.h>
 
 using namespace WallpaperEngine::Render;
 
-CTexture::CTexture (TextureUniquePtr header) : m_header (std::move (header)) {
+CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
+    Helpers::ContextAware (context), m_header (std::move (header)) {
     // ensure the header is parsed
     this->setupResolution ();
     const GLint internalFormat = this->setupInternalFormat ();
 
+    // videos are a bit special, they only have one framebuffer, one mipmap
+    if (this->m_header->isVideoMp4 || this->m_header->flags & TextureFlags_Video) {
+	if (this->m_header->images.empty () || this->m_header->images.begin ()->second.empty ()) {
+	    sLog.exception ("Cannot load video texture, no mipmaps found");
+	}
+
+	// generate the texture and set it up to be used by the player
+	this->m_textureID = new GLuint[1];
+	glGenTextures (1, this->m_textureID);
+	this->setupOpenGLParameters (0);
+
+	const auto mipmap = *this->m_header->images.begin ()->second.begin ();
+
+	this->m_player = std::make_unique<GLPlayer> (
+	    this->getContext (), this->m_textureID[0],
+	    std::make_unique<MemoryStreamProtocol> (mipmap->uncompressedData.get (), mipmap->uncompressedSize),
+	    this->m_header->textureWidth, this->m_header->textureHeight
+	);
+	// setup texture video player
+	this->m_player->setMuted ();
+	this->m_player->setVolume (0.0f);
+	this->m_player->setUntimed ();
+	// texture is ready, nothing else to do
+	return;
+    }
+
     // allocate texture ids list
     this->m_textureID = new GLuint[this->m_header->imageCount];
-    // ask opengl for the correct amount of textures
+    // ask opengl for the correct amount of textures and framebuffers
     glGenTextures (this->m_header->imageCount, this->m_textureID);
 
     for (const auto& [index, mipmaps] : this->m_header->images) {
@@ -33,10 +60,6 @@ CTexture::CTexture (TextureUniquePtr header) : m_header (std::move (header)) {
 	    const uint32_t bufferSize = mipmap->uncompressedSize;
 	    GLenum textureFormat = GL_RGBA;
 
-	    if (this->m_header->isVideoMp4 || this->m_header->flags & TextureFlags_Video) {
-	        sLog.exception ("MP4 textures are not supported yet");
-	    }
-
 	    if (this->m_header->freeImageFormat != FIF_UNKNOWN) {
 		int fileChannels;
 
@@ -84,8 +107,13 @@ CTexture::CTexture (TextureUniquePtr header) : m_header (std::move (header)) {
 }
 
 CTexture::~CTexture () {
-    for (uint32_t i = 0; i < this->m_header->imageCount; i++) {
-        glDeleteTextures (1, &this->m_textureID[i]);
+    // first release the player to prevent using null references
+    this->m_player.reset ();
+
+    if (this->m_header->isVideoMp4 || this->m_header->flags & TextureFlags_Video) {
+	glDeleteTextures (1, this->m_textureID);
+    } else {
+	glDeleteTextures (this->m_header->imageCount, this->m_textureID);
     }
 
     delete[] this->m_textureID;
@@ -137,6 +165,7 @@ GLint CTexture::setupInternalFormat () const {
 }
 
 void CTexture::setupOpenGLParameters (const uint32_t textureID) const {
+    // TODO: LABEL ELEMENTS TOO
     // bind the texture to assign information to it
     glBindTexture (GL_TEXTURE_2D, this->m_textureID[textureID]);
 
@@ -216,3 +245,21 @@ uint32_t CTexture::getSpritesheetRows () const { return this->getHeader ().sprit
 uint32_t CTexture::getSpritesheetFrames () const { return this->getHeader ().spritesheetFrames; }
 
 float CTexture::getSpritesheetDuration () const { return this->getHeader ().spritesheetDuration; }
+
+void CTexture::incrementUsageCount () const {
+    if (this->m_player) {
+	this->m_player->incrementUsageCount ();
+    }
+}
+
+void CTexture::decrementUsageCount () const {
+    if (this->m_player) {
+	this->m_player->decrementUsageCount ();
+    }
+}
+
+void CTexture::update () const {
+    if (this->m_player) {
+	this->m_player->render ();
+    }
+}

+ 31 - 4
src/WallpaperEngine/Render/CTexture.h

@@ -1,22 +1,28 @@
 #pragma once
 
+#include "Helpers/ContextAware.h"
 #include "TextureProvider.h"
 #include "WallpaperEngine/Data/Assets/Texture.h"
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
 
 #include <GL/glew.h>
 #include <glm/vec4.hpp>
 #include <memory>
+#include <mpv/client.h>
+#include <mpv/render.h>
+#include <mpv/render_gl.h>
 #include <vector>
 
 namespace WallpaperEngine::Render {
+class RenderContext;
 using namespace WallpaperEngine::Data::Assets;
-
+using namespace WallpaperEngine::VideoPlayback::MPV;
 /**
  * A normal texture file in WallpaperEngine's format
  */
-class CTexture final : public TextureProvider {
+class CTexture final : public TextureProvider, public Helpers::ContextAware {
 public:
-    explicit CTexture (TextureUniquePtr header);
+    explicit CTexture (RenderContext& context, TextureUniquePtr header);
     ~CTexture () override;
 
     [[nodiscard]] GLuint getTextureID (uint32_t imageIndex) const override;
@@ -34,6 +40,25 @@ public:
     [[nodiscard]] uint32_t getSpritesheetFrames () const override;
     [[nodiscard]] float getSpritesheetDuration () const override;
 
+    /**
+     * Increments the usage count of the texture
+     *
+     * Directly controls playback for video CTextures, only started when at least one thing is using it
+     * Initializes mpv if needed and starts playback
+     */
+    void incrementUsageCount () const override;
+    /**
+     * Decrements the usage count of the texture
+     *
+     * Directly controls playback for video CTextures, only stopped when nothing is using it
+     * De-initializes mpv if needed
+     */
+    void decrementUsageCount () const override;
+    /**
+     * Some textures need to be updated
+     */
+    void update () const override;
+
 private:
     /**
      * @return The texture header
@@ -51,7 +76,7 @@ private:
     /**
      * Prepares openGL parameters for loading texture data
      */
-    void setupOpenGLParameters (const uint32_t textureID) const;
+    void setupOpenGLParameters (uint32_t textureID) const;
 
     /** The texture header */
     TextureUniquePtr m_header;
@@ -59,5 +84,7 @@ private:
     GLuint* m_textureID = nullptr;
     /** Resolution vector of the texture */
     glm::vec4 m_resolution {};
+    /** The video player in use */
+    GLPlayerUniquePtr m_player;
 };
 } // namespace WallpaperEngine::Assets

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

@@ -7,9 +7,6 @@
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 
-#include <glm/glm.hpp>
-#include <glm/gtc/type_ptr.hpp>
-
 using namespace WallpaperEngine::Render;
 
 CWallpaper::CWallpaper (
@@ -185,6 +182,12 @@ void CWallpaper::setupShaders () {
 
 void CWallpaper::setDestinationFramebuffer (GLuint framebuffer) { this->m_destFramebuffer = framebuffer; }
 
+void CWallpaper::setSpanInfo (const SpanInfo& spanInfo) { this->m_spanInfo = spanInfo; }
+
+const CWallpaper::SpanInfo* CWallpaper::getSpanInfo () const {
+    return this->m_spanInfo.has_value () ? &this->m_spanInfo.value () : nullptr;
+}
+
 void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
     // update UVs if something has changed, otherwise use old values
     if (this->m_state.hasChanged (viewport, vflip, this->getWidth (), this->getHeight ())) {
@@ -193,18 +196,76 @@ void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
     }
 }
 
-void CWallpaper::render (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) {
+    // 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);
+    const glm::ivec4 sceneViewport = this->m_spanInfo.has_value ()
+	? glm::ivec4 { 0, 0, this->m_spanInfo->totalBounds.z, this->m_spanInfo->totalBounds.w }
+	: viewport;
+
 #if !NDEBUG
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene");
 #endif /* !NDEBUG */
-    this->renderFrame (viewport);
+    if (needsSceneRender) {
+	this->renderFrame (sceneViewport);
+	this->m_lastRenderedFrame = currentFrame;
+    }
 #if !NDEBUG
     glPopDebugGroup ();
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene to output");
 #endif /* !NDEBUG */
-    // Update UVs coordinates according to scaling mode of this wallpaper
-    updateUVs (viewport, vflip);
-    auto [ustart, uend, vstart, vend] = this->m_state.getTextureUVs ();
+
+    float ustart, uend, vstart, vend;
+
+    if (this->m_spanInfo.has_value ()) {
+	// Span mode: treat bounding box as virtual viewport, scale wallpaper using
+	// the normal scaling rules (fill/fit/stretch/default), then slice per monitor.
+	const auto& span = this->m_spanInfo.value ();
+	const float spanW = static_cast<float> (span.totalBounds.z);
+	const float spanH = static_cast<float> (span.totalBounds.w);
+	const float spanX = static_cast<float> (span.totalBounds.x);
+	const float spanY = static_cast<float> (span.totalBounds.y);
+
+	// Compute base UVs for the wallpaper scaled to the bounding box
+	this->updateUVs (span.totalBounds, vflip);
+	auto [baseUstart, baseUend, baseVstart, baseVend] = this->m_state.getTextureUVs ();
+
+	// This viewport's relative position within the bounding box [0..1]
+	// Use logicalSize (same coordinate space as globalPosition and totalBounds)
+	const float relLeft = (static_cast<float> (globalPosition.x) - spanX) / spanW;
+	const float relRight = (static_cast<float> (globalPosition.x + logicalSize.x) - spanX) / spanW;
+	const float relTop = (static_cast<float> (globalPosition.y) - spanY) / spanH;
+	const float relBottom = (static_cast<float> (globalPosition.y + logicalSize.y) - spanY) / spanH;
+
+	// Interpolate within the base UVs to get this viewport's slice
+	const float baseURange = baseUend - baseUstart;
+	const float baseVRange = baseVend - baseVstart;
+
+	ustart = baseUstart + relLeft * baseURange;
+	uend = baseUstart + relRight * baseURange;
+	vstart = baseVstart + relTop * baseVRange;
+	vend = baseVstart + relBottom * baseVRange;
+
+	// 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, "]");
+	}
+    } else {
+	// Normal mode: compute UVs based on viewport dimensions and wallpaper resolution
+	updateUVs (viewport, vflip);
+	auto uvs = this->m_state.getTextureUVs ();
+	ustart = uvs.ustart;
+	uend = uvs.uend;
+	vstart = uvs.vstart;
+	vend = uvs.vend;
+    }
 
     const GLfloat texCoords[] = {
 	ustart, vstart, uend, vstart, ustart, vend, ustart, vend, uend, vstart, uend, vend,

+ 25 - 2
src/WallpaperEngine/Render/CWallpaper.h

@@ -2,6 +2,7 @@
 
 #include <GL/glew.h>
 #include <GLFW/glfw3.h>
+#include <optional>
 
 #include "WallpaperEngine/Audio/AudioContext.h"
 
@@ -36,6 +37,12 @@ class CWallpaper : public Helpers::ContextAware, public FBOProvider {
     friend class WallpaperEngine::Application::WallpaperApplication;
 
 public:
+    /** Information for span-mode rendering: one wallpaper across multiple viewports */
+    struct SpanInfo {
+	/** Bounding box of the entire span group (x, y, width, height) in global desktop coordinates */
+	glm::ivec4 totalBounds;
+    };
+
     template <class T> [[nodiscard]] const T* as () const {
 	if (is<T> ()) {
 	    return static_cast<const T*> (this);
@@ -59,7 +66,8 @@ public:
     /**
      * Performs a render pass of the wallpaper
      */
-    void render (const glm::ivec4& viewport, const bool vflip);
+    void render (const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition = {0, 0},
+	const glm::ivec2& logicalSize = {0, 0});
 
     /**
      * Pause the renderer
@@ -114,6 +122,16 @@ public:
      */
     void setDestinationFramebuffer (GLuint framebuffer);
 
+    /**
+     * Sets span info for this wallpaper, enabling span-mode rendering
+     */
+    void setSpanInfo (const SpanInfo& spanInfo);
+
+    /**
+     * @return The span info if set, or nullptr
+     */
+    [[nodiscard]] const SpanInfo* getSpanInfo () const;
+
     /**
      * @return The width of this wallpaper
      */
@@ -163,6 +181,8 @@ protected:
     /** The FBO used for scene output */
     std::shared_ptr<const CFBO> m_sceneFBO = nullptr;
 
+    GLuint m_vaoBuffer = GL_NONE;
+
 private:
     /** The texture used for the scene output */
     GLuint m_texCoordBuffer = GL_NONE;
@@ -172,7 +192,6 @@ private:
     GLint g_Texture0 = GL_NONE;
     GLint a_Position = GL_NONE;
     GLint a_TexCoord = GL_NONE;
-    GLuint m_vaoBuffer = GL_NONE;
     /** The framebuffer to draw the background to */
     GLuint m_destFramebuffer = GL_NONE;
     /** Setups OpenGL's shaders for this wallpaper backbuffer */
@@ -183,5 +202,9 @@ private:
     AudioContext& m_audioContext;
     /** Current Wallpaper state */
     WallpaperState m_state;
+    /** Span info for multi-monitor spanning (optional) */
+    std::optional<SpanInfo> m_spanInfo = std::nullopt;
+    /** Frame counter to avoid redundant renderFrame calls when shared across viewports */
+    uint32_t m_lastRenderedFrame = UINT32_MAX;
 };
 } // namespace WallpaperEngine::Render

+ 1 - 1
src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.cpp

@@ -185,4 +185,4 @@ __attribute__ ((constructor)) void registerGLFWOpenGLDriver () {
 	    return std::make_unique<GLFWOpenGLDriver> ("wallpaperengine", context, application);
 	}
     );
-}
+}

+ 4 - 5
src/WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.cpp

@@ -54,11 +54,10 @@ void* GLFWWindowOutput::getImageBuffer () const { return nullptr; }
 uint32_t GLFWWindowOutput::getImageBufferSize () const { return 0; }
 
 void GLFWWindowOutput::updateRender () const {
-    if (this->m_context.settings.render.mode != Application::ApplicationContext::NORMAL_WINDOW) {
-	return;
-    }
-
-    // take the size from the driver (default window size)
+    // Track the current framebuffer dimensions regardless of window mode so
+    // runtime resizes (EXPLICIT_WINDOW mode via resizeWindow, WM-initiated
+    // host window resize) re-stretch the scene across the new viewport
+    // instead of cropping the original render at the old size.
     this->m_fullWidth = this->m_driver.getFramebufferSize ().x;
     this->m_fullHeight = this->m_driver.getFramebufferSize ().y;
 

+ 5 - 0
src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <glm/vec2.hpp>
 #include <glm/vec4.hpp>
 #include <string>
 
@@ -11,6 +12,10 @@ public:
 
     glm::ivec4 viewport;
     std::string name;
+    /** Global position of this viewport in the combined desktop coordinate space */
+    glm::ivec2 globalPosition = {0, 0};
+    /** Logical (unscaled) size in the same coordinate space as globalPosition */
+    glm::ivec2 logicalSize = {0, 0};
 
     /** Whether this viewport is single in the framebuffer or shares space with more viewports */
     bool single;

+ 81 - 5
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp

@@ -6,6 +6,7 @@
 #define static
 extern "C" {
 #include "wlr-layer-shell-unstable-v1-protocol.h"
+#include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 }
 #undef class
@@ -18,6 +19,7 @@ using namespace WallpaperEngine::Render::Drivers::Output;
 static void handleLSConfigure (void* data, zwlr_layer_surface_v1* surface, uint32_t serial, uint32_t w, uint32_t h) {
     const auto viewport = static_cast<WaylandOutputViewport*> (data);
     viewport->size = { w, h };
+    viewport->logicalSize = { w, h };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
     viewport->resize ();
 
@@ -34,13 +36,18 @@ static void geometry (
     void* data, wl_output* output, int32_t x, int32_t y, int32_t width_mm, int32_t height_mm, int32_t subpixel,
     const char* make, const char* model, int32_t transform
 ) {
-    // ignored
+    const auto viewport = static_cast<WaylandOutputViewport*> (data);
+    // only use geometry position as fallback if xdg-output hasn't provided one
+    if (!viewport->hasXdgLogicalPosition) {
+	viewport->globalPosition = { x, y };
+    }
+    sLog.debug ("SPAN DEBUG geometry: output '", viewport->name, "' position=(", x, ",", y, ") transform=", transform);
 }
 
 static void mode (void* data, wl_output* output, uint32_t flags, int32_t width, int32_t height, int32_t refresh) {
     const auto viewport = static_cast<WaylandOutputViewport*> (data);
 
-    // update viewport size too
+    // update viewport size (physical pixels; logicalSize comes from xdg-output or layer shell configure)
     viewport->size = { width, height };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
 
@@ -105,6 +112,41 @@ constexpr struct zwlr_layer_surface_v1_listener layerSurfaceListener = {
     .closed = handleLSClosed,
 };
 
+static void xdgOutputLogicalPosition (void* data, struct zxdg_output_v1* xdg_output, int32_t x, int32_t y) {
+    const auto viewport = static_cast<WaylandOutputViewport*> (data);
+    viewport->globalPosition = { x, y };
+    viewport->hasXdgLogicalPosition = true;
+    sLog.debug ("SPAN DEBUG xdg-output logical_position: '", viewport->name, "' position=(", x, ",", y, ")");
+}
+
+static void xdgOutputLogicalSize (void* data, struct zxdg_output_v1* xdg_output, int32_t width, int32_t height) {
+    const auto viewport = static_cast<WaylandOutputViewport*> (data);
+    viewport->logicalSize = { width, height };
+    if (viewport->initialized) {
+	viewport->getDriver ()->getOutput ().reset ();
+    }
+}
+
+static void xdgOutputDone (void* data, struct zxdg_output_v1* xdg_output) {
+    // deprecated since xdg-output v3, compositor uses wl_output.done instead
+}
+
+static void xdgOutputName (void* data, struct zxdg_output_v1* xdg_output, const char* name) {
+    // already handled by wl_output.name
+}
+
+static void xdgOutputDescription (void* data, struct zxdg_output_v1* xdg_output, const char* description) {
+    // ignored
+}
+
+constexpr struct zxdg_output_v1_listener xdgOutputListener = {
+    .logical_position = xdgOutputLogicalPosition,
+    .logical_size = xdgOutputLogicalSize,
+    .done = xdgOutputDone,
+    .name = xdgOutputName,
+    .description = xdgOutputDescription,
+};
+
 WaylandOutputViewport::WaylandOutputViewport (
     WaylandOpenGLDriver* driver, uint32_t waylandName, struct wl_registry* registry
 ) : OutputViewport ({ 0, 0, 0, 0 }, "", true), size ({ 0, 0 }), waylandName (waylandName), m_driver (driver) {
@@ -113,10 +155,33 @@ WaylandOutputViewport::WaylandOutputViewport (
     wl_output_add_listener (output, &outputListener, this);
 }
 
+void WaylandOutputViewport::setupXdgOutput (zxdg_output_manager_v1* manager) {
+    this->xdgOutput = zxdg_output_manager_v1_get_xdg_output (manager, this->output);
+    zxdg_output_v1_add_listener (this->xdgOutput, &xdgOutputListener, this);
+}
+
 void WaylandOutputViewport::setupLS () {
     surface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
+
+    zwlr_layer_shell_v1_layer wlrLayer;
+    switch (m_driver->getApp ().getContext ().settings.render.wayland.layer) {
+	case WallpaperEngine::Application::ApplicationContext::WAYLAND_LAYER_BACKGROUND:
+	    wlrLayer = ZWLR_LAYER_SHELL_V1_LAYER_BACKGROUND;
+	    break;
+	case WallpaperEngine::Application::ApplicationContext::WAYLAND_LAYER_TOP:
+	    wlrLayer = ZWLR_LAYER_SHELL_V1_LAYER_TOP;
+	    break;
+	case WallpaperEngine::Application::ApplicationContext::WAYLAND_LAYER_OVERLAY:
+	    wlrLayer = ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY;
+	    break;
+	case WallpaperEngine::Application::ApplicationContext::WAYLAND_LAYER_BOTTOM:
+	default:
+	    wlrLayer = ZWLR_LAYER_SHELL_V1_LAYER_BOTTOM;
+	    break;
+    }
+
     layerSurface = zwlr_layer_shell_v1_get_layer_surface (
-	m_driver->getWaylandContext ()->layerShell, surface, output, ZWLR_LAYER_SHELL_V1_LAYER_BACKGROUND,
+	m_driver->getWaylandContext ()->layerShell, surface, output, wlrLayer,
 	"linux-wallpaperengine"
     );
 
@@ -125,7 +190,17 @@ void WaylandOutputViewport::setupLS () {
     }
 
     wl_region* region = wl_compositor_create_region (m_driver->getWaylandContext ()->compositor);
-    wl_region_add (region, 0, 0, INT32_MAX, INT32_MAX);
+    if (m_driver->getApp ().getContext ().settings.mouse.enabled) {
+	wl_region_add (region, 0, 0, INT32_MAX, INT32_MAX);
+    }
+
+    // Mark the surface as fully opaque so the compositor can skip rendering
+    // anything below it and avoid alpha-blending. Wallpapers are by definition
+    // the bottommost visible content, so this is always a win.
+    wl_region* opaqueRegion = wl_compositor_create_region (m_driver->getWaylandContext ()->compositor);
+    wl_region_add (opaqueRegion, 0, 0, INT32_MAX, INT32_MAX);
+    wl_surface_set_opaque_region (surface, opaqueRegion);
+    wl_region_destroy (opaqueRegion);
 
     zwlr_layer_surface_v1_set_size (layerSurface, 0, 0);
     zwlr_layer_surface_v1_set_anchor (
@@ -137,6 +212,7 @@ void WaylandOutputViewport::setupLS () {
     zwlr_layer_surface_v1_add_listener (layerSurface, &layerSurfaceListener, this);
     zwlr_layer_surface_v1_set_exclusive_zone (layerSurface, -1);
     wl_surface_set_input_region (surface, region);
+    wl_region_destroy (region);
     wl_surface_commit (surface);
     wl_display_roundtrip (m_driver->getWaylandContext ()->display);
 
@@ -205,4 +281,4 @@ void WaylandOutputViewport::resize () {
     wl_egl_window_resize (this->eglWindow, this->size.x * this->scale, this->size.y * this->scale, 0, 0);
 
     this->getDriver ()->getOutput ().reset ();
-}
+}

+ 7 - 1
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h

@@ -16,6 +16,8 @@
 
 struct zwlr_layer_shell_v1;
 struct zwlr_layer_surface_v1;
+struct zxdg_output_v1;
+struct zxdg_output_manager_v1;
 
 namespace WallpaperEngine::Render::Drivers {
 class WaylandOpenGLDriver;
@@ -34,6 +36,7 @@ namespace Output {
 
 	wl_output* output = nullptr;
 	glm::ivec2 size = {};
+	glm::ivec2 position = {};
 	uint32_t waylandName;
 	int scale = 1;
 	bool initialized = false;
@@ -50,8 +53,11 @@ namespace Output {
 	wl_cursor* pointer = nullptr;
 	wl_surface* cursorSurface = nullptr;
 	bool callbackInitialized = false;
+	bool hasXdgLogicalPosition = false;
+	zxdg_output_v1* xdgOutput = nullptr;
 
 	void setupLS ();
+	void setupXdgOutput (zxdg_output_manager_v1* manager);
 
 	/**
 	 * Activates output's context for drawing
@@ -73,4 +79,4 @@ namespace Output {
     };
 } // namespace Output
 } // namespace WallpaperEngine::Render::Drivers
-#endif /* ENABLE_WAYLAND */
+#endif /* ENABLE_WAYLAND */

+ 54 - 15
src/WallpaperEngine/Render/Drivers/Output/X11Output.cpp

@@ -52,11 +52,12 @@ void X11Output::reset () {
 }
 
 void X11Output::free () {
-    // go through all the viewports and free them
-    for (const auto& [screen, viewport] : this->m_viewports) {
-	delete viewport;
+    // delete owned viewport objects (m_viewports holds non-owning aliases)
+    for (const auto& screen : this->m_screens) {
+	delete screen;
     }
 
+    this->m_screens.clear ();
     this->m_viewports.clear ();
 
     // free all the resources we've got
@@ -101,6 +102,13 @@ void X11Output::loadScreenInfo () {
 	return;
     }
 
+    discoverOutputs (screenResources);
+    XRRFreeScreenResources (screenResources);
+    validateOutputs ();
+    initX11Background ();
+}
+
+void X11Output::discoverOutputs (XRRScreenResources* screenResources) {
     for (int i = 0; i < screenResources->noutput; i++) {
 	const XRROutputInfo* info = XRRGetOutputInfo (this->m_display, screenResources, screenResources->outputs[i]);
 
@@ -116,37 +124,66 @@ void X11Output::loadScreenInfo () {
 	    continue;
 	}
 
-	// add the screen to the list of screens
-	this->m_screens.push_back (new GLFWOutputViewport { { crtc->x, crtc->y, crtc->width, crtc->height },
-							    info->name });
+	// check if this screen is part of a span group
+	bool inSpanGroup = false;
+	for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	    for (const auto& screen : spanGroup.screens) {
+		if (screen == info->name) {
+		    inSpanGroup = true;
+		    break;
+		}
+	    }
+	    if (inSpanGroup) {
+		break;
+	    }
+	}
 
 	// only keep info of registered screens
-	if (this->m_context.settings.general.screenBackgrounds.find (info->name)
-	    != this->m_context.settings.general.screenBackgrounds.end ()) {
+	if (inSpanGroup
+	    || this->m_context.settings.general.screenBackgrounds.find (info->name)
+		!= this->m_context.settings.general.screenBackgrounds.end ()) {
 	    sLog.out (
 		"Found requested screen: ", info->name, " -> ", crtc->x, "x", crtc->y, ":", crtc->width, "x",
 		crtc->height
 	    );
 
-	    this->m_viewports[info->name]
-		= new GLFWOutputViewport { { crtc->x, crtc->y, crtc->width, crtc->height }, info->name };
+	    auto* vp = new GLFWOutputViewport { { crtc->x, crtc->y, crtc->width, crtc->height }, info->name };
+	    vp->globalPosition = { crtc->x, crtc->y };
+	    vp->logicalSize = { crtc->width, crtc->height };
+	    this->m_screens.push_back (vp);
+	    this->m_viewports[info->name] = vp;
 	}
 
 	XRRFreeCrtcInfo (crtc);
     }
+}
 
-    XRRFreeScreenResources (screenResources);
-
+void X11Output::validateOutputs () const {
     bool any = false;
 
     for (const auto& o : this->m_screens) {
 	const auto cur = this->m_context.settings.general.screenBackgrounds.find (o->name);
 
-	if (cur == this->m_context.settings.general.screenBackgrounds.end ()) {
-	    continue;
+	if (cur != this->m_context.settings.general.screenBackgrounds.end ()) {
+	    any = true;
+	    break;
 	}
 
-	any = true;
+	// also check span groups
+	for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	    for (const auto& screen : spanGroup.screens) {
+		if (screen == o->name) {
+		    any = true;
+		    break;
+		}
+	    }
+	    if (any) {
+		break;
+	    }
+	}
+	if (any) {
+	    break;
+	}
     }
 
     if (!any) {
@@ -165,7 +202,9 @@ void X11Output::loadScreenInfo () {
 
 	sLog.exception ("Cannot continue...");
     }
+}
 
+void X11Output::initX11Background () {
     // create pixmap so we can draw things in there
     this->m_pixmap = XCreatePixmap (this->m_display, this->m_root, this->m_fullWidth, this->m_fullHeight, 24);
     this->m_gc = XCreateGC (this->m_display, this->m_pixmap, 0, nullptr);

+ 4 - 0
src/WallpaperEngine/Render/Drivers/Output/X11Output.h

@@ -5,6 +5,7 @@
 #include <string>
 
 #include <X11/Xlib.h>
+#include <X11/extensions/Xrandr.h>
 
 #include "Output.h"
 #include "WallpaperEngine/Render/Drivers/VideoDriver.h"
@@ -26,6 +27,9 @@ public:
 
 private:
     void loadScreenInfo ();
+    void discoverOutputs (XRRScreenResources* screenResources);
+    void validateOutputs () const;
+    void initX11Background ();
     void free ();
 
     Display* m_display = nullptr;

+ 1 - 0
src/WallpaperEngine/Render/Drivers/VideoFactories.h

@@ -16,6 +16,7 @@ public:
     using FullscreenDetectorConstructionFunc
 	= std::function<std::unique_ptr<Detectors::FullScreenDetector> (ApplicationContext&, VideoDriver&)>;
     VideoFactories ();
+    ~VideoFactories () = default;
 
     static VideoFactories& get ();
 

+ 73 - 5
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp

@@ -8,6 +8,7 @@
 #define static
 extern "C" {
 #include "wlr-layer-shell-unstable-v1-protocol.h"
+#include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 #include <linux/input-event-codes.h>
 }
@@ -15,6 +16,7 @@ extern "C" {
 #undef namespace
 #undef static
 
+#include <algorithm>
 #include <string.h>
 #include <unistd.h>
 
@@ -119,6 +121,10 @@ handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const cha
 	driver->getWaylandContext ()->seat
 	    = static_cast<wl_seat*> (wl_registry_bind (registry, name, &wl_seat_interface, 1));
 	wl_seat_add_listener (driver->getWaylandContext ()->seat, &seatListener, driver);
+    } else if (strcmp (interface, zxdg_output_manager_v1_interface.name) == 0) {
+	driver->getWaylandContext ()->xdgOutputManager = static_cast<zxdg_output_manager_v1*> (
+	    wl_registry_bind (registry, name, &zxdg_output_manager_v1_interface, std::min (version, 3u))
+	);
     }
 }
 
@@ -240,6 +246,11 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 	zwlr_layer_surface_v1_destroy (viewport->layerSurface);
     }
 
+    if (viewport->xdgOutput) {
+	zxdg_output_v1_destroy (viewport->xdgOutput);
+	viewport->xdgOutput = nullptr;
+    }
+
     if (viewport->surface) {
 	wl_surface_destroy (viewport->surface);
     }
@@ -257,6 +268,13 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app) :
     VideoDriver (app, m_mouseInput), m_output (context, *this), m_requestedExit (false), m_frameCounter (0),
     m_context (context), m_mouseInput (*this) {
+    initWaylandRegistry ();
+    initEGL ();
+    setupOutputLayerSurfaces ();
+    initGLEW ();
+}
+
+void WaylandOpenGLDriver::initWaylandRegistry () {
     m_waylandContext.display = wl_display_connect (nullptr);
 
     if (!m_waylandContext.display) {
@@ -274,12 +292,39 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
 	sLog.exception ("Failed to bind to required interfaces");
     }
 
-    initEGL ();
+    // If xdg-output-manager is available, use it to get logical output positions
+    if (m_waylandContext.xdgOutputManager) {
+	for (const auto& o : this->m_screens) {
+	    o->setupXdgOutput (m_waylandContext.xdgOutputManager);
+	}
+	wl_display_roundtrip (m_waylandContext.display);
+    } else if (!m_context.settings.general.spanGroups.empty ()) {
+	sLog.error ("zxdg_output_manager_v1 is unavailable; screen-span positions will be incorrect.");
+    }
+}
 
+void WaylandOpenGLDriver::setupOutputLayerSurfaces () {
     bool any = false;
 
     for (const auto& o : this->m_screens) {
-	if (!context.settings.general.screenBackgrounds.contains (o->name)) {
+	bool shouldSetup = m_context.settings.general.screenBackgrounds.contains (o->name);
+
+	// also check if this screen is in any span group
+	if (!shouldSetup) {
+	    for (const auto& spanGroup : m_context.settings.general.spanGroups) {
+		for (const auto& screen : spanGroup.screens) {
+		    if (screen == o->name) {
+			shouldSetup = true;
+			break;
+		    }
+		}
+		if (shouldSetup) {
+		    break;
+		}
+	    }
+	}
+
+	if (!shouldSetup) {
 	    continue;
 	}
 
@@ -297,19 +342,42 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
 
 	sLog.error ("Requested: ");
 
-	for (const auto& o : context.settings.general.screenBackgrounds | std::views::keys) {
+	for (const auto& o : m_context.settings.general.screenBackgrounds | std::views::keys) {
 	    sLog.error ("  ", o);
 	}
 
+	for (const auto& spanGroup : m_context.settings.general.spanGroups) {
+	    for (const auto& screen : spanGroup.screens) {
+		sLog.error ("  ", screen, " (span group)");
+	    }
+	}
+
 	sLog.exception ("Cannot continue...");
     }
+}
 
+void WaylandOpenGLDriver::initGLEW () {
+    glewExperimental = GL_TRUE;
     if (const GLenum result = glewInit (); result != GLEW_OK) {
-	sLog.error ("Failed to initialize GLEW: ", glewGetErrorString (result));
+	if (result == GLEW_ERROR_NO_GLX_DISPLAY) {
+	    sLog.out ("Failed to initialize GLEW, but continuing with EGL context: No GLX display");
+	} else {
+	    const char* error = reinterpret_cast<const char*> (glewGetErrorString (result));
+	    sLog.error ("Failed to initialize GLEW: ", error ? error : "Unknown error");
+	    sLog.exception ("Cannot continue...");
+	}
     }
 }
 
 WaylandOpenGLDriver::~WaylandOpenGLDriver () {
+    // destroy xdg outputs
+    for (const auto& screen : this->m_screens) {
+	if (screen->xdgOutput) {
+	    zxdg_output_v1_destroy (screen->xdgOutput);
+	    screen->xdgOutput = nullptr;
+	}
+    }
+
     // stop EGL
     eglMakeCurrent (EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
 
@@ -409,4 +477,4 @@ __attribute__ ((constructor)) void registerWaylandOpenGL () {
 	    return std::make_unique<WaylandOpenGLDriver> (context, application);
 	}
     );
-}
+}

+ 5 - 0
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h

@@ -24,6 +24,7 @@ class WallpaperApplication;
 
 struct zwlr_layer_shell_v1;
 struct zwlr_layer_surface_v1;
+struct zxdg_output_manager_v1;
 
 namespace WallpaperEngine::Render::Drivers {
 using namespace WallpaperEngine::Application;
@@ -54,6 +55,7 @@ public:
 	wl_shm* shm = nullptr;
 	zwlr_layer_shell_v1* layerShell = nullptr;
 	wl_seat* seat = nullptr;
+	zxdg_output_manager_v1* xdgOutputManager = nullptr;
     };
 
     explicit WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
@@ -91,7 +93,10 @@ private:
     WaylandContext m_waylandContext = {};
     mutable bool m_requestedExit;
 
+    void initWaylandRegistry ();
+    void setupOutputLayerSurfaces ();
     void initEGL ();
+    void initGLEW ();
     void finishEGL () const;
 
     uint32_t m_frameCounter = 0;

+ 649 - 54
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -2,11 +2,21 @@
 
 #include "CRenderable.h"
 
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <iterator>
+#include <optional>
 #include <sstream>
 
+#include <glm/gtc/matrix_transform.hpp>
+
+#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/Logging/Log.h"
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render::Objects;
@@ -14,6 +24,152 @@ using namespace WallpaperEngine::Render::Objects::Effects;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Builders;
 
+namespace {
+glm::vec2 rotateVec2 (const glm::vec2& value, float angle) {
+    const float cosAngle = std::cos (angle);
+    const float sinAngle = std::sin (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;
+}
+
+std::optional<glm::vec3> findMagentaCompositeTint (const Image& image, const std::vector<int>& skippedEffectIds) {
+    for (const auto& effect : image.effects) {
+	if (std::find (skippedEffectIds.begin (), skippedEffectIds.end (), static_cast<int> (effect->id))
+	    != skippedEffectIds.end ()) {
+	    continue;
+	}
+	if (!effect->visible->value->getBool ()) {
+	    continue;
+	}
+
+	for (const auto& passOverride : effect->passOverrides) {
+	    const auto compositeCombo = passOverride->combos.find ("COMPOSITE");
+	    if (compositeCombo == passOverride->combos.end () || compositeCombo->second != 2) {
+		continue;
+	    }
+
+	    const auto compositeColor = passOverride->constants.find ("compositecolor");
+	    if (compositeColor == passOverride->constants.end () || compositeColor->second == nullptr
+		|| compositeColor->second->value == nullptr) {
+		continue;
+	    }
+
+	    const auto tint = compositeColor->second->value->getVec3 ();
+	    if (isMagentaNeonTint (tint)) {
+		return tint;
+	    }
+	}
+    }
+
+    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;
+    uint32_t indexBytes = 0;
+};
+
+std::optional<PuppetMeshBlock> findPuppetMeshBlock (
+    const std::vector<char>& data, 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));
+	const size_t verticesOffset = offset + meshHeaderSize;
+	const size_t indexLengthOffset = verticesOffset + candidateVertexBytes;
+
+	if (candidateVertexBytes == 0 || candidateVertexBytes % vertexStride != 0
+	    || indexLengthOffset + sizeof (uint32_t) > mdlsOffset) {
+	    continue;
+	}
+
+	const uint32_t candidateIndexBytes = readU32 (data, indexLengthOffset);
+	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 std::nullopt;
+}
+}
+
+CImage::ResolvedTransform CImage::resolveTransform (const Object& object, const int depth) const {
+    constexpr int kMaxParentDepth = 32;
+    glm::vec3 origin = object.origin->value->getVec3 ();
+    glm::vec3 scale = glm::vec3 (1.0f);
+    float angle = 0.0f;
+
+    if (object.is<Image> ()) {
+	const auto* image = object.as<Image> ();
+	scale = image->scale->value->getVec3 ();
+	angle = image->angles->value->getVec3 ().z;
+    } else if (object.is<Text> ()) {
+	const auto* text = object.as<Text> ();
+	scale = text->scale->value->getVec3 ();
+    } else {
+	scale = object.groupScale->value->getVec3 ();
+	angle = object.groupAngles->value->getVec3 ().z;
+    }
+
+    if (!object.parent.has_value ()) {
+	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 };
+    }
+
+    const auto* parentObject = this->getScene ().getObject (object.parent.value ());
+    if (parentObject == nullptr) {
+	return { origin, scale, angle };
+    }
+
+    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;
+
+    return { origin, scale, angle };
+}
+
 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 (),
@@ -25,10 +181,10 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     auto scene_width = static_cast<float> (scene.getWidth ());
     auto scene_height = static_cast<float> (scene.getHeight ());
 
-    // TODO: MAKE USE OF THE USER PROPERTIES POINTER HERE TOO! SO EVERYTHING IS UPDATED ACCORDINGLY
-    glm::vec3 origin = this->getImage ().origin->value->getVec3 ();
+    const auto transform = this->resolveTransform (this->getImage ());
+    glm::vec3 origin = transform.origin;
     glm::vec2 size = this->getSize ();
-    glm::vec3 scale = this->getImage ().scale->value->getVec3 ();
+    glm::vec3 scale = transform.scale;
 
     this->detectTexture ();
 
@@ -65,6 +221,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 
 	// TODO: CHANGE ALIGNMENT TOO?
     }
+    this->m_size = size;
 
     glm::vec2 scaledSize = size * glm::vec2 (scale);
 
@@ -154,10 +311,16 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     GLfloat realY = 0.0;
 
     if (this->getImage ().model->passthrough) {
-	x = -((this->m_pos.x + (scene_width / 2)) / size.x);
-	y = -((this->m_pos.w + (scene_height / 2)) / size.y);
-	height = (this->m_pos.y + (scene_height / 2)) / size.y;
-	width = (this->m_pos.z + (scene_width / 2)) / size.x;
+	// Passthrough shaders fill the destination FBO from texcoords and sample the scene using positions.
+	// Keep the destination quad full-screen in local FBO space, but pass scene-space positions through.
+	x = 0.0f;
+	y = 0.0f;
+	width = 1.0f;
+	height = 1.0f;
+	realX = this->m_pos.x;
+	realY = this->m_pos.w;
+	realWidth = this->m_pos.z;
+	realHeight = this->m_pos.y;
 
 	if (this->getImage ().model->fullscreen) {
 	    realX = -1.0;
@@ -199,18 +362,35 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texcoordPass);
     glBufferData (GL_ARRAY_BUFFER, sizeof (texcoordPass), texcoordPass, GL_STATIC_DRAW);
 
+    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_modelViewProjectionScreen
 	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt ();
 
-    this->m_modelViewProjectionScreenInverse = glm::inverse (this->m_modelViewProjectionScreen);
-
-    this->m_modelViewProjectionCopy = glm::ortho<float> (0.0, size.x, 0.0, size.y);
+    if (this->getImage ().model->passthrough) {
+	this->m_modelViewProjectionCopy = this->m_modelViewProjectionScreen;
+    } else {
+	this->m_modelViewProjectionCopy = glm::ortho<float> (0.0, size.x, 0.0, size.y);
+    }
     this->m_modelViewProjectionCopyInverse = glm::inverse (this->m_modelViewProjectionCopy);
     this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
     this->m_viewProjectionMatrix = glm::mat4 (1.0);
+
+    // ensure the input texture is marked as used
+    // this makes video playback start if it's not already
+    this->m_texture->incrementUsageCount ();
 }
 
 CImage::~CImage () {
+    this->m_texture->decrementUsageCount ();
+
     // delete passes first as they depend on the image's data
     for (auto* pass : this->m_passes) {
 	delete pass;
@@ -224,6 +404,177 @@ CImage::~CImage () {
     glDeleteBuffers (1, &this->m_passSpacePosition);
     glDeleteBuffers (1, &this->m_texcoordCopy);
     glDeleteBuffers (1, &this->m_texcoordPass);
+    if (this->m_puppetSpacePosition != GL_NONE) {
+	glDeleteBuffers (1, &this->m_puppetSpacePosition);
+    }
+    if (this->m_puppetTexCoord != GL_NONE) {
+	glDeleteBuffers (1, &this->m_puppetTexCoord);
+    }
+    if (this->m_puppetIndices != GL_NONE) {
+	glDeleteBuffers (1, &this->m_puppetIndices);
+    }
+}
+
+bool CImage::loadPuppetMesh (const glm::vec2& size) {
+    if (!this->getImage ().model->puppet.has_value ()) {
+	return false;
+    }
+
+    try {
+	const auto stream = this->getScene ().getScene ().project.assetLocator->read (*this->getImage ().model->puppet);
+	std::vector<char> data { std::istreambuf_iterator<char> (*stream), std::istreambuf_iterator<char> () };
+
+	constexpr size_t markerSize = 9;
+	constexpr size_t meshHeaderSize = sizeof (uint32_t) * 2;
+	constexpr size_t vertexStride = 80;
+	constexpr size_t positionOffset = 0;
+	constexpr size_t uvOffset = 72;
+
+	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;
+	}
+
+	const size_t mdlsOffset = [&data] () -> size_t {
+	    for (size_t offset = markerSize; offset + strlen ("MDLS") < data.size (); offset++) {
+		if (std::memcmp (data.data () + offset, "MDLS", strlen ("MDLS")) == 0) {
+		    return offset;
+		}
+	    }
+	    return data.size ();
+	} ();
+
+	const auto meshBlock = findPuppetMeshBlock (data, 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;
+	}
+
+	const size_t vertexCount = meshBlock->vertexBytes / vertexStride;
+	const size_t verticesOffset = meshBlock->headerOffset + meshHeaderSize;
+	const size_t indicesOffset = verticesOffset + meshBlock->vertexBytes + sizeof (uint32_t);
+	const size_t indexCount = meshBlock->indexBytes / sizeof (uint16_t);
+	std::vector<GLfloat> texcoords;
+	std::vector<GLushort> indices;
+
+	this->m_puppetRawPositions.clear ();
+	this->m_puppetRawPositions.reserve (vertexCount * 3);
+	texcoords.reserve (vertexCount * 2);
+	indices.reserve (indexCount);
+
+	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));
+
+	    this->m_puppetRawPositions.push_back (x);
+	    this->m_puppetRawPositions.push_back (y);
+	    this->m_puppetRawPositions.push_back (z);
+	    texcoords.push_back (u);
+	    texcoords.push_back (v);
+	}
+
+	for (size_t index = 0; index < indexCount; index++) {
+	    uint16_t value = 0;
+	    std::memcpy (&value, data.data () + indicesOffset + index * sizeof (uint16_t), sizeof (value));
+	    if (value >= vertexCount) {
+		sLog.error ("Invalid puppet mesh index ", value, " in ", *this->getImage ().model->puppet);
+		return false;
+	    }
+	    indices.push_back (value);
+	}
+
+	this->updatePuppetPositionBuffer (size);
+
+	glGenBuffers (1, &this->m_puppetTexCoord);
+	glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetTexCoord);
+	glBufferData (GL_ARRAY_BUFFER, texcoords.size () * sizeof (GLfloat), texcoords.data (), GL_STATIC_DRAW);
+
+	glGenBuffers (1, &this->m_puppetIndices);
+	glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
+	glBufferData (GL_ELEMENT_ARRAY_BUFFER, indices.size () * sizeof (GLushort), indices.data (), GL_STATIC_DRAW);
+
+	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
+	);
+
+	return true;
+    } catch (const std::exception& ex) {
+	sLog.error ("Could not load puppet mesh ", *this->getImage ().model->puppet, ": ", ex.what ());
+	return false;
+    }
+}
+
+void CImage::updatePuppetPositionBuffer (const glm::vec2& size) {
+    if (this->m_puppetRawPositions.empty ()) {
+	return;
+    }
+
+    std::vector<GLfloat> positions;
+    positions.reserve (this->m_puppetRawPositions.size ());
+    for (size_t index = 0; index + 2 < this->m_puppetRawPositions.size (); index += 3) {
+	positions.push_back (size.x / 2.0f + this->m_puppetRawPositions[index]);
+	positions.push_back (size.y / 2.0f - this->m_puppetRawPositions[index + 1]);
+	positions.push_back (this->m_puppetRawPositions[index + 2]);
+    }
+
+    if (this->m_puppetSpacePosition == GL_NONE) {
+	glGenBuffers (1, &this->m_puppetSpacePosition);
+    }
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
+    glBufferData (GL_ARRAY_BUFFER, positions.size () * sizeof (GLfloat), positions.data (), GL_DYNAMIC_DRAW);
+}
+
+void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
+    pass->setGeometryCallback (
+	[this, pass] () {
+	    const GLint position = glGetAttribLocation (pass->getProgramID (), "a_Position");
+	    const GLint texCoord = glGetAttribLocation (pass->getProgramID (), "a_TexCoord");
+
+	    if (position >= 0) {
+		glEnableVertexAttribArray (position);
+		glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
+		glVertexAttribPointer (position, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+	    }
+
+	    if (texCoord >= 0) {
+		glEnableVertexAttribArray (texCoord);
+		glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetTexCoord);
+		glVertexAttribPointer (texCoord, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
+	    }
+	},
+	[this] () {
+	    GLint currentFramebuffer = 0;
+	    glGetIntegerv (GL_DRAW_FRAMEBUFFER_BINDING, &currentFramebuffer);
+	    if (currentFramebuffer != static_cast<GLint> (this->getScene ().getFBO ()->getFramebuffer ())) {
+		GLfloat previousClearColor[4] = {};
+		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]);
+	    }
+	    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
+	    glDrawElements (GL_TRIANGLES, this->m_puppetIndexCount, GL_UNSIGNED_SHORT, nullptr);
+	},
+	[pass] () {
+	    const GLint position = glGetAttribLocation (pass->getProgramID (), "a_Position");
+	    const GLint texCoord = glGetAttribLocation (pass->getProgramID (), "a_TexCoord");
+
+	    if (position >= 0) {
+		glDisableVertexAttribArray (position);
+	    }
+
+	    if (texCoord >= 0) {
+		glDisableVertexAttribArray (texCoord);
+	    }
+	}
+    );
 }
 
 void CImage::setup () {
@@ -235,11 +586,28 @@ void CImage::setup () {
     // TODO: CHECK ORDER OF THINGS, 2419444134'S ID 27 DEPENDS ON 104'S COMPOSITE_A WHEN OUR LAST RENDER IS ON
     // COMPOSITE_B
     // TODO: SUPPORT PASSTHROUGH (IT'S A SHADER)
-    // passthrough images without effects are bad, do not draw them
-    if (this->m_image.model->passthrough && this->m_image.effects.empty ()) {
-	return;
+    if (this->m_image.model->passthrough) {
+		// 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;
+			}
+		}
+
+		if (allEffectsInvisible) {
+			return;
+		}
     }
 
+    const auto& debug = this->getScene ().getContext ().getApp ().getContext ().settings.render.debug;
+
     // copy pass to the composite layer
     for (const auto& cur : this->getImage ().model->material->passes) {
 	this->m_passes.push_back (
@@ -248,9 +616,14 @@ void CImage::setup () {
     }
 
     // prepare the passes list
-    if (!this->getImage ().effects.empty ()) {
+    if (!debug.baseOnly && !this->getImage ().effects.empty ()) {
 	// generate the effects used by this material
 	for (const auto& cur : this->m_image.effects) {
+	    if (std::find (debug.skipEffects.begin (), debug.skipEffects.end (), static_cast<int> (cur->id))
+		!= debug.skipEffects.end ()) {
+		continue;
+	    }
+
 	    // do not add non-visible effects, this might need some adjustements tho as some effects might not be
 	    // visible but affect the output of the image...
 	    if (!cur->visible->value->getBool ()) {
@@ -331,14 +704,41 @@ void CImage::setup () {
 	}
     }
 
+    if (!debug.baseOnly) {
+	const auto magentaCompositeTint = findMagentaCompositeTint (this->m_image, debug.skipEffects);
+	if (magentaCompositeTint.has_value ()) {
+	    auto tintOverride = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
+		.id = -1,
+		.combos = {
+		    { "BLENDMODE", 30 },
+		},
+		.constants = {},
+		.textures = {},
+	    });
+	    tintOverride->constants.emplace ("color", makeStaticSetting (magentaCompositeTint.value ()));
+	    tintOverride->constants.emplace ("alpha", makeStaticSetting (1.0f));
+
+	    this->m_materials.compatibilityMaterials.emplace_back (
+		MaterialParser::load (this->getScene ().getScene ().project, "materials/effects/tint.json")
+	    );
+	    this->m_materials.compatibilityOverrides.emplace_back (std::move (tintOverride));
+
+	    this->m_passes.push_back (new CPass (
+		*this, std::make_shared<FBOProvider> (this),
+		**this->m_materials.compatibilityMaterials.back ()->passes.begin (),
+		*this->m_materials.compatibilityOverrides.back (), std::nullopt, std::nullopt
+	    ));
+	}
+    }
+
     // extra render pass if there's any blending to be done
-    if (this->m_image.colorBlendMode > 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 {
             .id = -1,
             .combos = {
-                {"BLENDMODE", this->m_image.colorBlendMode},
+                {"BLENDMODE", this->m_image.colorBlendMode->value->getInt()},
             },
             .constants = {},
             .textures = {},
@@ -374,6 +774,8 @@ void CImage::setupPasses () {
     auto cur = this->m_passes.begin ();
     auto end = this->m_passes.end ();
     bool first = true;
+    bool inTargetEffectSequence = false;
+    std::shared_ptr<const TextureProvider> effectInput = nullptr;
 
     for (; cur != end; ++cur) {
 	// TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
@@ -381,31 +783,28 @@ void CImage::setupPasses () {
 	// TODO: WHICH ONE IS THE LAST + A FEW OTHER THINGS
 	Effects::CPass* pass = *cur;
 	std::shared_ptr<const CFBO> prevDrawTo = drawTo;
-	GLuint spacePosition = (first) ? this->getCopySpacePosition () : this->getPassSpacePosition ();
-	const glm::mat4* projection = (first) ? &this->m_modelViewProjectionCopy : &this->m_modelViewProjectionPass;
+	bool writesToTarget = false;
+	const bool isFirstPass = first;
+	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* inverseProjection
-	    = (first) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
+	    = (isFirstPass) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
 	first = false;
 
+	if (isFirstPass && this->m_hasPuppetMesh) {
+	    pass->setBlendingMode (BlendingMode_Translucent);
+	    this->setupPuppetGeometryCallback (pass);
+	}
+
 	pass->setModelMatrix (&this->m_modelMatrix);
 	pass->setViewProjectionMatrix (&this->m_viewProjectionMatrix);
 
-	// set viewport and target texture if needed
-	if (pass->getTarget ().has_value ()) {
-	    // setup target texture
-	    std::string target = pass->getTarget ().value ();
-	    drawTo = pass->getFBOProvider ()->find (target);
-	    // spacePosition = this->getPassSpacePosition ();
-
-	    // not a local fbo, try to find a scene fbo with the same name
-	    if (drawTo == nullptr) {
-		// this one throws if no fbo was found
-		drawTo = this->getScene ().findFBO (target);
-	    }
-	}
+	writesToTarget = this->configurePassTarget (pass, drawTo, asInput, effectInput, inTargetEffectSequence);
 	// determine if it's the last element in the list as this is a screen-copy-like process
 	// TODO: PROPERLY CHECK IF THIS IS ALL THAT'S NEEDED
-	else if (std::next (cur) == end && this->getImage ().visible->value->getBool ()) {
+	if (!writesToTarget && this->shouldRenderFinalPass (std::next (cur) == end)) {
 	    // TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
 	    spacePosition = this->getSceneSpacePosition ();
 	    drawTo = this->getScene ().getFBO ();
@@ -415,20 +814,67 @@ void CImage::setupPasses () {
 
 	pass->setDestination (drawTo);
 	pass->setInput (asInput);
+	pass->setPreviousInput (inTargetEffectSequence ? effectInput : nullptr);
 	pass->setPosition (spacePosition);
 	pass->setTexCoord (texcoord);
 	pass->setModelViewProjectionMatrix (projection);
 	pass->setModelViewProjectionMatrixInverse (inverseProjection);
 
 	texcoord = this->getTexCoordPass ();
-	drawTo = prevDrawTo;
 
-	if (!pass->getTarget ().has_value ()) {
+	if (writesToTarget) {
+	    asInput = drawTo;
+	    drawTo = prevDrawTo;
+	} else {
+	    drawTo = prevDrawTo;
 	    this->pinpongFramebuffer (&drawTo, &asInput);
+	    inTargetEffectSequence = false;
+	    effectInput = nullptr;
 	}
     }
 }
 
+bool CImage::shouldRenderFinalPass (bool isLastPass) const {
+    if (!isLastPass || !this->getImage ().visible->value->getBool ()) {
+	return false;
+    }
+
+    const auto& debug = this->getScene ().getContext ().getApp ().getContext ().settings.render.debug;
+    return !(debug.noSolidFinal && this->getImage ().model->solidlayer);
+}
+
+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
+) {
+    if (!pass->getTarget ().has_value ()) {
+	return false;
+    }
+
+    const std::string target = pass->getTarget ().value ();
+    std::shared_ptr<const CFBO> resolved = pass->getFBOProvider ()->find (target);
+    if (resolved == nullptr) {
+	resolved = this->getScene ().findFBO (target);
+    }
+    if (resolved == nullptr) {
+	sLog.error (
+	    "Pass target FBO '", target, "' could not be resolved for object ", pass->getRenderable ().getId (),
+	    " shader=", pass->getPass ().shader
+	);
+	return false;
+    }
+
+    if (!inTargetEffectSequence) {
+	effectInput = asInput;
+	inTargetEffectSequence = true;
+    }
+    drawTo = resolved;
+    return true;
+}
+
 void CImage::pinpongFramebuffer (std::shared_ptr<const CFBO>* drawTo, std::shared_ptr<const TextureProvider>* asInput) {
     // temporarily store FBOs used
     std::shared_ptr<const CFBO> currentMainFBO = this->m_currentMainFBO;
@@ -448,18 +894,18 @@ void CImage::pinpongFramebuffer (std::shared_ptr<const CFBO>* drawTo, std::share
 
 void CImage::render () {
     // do not try to render something that did not initialize successfully
-    // non-visible materials do need to be rendered
     if (!this->m_initialized) {
 	return;
     }
 
+    if (!this->getImage ().visible->value->getBool ()) {
+	return;
+    }
+
     glColorMask (true, true, true, true);
 
-    // update the position if required
-    // TODO: There's more images that are not affected by parallax, autosize or fullscreen are not affected
-    if (this->getScene ().getScene ().camera.parallax.enabled && !this->getImage ().model->fullscreen) {
-	this->updateScreenSpacePosition ();
-    }
+    // Always update screen transform (handles rotation + parallax dynamically)
+    this->updateScreenSpacePosition ();
 
 #if !NDEBUG
     std::string str = "Rendering ";
@@ -489,7 +935,7 @@ void CImage::render () {
 #endif /* DEBUG */
 }
 
-const float& CImage::getBrightness () const { return this->m_image.brightness; }
+const float& CImage::getBrightness () const { return this->m_image.brightness->value->getFloat(); }
 
 const float& CImage::getUserAlpha () const { return this->m_image.alpha->value->getFloat (); }
 
@@ -501,22 +947,171 @@ const glm::vec4& CImage::getColor4 () const { return this->m_image.color->value-
 
 const glm::vec3& CImage::getCompositeColor () const { return this->m_image.color->value->getVec3 (); }
 
-void CImage::updateScreenSpacePosition () {
-    // do not perform any changes to the image based on the parallax if it was explicitly disabled
-    if (this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
-	return;
+glm::vec2 CImage::resolveGeometrySize (float sceneWidth, float sceneHeight, glm::vec3& origin) const {
+    glm::vec2 size = this->getSize ();
+
+    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 ()) {
+	size.x = static_cast<float> (this->getImage ().model->width.value ());
+	size.y = static_cast<float> (this->getImage ().model->height.value ());
     }
 
-    const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
-    const glm::vec2 depth = this->getImage ().parallaxDepth;
-    const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
+    if (this->getImage ().model->fullscreen) {
+	size = { sceneWidth, sceneHeight };
+	origin = { sceneWidth / 2.0f, sceneHeight / 2.0f, 0.0f };
+    }
 
-    float x = (depth.x + parallaxAmount) * displacement->x * this->getSize ().x;
-    float y = (depth.y + parallaxAmount) * displacement->y * this->getSize ().x;
+    return size;
+}
 
-    this->m_modelViewProjectionScreen = glm::translate (
-	this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt (), { x, y, 0.0f }
-    );
+void CImage::updateScenePosition (
+    const glm::vec3& origin, const glm::vec2& size, const glm::vec3& scale, float sceneWidth, float sceneHeight
+) {
+    const glm::vec2 scaledSize = size * glm::vec2 (scale);
+    this->m_pos.x = origin.x - (scaledSize.x / 2.0f);
+    this->m_pos.w = origin.y + (scaledSize.y / 2.0f);
+    this->m_pos.z = origin.x + (scaledSize.x / 2.0f);
+    this->m_pos.y = origin.y - (scaledSize.y / 2.0f);
+
+    if (this->getImage ().alignment.find ("top") != std::string::npos) {
+	this->m_pos.y -= scaledSize.y / 2.0f;
+	this->m_pos.w -= scaledSize.y / 2.0f;
+    } else if (this->getImage ().alignment.find ("bottom") != std::string::npos) {
+	this->m_pos.y += scaledSize.y / 2.0f;
+	this->m_pos.w += scaledSize.y / 2.0f;
+    }
+
+    if (this->getImage ().alignment.find ("left") != std::string::npos) {
+	this->m_pos.x += scaledSize.x / 2.0f;
+	this->m_pos.z += scaledSize.x / 2.0f;
+    } else if (this->getImage ().alignment.find ("right") != std::string::npos) {
+	this->m_pos.x -= scaledSize.x / 2.0f;
+	this->m_pos.z -= scaledSize.x / 2.0f;
+    }
+
+    this->m_pos.x -= sceneWidth / 2.0f;
+    this->m_pos.y = sceneHeight / 2.0f - this->m_pos.y;
+    this->m_pos.z -= sceneWidth / 2.0f;
+    this->m_pos.w = sceneHeight / 2.0f - this->m_pos.w;
+}
+
+void CImage::uploadGeometryBuffers (const glm::vec2& size) {
+    GLfloat sceneSpacePosition[] = { this->m_pos.x, this->m_pos.y, 0.0f, this->m_pos.x, this->m_pos.w, 0.0f,
+				     this->m_pos.z, this->m_pos.y, 0.0f, this->m_pos.z, this->m_pos.y, 0.0f,
+				     this->m_pos.x, this->m_pos.w, 0.0f, this->m_pos.z, this->m_pos.w, 0.0f };
+
+    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 ()
+	)
+    ) {
+	width = static_cast<float> (this->getTexture ()->getRealWidth ())
+	    / static_cast<float> (this->getTexture ()->getTextureWidth (0));
+	height = static_cast<float> (this->getTexture ()->getRealHeight ())
+	    / static_cast<float> (this->getTexture ()->getTextureHeight (0));
+    }
+
+    float x = 0.0f;
+    float y = 0.0f;
+    GLfloat realWidth = size.x;
+    GLfloat realHeight = size.y;
+    GLfloat realX = 0.0f;
+    GLfloat realY = 0.0f;
+
+    if (this->getImage ().model->passthrough) {
+	width = 1.0f;
+	height = 1.0f;
+	realX = this->m_pos.x;
+	realY = this->m_pos.w;
+	realWidth = this->m_pos.z;
+	realHeight = this->m_pos.y;
+
+	if (this->getImage ().model->fullscreen) {
+	    realX = -1.0f;
+	    realY = -1.0f;
+	    realWidth = 1.0f;
+	    realHeight = 1.0f;
+	}
+    }
+
+    GLfloat texcoordCopy[] = { x, height, x, y, width, height, width, height, x, y, width, y };
+    GLfloat copySpacePosition[] = { realX,     realHeight, 0.0f, realX, realY, 0.0f, realWidth, realHeight, 0.0f,
+				    realWidth, realHeight, 0.0f, realX, realY, 0.0f, realWidth, realY,      0.0f };
+
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_sceneSpacePosition);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (sceneSpacePosition), sceneSpacePosition, GL_DYNAMIC_DRAW);
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_copySpacePosition);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (copySpacePosition), copySpacePosition, GL_DYNAMIC_DRAW);
+    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_modelViewProjectionCopy = this->getImage ().model->passthrough
+	? this->m_modelViewProjectionScreen
+	: glm::ortho<float> (0.0, size.x, 0.0, size.y);
+    this->m_modelViewProjectionCopyInverse = glm::inverse (this->m_modelViewProjectionCopy);
+    this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
+}
+
+void 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 ());
+    glm::vec3 origin = transform.origin;
+    const glm::vec3 scale = transform.scale;
+    const glm::vec2 size = this->resolveGeometrySize (sceneWidth, sceneHeight, origin);
+    const glm::vec2 previousSize = this->m_size;
+    this->m_size = size;
+    if (this->m_hasPuppetMesh && size != previousSize) {
+	this->updatePuppetPositionBuffer (size);
+    }
+
+    this->updateScenePosition (origin, size, scale, sceneWidth, sceneHeight);
+    this->uploadGeometryBuffers (size);
+}
+
+void CImage::updateScreenSpacePosition () {
+    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;
+    glm::mat4 rotModel = glm::mat4 (1.0f);
+    if (angle != 0.0f) {
+	rotModel = glm::translate (rotModel, this->m_sceneCenter);
+	rotModel = glm::rotate (rotModel, -angle, glm::vec3 (0.0f, 0.0f, 1.0f));
+	rotModel = glm::translate (rotModel, -this->m_sceneCenter);
+    }
+
+    glm::mat4 mvp = this->getScene ().getCamera ().getProjection ()
+		   * this->getScene ().getCamera ().getLookAt ()
+		   * rotModel;
+
+    // Apply parallax displacement if enabled
+    if (this->getScene ().getScene ().camera.parallax.enabled
+	&& !this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
+	const glm::vec2 depth = this->getImage ().parallaxDepth->value->getVec2 ();
+	const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
+	const float referenceSize = static_cast<float> (this->getScene ().getWidth ());
+	float x = (depth.x + parallaxAmount) * displacement->x * referenceSize;
+	float y = (depth.y + parallaxAmount) * displacement->y * referenceSize;
+	mvp = glm::translate (mvp, { x, y, 0.0f });
+    }
+
+    this->m_modelViewProjectionScreen = mvp;
+    this->m_modelViewProjectionScreenInverse = glm::inverse (mvp);
+    if (this->getImage ().model->passthrough) {
+	this->m_modelViewProjectionCopy = this->m_modelViewProjectionScreen;
+	this->m_modelViewProjectionCopyInverse = this->m_modelViewProjectionScreenInverse;
+    }
 }
 
 const Image& CImage::getImage () const { return this->m_image; }

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

@@ -10,6 +10,7 @@
 #include "../TextureProvider.h"
 
 #include <glm/vec3.hpp>
+#include <vector>
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
@@ -63,12 +64,42 @@ protected:
 
     void updateScreenSpacePosition ();
 
+    struct ResolvedTransform {
+	glm::vec3 origin;
+	glm::vec3 scale;
+	float angle;
+    };
+
+    [[nodiscard]] ResolvedTransform resolveTransform (const WallpaperEngine::Data::Model::Object& object, int depth = 0) const;
+
 private:
+    bool loadPuppetMesh (const glm::vec2& size);
+    void updatePuppetPositionBuffer (const glm::vec2& size);
+    void setupPuppetGeometryCallback (Effects::CPass* pass) const;
+    void 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 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,
+	bool& inTargetEffectSequence
+    );
+
     GLuint m_sceneSpacePosition;
     GLuint m_copySpacePosition;
     GLuint m_passSpacePosition;
     GLuint m_texcoordCopy;
     GLuint m_texcoordPass;
+    GLuint m_puppetSpacePosition = GL_NONE;
+    GLuint m_puppetTexCoord = GL_NONE;
+    GLuint m_puppetIndices = GL_NONE;
+    GLsizei m_puppetIndexCount = 0;
+    bool m_hasPuppetMesh = false;
+    std::vector<GLfloat> m_puppetRawPositions = {};
 
     glm::mat4 m_modelViewProjectionScreen = {};
     glm::mat4 m_modelViewProjectionPass = {};
@@ -94,6 +125,8 @@ private:
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
 
     glm::vec4 m_pos = {};
+    glm::vec3 m_sceneCenter = {};
+    glm::vec2 m_size = {};
 
     bool m_initialized = false;
 
@@ -102,6 +135,8 @@ private:
 	    MaterialUniquePtr material;
 	    ImageEffectPassOverrideUniquePtr override;
 	} colorBlending;
+	std::vector<MaterialUniquePtr> compatibilityMaterials = {};
+	std::vector<ImageEffectPassOverrideUniquePtr> compatibilityOverrides = {};
     } m_materials;
 };
 } // namespace WallpaperEngine::Render::Objects

+ 53 - 27
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -259,7 +259,7 @@ void CParticle::update (float dt) {
     }
 
     // Update control points with mouse position
-    const glm::vec2* mousePos = getScene ().getMousePosition ();
+    const glm::vec2* mousePos = getScene ().getMousePositionNormalized ();
     if (mousePos) {
 
 	for (auto& cp : m_controlPoints) {
@@ -413,16 +413,14 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
     bool randomPeriodicEmission = (emitter.flags & 4) != 0;
 
     return [this, emitter, transformedEmitterOrigin, controlPointIndex, rate, flippedDirections, limitOnePerFrame,
-	    randomPeriodicEmission, emissionTimer = 0.0f, elapsedTime = 0.0f, delayTimer = emitter.delay,
-	    durationTimer = 0.0f, periodicTimer = 0.0f, periodicDuration = 0.0f, periodicDelay = 0.0f, emitting = false,
+	    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;
 	}
 
-	elapsedTime += dt;
-
 	// Handle delay
 	if (delayTimer > 0.0f) {
 	    delayTimer -= dt;
@@ -561,12 +559,11 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
     }
 
     bool limitOnePerFrame = (emitter.flags & 2) != 0;
-    bool isRope = m_useRopeRenderer;
-    std::string particleName = m_particle.name;
 
-    return [this, emitter, transformedEmitterOrigin, controlPointIndex, rate, lifetime, limitOnePerFrame, isRope,
-	    particleName, emissionTimer = 0.0f, remaining = emitter.instantaneous, lastSpawnTime = -1.0f,
-	    noEmitFrames = 0u] (std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
+    return [this, emitter, transformedEmitterOrigin, controlPointIndex, rate, lifetime, limitOnePerFrame,
+	    emissionTimer = 0.0f,
+	    remaining
+	    = emitter.instantaneous] (std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
 	if (count >= particles.size ()) {
 	    return;
 	}
@@ -1328,7 +1325,7 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
     bool maintainDistance = (flags & 2) != 0;
     bool ringShape = (flags & 4) != 0;
 
-    return [this, controlPoint, axisValue, offsetValue, distanceInnerValue, distanceOuterValue, speedInnerValue,
+    return [controlPoint, axisValue, offsetValue, distanceInnerValue, distanceOuterValue, speedInnerValue,
 	    speedOuterValue, centerForceValue, ringRadiusValue, ringWidthValue, ringPullDistanceValue,
 	    ringPullForceValue, audioMode, infiniteAxis, maintainDistance, ringShape, speedOverride] (
 	       std::vector<ParticleInstance>& particles, uint32_t count,
@@ -1469,7 +1466,7 @@ OperatorFunc CParticle::createControlPointAttractOperator (const ControlPointAtt
     DynamicValue* thresholdValue = op.threshold->value.get ();
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
 
-    return [this, controlPoint, originValue, scaleValue, thresholdValue, speedOverride] (
+    return [controlPoint, originValue, scaleValue, thresholdValue, speedOverride] (
 	       std::vector<ParticleInstance>& particles, uint32_t count,
 	       const std::vector<ControlPointData>& controlPoints, float currentTime, float dt
 	   ) {
@@ -1840,12 +1837,13 @@ void CParticle::setupParticleUniforms () {
 }
 
 void CParticle::updateMatrices () {
-    // Build model matrix from particle object transform
     glm::vec3 scale = m_particle.scale->value->getVec3 ();
     glm::vec3 angles = m_particle.angles->value->getVec3 ();
 
     m_modelMatrix = glm::mat4 (1.0f);
     m_modelMatrix = glm::translate (m_modelMatrix, m_transformedOrigin);
+    this->applyParallaxToModelMatrix ();
+
     // Negate X and Z rotations to account for Y-flipped coordinate system
     m_modelMatrix = glm::rotate (m_modelMatrix, -angles.z, glm::vec3 (0, 0, 1));
     m_modelMatrix = glm::rotate (m_modelMatrix, angles.y, glm::vec3 (0, 1, 0));
@@ -1853,7 +1851,46 @@ void CParticle::updateMatrices () {
     m_modelMatrix = glm::scale (m_modelMatrix, scale);
     m_modelMatrixInverse = glm::inverse (m_modelMatrix);
 
-    // Build model-view-projection matrix
+    this->updateParticleViewProjection ();
+    m_mvpMatrix = m_viewProjectionMatrix * m_modelMatrix;
+    m_mvpMatrixInverse = glm::inverse (m_mvpMatrix);
+
+    m_orientationUp = glm::vec3 (0.0f, 1.0f, 0.0f);
+    m_orientationRight = glm::vec3 (1.0f, 0.0f, 0.0f);
+    m_orientationForward = glm::vec3 (0.0f, 0.0f, 1.0f);
+    m_viewUp = glm::vec3 (0.0f, 1.0f, 0.0f);
+    m_viewRight = glm::vec3 (1.0f, 0.0f, 0.0f);
+
+    this->updateParticleRenderVars ();
+}
+
+void CParticle::applyParallaxToModelMatrix () {
+    if (!getScene ().getScene ().camera.parallax.enabled
+	|| getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	return;
+    }
+
+    const float parallaxAmount = getScene ().getScene ().camera.parallax.amount->value->getFloat ();
+    glm::vec2 depth = m_particle.parallaxDepth->value->getVec2 ();
+    constexpr float minimumParticleDepth = 0.65f;
+    if (std::abs (depth.x) < minimumParticleDepth) {
+	depth.x = depth.x < 0.0f ? -minimumParticleDepth : minimumParticleDepth;
+    }
+    if (std::abs (depth.y) < minimumParticleDepth) {
+	depth.y = depth.y < 0.0f ? -minimumParticleDepth : minimumParticleDepth;
+    }
+
+    const glm::vec2* displacement = getScene ().getParallaxDisplacement ();
+    const float referenceSize = static_cast<float> (getScene ().getWidth ());
+    const glm::vec3 parallaxOffset {
+	(depth.x + parallaxAmount) * displacement->x * referenceSize,
+	(depth.y + parallaxAmount) * displacement->y * referenceSize,
+	0.0f,
+    };
+    m_modelMatrix = glm::translate (m_modelMatrix, parallaxOffset);
+}
+
+void CParticle::updateParticleViewProjection () {
     if ((m_particle.flags & 4) != 0) {
 	// Perspective particles use a dedicated perspective projection
 	float width = getScene ().getCamera ().getWidth ();
@@ -1880,22 +1917,11 @@ void CParticle::updateMatrices () {
 	// so the cross product produces a visible XY perpendicular direction.
 	m_eyePosition = glm::vec3 (0.0f, 0.0f, 1000.0f);
     }
+}
 
-    m_mvpMatrix = m_viewProjectionMatrix * m_modelMatrix;
-    m_mvpMatrixInverse = glm::inverse (m_mvpMatrix);
-
-    // Orientation vectors for billboard computation (screen-aligned)
-    // These define the coordinate frame that common_particles.h uses for billboard orientation
-    m_orientationUp = glm::vec3 (0.0f, 1.0f, 0.0f);
-    m_orientationRight = glm::vec3 (1.0f, 0.0f, 0.0f);
-    m_orientationForward = glm::vec3 (0.0f, 0.0f, 1.0f);
-    m_viewUp = glm::vec3 (0.0f, 1.0f, 0.0f);
-    m_viewRight = glm::vec3 (1.0f, 0.0f, 0.0f);
-
-    // Update g_RenderVar0: trail parameters (.x=length, .y=maxLength, .z=minLength)
+void CParticle::updateParticleRenderVars () {
     m_renderVar0 = glm::vec4 (m_trailLength, m_trailMaxLength, m_trailMinLength, 0.0f);
 
-    // Update g_RenderVar1: spritesheet params (.x=frameWidth, .y=frameHeight, .z=numFrames, .w=textureRatio)
     if (m_spritesheetFrames > 0 && m_spritesheetCols > 0 && m_spritesheetRows > 0) {
 	float frameWidth = 1.0f / static_cast<float> (m_spritesheetCols);
 	float frameHeight = 1.0f / static_cast<float> (m_spritesheetRows);

+ 3 - 0
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -166,6 +166,9 @@ protected:
     void setupGeometryCallbacks ();
     void setupParticleUniforms ();
     void updateMatrices ();
+    void applyParallaxToModelMatrix ();
+    void updateParticleViewProjection ();
+    void updateParticleRenderVars ();
 
 private:
     const Particle& m_particle;

+ 403 - 0
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -0,0 +1,403 @@
+#include "CText.h"
+
+#include <filesystem>
+#include <vector>
+
+#include <ft2build.h>
+#include FT_FREETYPE_H
+
+#include <glm/gtc/matrix_transform.hpp>
+#include <glm/gtc/type_ptr.hpp>
+
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
+#include "WallpaperEngine/Data/Model/Object.h"
+#include "WallpaperEngine/Data/Model/UserSetting.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/Camera.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+using namespace WallpaperEngine::Render::Objects;
+
+namespace {
+// TODO: Phase 2 – load font from wallpaper's materials/fonts/ using AssetLocator
+// Phase 1 uses a system font instead of the font shipped by the wallpaper.
+// Wallpaper Engine bundles .ttf files in `materials/fonts/`; wiring those in
+// is deferred to Phase 2 along with dynamic/scripted text.
+const std::vector<std::string> kFontCandidates = {
+    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+    "/usr/share/fonts/TTF/DejaVuSans.ttf",
+    "/usr/share/fonts/dejavu/DejaVuSans.ttf",
+    "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
+};
+
+const char* kVertexShader = R"glsl(
+#version 330 core
+layout(location = 0) in vec2 aPos;
+layout(location = 1) in vec2 aUV;
+uniform mat4 uMVP;
+out vec2 vUV;
+void main() {
+    vUV = aUV;
+    gl_Position = uMVP * vec4(aPos, 0.0, 1.0);
+}
+)glsl";
+
+const char* kFragmentShader = R"glsl(
+#version 330 core
+in vec2 vUV;
+uniform sampler2D uTexture;
+uniform vec4 uColor;
+out vec4 FragColor;
+void main() {
+    float coverage = texture(uTexture, vUV).r;
+    FragColor = vec4(uColor.rgb, uColor.a * coverage);
+}
+)glsl";
+
+GLuint compileShader (GLenum type, const char* source) {
+    GLuint shader = glCreateShader (type);
+    glShaderSource (shader, 1, &source, nullptr);
+    glCompileShader (shader);
+
+    GLint status = GL_FALSE;
+    glGetShaderiv (shader, GL_COMPILE_STATUS, &status);
+    if (status != GL_TRUE) {
+	char log[1024];
+	glGetShaderInfoLog (shader, sizeof (log), nullptr, log);
+	sLog.error ("CText shader compile failed: ", log);
+	glDeleteShader (shader);
+	return 0;
+    }
+    return shader;
+}
+} // namespace
+
+CText::CText (Wallpapers::CScene& scene, const Text& text) : CObject (scene, text), m_text (text) {}
+
+CText::~CText () {
+    if (m_layerHandle != Scripting::kInvalidLayerHandle) {
+	Scripting::ScriptEngine::instance ().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);
+}
+
+void CText::setup () {
+    const bool scripted = !m_text.script.empty ();
+
+    // Nothing to render and no script to produce text later → bail.
+    if (m_text.text.empty () && !scripted)
+	return;
+
+    if (!initFreeType ())
+	return;
+
+    if (!loadEmbeddedFont () && !loadSystemFont ())
+	return;
+
+    m_lastPixelSize = computeEffectivePixelSize ();
+    FT_Set_Pixel_Sizes (m_ftFace, 0, static_cast<FT_UInt> (m_lastPixelSize));
+
+    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);
+
+    if (scripted)
+	initScriptLayer ();
+
+    m_valid = m_texture != 0 && m_program != 0 && m_vao != 0;
+}
+
+bool CText::initFreeType () {
+    if (FT_Init_FreeType (&m_ftLibrary) == 0)
+	return true;
+    sLog.error ("CText: FT_Init_FreeType failed for object ", m_text.name);
+    return false;
+}
+
+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)
+	return false;
+
+    try {
+	auto stream = getAssetLocator ().read (m_text.font);
+	stream->seekg (0, std::ios::end);
+	const auto size = stream->tellg ();
+	stream->seekg (0, std::ios::beg);
+	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)
+	    return true;
+
+	sLog.error ("CText: FT_New_Memory_Face failed for '", m_text.font, "', falling back to system font");
+    } catch (const std::exception& e) {
+	sLog.error ("CText: cannot read font '", m_text.font, "': ", e.what (), ", falling back to system font");
+    }
+
+    m_fontData.clear ();
+    return false;
+}
+
+bool CText::loadSystemFont () {
+    std::string fontPath;
+    for (const auto& candidate : kFontCandidates) {
+	if (std::filesystem::exists (candidate)) {
+	    fontPath = candidate;
+	    break;
+	}
+    }
+    if (fontPath.empty ()) {
+	sLog.error ("CText: no usable system font found");
+	return false;
+    }
+    if (FT_New_Face (m_ftLibrary, fontPath.c_str (), 0, &m_ftFace) != 0) {
+	sLog.error ("CText: FT_New_Face failed for ", fontPath);
+	return false;
+    }
+    return true;
+}
+
+unsigned int CText::computeEffectivePixelSize () const {
+    // WE text objects often come with scale ~0.09 that, combined with a modest
+    // pointsize, would rasterize glyphs to ~2px on screen (invisible). Rasterize
+    // at higher resolution so that after the model scale is applied in render()
+    // 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));
+}
+
+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 ());
+    }
+    m_layerHandle = Scripting::ScriptEngine::instance ().createLayerScript (
+	m_text.script, initialProps, m_text.text
+    );
+    if (m_layerHandle == Scripting::kInvalidLayerHandle)
+	sLog.error ("CText: createLayerScript failed for '", m_text.name, "'");
+}
+
+void CText::rebuildTextureFrom (const std::string& text) {
+    // Two-pass rasterization: first measure the bounding box, then rasterize
+    // every glyph into a single grayscale bitmap. Phase 1 renders one line —
+    // multi-line wrapping, alignment, and padding come with Phase 2.
+    //
+    // Safe to call repeatedly: GL handles (texture, VAO, VBO) are reused when
+    // already allocated, so dynamic/scripted text can regenerate the glyph
+    // bitmap every time the rendered string changes without leaking.
+    FT_GlyphSlot slot = m_ftFace->glyph;
+
+    int penX = 0;
+    int maxAscent = 0;
+    int maxDescent = 0;
+
+    for (unsigned char c : text) {
+	if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
+	    continue;
+	}
+	penX += slot->advance.x >> 6;
+	maxAscent = std::max (maxAscent, slot->bitmap_top);
+	maxDescent = std::max (maxDescent, static_cast<int> (slot->bitmap.rows) - slot->bitmap_top);
+    }
+
+    const int width = std::max (1, penX);
+    const int height = std::max (1, maxAscent + maxDescent);
+    std::vector<uint8_t> pixels (static_cast<size_t> (width) * height, 0);
+
+    penX = 0;
+    for (unsigned char c : text) {
+	if (FT_Load_Char (m_ftFace, static_cast<FT_ULong> (c), FT_LOAD_RENDER) != 0) {
+	    continue;
+	}
+
+	const auto& bmp = slot->bitmap;
+	const int originX = penX + slot->bitmap_left;
+	const int originY = maxAscent - slot->bitmap_top;
+
+	for (unsigned int row = 0; row < bmp.rows; ++row) {
+	    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;
+		pixels[static_cast<size_t> (dstY) * width + dstX] = bmp.buffer[row * bmp.pitch + col];
+	    }
+	}
+
+	penX += slot->advance.x >> 6;
+    }
+
+    const bool firstUpload = (m_texture == 0);
+    if (firstUpload) {
+	glGenTextures (1, &m_texture);
+    }
+    glBindTexture (GL_TEXTURE_2D, m_texture);
+    glPixelStorei (GL_UNPACK_ALIGNMENT, 1);
+    glTexImage2D (GL_TEXTURE_2D, 0, GL_RED, width, height, 0, GL_RED, GL_UNSIGNED_BYTE, pixels.data ());
+    if (firstUpload) {
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+	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_lastRenderedText = text;
+
+    uploadQuadVertices ();
+}
+
+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);
+	return;
+    }
+
+    m_program = glCreateProgram ();
+    glAttachShader (m_program, vs);
+    glAttachShader (m_program, fs);
+    glLinkProgram (m_program);
+    glDeleteShader (vs);
+    glDeleteShader (fs);
+
+    GLint status = GL_FALSE;
+    glGetProgramiv (m_program, GL_LINK_STATUS, &status);
+    if (status != GL_TRUE) {
+	char log[1024];
+	glGetProgramInfoLog (m_program, sizeof (log), nullptr, log);
+	sLog.error ("CText program link failed: ", log);
+	glDeleteProgram (m_program);
+	m_program = 0;
+	return;
+    }
+
+    m_uMVP = glGetUniformLocation (m_program, "uMVP");
+    m_uColor = glGetUniformLocation (m_program, "uColor");
+    m_uTexture = glGetUniformLocation (m_program, "uTexture");
+}
+
+void CText::uploadQuadVertices () {
+    // Quad centered at the origin, sized in pixels. Scene-space placement is
+    // done via the model matrix using the object's origin/scale. VBO contents
+    // are re-uploaded whenever the glyph bitmap is rebuilt so the quad always
+    // matches the current texture dimensions.
+    const float hx = m_quadSize.x * 0.5f;
+    const float hy = m_quadSize.y * 0.5f;
+    // With vflip=true (Wayland/GLFW), GL y- = screen top. So the quad bottom (y=-hy,
+    // 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,
+    };
+
+    const bool firstUpload = (m_vao == 0);
+    if (firstUpload) {
+	glGenVertexArrays (1, &m_vao);
+	glGenBuffers (1, &m_vbo);
+    }
+    glBindVertexArray (m_vao);
+    glBindBuffer (GL_ARRAY_BUFFER, m_vbo);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (verts), verts, GL_DYNAMIC_DRAW);
+    if (firstUpload) {
+	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)));
+    }
+    glBindVertexArray (0);
+}
+
+void CText::render () {
+    if (!m_valid) return;
+    if (!m_text.visible->value->getBool ()) return;
+
+    std::string renderedText = m_lastRenderedText;
+    if (m_layerHandle != Scripting::kInvalidLayerHandle) {
+	auto& se = Scripting::ScriptEngine::instance ();
+	se.tickLayer (
+	    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;
+    }
+
+    const unsigned int pixelSize = computeEffectivePixelSize ();
+    if (pixelSize != m_lastPixelSize) {
+	m_lastPixelSize = pixelSize;
+	FT_Set_Pixel_Sizes (m_ftFace, 0, static_cast<FT_UInt> (m_lastPixelSize));
+	rebuildTextureFrom (renderedText);
+    } else if (renderedText != m_lastRenderedText) {
+	rebuildTextureFrom (renderedText);
+    }
+
+    const glm::vec4 color = m_text.color->value->getVec4 ();
+    const float alpha = m_text.alpha->value->getFloat ();
+    const glm::vec3 scale = m_text.scale->value->getVec3 ();
+    const glm::vec3 origin = m_text.origin->value->getVec3 ();
+
+    // WE uses a Y-down coordinate system (origin at top-left, y increases downward).
+    // The final FBO is presented to screen with vflip=true on Wayland/GLFW, which maps
+    // GL y- to screen top and GL y+ to screen bottom. This effectively inverts Y again,
+    // so we need: gl_y = origin.y - scene_h/2  (not the CImage-style scene_h/2 - origin.y).
+    // CImage pre-compensates for X11 (no vflip) and gets corrected by the Wayland vflip.
+    // CText renders with direct vflip-aware coordinates.
+    const float scene_w = getScene ().getCamera ().getWidth ();
+    const float scene_h = getScene ().getCamera ().getHeight ();
+    const glm::vec3 gl_origin = {
+	origin.x - scene_w * 0.5f,
+	origin.y - scene_h * 0.5f,
+	origin.z,
+    };
+
+    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;
+
+    glEnable (GL_BLEND);
+    glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+
+    glUseProgram (m_program);
+    glUniformMatrix4fv (m_uMVP, 1, GL_FALSE, glm::value_ptr (mvp));
+    glUniform4f (m_uColor, color.r, color.g, color.b, color.a * alpha);
+
+    glActiveTexture (GL_TEXTURE0);
+    glBindTexture (GL_TEXTURE_2D, m_texture);
+    glUniform1i (m_uTexture, 0);
+
+    glBindVertexArray (m_vao);
+    glDrawArrays (GL_TRIANGLES, 0, 6);
+    glBindVertexArray (0);
+}

+ 85 - 0
src/WallpaperEngine/Render/Objects/CText.h

@@ -0,0 +1,85 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <GL/glew.h>
+#include <glm/vec2.hpp>
+#include <glm/vec4.hpp>
+
+#include "WallpaperEngine/Render/CObject.h"
+#include "WallpaperEngine/Scripting/ScriptEngine.h"
+
+// Forward-declare FreeType types to avoid leaking the header into users.
+struct FT_LibraryRec_;
+struct FT_FaceRec_;
+typedef struct FT_LibraryRec_* FT_Library;
+typedef struct FT_FaceRec_* FT_Face;
+
+namespace WallpaperEngine::Render::Wallpapers {
+class CScene;
+}
+
+namespace WallpaperEngine::Render::Objects {
+using namespace WallpaperEngine::Data::Model;
+
+/**
+ * Phase 1 text renderer.
+ *
+ * Renders static text objects as a single FreeType-rasterized RGBA texture
+ * drawn on a textured quad with its own minimal GLSL shader. Does NOT go
+ * through CRenderable / materials / passes — Phase 1 does not need effects.
+ *
+ * Phase 2 (scripted/dynamic text, alignment from properties, effect passes)
+ * is intentionally not implemented here. When the scene provides a dynamic
+ * `text: { script: "..." }` object this class captures the script source in
+ * 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 {
+public:
+    CText (Wallpapers::CScene& scene, const Text& text);
+    ~CText () override;
+
+    void setup ();
+    void render () override;
+
+private:
+    // Rebuilds the glyph texture (and matching quad VBO) from the given string.
+    // Reuses existing GL handles if already allocated, so this is safe to call
+    // every time the rendered text changes.
+    void rebuildTextureFrom (const std::string& text);
+    void buildShader ();
+    void uploadQuadVertices ();
+
+    // setup() helpers (kept small to keep the setup flow linear).
+    bool initFreeType ();
+    bool loadEmbeddedFont ();
+    bool loadSystemFont ();
+    unsigned int computeEffectivePixelSize () const;
+    void initScriptLayer ();
+
+    const Text& m_text;
+    std::string m_lastRenderedText;
+    unsigned int m_lastPixelSize = 0;
+    Scripting::ScriptLayerHandle m_layerHandle = Scripting::kInvalidLayerHandle;
+
+    FT_Library m_ftLibrary = nullptr;
+    FT_Face m_ftFace = nullptr;
+    std::vector<uint8_t> m_fontData;
+
+    GLuint m_texture = 0;
+    GLuint m_program = 0;
+    GLuint m_vao = 0;
+    GLuint m_vbo = 0;
+
+    GLint m_uMVP = -1;
+    GLint m_uColor = -1;
+    GLint m_uTexture = -1;
+
+    glm::ivec2 m_textureSize = {0, 0};
+    glm::vec2 m_quadSize = {0.0f, 0.0f};
+
+    bool m_valid = false;
+};
+} // namespace WallpaperEngine::Render::Objects

+ 178 - 69
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -32,6 +32,16 @@ extern float g_Daytime;
 const TextureMap DEFAULT_BINDS = {};
 const ImageEffectPassOverride DEFAULT_OVERRIDE = {};
 
+namespace {
+std::string textureSizeLabel (const std::shared_ptr<const TextureProvider>& texture) {
+    if (texture == nullptr) {
+	return "<null>";
+    }
+
+    return std::to_string (texture->getRealWidth ()) + "x" + std::to_string (texture->getRealHeight ());
+}
+}
+
 CPass::CPass (
     CRenderable& renderable, std::shared_ptr<const FBOProvider> fboProvider, const MaterialPass& pass,
     std::optional<std::reference_wrapper<const ImageEffectPassOverride>> override,
@@ -47,17 +57,22 @@ CPass::CPass (
 
 CPass::~CPass () {
     // destroy shader programs
-    GLuint attachedShaders[2];
-    GLsizei attachedCount;
+    if (!glIsProgram(this->m_programID)) return; // program already invalid or deleted
 
-    // destroy shaders (we only attach 2 to each program)
-    glGetAttachedShaders (this->m_programID, 2, &attachedCount, attachedShaders);
+    GLint shaderCount = 0;
+    glGetProgramiv(this->m_programID, GL_ATTACHED_SHADERS, &shaderCount);
 
-    for (auto i = 0; i < attachedCount; i++) {
-	glDeleteShader (attachedShaders[i]);
+    if (shaderCount > 0) {
+        std::vector<GLuint> attachedShaders(shaderCount);
+        glGetAttachedShaders(this->m_programID, shaderCount, nullptr, attachedShaders.data());
+
+        for (GLuint s : attachedShaders) {
+            if (glIsShader(s)) glDeleteShader(s);
+        }
     }
 
-    glDeleteProgram (this->m_programID);
+    glDeleteProgram(this->m_programID);
+    this->m_programID = 0;
 }
 
 std::shared_ptr<const TextureProvider> CPass::resolveTexture (
@@ -78,7 +93,7 @@ std::shared_ptr<const TextureProvider> CPass::resolveTexture (
 
     // a bind named "previous" is just another way of telling it to use whatever texture there was already
     if (it->second == "previous") {
-	return previous ?: expected;
+	return this->m_previousInput ?: (previous ?: expected);
     }
 
     // the bind actually has a name, search the FBO in the effect and return it
@@ -124,6 +139,7 @@ void CPass::setupRenderFramebuffer () const {
     switch (this->m_pass.depthtest) {
 	case DepthtestMode_Enabled:
 	    glEnable (GL_DEPTH_TEST);
+	    glDepthFunc (GL_LEQUAL);
 	    break;
 	case DepthtestMode_Disabled:
 	default:
@@ -158,65 +174,98 @@ void CPass::setupRenderTexture () {
     // use the shader we have registered
     glUseProgram (this->m_programID);
 
-    // maybe we can do this when setting the texture?
-    auto texture = this->resolveTexture (this->m_input, 0, this->m_input);
+    auto texture0 = this->resolveTexture0 ();
+    const auto animation = this->resolveTextureAnimationState (texture0);
 
-    uint32_t currentTexture = 0;
-    glm::vec2 translation = { 0.0f, 0.0f };
-    glm::vec4 rotation = { 0.0f, 0.0f, 0.0f, 0.0f };
+    this->bindTextureUnit (0, texture0, animation.currentTexture);
+    this->bindTextureOverrides (animation.currentTexture, texture0);
 
-    if (texture->isAnimated ()) {
-	// calculate current texture and frame
-	double currentRenderTime = fmod (
-	    static_cast<double> (this->getContext ().getDriver ().getRenderTime ()),
-	    this->m_renderable.getAnimationTime ()
+    if (texture0 != nullptr) {
+	this->m_texture0Resolution = *texture0->getResolution ();
+    }
+
+    // used in animations when one of the frames is vertical instead of horizontal
+    // rotation with translation = origin and end of the image to display
+    if (this->g_Texture0Rotation != -1) {
+	glUniform4f (
+	    this->g_Texture0Rotation, animation.rotation.x, animation.rotation.y, animation.rotation.z,
+	    animation.rotation.w
 	);
+    }
+    // this actually picks the origin point of the image from the atlast
+    if (this->g_Texture0Translation != -1) {
+	glUniform2f (this->g_Texture0Translation, animation.translation.x, animation.translation.y);
+    }
+}
 
-	for (const auto& frameCur : texture->getFrames ()) {
-	    currentRenderTime -= frameCur->frametime;
+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;
+    }
 
-	    if (currentRenderTime <= 0.0f) {
-		// frame found, store coordinates and done
-		currentTexture = frameCur->frameNumber;
+    return texture0;
+}
 
-		translation.x = frameCur->x / texture->getTextureWidth (currentTexture);
-		translation.y = frameCur->y / texture->getTextureHeight (currentTexture);
+CPass::TextureAnimationState CPass::resolveTextureAnimationState (
+    const std::shared_ptr<const TextureProvider>& texture
+) const {
+    TextureAnimationState state;
 
-		rotation.x = frameCur->width1 / static_cast<float> (texture->getTextureWidth (currentTexture));
-		rotation.y = frameCur->width2 / static_cast<float> (texture->getTextureWidth (currentTexture));
-		rotation.z = frameCur->height2 / static_cast<float> (texture->getTextureHeight (currentTexture));
-		rotation.w = frameCur->height1 / static_cast<float> (texture->getTextureHeight (currentTexture));
-		break;
-	    }
-	}
+    if (texture == nullptr || !texture->isAnimated ()) {
+	return state;
     }
 
-    // first texture is a bit special as we have to take what comes from the chain first
-    glActiveTexture (GL_TEXTURE0);
-    glBindTexture (GL_TEXTURE_2D, texture->getTextureID (currentTexture));
+    double currentRenderTime = fmod (
+	static_cast<double> (this->getContext ().getDriver ().getRenderTime ()),
+	this->m_renderable.getAnimationTime ()
+    );
 
-    // continue on the map from the second texture
-    if (!this->m_textures.empty ()) {
-	for (const auto& [index, expectedTexture] : this->m_textures) {
-	    if (expectedTexture == nullptr) {
-		texture = this->m_input;
-	    } else {
-		texture = expectedTexture;
-	    }
+    for (const auto& frameCur : texture->getFrames ()) {
+	currentRenderTime -= frameCur->frametime;
 
-	    glActiveTexture (GL_TEXTURE0 + index);
-	    glBindTexture (GL_TEXTURE_2D, texture->getTextureID (0));
+	if (currentRenderTime > 0.0f) {
+	    continue;
 	}
+
+	state.currentTexture = frameCur->frameNumber;
+	state.translation.x = frameCur->x / texture->getTextureWidth (state.currentTexture);
+	state.translation.y = frameCur->y / texture->getTextureHeight (state.currentTexture);
+
+	state.rotation.x = frameCur->width1 / static_cast<float> (texture->getTextureWidth (state.currentTexture));
+	state.rotation.y = frameCur->width2 / static_cast<float> (texture->getTextureWidth (state.currentTexture));
+	state.rotation.z = frameCur->height2 / static_cast<float> (texture->getTextureHeight (state.currentTexture));
+	state.rotation.w = frameCur->height1 / static_cast<float> (texture->getTextureHeight (state.currentTexture));
+	break;
     }
 
-    // used in animations when one of the frames is vertical instead of horizontal
-    // rotation with translation = origin and end of the image to display
-    if (this->g_Texture0Rotation != -1) {
-	glUniform4f (this->g_Texture0Rotation, rotation.x, rotation.y, rotation.z, rotation.w);
+    return state;
+}
+
+void CPass::bindTextureUnit (
+    int index, const std::shared_ptr<const TextureProvider>& texture, uint32_t frame
+) const {
+    if (texture == nullptr) {
+	return;
     }
-    // this actually picks the origin point of the image from the atlast
-    if (this->g_Texture0Translation != -1) {
-	glUniform2f (this->g_Texture0Translation, translation.x, translation.y);
+
+    glActiveTexture (GL_TEXTURE0 + index);
+    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;
+	}
+
+	this->bindTextureUnit (index, texture, index == 0 ? currentTexture : 0);
+	if (index == 0) {
+	    texture0 = texture;
+	}
     }
 }
 
@@ -347,6 +396,47 @@ void CPass::cleanupRenderSetup () {
 }
 
 void CPass::render () {
+    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>"),
+	    " drawTo=", this->m_drawTo ? this->m_drawTo->getName () : std::string ("<null>"),
+	    " drawSize=", textureSizeLabel (this->m_drawTo), " inputSize=", textureSizeLabel (this->m_input)
+	);
+	for (const auto* uniformName : { "g_TintColor", "g_CompositeColor", "g_BlendAlpha", "g_CompositeAlpha" }) {
+	    const auto uniform = this->m_uniforms.find (uniformName);
+	    if (uniform == this->m_uniforms.end ()) {
+		continue;
+	    }
+
+	    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;
+		}
+		default:
+		    break;
+	    }
+	}
+    }
+
+    if (this->m_drawTo == nullptr) {
+	sLog.error ("Skipping render pass for object ", this->m_renderable.getId (), ": no destination FBO set");
+	return;
+    }
+
+    if (this->m_input == nullptr) {
+	sLog.error ("Skipping render pass for object ", this->m_renderable.getId (), ": no input texture set");
+	return;
+    }
+
     this->setupRenderFramebuffer ();
     this->setupRenderTexture ();
     this->setupRenderUniforms ();
@@ -364,6 +454,10 @@ void CPass::setDestination (std::shared_ptr<const CFBO> drawTo) { this->m_drawTo
 
 void CPass::setInput (std::shared_ptr<const TextureProvider> input) { this->m_input = std::move (input); }
 
+void CPass::setPreviousInput (std::shared_ptr<const TextureProvider> input) {
+    this->m_previousInput = std::move (input);
+}
+
 void CPass::setModelViewProjectionMatrix (const glm::mat4* projection) {
     this->m_modelViewProjectionMatrix = projection;
 }
@@ -461,9 +555,14 @@ void CPass::setupShaders () {
     const std::string& shaderName
 	= this->m_override.shaderOverride.has_value () ? this->m_override.shaderOverride.value () : this->m_pass.shader;
 
+    TextureMap passTextures = this->m_pass.textures;
+    for (const auto& [index, texture] : this->m_pass.usertextures) {
+	passTextures.insert_or_assign (index, texture);
+    }
+
     this->m_shader = new Render::Shaders::Shader (
 	this->m_renderable.getAssetLocator (), shaderName, this->m_combos, this->m_override.combos,
-	this->m_pass.textures, this->m_override.textures, this->m_override.constants
+	passTextures, this->m_override.textures, this->m_override.constants
     );
 
     const auto [vertex, fragment]
@@ -564,11 +663,18 @@ void CPass::setupTextureUniforms () {
     }
 
     for (const auto& [index, textureName] : this->m_pass.textures) {
-	// ignore first texture as that'll be the input of the previous pass (or the image if it's the first pass)
-	if (index == 0) {
-	    continue;
+	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);
+	    }
+	} catch (std::runtime_error& ex) {
+	    sLog.error ("Cannot resolve texture ", textureName, " for pass ", ex.what ());
 	}
+    }
 
+    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);
@@ -576,16 +682,12 @@ void CPass::setupTextureUniforms () {
 		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
 	    }
 	} catch (std::runtime_error& ex) {
-	    sLog.error ("Cannot resolve texture ", textureName, " for pass ", ex.what ());
+	    sLog.error ("Cannot resolve user texture ", textureName, " for pass ", ex.what ());
 	}
     }
 
     // override any texture
     for (const auto& [index, textureName] : this->m_override.textures) {
-	if (index == 0) {
-	    continue;
-	}
-
 	try {
 	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
 		this->m_textures[index] = this->resolveFBO (textureName);
@@ -619,7 +721,9 @@ void CPass::setupTextureUniforms () {
     this->addUniform ("g_Texture5", 5);
     this->addUniform ("g_Texture6", 6);
     this->addUniform ("g_Texture7", 7);
-    this->addUniform ("g_Texture0Resolution", texture->getResolution ());
+    this->addUniform ("g_TextureReductionScale", 1.0f);
+    this->m_texture0Resolution = *texture->getResolution ();
+    this->addUniform ("g_Texture0Resolution", &this->m_texture0Resolution);
 
     for (const auto& [textureIndex, expectedTexture] : this->m_textures) {
 	std::ostringstream namestream;
@@ -629,6 +733,8 @@ void CPass::setupTextureUniforms () {
 	texture = this->resolveTexture (expectedTexture, textureIndex, texture);
 	this->addUniform (namestream.str (), texture->getResolution ());
     }
+
+    this->addUniform ("g_Texture0Resolution", &this->m_texture0Resolution);
 }
 
 void CPass::setupUniforms () {
@@ -648,15 +754,18 @@ void CPass::setupUniforms () {
     this->addUniform ("g_Alpha", renderable.getAlpha ());
     this->addUniform ("g_Color", renderable.getColor ());
     this->addUniform ("g_Color4", renderable.getColor4 ());
-    // TODO: VALIDATE THAT G_COMPOSITECOLOR REALLY COMES FROM THIS ONE
-    this->addUniform ("g_CompositeColor", renderable.getCompositeColor ());
+    if (!this->m_uniforms.contains ("g_CompositeColor")) {
+	this->addUniform ("g_CompositeColor", renderable.getCompositeColor ());
+    }
     // add some external variables
     this->addUniform ("g_Time", &g_Time);
     this->addUniform ("g_Daytime", &g_Daytime);
     // add model-view-projection matrix
     this->addUniform ("g_ModelViewProjectionMatrixInverse", &this->m_modelViewProjectionMatrixInverse);
     this->addUniform ("g_ModelViewProjectionMatrix", &this->m_modelViewProjectionMatrix);
+    this->addUniform ("g_EffectModelViewProjectionMatrix", &this->m_modelViewProjectionMatrix);
     this->addUniform ("g_ModelMatrix", &this->m_modelMatrix);
+    this->addUniform ("g_EffectModelMatrix", &this->m_modelMatrix);
     this->addUniform ("g_NormalModelMatrix", glm::identity<glm::mat3> ());
     this->addUniform ("g_ViewProjectionMatrix", &this->m_viewProjectionMatrix);
     this->addUniform ("g_PointerPosition", scene.getMousePosition ());
@@ -792,15 +901,15 @@ void CPass::addUniform (ShaderVariable* value) {
 
 void CPass::addUniform (const ShaderVariable* value, const DynamicValue* setting) {
     if (value->is<ShaderVariableFloat> ()) {
-	this->addUniform (value->getName (), setting->getFloat ());
+	this->addUniform (value->getName (), &setting->getFloat ());
     } else if (value->is<ShaderVariableInteger> ()) {
-	this->addUniform (value->getName (), setting->getInt ());
+	this->addUniform (value->getName (), &setting->getInt ());
     } else if (value->is<ShaderVariableVector2> ()) {
-	this->addUniform (value->getName (), setting->getVec2 ());
+	this->addUniform (value->getName (), &setting->getVec2 ());
     } else if (value->is<ShaderVariableVector3> ()) {
-	this->addUniform (value->getName (), setting->getVec3 ());
+	this->addUniform (value->getName (), &setting->getVec3 ());
     } else if (value->is<ShaderVariableVector4> ()) {
-	this->addUniform (value->getName (), setting->getVec4 ());
+	this->addUniform (value->getName (), &setting->getVec4 ());
     } else {
 	sLog.error ("Cannot convert setting dynamic value  to ", value->getName (), ". Using default value");
     }

+ 19 - 5
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -31,10 +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 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);
@@ -110,6 +111,12 @@ private:
 	const GLuint* value;
     };
 
+    struct TextureAnimationState {
+	uint32_t currentTexture = 0;
+	glm::vec2 translation = { 0.0f, 0.0f };
+	glm::vec4 rotation = { 0.0f, 0.0f, 0.0f, 0.0f };
+    };
+
     static GLuint compileShader (const char* shader, GLuint type);
     void setupShaders ();
     void setupShaderVariables ();
@@ -145,6 +152,11 @@ private:
 
     void setupRenderFramebuffer () const;
     void setupRenderTexture ();
+    [[nodiscard]] std::shared_ptr<const TextureProvider> resolveTexture0 ();
+    [[nodiscard]] TextureAnimationState
+    resolveTextureAnimationState (const std::shared_ptr<const TextureProvider>& texture) const;
+    void bindTextureUnit (int index, const std::shared_ptr<const TextureProvider>& texture, uint32_t frame) const;
+    void bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const TextureProvider>& texture0) const;
     void setupRenderUniforms ();
     void setupRenderReferenceUniforms ();
     void setupRenderAttributes () const;
@@ -180,8 +192,10 @@ private:
 
     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 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;
 

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

@@ -24,7 +24,8 @@ 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 ());
+	ref->second->render (viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition,
+	    viewport->logicalSize);
     }
 
 #if !NDEBUG
@@ -35,6 +36,7 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
 }
 
 void RenderContext::setWallpaper (const std::string& display, std::shared_ptr<CWallpaper> wallpaper) {
+    wallpaper->setDestinationFramebuffer (this->m_app.getDestinationFramebuffer ());
     this->m_wallpapers.insert_or_assign (display, wallpaper);
 }
 
@@ -59,4 +61,5 @@ std::shared_ptr<const TextureProvider> RenderContext::resolveTexture (const std:
 const std::map<std::string, std::shared_ptr<CWallpaper>>& RenderContext::getWallpapers () const {
     return this->m_wallpapers;
 }
+
 } // namespace WallpaperEngine::Render

+ 1 - 2
src/WallpaperEngine/Render/Shaders/Shader.cpp

@@ -28,8 +28,7 @@ Shader::Shader (
 	GLSLContext::UnitType_Fragment, filename, assetLocator.fragmentShader (filename), assetLocator, constants,
 	textures, overrideTextures, combos, overrideCombos
     ),
-    m_file (std::move (filename)), m_combos (combos), m_overrideCombos (overrideCombos), m_passTextures (textures),
-    m_overrideTextures (overrideTextures) {
+    m_file (std::move (filename)), m_combos (combos), m_passTextures (textures) {
     // link shaders between them
     this->m_vertex.linkToUnit (&this->m_fragment);
     this->m_fragment.linkToUnit (&this->m_vertex);

+ 0 - 8
src/WallpaperEngine/Render/Shaders/Shader.h

@@ -92,17 +92,9 @@ private:
      * The combos the shader should be generated with
      */
     const ComboMap& m_combos;
-    /**
-     * The overriden combos
-     */
-    const ComboMap& m_overrideCombos;
     /**
      * The list of textures the pass knows about
      */
     const TextureMap m_passTextures;
-    /**
-     * The list of the override textures
-     */
-    const TextureMap& m_overrideTextures;
 };
 } // namespace WallpaperEngine::Render::Shaders

+ 153 - 22
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -1,6 +1,7 @@
 #include "ShaderUnit.h"
 
 #include "WallpaperEngine/Logging/Log.h"
+#include <exception>
 #include <regex>
 #include <stack>
 #include <string>
@@ -34,10 +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 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"                                                                                         \
@@ -71,9 +78,9 @@ void ShaderUnit::preprocess () {
     this->m_preprocessed = this->m_content;
     this->m_includes = "";
 
-    this->preprocessVariables ();
     this->preprocessIncludes ();
     this->preprocessRequires ();
+    this->preprocessVariables ();
 
     // replace gl_FragColor with the equivalent
     const std::string from = "gl_FragColor";
@@ -87,9 +94,6 @@ void ShaderUnit::preprocess () {
 }
 
 void ShaderUnit::preprocessVariables () {
-    this->m_preprocessed = this->m_content;
-    this->m_includes = "";
-
     size_t start = 0, end = 0;
     while ((end = this->m_preprocessed.find ('\n', start)) != std::string::npos) {
 	// Extract a line from the string
@@ -101,10 +105,12 @@ void ShaderUnit::preprocessVariables () {
 
 	if (combo != std::string::npos) {
 	    this->parseComboConfiguration (line.substr (combo + strlen ("// [COMBO] ")), 0);
-	} else if (uniform != std::string::npos && comment != std::string::npos && semicolon != std::string::npos &&
-		   // this check ensures that the comment is after the semicolon (so it's not a commented-out line)
-		   // this needs further refining as it's not taking into account block comments
-		   semicolon < comment) {
+	} else if (
+	    uniform != std::string::npos && comment != std::string::npos && semicolon != std::string::npos &&
+	    // this check ensures that the comment is after the semicolon (so it's not a commented-out line)
+	    // this needs further refining as it's not taking into account block comments
+	    semicolon < comment
+	) {
 	    // uniforms with comments should never have a value assigned, use this fact to detect the required parts
 	    const size_t last_space = line.find_last_of (' ', semicolon);
 
@@ -258,8 +264,7 @@ void ShaderUnit::preprocessIncludes () {
 	size_t current = 0;
 
 	while (
-	    std::regex_search (this->m_preprocessed.cbegin () + current, this->m_preprocessed.cend (), match, ifdef)
-	) {
+	    std::regex_search (this->m_preprocessed.cbegin () + current, this->m_preprocessed.cend (), match, ifdef)) {
 	    current += match.position ();
 
 	    // if it's opening an #ifdef keep track of the start of the block
@@ -307,22 +312,142 @@ void ShaderUnit::preprocessIncludes () {
 
 void ShaderUnit::preprocessRequires () {
     size_t start = 0, end = 0;
-    // comment out requires
+
     while ((start = this->m_preprocessed.find ("#require", end)) != std::string::npos) {
-	// TODO: CHECK FOR ERRORS HERE
 	const size_t lineEnd = this->m_preprocessed.find_first_of ('\n', start);
-	sLog.out ("Shader has a require block ", this->m_preprocessed.substr (start, lineEnd - start));
-	// replace the first two letters with a comment so the filelength doesn't change
+
+	const size_t nameStart = start + std::string ("#require ").length ();
+
+	if (nameStart >= lineEnd) {
+	    sLog.error ("Malformed #require directive (no module name) in shader ", this->m_file);
+	    end = lineEnd;
+	    continue;
+	}
+
+	std::string moduleName = this->m_preprocessed.substr (nameStart, lineEnd - nameStart);
+
+	while (!moduleName.empty () && (moduleName.back () == ' ' || moduleName.back () == '\r')) {
+	    moduleName.pop_back ();
+	}
+
+	if (moduleName.empty ()) {
+	    sLog.error ("Malformed #require directive (empty module name) in shader ", this->m_file);
+	    end = lineEnd;
+	    continue;
+	}
+
+	sLog.out ("Resolving require module: ", moduleName, " in shader ", this->m_file);
+
+	std::string moduleCode = this->resolveRequireModule (moduleName);
+
+	// comment out the #require directive
 	this->m_preprocessed = this->m_preprocessed.replace (start, 2, "//");
 
-	// go to the end of the line
-	end = lineEnd;
+	if (!moduleCode.empty ()) {
+	    // insert the generated code directly into m_preprocessed at the #require location
+	    // (m_includes was already consumed by preprocessIncludes, so appending there would be lost)
+	    this->m_preprocessed.insert (start, moduleCode);
+	    end = start + moduleCode.length ();
+	} else {
+	    end = lineEnd;
+	}
     }
 }
 
+std::string ShaderUnit::resolveRequireModule (const std::string& moduleName) const {
+    if (moduleName == "LightingV1") {
+	return this->generateLightingV1 ();
+    }
+
+    sLog.error ("Unknown #require module: ", moduleName, " in shader ", this->m_file);
+    return "";
+}
+
+std::string ShaderUnit::generateLightingV1 () const {
+    // PerformLighting_V1 is dynamically generated by Wallpaper Engine based on the scene's
+    // light sources. Since linux-wallpaperengine does not yet support light objects, we
+    // generate a stub that returns no dynamic light contribution.
+    return "// begin of generated module LightingV1\n"
+	   "vec3 PerformLighting_V1(vec3 worldPos, vec3 albedo, vec3 normal, vec3 viewDir,\n"
+	   "    vec3 specularTint, vec3 baseReflectance, float roughness, float metallic)\n"
+	   "{\n"
+	   "    return vec3(0.0);\n"
+	   "}\n"
+	   "// end of generated module LightingV1\n";
+}
+
+std::string ShaderUnit::applyLinkedVaryingCompatibility (std::string source) const {
+    if (this->m_type != GLSLContext::UnitType_Vertex || this->m_link == nullptr) {
+	return source;
+    }
+
+    std::regex fragmentVec4Varying (R"(\bvarying\s+vec4\s+([A-Za-z_][A-Za-z0-9_]*)\s*;)");
+    std::smatch varyingMatch;
+    std::string linked = this->m_link->m_preprocessed;
+    size_t linkedOffset = 0;
+
+    while (std::regex_search (linked.cbegin () + linkedOffset, linked.cend (), varyingMatch, fragmentVec4Varying)) {
+	const std::string name = varyingMatch[1].str ();
+	linkedOffset += varyingMatch.position () + varyingMatch.length ();
+
+	const std::regex vertexVec2Decl ("\\bvarying\\s+vec2\\s+" + name + "\\s*;");
+	if (!std::regex_search (source, vertexVec2Decl)) {
+	    continue;
+	}
+
+	source = std::regex_replace (source, vertexVec2Decl, "varying vec4 " + name + ";");
+
+	const std::regex assignment ("(^|\\n)([ \\t]*)" + name + "\\s*=\\s*([^;\\n]+);");
+	std::smatch assignmentMatch;
+	size_t offset = 0;
+	while (std::regex_search (source.cbegin () + offset, source.cend (), assignmentMatch, assignment)) {
+	    const std::string prefix = assignmentMatch[1].str ();
+	    const std::string indent = assignmentMatch[2].str ();
+	    const std::string expression = assignmentMatch[3].str ();
+	    const std::string replacement = prefix + indent + name + " = vec4(" + expression + ", 0.0, 1.0);";
+	    const size_t position = offset + assignmentMatch.position ();
+	    source.replace (position, assignmentMatch.length (), replacement);
+	    offset = position + replacement.length ();
+	}
+    }
+
+    return source;
+}
+
+std::string ShaderUnit::applyFragmentTexCoordCompatibility (std::string source) const {
+    if (this->m_type != GLSLContext::UnitType_Fragment) {
+	return source;
+    }
+
+    const std::regex texCoordBeforeCast2 (R"(\bv_TexCoord\b(\s*[-+*/]\s*CAST2\s*\())");
+    const std::regex cast2BeforeTexCoord (R"((CAST2\s*\([^)]+\)\s*[-+*/]\s*)\bv_TexCoord\b)");
+
+    const std::regex wideTexCoordDecl (R"(\bvarying\s+vec[34]\s+v_TexCoord\s*;)");
+    if (!std::regex_search (source, wideTexCoordDecl)
+	|| (!std::regex_search (source, texCoordBeforeCast2) && !std::regex_search (source, cast2BeforeTexCoord))) {
+	return source;
+    }
+
+    const std::string original = source;
+    source = std::regex_replace (source, texCoordBeforeCast2, "v_TexCoord.xy$1");
+    source = std::regex_replace (source, cast2BeforeTexCoord, "$1v_TexCoord.xy");
+
+    if (source != original) {
+	sLog.out ("Applied fragment TexCoord vec2 compatibility in ", this->m_file);
+    }
+
+    return source;
+}
+
 void ShaderUnit::parseComboConfiguration (const std::string& content, const int defaultValue) {
     // TODO: SUPPORT REQUIRES SO WE PROPERLY FOLLOW THE REQUIRED CHAIN
-    const auto data = JSON::parse (content);
+    JSON data;
+    try {
+	data = JSON::parse (content);
+    } catch (const std::exception& e) {
+	sLog.error ("Cannot parse combo metadata in shader ", this->m_file, ": ", e.what ());
+	return;
+    }
     const auto combo = data.require<std::string> ("combo", "cannot parse combo information");
     // ignore type as it seems to be used only on the editor
     // const auto type = data.find ("type");
@@ -357,7 +482,13 @@ void ShaderUnit::parseComboConfiguration (const std::string& content, const int
 void ShaderUnit::parseParameterConfiguration (
     const std::string& type, const std::string& name, const std::string& content
 ) {
-    const auto data = JSON::parse (content);
+    JSON data;
+    try {
+	data = JSON::parse (content);
+    } catch (const std::exception& e) {
+	sLog.error ("Cannot parse parameter metadata for ", name, " in shader ", this->m_file, ": ", e.what ());
+	return;
+    }
     const auto material = data.optional ("material");
     const auto defvalue = data.optional ("default");
     // auto range = data.find ("range");
@@ -402,7 +533,6 @@ void ShaderUnit::parseParameterConfiguration (
 	// if needed
 	const auto textureName = data.find ("default");
 	// TODO: CREATE TEXTURE WITH THE GIVEN COLOR
-	const auto paintDefaultColor = data.find ("paintdefaultcolor");
 	// extract the texture number from the name
 	const char value = name.at (std::string ("g_Texture").length ());
 	const auto requireany = data.find ("requireany");
@@ -584,11 +714,12 @@ const std::string& ShaderUnit::compile () {
     }
 
     // this should be the rest of the shader
-    this->m_final += this->m_preprocessed;
+    this->m_final
+	+= this->applyFragmentTexCoordCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed));
 
     // the pass itself handles shader compilation, the unit doesn't have enough information for this step
     return this->m_final;
 }
 
 const std::vector<Variables::ShaderVariable*>& ShaderUnit::getParameters () const { return this->m_parameters; }
-const TextureMap& ShaderUnit::getTextures () const { return this->m_defaultTextures; }
+const TextureMap& ShaderUnit::getTextures () const { return this->m_defaultTextures; }

+ 22 - 1
src/WallpaperEngine/Render/Shaders/ShaderUnit.h

@@ -80,9 +80,30 @@ private:
      */
     void preprocessIncludes ();
     /**
-     * Parses the input shader lookin for require directives to comment them out for now
+     * Parses the input shader looking for require directives and resolves them into generated code
      */
     void preprocessRequires ();
+    /**
+     * Resolves a #require module name to generated GLSL code
+     *
+     * @param moduleName The module to resolve (e.g. "LightingV1")
+     * @return Generated GLSL code for the module, or empty string if unknown
+     */
+    [[nodiscard]] std::string resolveRequireModule (const std::string& moduleName) const;
+    /**
+     * Generates the LightingV1 module stub (PerformLighting_V1 function)
+     *
+     * @return GLSL code defining PerformLighting_V1
+     */
+    [[nodiscard]] std::string generateLightingV1 () const;
+    /**
+     * Adjusts vertex varyings when a workshop shader declares a narrower vertex type than its fragment peer.
+     */
+    [[nodiscard]] std::string applyLinkedVaryingCompatibility (std::string source) const;
+    /**
+     * Adjusts fragment shaders that use wide texture coordinates as vec2 values in Wallpaper Engine effects.
+     */
+    [[nodiscard]] std::string applyFragmentTexCoordCompatibility (std::string source) const;
 
     /**
      * Parses a COMBO value to add the proper define to the code

+ 394 - 3
src/WallpaperEngine/Render/TextureCache.cpp

@@ -9,13 +9,405 @@
 #include "WallpaperEngine/Data/Model/Project.h"
 #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;
+
+namespace {
+constexpr size_t kMaxThumbnailBytes = 16 * 1024 * 1024;
+
+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;
+    }
+
+    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]);
+
+    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]);
+
+    if (spawnResult != 0) {
+	close (outputPipe[0]);
+	return std::nullopt;
+    }
+
+    readFd = outputPipe[0];
+    return pid;
+}
+
+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;
+	}
+	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) { }
 
 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;
     }
@@ -27,7 +419,6 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	    auto stream = BinaryReader (contents);
 
 	    // Create metadata loader lambda that captures the assetLocator
-	    // Note: Unlike texture() which prepends "materials/", readString() doesn't,
 	    // so we need to construct the full path here
 	    auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
 		std::filesystem::path fullPath = std::filesystem::path ("materials") / metaFilename;
@@ -35,7 +426,7 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	    };
 
 	    auto parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
-	    auto texture = std::make_shared<CTexture> (std::move (parsedTexture));
+	    auto texture = std::make_shared<CTexture> (this->getContext (), std::move (parsedTexture));
 
 	    this->store (filename, texture);
 
@@ -50,4 +441,4 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 
 void TextureCache::store (const std::string& name, std::shared_ptr<const TextureProvider> texture) {
     this->m_textureCache.insert_or_assign (name, texture);
-}
+}

+ 19 - 0
src/WallpaperEngine/Render/TextureProvider.h

@@ -78,5 +78,24 @@ public:
      * @return Duration of spritesheet animation in seconds
      */
     [[nodiscard]] virtual float getSpritesheetDuration () const = 0;
+
+    /**
+     * Increments the usage count of the texture
+     *
+     * Directly controls playback for video CTextures, only started when at least one thing is using it
+     * Initializes mpv if needed and starts playback
+     */
+    virtual void incrementUsageCount () const = 0;
+    /**
+     * Decrements the usage count of the texture
+     *
+     * Directly controls playback for video CTextures, only stopped when nothing is using it
+     * De-initializes mpv if needed
+     */
+    virtual void decrementUsageCount () const = 0;
+    /**
+     * Allows the texture contents to be updated (for example, for video textures)
+     */
+    virtual void update () const = 0;
 };
 } // namespace WallpaperEngine::Render

+ 231 - 30
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -1,6 +1,7 @@
 #include "WallpaperEngine/Render/Objects/CImage.h"
 #include "WallpaperEngine/Render/Objects/CParticle.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
+#include "WallpaperEngine/Render/Objects/CText.h"
 
 #include "WallpaperEngine/Render/WallpaperState.h"
 
@@ -10,6 +11,8 @@
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Parsers/ObjectParser.h"
 
+#include <ranges>
+
 extern float g_Time;
 extern float g_TimeLast;
 
@@ -20,6 +23,37 @@ 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,
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
@@ -35,7 +69,33 @@ CScene::CScene (
 
     // detect size if the orthogonal project is auto
     if (scene->camera.projection.isAuto) {
-	// TODO: CALCULATE ORTHOGONAL PROJECTION BASED ON CONTENT'S SIZE HERE
+	glm::vec2 maxExtent = { 0.0f, 0.0f };
+
+	for (const auto& object : scene->objects) {
+	    if (!object->is<Image> ()) {
+		continue;
+	    }
+
+	    const auto* image = object->as<Image> ();
+	    if (!image->origin || !image->origin->value) {
+		continue;
+	    }
+
+	    const glm::vec3 origin = image->origin->value->getVec3 ();
+	    const glm::vec2 halfSize = image->size / 2.0f;
+
+	    maxExtent.x = glm::max (maxExtent.x, glm::abs (origin.x) + halfSize.x);
+	    maxExtent.y = glm::max (maxExtent.y, glm::abs (origin.y) + halfSize.y);
+	}
+
+	if (maxExtent.x > 0.0f && maxExtent.y > 0.0f) {
+	    width = maxExtent.x * 2.0f;
+	    height = maxExtent.y * 2.0f;
+	} else {
+	    width = this->getContext ().getOutput ().getFullWidth ();
+	    height = this->getContext ().getOutput ().getFullHeight ();
+	    sLog.debug ("Auto projection: falling back to screen resolution ", width, "x", height);
+	}
     }
 
     this->m_parallaxDisplacement = { 0, 0 };
@@ -70,6 +130,8 @@ 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 },
@@ -171,36 +233,23 @@ Render::CObject* CScene::createObject (const Object& object) {
 	}
     }
 
-    if (object.is<Image> ()) {
-	auto* image = new Objects::CImage (*this, *object.as<Image> ());
+    // check if the item has any parent and also create it first
+    if (object.parent.has_value ()) {
+	int parentId = object.parent.value ();
 
-	try {
-	    image->setup ();
-	} catch (std::runtime_error&) {
-	    // this error message is already printed, so just show extra info about it
-	    sLog.error ("Cannot setup image ", image->getImage ().name);
-	}
+	const auto dep = std::ranges::find_if (this->getScene ().objects, [&parentId] (const auto& o) {
+	    return o->id == parentId;
+	});
 
-	renderObject = image;
-    } else if (object.is<Sound> ()) {
-	renderObject = new Objects::CSound (*this, *object.as<Sound> ());
-    } else if (object.is<Particle> ()) {
-	if (this->getContext ().getApp ().getContext ().settings.general.disableParticles == true) {
-	    sLog.debug ("Ignoring particle system (disabled in settings): ", object.as<Particle> ()->name);
-	    return nullptr;
+	if (dep == this->getScene ().objects.end ()) {
+	    sLog.exception ("Cannot find parent ", parentId, " for object ", object.id);
 	}
 
-	auto* particle = new Objects::CParticle (*this, *object.as<Particle> ());
-
-	try {
-	    particle->setup ();
-	} catch (std::runtime_error&) {
-	    sLog.error ("Cannot setup particle ", particle->getParticle ().name);
-	}
-
-	renderObject = particle;
+	this->createObject (**dep);
     }
 
+    renderObject = this->dispatchObjectType (object);
+
     if (renderObject != nullptr) {
 	this->m_objects.emplace (renderObject->getId (), renderObject);
     }
@@ -208,6 +257,33 @@ Render::CObject* CScene::createObject (const Object& object) {
     return renderObject;
 }
 
+Render::CObject* CScene::dispatchObjectType (const Object& object) {
+    Render::CObject* renderObject = nullptr;
+
+    if (object.is<Image> ()) {
+	renderObject = createImageObject (*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;
+	    return nullptr;
+	}
+	renderObject = text;
+    } else if (object.is<Particle> ()) {
+	renderObject = createParticleObject (*this, *object.as<Particle> ());
+    } else {
+	sLog.debug ("Unknown object type, creating placeholder, empty object: ", object.id);
+	renderObject = new CObject (*this, object);
+    }
+
+    return renderObject;
+}
+
 void CScene::addObjectToRenderOrder (const Object& object) {
     const auto obj = this->m_objects.find (object.id);
 
@@ -243,25 +319,115 @@ 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);
+    }
+}
+
 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) {
 	const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
 	const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
-	const float delay = glm::min (
-	    static_cast<float> (this->getScene ().camera.parallax.delay->value->getBool ()), g_Time - g_TimeLast
+	const float delay = glm::clamp (
+	    this->getScene ().camera.parallax.delay->value->getFloat () * (g_Time - g_TimeLast), 0.0f, 1.0f
 	);
 
+	const glm::vec2 centeredMouse = this->m_mousePosition - glm::vec2 (0.5f, 0.5f);
 	this->m_parallaxDisplacement
-	    = glm::mix (this->m_parallaxDisplacement, (this->m_mousePosition * amount) * influence, delay);
+	    = glm::mix (this->m_parallaxDisplacement, (centeredMouse * amount) * influence, delay);
     }
 
+    // update main textures for images
+    for (const auto& cur : this->m_objectsByRenderOrder) {
+	if (!cur->is<Objects::CImage> ()) {
+	    continue;
+	}
+
+	const Objects::CImage* image = cur->as<Objects::CImage> ();
+
+#if !NDEBUG
+	const std::string message = "Updating texture " + image->getImage ().model->filename;
+
+	glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, message.c_str ());
+#endif
+
+	image->getTexture ()->update ();
+
+#if !NDEBUG
+	glPopDebugGroup ();
+#endif
+    }
+
+    // bind the vertex array
+    glBindVertexArray (this->m_vaoBuffer);
     // use the scene's framebuffer by default
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
     // ensure we render over the whole framebuffer
@@ -270,6 +436,14 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
     for (const auto& cur : this->m_objectsByRenderOrder) {
+	const auto& debug = this->getContext ().getApp ().getContext ().settings.render.debug;
+	if (debug.objectFilter.has_value () && cur->getId () != debug.objectFilter.value ()) {
+	    continue;
+	}
+	if (std::ranges::find (debug.skipObjects, cur->getId ()) != debug.skipObjects.end ()) {
+	    continue;
+	}
+
 	cur->render ();
     }
 }
@@ -285,7 +459,7 @@ void CScene::updateMouse (const glm::ivec4& viewport) {
     double mouseX = glm::clamp ((position.x - viewport.x) / viewport.z, 0.0, 1.0);
     // Normalize Y coordinate (OpenGL convention: 0=bottom, 1=top)
     // Particle code expects this convention: 0=bottom results in negative Y (down), 1=top results in positive Y (up)
-    double mouseY = glm::clamp ((position.y - viewport.y) / viewport.w, 0.0, 1.0);
+    double normalizedMouseY = glm::clamp ((position.y - viewport.y) / viewport.w, 0.0, 1.0);
 
     // Account for UV cropping when using fill/fit scaling modes
     // The scene may be rendered larger than viewport and cropped via UVs
@@ -293,7 +467,13 @@ void CScene::updateMouse (const glm::ivec4& viewport) {
 
     // Map mouse position from viewport space to scene UV space
     // UVs define what portion of the scene texture is visible
-    this->m_mousePosition.x = uvs.ustart + mouseX * (uvs.uend - uvs.ustart);
+    this->m_mousePositionNormalized.x = uvs.ustart + mouseX * (uvs.uend - uvs.ustart);
+    this->m_mousePositionNormalized.y = uvs.vstart + normalizedMouseY * (uvs.vend - uvs.vstart);
+
+    // Invert previous normalization of Y to match what the shader expects
+    double mouseY = 1.0 - normalizedMouseY;
+
+    this->m_mousePosition.x = this->m_mousePositionNormalized.x;
     this->m_mousePosition.y = uvs.vstart + mouseY * (uvs.vend - uvs.vstart);
 }
 
@@ -303,10 +483,31 @@ int CScene::getWidth () const { return this->m_camera->getWidth (); }
 
 int CScene::getHeight () const { return this->m_camera->getHeight (); }
 
+float CScene::getTime () const { return g_Time; }
+
+float CScene::getDeltaTime () const { return g_Time - g_TimeLast; }
+
+float CScene::getFps () const {
+    const float dt = g_Time - g_TimeLast;
+    // Guard against the first frame (where g_TimeLast is 0 so dt == g_Time)
+    // and division by zero on the very first call.
+    if (dt <= 1e-6f) {
+	return 60.0f;
+    }
+    return 1.0f / dt;
+}
+
 const glm::vec2* CScene::getMousePosition () const { return &this->m_mousePosition; }
 
 const glm::vec2* CScene::getMousePositionLast () const { return &this->m_mousePositionLast; }
 
+const glm::vec2* CScene::getMousePositionNormalized () const { return &this->m_mousePositionNormalized; }
+
 const glm::vec2* CScene::getParallaxDisplacement () const { return &this->m_parallaxDisplacement; }
 
 const std::vector<CObject*>& CScene::getObjectsByRenderOrder () const { return this->m_objectsByRenderOrder; }
+
+const CObject* CScene::getObject (int id) const {
+    const auto object = this->m_objects.find (id);
+    return object == this->m_objects.end () ? nullptr : object->second;
+}

+ 16 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -2,6 +2,7 @@
 
 #include "WallpaperEngine/Render/Camera.h"
 
+#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
 
 namespace WallpaperEngine::Render {
@@ -28,11 +29,20 @@ public:
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () const override;
 
+    // Time accessors used by dynamic text layers (CText + ScriptEngine).
+    // Read from the application-wide g_Time/g_TimeLast globals that other
+    // renderers already consume via extern (e.g. CParticle).
+    [[nodiscard]] float getTime () const;
+    [[nodiscard]] float getDeltaTime () const;
+    [[nodiscard]] float getFps () const;
+
     const glm::vec2* getMousePosition () const;
     const glm::vec2* getMousePositionLast () const;
+    const glm::vec2* getMousePositionNormalized () const;
     const glm::vec2* getParallaxDisplacement () const;
 
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
+    [[nodiscard]] const CObject* getObject (int id) const;
 
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
@@ -42,15 +52,21 @@ protected:
 
 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<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 = {};
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
+    glm::vec2 m_mousePositionNormalized = {};
     glm::vec2 m_parallaxDisplacement = {};
     std::shared_ptr<const CFBO> _rt_4FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_8FrameBuffer = nullptr;

+ 26 - 134
src/WallpaperEngine/Render/Wallpapers/CVideo.cpp

@@ -1,113 +1,36 @@
 #include "CVideo.h"
-#include "WallpaperEngine/Logging/Log.h"
-
-#include <GL/glew.h>
 
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render::Wallpapers;
-
-void* get_proc_address (void* ctx, const char* name) {
-    return static_cast<CVideo*> (ctx)->getContext ().getDriver ().getProcAddress (name);
-}
+using namespace WallpaperEngine::VideoPlayback::MPV;
 
 CVideo::CVideo (
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
-    double volume = this->getContext ().getApp ().getContext ().settings.audio.volume * 100.0 / 128.0;
-
-    // create mpv contexts
-    this->m_mpv = mpv_create ();
-
-    if (this->m_mpv == nullptr) {
-	sLog.exception ("Could not create mpv context");
-    }
-
-    mpv_set_option_string (this->m_mpv, "terminal", "yes");
-#if NDEBUG
-    mpv_set_option_string (this->m_mpv, "msg-level", "all=status,statusline=no");
-#else
-    mpv_set_option_string (this->m_mpv, "msg-level", "all=v");
-#endif
-    mpv_set_option_string (this->m_mpv, "input-cursor", "no");
-    mpv_set_option_string (this->m_mpv, "cursor-autohide", "no");
-    mpv_set_option_string (this->m_mpv, "config", "no");
-    mpv_set_option_string (this->m_mpv, "fbo-format", "rgba8");
-    mpv_set_option_string (this->m_mpv, "vo", "libmpv");
-
-    if (mpv_initialize (this->m_mpv) < 0) {
-	sLog.exception ("Could not initialize mpv context");
-    }
-
-    mpv_set_option_string (this->m_mpv, "hwdec", "auto");
-    mpv_set_option_string (this->m_mpv, "loop", "inf");
-    mpv_set_option (this->m_mpv, "volume", MPV_FORMAT_DOUBLE, &volume);
-
-    if (!this->getContext ().getApp ().getContext ().settings.audio.enabled) {
-	this->m_muted = true;
-	mpv_set_option_string (this->m_mpv, "mute", "yes");
-    }
-
-    // initialize gl context for mpv
-    mpv_opengl_init_params gl_init_params { get_proc_address, this };
-    mpv_render_param params[] { { MPV_RENDER_PARAM_API_TYPE, const_cast<char*> (MPV_RENDER_API_TYPE_OPENGL) },
-				{ MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params },
-				{ MPV_RENDER_PARAM_INVALID, nullptr } };
-
-    if (mpv_render_context_create (&this->m_mpvGl, this->m_mpv, params) < 0) {
-	sLog.exception ("Failed to initialize MPV's GL context");
-    }
-
-    const std::filesystem::path videopath
-	= this->getVideo ().project.assetLocator->physicalPath (this->getVideo ().filename);
-
-    // build the path to the video file
-    const char* command[] = { "loadfile", videopath.c_str (), nullptr };
-
-    if (mpv_command (this->m_mpv, command) < 0) {
-	sLog.exception ("Cannot load video to play");
-    }
-
-    if (!this->getContext ().getApp ().getContext ().settings.audio.enabled) {
-	const char* mutecommand[] = { "set", "mute", "yes", nullptr };
-
-	mpv_command (this->m_mpv, mutecommand);
-    }
-
     // setup framebuffers
     this->setupFramebuffers ();
-}
 
-CVideo::~CVideo () {
-    // stop mpv and cleanup resources
-    if (this->m_mpvGl) {
-	mpv_render_context_free (this->m_mpvGl);
-	this->m_mpvGl = nullptr;
-    }
+    const std::filesystem::path videopath
+	= this->getVideo ().project.assetLocator->physicalPath (this->getVideo ().filename);
 
-    if (this->m_mpv) {
-	mpv_terminate_destroy (this->m_mpv);
-	this->m_mpv = nullptr;
-    }
+    // create a player with a small framebuffer
+    // this will be changed after mpv starts playback and sees the video resolution
+    this->m_player = std::make_unique<GLPlayer> (
+	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);
+    // make sure the video has at least one usage marked, this ensures the video plays
+    this->m_player->incrementUsageCount ();
 }
 
-void CVideo::setSize (const int width, const int height) {
-    this->m_width = width > 0 ? width : this->m_width;
-    this->m_height = height > 0 ? height : this->m_height;
-
-    // do not refresh the texture if any of the sizes are invalid
-    if (this->m_width <= 0 || this->m_height <= 0) {
-	return;
-    }
-
-    // reconfigure the texture
-    glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
-    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, this->m_width, this->m_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
-}
+CVideo::~CVideo () { this->m_player->decrementUsageCount (); }
 
 void CVideo::renderFrame (const glm::ivec4& viewport) {
     // ensure the video's audio follows audio detection rules
@@ -115,57 +38,26 @@ void CVideo::renderFrame (const glm::ivec4& viewport) {
 	&& this->m_muted != this->getAudioContext ().getDriver ().getAudioDetector ().anythingPlaying ()) {
 	this->m_muted = !this->m_muted;
 
-	const char* mutecommand[] = { "set", "mute", this->m_muted ? "yes" : "no", nullptr };
-
-	mpv_command (this->m_mpv, mutecommand);
-    }
-
-    // read any and all the events available
-    while (this->m_mpv) {
-	const mpv_event* event = mpv_wait_event (this->m_mpv, 0);
-
-	if (event == nullptr || event->event_id == MPV_EVENT_NONE) {
-	    break;
-	}
-
-	// we do not care about any of the events
-	if (event->event_id == MPV_EVENT_VIDEO_RECONFIG) {
-	    int64_t width, height;
-
-	    if (mpv_get_property (this->m_mpv, "dwidth", MPV_FORMAT_INT64, &width) >= 0
-		&& mpv_get_property (this->m_mpv, "dheight", MPV_FORMAT_INT64, &height) >= 0) {
-		this->setSize (width, height);
-	    }
+	if (this->m_muted) {
+	    this->m_player->setMuted ();
+	} else {
+	    this->m_player->clearMuted ();
 	}
     }
 
-    // render the next
-    glViewport (0, 0, this->getWidth (), this->getHeight ());
-
-    mpv_opengl_fbo fbo { static_cast<int> (this->getWallpaperFramebuffer ()), static_cast<int> (this->m_width),
-			 static_cast<int> (this->m_height), GL_RGBA8 };
-
-    // no need to flip as it'll be handled by the wallpaper rendering code
-    int flip_y = 0;
-
-    mpv_render_param params[] = { { MPV_RENDER_PARAM_OPENGL_FBO, &fbo },
-				  { MPV_RENDER_PARAM_FLIP_Y, &flip_y },
-				  { MPV_RENDER_PARAM_INVALID, nullptr } };
-
-    mpv_render_context_render (this->m_mpvGl, params);
+    this->m_player->render ();
 }
 
-const Video& CVideo::getVideo () const { return *this->getWallpaperData ().as<Video> (); }
+const Data::Model::Video& CVideo::getVideo () const { return *this->getWallpaperData ().as<Data::Model::Video> (); }
 
 void CVideo::setPause (bool newState) {
-    if (m_paused == newState) {
-	return;
+    if (newState) {
+	this->m_player->setPaused ();
+    } else {
+	this->m_player->clearPaused ();
     }
-    m_paused = newState;
-    int pause = newState;
-    mpv_set_property (m_mpv, "pause", MPV_FORMAT_FLAG, &pause);
 }
 
-int CVideo::getWidth () const { return this->m_width; }
+int CVideo::getWidth () const { return this->m_player ? this->m_player->getWidth () : 16; }
 
-int CVideo::getHeight () const { return this->m_height; }
+int CVideo::getHeight () const { return this->m_player ? this->m_player->getHeight () : 16; }

+ 5 - 9
src/WallpaperEngine/Render/Wallpapers/CVideo.h

@@ -2,10 +2,11 @@
 
 #include "WallpaperEngine/Audio/AudioStream.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
-#include <mpv/client.h>
-#include <mpv/render_gl.h>
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
 
 namespace WallpaperEngine::Render::Wallpapers {
+using namespace WallpaperEngine::VideoPlayback::MPV;
+
 class CVideo final : public CWallpaper {
 public:
     CVideo (
@@ -15,13 +16,12 @@ public:
 
     ~CVideo () override;
 
-    const Video& getVideo () const;
+    const Data::Model::Video& getVideo () const;
 
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () const override;
 
     void setPause (bool newState) override;
-    void setSize (int width, int height);
 
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
@@ -29,12 +29,8 @@ protected:
     friend class CWallpaper;
 
 private:
-    mpv_handle* m_mpv = nullptr;
-    mpv_render_context* m_mpvGl = nullptr;
+    GLPlayerUniquePtr m_player;
 
-    bool m_paused = false;
-    int64_t m_width = 16;
-    int64_t m_height = 16;
     bool m_muted = false;
 };
 } // namespace WallpaperEngine::Render::Wallpapers

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

@@ -0,0 +1,1754 @@
+#include "ScriptEngine.h"
+#include "WallpaperEngine/Audio/AudioContext.h"
+#include "WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+
+#include <algorithm>
+#include <array>
+#include <cerrno>
+#include <chrono>
+#include <cmath>
+#include <ctime>
+#include <cstring>
+#include <future>
+#include <optional>
+#include <poll.h>
+#include <signal.h>
+#include <spawn.h>
+#include <ranges>
+#include <sstream>
+#include <string_view>
+#include <sys/wait.h>
+#include <unistd.h>
+#include <vector>
+
+using namespace WallpaperEngine::Scripting;
+using namespace WallpaperEngine::Data::Model;
+
+extern char** environ;
+extern float g_Time;
+extern float g_TimeLast;
+
+static std::unique_ptr<ScriptEngine> sScriptEngine;
+static const auto sStartTime = std::chrono::steady_clock::now ();
+
+namespace {
+std::string trimTrailingNewlines (std::string value) {
+    while (!value.empty () && (value.back () == '\n' || value.back () == '\r')) {
+	value.pop_back ();
+    }
+    return value;
+}
+
+std::vector<std::string> splitTabs (const std::string& value) {
+    std::vector<std::string> result;
+    size_t start = 0;
+    while (start <= value.size ()) {
+	const size_t end = value.find ('\t', start);
+	if (end == std::string::npos) {
+	    result.emplace_back (value.substr (start));
+	    break;
+	}
+	result.emplace_back (value.substr (start, end - start));
+	start = end + 1;
+    }
+    return result;
+}
+
+double parseDoubleOrZero (const std::string& value) {
+    try {
+	return std::stod (value);
+    } catch (...) {
+	return 0.0;
+    }
+}
+
+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;
+    }
+
+    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]);
+
+    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);
+
+    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]);
+
+    if (spawnResult != 0) {
+	close (outputPipe[0]);
+	return std::nullopt;
+    }
+
+    readFd = outputPipe[0];
+    return pid;
+}
+
+bool readFirstLineUntil (
+    int readFd, const std::chrono::steady_clock::time_point& deadline, std::string& output
+) {
+    std::array<char, 512> buffer {};
+    while (std::chrono::steady_clock::now () < deadline) {
+	const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds> (
+	    deadline - std::chrono::steady_clock::now ()
+	);
+	pollfd readPoll {
+	    .fd = readFd,
+	    .events = POLLIN,
+	    .revents = 0,
+	};
+
+	const int pollResult = poll (&readPoll, 1, static_cast<int> (std::max (remaining.count (), int64_t { 1 })));
+	if (pollResult == 0) {
+	    return false;
+	}
+	if (pollResult < 0) {
+	    if (errno == EINTR) {
+		continue;
+	    }
+	    return false;
+	}
+	if (readPoll.revents & (POLLERR | POLLNVAL)) {
+	    return false;
+	}
+	if (!(readPoll.revents & POLLIN)) {
+	    return (readPoll.revents & POLLHUP) != 0;
+	}
+
+	const ssize_t bytesRead = read (readFd, buffer.data (), buffer.size ());
+	if (bytesRead <= 0) {
+	    return true;
+	}
+	output.append (buffer.data (), static_cast<size_t> (bytesRead));
+	if (const auto newline = output.find ('\n'); newline != std::string::npos) {
+	    output.erase (newline + 1);
+	    return true;
+	}
+    }
+
+    return false;
+}
+
+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;
+	}
+	if (waitResult < 0) {
+	    return false;
+	}
+	usleep (1000);
+    }
+
+    kill (pid, SIGKILL);
+    waitpid (pid, &status, 0);
+    return false;
+}
+
+std::optional<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::string {};
+    }
+
+    std::string output;
+    const bool readCompleted = readFirstLineUntil (outputFd, deadline, output);
+    close (outputFd);
+
+    int status = 0;
+    if (!readCompleted || !waitForProcessUntil (*pid, deadline, status)) {
+	return std::nullopt;
+    }
+    if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) {
+	return std::string {};
+    }
+
+    return trimTrailingNewlines (output);
+}
+
+std::string formatMediaTime (double seconds) {
+    const auto totalSeconds = static_cast<int> (std::max (0.0, std::floor (seconds)));
+    const int hours = totalSeconds / 3600;
+    const int minutes = (totalSeconds % 3600) / 60;
+    const int remainingSeconds = totalSeconds % 60;
+
+    std::ostringstream stream;
+    if (hours > 0) {
+	stream << hours << ":";
+	if (minutes < 10) {
+	    stream << "0";
+	}
+    }
+    stream << minutes << ":";
+    if (remainingSeconds < 10) {
+	stream << "0";
+    }
+    stream << remainingSeconds;
+    return stream.str ();
+}
+
+std::string shortenMediaText (const std::string& value, size_t maxLength) {
+    if (value.size () <= maxLength) {
+	return value;
+    }
+    if (maxLength <= 3) {
+	return value.substr (0, maxLength);
+    }
+    return value.substr (0, maxLength - 3) + "...";
+}
+}
+
+ScriptEngine& ScriptEngine::instance () {
+    if (!sScriptEngine) {
+	sScriptEngine = std::unique_ptr<ScriptEngine> (new ScriptEngine ());
+    }
+    return *sScriptEngine;
+}
+
+ScriptEngine::ScriptEngine () {
+    this->m_runtime = JS_NewRuntime ();
+    if (!this->m_runtime) {
+	sLog.error ("ScriptEngine: Failed to create JS runtime");
+	return;
+    }
+
+    this->m_context = JS_NewContext (this->m_runtime);
+    if (!this->m_context) {
+	sLog.error ("ScriptEngine: Failed to create JS context");
+	JS_FreeRuntime (this->m_runtime);
+	this->m_runtime = nullptr;
+	return;
+    }
+}
+
+ScriptEngine::~ScriptEngine () {
+    if (this->m_mediaPollFuture.valid ()) {
+	this->m_mediaPollFuture.wait ();
+    }
+
+    if (this->m_context) {
+	for (const auto& module : this->m_modules | std::views::values) {
+	    JS_FreeValue (this->m_context, module);
+	}
+    }
+    this->m_modules.clear ();
+
+    if (this->m_context) {
+	JS_FreeContext (this->m_context);
+    }
+    if (this->m_runtime) {
+	JS_FreeRuntime (this->m_runtime);
+    }
+}
+
+static JSValue constructVectorObject (JSContext* ctx, const char* name, const std::vector<double>& values) {
+    auto constructPlainObject = [&] () {
+	JSValue obj = JS_NewObject (ctx);
+	if (!values.empty ()) JS_SetPropertyStr (ctx, obj, "x", JS_NewFloat64 (ctx, values[0]));
+	if (values.size () > 1) JS_SetPropertyStr (ctx, obj, "y", JS_NewFloat64 (ctx, values[1]));
+	if (values.size () > 2) JS_SetPropertyStr (ctx, obj, "z", JS_NewFloat64 (ctx, values[2]));
+	if (values.size () > 3) JS_SetPropertyStr (ctx, obj, "w", JS_NewFloat64 (ctx, values[3]));
+	return obj;
+    };
+
+    JSValue global = JS_GetGlobalObject (ctx);
+    JSValue ctor = JS_GetPropertyStr (ctx, global, name);
+    JS_FreeValue (ctx, global);
+
+    if (!JS_IsFunction (ctx, ctor)) {
+	JS_FreeValue (ctx, ctor);
+	return constructPlainObject ();
+    }
+
+    std::vector<JSValue> args;
+    args.reserve (values.size ());
+    for (const auto& v : values) {
+	args.emplace_back (JS_NewFloat64 (ctx, v));
+    }
+
+    JSValue obj = JS_CallConstructor (ctx, ctor, args.size (), args.data ());
+    for (auto& arg : args) {
+	JS_FreeValue (ctx, arg);
+    }
+    JS_FreeValue (ctx, ctor);
+    if (JS_IsException (obj)) {
+	JSValue exception = JS_GetException (ctx);
+	JS_FreeValue (ctx, exception);
+	return constructPlainObject ();
+    }
+    return obj;
+}
+
+JSValue ScriptEngine::dynamicValueToJS (const DynamicValue& value) const {
+    JSContext* ctx = this->m_context;
+
+    switch (value.getType ()) {
+	case DynamicValue::String:
+	    return JS_NewString (ctx, value.getString ().c_str ());
+	case DynamicValue::Float:
+	    return JS_NewFloat64 (ctx, value.getFloat ());
+	case DynamicValue::Int:
+	    return JS_NewInt32 (ctx, value.getInt ());
+	case DynamicValue::Boolean:
+	    return JS_NewBool (ctx, value.getBool ());
+	case DynamicValue::Vec2:
+	    return constructVectorObject (ctx, "Vec2", { value.getVec2 ().x, value.getVec2 ().y });
+	case DynamicValue::Vec3:
+	    return constructVectorObject (ctx, "Vec3", { value.getVec3 ().x, value.getVec3 ().y, value.getVec3 ().z });
+	case DynamicValue::Vec4:
+	    return constructVectorObject (ctx, "Vec4", { value.getVec4 ().x, value.getVec4 ().y, value.getVec4 ().z, value.getVec4 ().w });
+	case DynamicValue::IVec2:
+	    return constructVectorObject (ctx, "Vec2", { value.getIVec2 ().x, value.getIVec2 ().y });
+	case DynamicValue::IVec3:
+	    return constructVectorObject (ctx, "Vec3", { value.getIVec3 ().x, value.getIVec3 ().y, value.getIVec3 ().z });
+	case DynamicValue::IVec4:
+	    return constructVectorObject (ctx, "Vec4", { value.getIVec4 ().x, value.getIVec4 ().y, value.getIVec4 ().z, value.getIVec4 ().w });
+	default:
+	    return JS_UNDEFINED;
+    }
+}
+
+DynamicValueUniquePtr ScriptEngine::jsToDynamicValue (JSValue val, DynamicValue::UnderlyingType hint) const {
+    JSContext* ctx = this->m_context;
+    auto result = std::make_unique<DynamicValue> ();
+
+    if (JS_IsException (val)) {
+	return result;
+    }
+
+    auto updateString = [&] {
+	const char* str = JS_ToCString (ctx, val);
+	result->update (str == nullptr ? "" : str);
+	JS_FreeCString (ctx, str);
+    };
+
+    // scalar types returned directly
+    int tag = JS_VALUE_GET_TAG (val);
+    if (tag == JS_TAG_INT) {
+	if (hint == DynamicValue::String) {
+	    updateString ();
+	    return result;
+	}
+	int32_t i;
+	JS_ToInt32 (ctx, &i, val);
+	if (hint == DynamicValue::Float) {
+	    result->update (static_cast<float> (i));
+	} else {
+	    result->update (static_cast<int> (i));
+	}
+	return result;
+    }
+    if (tag == JS_TAG_BOOL) {
+	if (hint == DynamicValue::String) {
+	    updateString ();
+	    return result;
+	}
+	result->update (static_cast<bool> (JS_ToBool (ctx, val)));
+	return result;
+    }
+    if (JS_TAG_IS_FLOAT64 (tag)) {
+	if (hint == DynamicValue::String) {
+	    updateString ();
+	    return result;
+	}
+	double d;
+	JS_ToFloat64 (ctx, &d, val);
+	result->update (static_cast<float> (d));
+	return result;
+    }
+    if (tag == JS_TAG_STRING) {
+	const char* str = JS_ToCString (ctx, val);
+	result->update (str == nullptr ? "" : str);
+	JS_FreeCString (ctx, str);
+	return result;
+    }
+
+    // object - extract x/y/z/w properties based on the hint type
+    if (tag == JS_TAG_OBJECT) {
+	auto readFloat = [&] (const char* prop) -> float {
+	    JSValue v = JS_GetPropertyStr (ctx, val, prop);
+	    double d = 0.0;
+	    if (!JS_IsException (v) && !JS_IsUndefined (v)) {
+		JS_ToFloat64 (ctx, &d, v);
+	    }
+	    JS_FreeValue (ctx, v);
+	    return static_cast<float> (d);
+	};
+
+	auto readInt = [&] (const char* prop) -> int {
+	    JSValue v = JS_GetPropertyStr (ctx, val, prop);
+	    int32_t i = 0;
+	    if (!JS_IsException (v) && !JS_IsUndefined (v)) {
+		JS_ToInt32 (ctx, &i, v);
+	    }
+	    JS_FreeValue (ctx, v);
+	    return static_cast<int> (i);
+	};
+
+	switch (hint) {
+	    case DynamicValue::Vec2:
+		result->update (glm::vec2 (readFloat ("x"), readFloat ("y")));
+		break;
+	    case DynamicValue::Vec3:
+		result->update (glm::vec3 (readFloat ("x"), readFloat ("y"), readFloat ("z")));
+		break;
+	    case DynamicValue::Vec4:
+		result->update (
+		    glm::vec4 (readFloat ("x"), readFloat ("y"), readFloat ("z"), readFloat ("w")));
+		break;
+	    case DynamicValue::IVec2:
+		result->update (glm::ivec2 (readInt ("x"), readInt ("y")));
+		break;
+	    case DynamicValue::IVec3:
+		result->update (glm::ivec3 (readInt ("x"), readInt ("y"), readInt ("z")));
+		break;
+	    case DynamicValue::IVec4:
+		result->update (
+		    glm::ivec4 (readInt ("x"), readInt ("y"), readInt ("z"), readInt ("w")));
+		break;
+	    default: {
+		// try to read as vec3 by default (most common for origin/angles)
+		float x = readFloat ("x");
+		float y = readFloat ("y");
+		float z = readFloat ("z");
+		result->update (glm::vec3 (x, y, z));
+		break;
+	    }
+	}
+	return result;
+    }
+
+    // fallback: try as float
+    double d;
+    if (JS_ToFloat64 (ctx, &d, val) == 0) {
+	result->update (static_cast<float> (d));
+    }
+    return result;
+}
+
+/// Helper to check for and log JS exceptions
+static void logJSException (JSContext* ctx, const char* context) {
+    JSValue exc = JS_GetException (ctx);
+    if (!JS_IsNull (exc) && !JS_IsUndefined (exc)) {
+	const char* str = JS_ToCString (ctx, exc);
+	if (str) {
+	    sLog.error ("ScriptEngine [", context, "]: ", str);
+	    JS_FreeCString (ctx, str);
+	}
+    }
+    JS_FreeValue (ctx, exc);
+}
+
+static UserSetting* imageSettingForProperty (const Image& image, const std::string& property) {
+    if (property == "scale") return image.scale.get ();
+    if (property == "angles") return image.angles.get ();
+    if (property == "visible") return image.visible.get ();
+    if (property == "alpha") return image.alpha.get ();
+    if (property == "color") return image.color.get ();
+    if (property == "parallaxDepth") return image.parallaxDepth.get ();
+    return nullptr;
+}
+
+static UserSetting* particleSettingForProperty (const Particle& particle, const std::string& property) {
+    if (property == "scale") return particle.scale.get ();
+    if (property == "angles") return particle.angles.get ();
+    if (property == "visible") return particle.visible.get ();
+    if (property == "parallaxDepth") return particle.parallaxDepth.get ();
+    return nullptr;
+}
+
+static UserSetting* textSettingForProperty (const Text& text, const std::string& property) {
+    if (property == "scale") return text.scale.get ();
+    if (property == "visible") return text.visible.get ();
+    if (property == "alpha") return text.alpha.get ();
+    if (property == "color") return text.color.get ();
+    if (property == "pointSize") return text.pointSize.get ();
+    return nullptr;
+}
+
+static UserSetting* settingForProperty (const Object& object, const std::string& property) {
+    if (property == "origin") {
+	return object.origin.get ();
+    }
+
+    if (object.is<Image> ()) {
+	return imageSettingForProperty (*object.as<Image> (), property);
+    }
+
+    if (object.is<Particle> ()) {
+	return particleSettingForProperty (*object.as<Particle> (), property);
+    }
+
+    if (object.is<Text> ()) {
+	return textSettingForProperty (*object.as<Text> (), property);
+    }
+
+    if (property == "scale") return object.groupScale.get ();
+    if (property == "angles") return object.groupAngles.get ();
+    if (property == "visible") return object.groupVisible.get ();
+
+    return nullptr;
+}
+
+static void setObjectPropertyFromDynamicValue (JSContext* ctx, JSValue obj, const char* name, const DynamicValue& value) {
+    switch (value.getType ()) {
+	case DynamicValue::Float:
+	    JS_SetPropertyStr (ctx, obj, name, JS_NewFloat64 (ctx, value.getFloat ()));
+	    break;
+	case DynamicValue::Int:
+	    JS_SetPropertyStr (ctx, obj, name, JS_NewInt32 (ctx, value.getInt ()));
+	    break;
+	case DynamicValue::Boolean:
+	    JS_SetPropertyStr (ctx, obj, name, JS_NewBool (ctx, value.getBool ()));
+	    break;
+	case DynamicValue::String:
+	    JS_SetPropertyStr (ctx, obj, name, JS_NewString (ctx, value.getString ().c_str ()));
+	    break;
+	case DynamicValue::Vec2:
+	    JS_SetPropertyStr (ctx, obj, name, constructVectorObject (ctx, "Vec2", { value.getVec2 ().x, value.getVec2 ().y }));
+	    break;
+	case DynamicValue::Vec3:
+	    JS_SetPropertyStr (
+		ctx, obj, name,
+		constructVectorObject (ctx, "Vec3", { value.getVec3 ().x, value.getVec3 ().y, value.getVec3 ().z })
+	    );
+	    break;
+	case DynamicValue::Vec4:
+	    JS_SetPropertyStr (
+		ctx, obj, name,
+		constructVectorObject (
+		    ctx, "Vec4", { value.getVec4 ().x, value.getVec4 ().y, value.getVec4 ().z, value.getVec4 ().w }
+		)
+	    );
+	    break;
+	case DynamicValue::IVec2:
+	    JS_SetPropertyStr (
+		ctx, obj, name,
+		constructVectorObject (
+		    ctx, "Vec2", { static_cast<double> (value.getIVec2 ().x), static_cast<double> (value.getIVec2 ().y) }
+		)
+	    );
+	    break;
+	case DynamicValue::IVec3:
+	    JS_SetPropertyStr (
+		ctx, obj, name,
+		constructVectorObject (
+		    ctx, "Vec3",
+		    {
+			static_cast<double> (value.getIVec3 ().x),
+			static_cast<double> (value.getIVec3 ().y),
+			static_cast<double> (value.getIVec3 ().z),
+		    }
+		)
+	    );
+	    break;
+	case DynamicValue::IVec4:
+	    JS_SetPropertyStr (
+		ctx, obj, name,
+		constructVectorObject (
+		    ctx, "Vec4",
+		    {
+			static_cast<double> (value.getIVec4 ().x),
+			static_cast<double> (value.getIVec4 ().y),
+			static_cast<double> (value.getIVec4 ().z),
+			static_cast<double> (value.getIVec4 ().w),
+		    }
+		)
+	    );
+	    break;
+	default:
+	    break;
+    }
+}
+
+static void updateAudioArray (JSContext* ctx, JSValue global, const char* name, const float* values, int count) {
+    JSValue audio = JS_GetPropertyStr (ctx, global, name);
+    if (JS_IsUndefined (audio)) {
+	audio = JS_NewObject (ctx);
+    }
+
+    auto setChannel = [&] (const char* channel) {
+	JSValue array = JS_NewArray (ctx);
+	for (int i = 0; i < count; i++) {
+	    JS_SetPropertyUint32 (ctx, array, i, JS_NewFloat64 (ctx, values[i]));
+	}
+	JS_SetPropertyStr (ctx, audio, channel, array);
+    };
+
+    setChannel ("average");
+    // PlaybackRecorder currently exposes averaged samples only. Mirror that
+    // data into WE's stereo fields until separate channels are available.
+    setChannel ("left");
+    setChannel ("right");
+
+    JS_SetPropertyStr (ctx, global, name, audio);
+}
+
+void ScriptEngine::installBuiltins () {
+    if (this->m_builtinsInstalled || !this->m_context) {
+	return;
+    }
+
+    static constexpr const char* builtins = R"JS(
+globalThis.__weNum = function(v, fallback) {
+  const n = parseFloat(v);
+  return Number.isFinite(n) ? n : fallback;
+};
+globalThis.__weParts = function(v) {
+  if (typeof v === 'string') return v.trim().split(/\s+/).map(Number);
+  if (typeof v === 'number') return [v, v, v, v];
+  if (v && typeof v === 'object') return [v.x || 0, v.y || 0, v.z || 0, v.w || 0];
+  return [0, 0, 0, 0];
+};
+globalThis.Vec2 = class Vec2 {
+  constructor(x, y) { const p = __weParts(x); this.x = __weNum(x, p[0] || 0); this.y = __weNum(y, p[1] || 0); }
+  copy() { return new Vec2(this.x, this.y); }
+  add(v) { const p = __weParts(v); return new Vec2(this.x + p[0], this.y + p[1]); }
+  subtract(v) { const p = __weParts(v); return new Vec2(this.x - p[0], this.y - p[1]); }
+  multiply(v) { const p = __weParts(v); return new Vec2(this.x * p[0], this.y * p[1]); }
+  divide(v) { const p = __weParts(v); return new Vec2(this.x / p[0], this.y / p[1]); }
+  toString() { return this.x + ' ' + this.y; }
+};
+globalThis.Vec3 = class Vec3 {
+  constructor(x, y, z) { const p = __weParts(x); this.x = __weNum(x, p[0] || 0); this.y = __weNum(y, p[1] || 0); this.z = __weNum(z, p[2] || 0); }
+  copy() { return new Vec3(this.x, this.y, this.z); }
+  equals(v) { return Math.abs(this.x - v.x) < 0.0001 && Math.abs(this.y - v.y) < 0.0001 && Math.abs(this.z - v.z) < 0.0001; }
+  lengthSqr() { return this.x * this.x + this.y * this.y + this.z * this.z; }
+  length() { return Math.sqrt(this.lengthSqr()); }
+  normalize() { const l = this.length(); return l ? this.divide(l) : new Vec3(0, 0, 0); }
+  add(v) { const p = __weParts(v); return new Vec3(this.x + p[0], this.y + p[1], this.z + p[2]); }
+  subtract(v) { const p = __weParts(v); return new Vec3(this.x - p[0], this.y - p[1], this.z - p[2]); }
+  multiply(v) { const p = __weParts(v); return new Vec3(this.x * p[0], this.y * p[1], this.z * p[2]); }
+  divide(v) { const p = __weParts(v); return new Vec3(this.x / p[0], this.y / p[1], this.z / p[2]); }
+  dot(v) { return this.x * v.x + this.y * v.y + this.z * v.z; }
+  cross(v) { return new Vec3(this.y * v.z - this.z * v.y, this.z * v.x - this.x * v.z, this.x * v.y - this.y * v.x); }
+  mix(v, a) { return new Vec3(this.x + (v.x - this.x) * a, this.y + (v.y - this.y) * a, this.z + (v.z - this.z) * a); }
+  min(v) { return new Vec3(Math.min(this.x, v.x), Math.min(this.y, v.y), Math.min(this.z, v.z)); }
+  max(v) { return new Vec3(Math.max(this.x, v.x), Math.max(this.y, v.y), Math.max(this.z, v.z)); }
+  abs() { return new Vec3(Math.abs(this.x), Math.abs(this.y), Math.abs(this.z)); }
+  sign() { return new Vec3(Math.sign(this.x), Math.sign(this.y), Math.sign(this.z)); }
+  round() { return new Vec3(Math.round(this.x), Math.round(this.y), Math.round(this.z)); }
+  floor() { return new Vec3(Math.floor(this.x), Math.floor(this.y), Math.floor(this.z)); }
+  ceil() { return new Vec3(Math.ceil(this.x), Math.ceil(this.y), Math.ceil(this.z)); }
+  toString() { return this.x + ' ' + this.y + ' ' + this.z; }
+};
+globalThis.Vec4 = class Vec4 {
+  constructor(x, y, z, w) { const p = __weParts(x); this.x = __weNum(x, p[0] || 0); this.y = __weNum(y, p[1] || 0); this.z = __weNum(z, p[2] || 0); this.w = __weNum(w, p[3] || 0); }
+  copy() { return new Vec4(this.x, this.y, this.z, this.w); }
+  toString() { return this.x + ' ' + this.y + ' ' + this.z + ' ' + this.w; }
+};
+globalThis.WEColor = {
+  rgb2hsv(c) {
+    const r = c.x, g = c.y, b = c.z, max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
+    let h = 0;
+    if (d !== 0) h = max === r ? (((g - b) / d) % 6) : max === g ? ((b - r) / d + 2) : ((r - g) / d + 4);
+    h = ((h / 6) + 1) % 1;
+    return new Vec3(h, max === 0 ? 0 : d / max, max);
+  },
+  hsv2rgb(c) {
+    const h = ((c.x % 1) + 1) % 1, s = c.y, v = c.z, i = Math.floor(h * 6), f = h * 6 - i;
+    const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
+    switch (i % 6) { case 0: return new Vec3(v, t, p); case 1: return new Vec3(q, v, p); case 2: return new Vec3(p, v, t); case 3: return new Vec3(p, q, v); case 4: return new Vec3(t, p, v); default: return new Vec3(v, p, q); }
+  }
+};
+globalThis.WEMath = {
+  smoothStep(edge0, edge1, x) {
+    edge0 = Number(edge0); edge1 = Number(edge1); x = Number(x);
+    if (!Number.isFinite(edge0) || !Number.isFinite(edge1) || !Number.isFinite(x)) return 0;
+    if (edge0 === edge1) return x < edge0 ? 0 : 1;
+    const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
+    return t * t * (3 - 2 * t);
+  },
+  smootherStep(edge0, edge1, x) {
+    edge0 = Number(edge0); edge1 = Number(edge1); x = Number(x);
+    if (!Number.isFinite(edge0) || !Number.isFinite(edge1) || !Number.isFinite(x)) return 0;
+    if (edge0 === edge1) return x < edge0 ? 0 : 1;
+    const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
+    return t * t * t * (t * (t * 6 - 15) + 10);
+  },
+  clamp(x, min, max) {
+    x = Number(x); min = Number(min); max = Number(max);
+    if (!Number.isFinite(x)) return 0;
+    return Math.max(min, Math.min(max, x));
+  },
+  saturate(x) {
+    x = Number(x);
+    if (!Number.isFinite(x)) return 0;
+    return Math.max(0, Math.min(1, x));
+  },
+  mix(a, b, t) {
+    a = Number(a); b = Number(b); t = Number(t);
+    if (!Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(t)) return 0;
+    return a + (b - a) * t;
+  },
+  lerp(a, b, t) {
+    a = Number(a); b = Number(b); t = Number(t);
+    if (!Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(t)) return 0;
+    return a + (b - a) * t;
+  }
+};
+globalThis.__audio16 = { average: Array(16).fill(0), left: Array(16).fill(0), right: Array(16).fill(0) };
+globalThis.__audio32 = { average: Array(32).fill(0), left: Array(32).fill(0), right: Array(32).fill(0) };
+globalThis.__audio64 = { average: Array(64).fill(0), left: Array(64).fill(0), right: Array(64).fill(0) };
+globalThis.__intervals = Object.create(null);
+globalThis.__weIntervalBucket = function(bindingKey) {
+  const key = String(bindingKey || globalThis.__currentBindingKey || 'default');
+  if (!globalThis.__intervals[key]) globalThis.__intervals[key] = [];
+  return globalThis.__intervals[key];
+};
+globalThis.shared = globalThis.shared || {};
+globalThis.localStorage = globalThis.localStorage || {
+  __data: Object.create(null),
+  get(key) {
+    key = String(key);
+    return Object.prototype.hasOwnProperty.call(this.__data, key) ? this.__data[key] : null;
+  },
+  set(key, value) { this.__data[String(key)] = String(value); },
+  remove(key) { delete this.__data[String(key)]; },
+  clear() { this.__data = Object.create(null); }
+};
+globalThis.MediaPlaybackEvent = globalThis.MediaPlaybackEvent || {
+  PLAYBACK_STOPPED: 0,
+  PLAYBACK_PLAYING: 1,
+  PLAYBACK_PAUSED: 2
+};
+globalThis.input = globalThis.input || {
+  cursorPosition: new Vec2(0, 0),
+  cursorWorldPosition: new Vec3(0, 0, 0)
+};
+globalThis.console = globalThis.console || {
+  log() {},
+  warn() {},
+  error() {},
+  info() {}
+};
+globalThis.__weRunIntervals = function(bindingKey) {
+  for (const interval of globalThis.__weIntervalBucket(bindingKey)) {
+    if (!interval.active || typeof interval.callback !== 'function') continue;
+    if (engine.runtime < interval.next) continue;
+    interval.next = engine.runtime + interval.delay;
+    interval.callback();
+  }
+};
+globalThis.__missingLayer = { origin: new Vec3(0, 0, 0), scale: new Vec3(1, 1, 1), angles: new Vec3(0, 0, 0), visible: false, alpha: 0, color: new Vec4(0, 0, 0, 0), parallaxDepth: new Vec2(0, 0) };
+globalThis.engine = {
+  runtime: 0,
+  frametime: 0,
+  AUDIO_RESOLUTION_16: 16,
+  AUDIO_RESOLUTION_32: 32,
+  AUDIO_RESOLUTION_64: 64,
+  registerAudioBuffers(resolution) {
+    if (resolution === 64) return globalThis.__audio64;
+    if (resolution === 32) return globalThis.__audio32;
+    return globalThis.__audio16;
+  },
+  setInterval(callback, delayMs) {
+    const interval = {
+      callback,
+      delay: Math.max(0.001, Number(delayMs || 0) / 1000),
+      next: this.runtime + Math.max(0.001, Number(delayMs || 0) / 1000),
+      active: true
+    };
+    globalThis.__weIntervalBucket().push(interval);
+    return function() { interval.active = false; };
+  },
+  openUserShortcut() {
+    return undefined;
+  }
+};
+globalThis.thisScene = {
+  getLayer(name) {
+    const key = String(name);
+    return globalThis.__layers && globalThis.__layers[key] ? globalThis.__layers[key] : globalThis.__missingLayer;
+  },
+  enumerateLayers() {
+    return globalThis.__layerList || [];
+  }
+};
+globalThis.createScriptProperties = function() {
+  const props = globalThis.__scriptProps || {};
+  const builder = {};
+  for (const name of ['addSlider', 'addCheckbox', 'addCombo', 'addColor', 'addText']) {
+    builder[name] = function(opts) {
+      if (!(opts.name in props)) props[opts.name] = opts.value;
+      return builder;
+    };
+  }
+  builder.finish = function() { return props; };
+  return builder;
+};
+)JS";
+
+    JSValue result = JS_Eval (this->m_context, builtins, strlen (builtins), "<scene-script-builtins>", JS_EVAL_TYPE_GLOBAL);
+    if (JS_IsException (result)) {
+	logJSException (this->m_context, "installBuiltins");
+    }
+    JS_FreeValue (this->m_context, result);
+    this->m_builtinsInstalled = true;
+}
+
+JSValue ScriptEngine::ensureModule (const void* bindingKey, const std::string& scriptSource) {
+    const auto existing = this->m_modules.find (bindingKey);
+    if (existing != this->m_modules.end ()) {
+	return existing->second;
+    }
+
+    std::string body = scriptSource;
+    size_t pos;
+    const auto removeAll = [&body] (const std::string& pattern) {
+	size_t position;
+	while ((position = body.find (pattern)) != std::string::npos) {
+	    body.erase (position, pattern.length ());
+	}
+    };
+    removeAll ("'use strict';");
+    removeAll ("\"use strict\";");
+    removeAll ("import * as WEColor from 'WEColor';");
+    removeAll ("import * as WEColor from \"WEColor\";");
+    removeAll ("import * as WEMath from 'WEMath';");
+    removeAll ("import * as WEMath from \"WEMath\";");
+    while ((pos = body.find ("export ")) != std::string::npos) body.erase (pos, 7);
+
+    std::ostringstream wrapper;
+    wrapper << "(function() {\n"
+	    << body << "\n"
+	    << "return {\n"
+	    << "  update: typeof update === 'function' ? update : null,\n"
+	    << "  init: typeof init === 'function' ? init : null,\n"
+	    << "  applyUserProperties: typeof applyUserProperties === 'function' ? applyUserProperties : null,\n"
+	    << "  mediaPropertiesChanged: typeof mediaPropertiesChanged === 'function' ? mediaPropertiesChanged : null,\n"
+	    << "  mediaPlaybackChanged: typeof mediaPlaybackChanged === 'function' ? mediaPlaybackChanged : null,\n"
+	    << "  mediaTimelineChanged: typeof mediaTimelineChanged === 'function' ? mediaTimelineChanged : null,\n"
+	    << "  mediaThumbnailChanged: typeof mediaThumbnailChanged === 'function' ? mediaThumbnailChanged : null,\n"
+	    << "  setScriptProperties: function(props) { if (typeof scriptProperties !== 'undefined' && scriptProperties) Object.assign(scriptProperties, props); }\n"
+	    << "};\n"
+	    << "})();\n";
+
+    const std::string source = wrapper.str ();
+    JSValue module = JS_Eval (this->m_context, source.c_str (), source.size (), "<scene-script-module>", JS_EVAL_TYPE_GLOBAL);
+    if (JS_IsException (module)) {
+	logJSException (this->m_context, "module");
+	JS_FreeValue (this->m_context, module);
+	module = JS_NewObject (this->m_context);
+    }
+
+    this->m_modules.emplace (bindingKey, module);
+    return module;
+}
+
+void ScriptEngine::releaseBinding (const void* bindingKey) {
+    if (this->m_context) {
+	JSValue global = JS_GetGlobalObject (this->m_context);
+	JSValue intervals = JS_GetPropertyStr (this->m_context, global, "__intervals");
+	if (JS_IsObject (intervals)) {
+	    const std::string key = std::to_string (reinterpret_cast<uintptr_t> (bindingKey));
+	    JSAtom keyAtom = JS_NewAtom (this->m_context, key.c_str ());
+	    JS_DeleteProperty (this->m_context, intervals, keyAtom, 0);
+	    JS_FreeAtom (this->m_context, keyAtom);
+	}
+	JS_FreeValue (this->m_context, intervals);
+	JS_FreeValue (this->m_context, global);
+
+	if (const auto module = this->m_modules.find (bindingKey); module != this->m_modules.end ()) {
+	    JS_FreeValue (this->m_context, module->second);
+	    this->m_modules.erase (module);
+	}
+    } else if (const auto module = this->m_modules.find (bindingKey); module != this->m_modules.end ()) {
+	if (this->m_runtime) {
+	    JS_FreeValueRT (this->m_runtime, module->second);
+	}
+	this->m_modules.erase (module);
+    }
+
+    this->m_initializedModules.erase (bindingKey);
+    this->m_lastMediaProperties.erase (bindingKey);
+    this->m_lastMediaPlayback.erase (bindingKey);
+    this->m_lastMediaTimeline.erase (bindingKey);
+    this->m_lastMediaThumbnail.erase (bindingKey);
+}
+
+static JSValue buildScriptPropertiesObject (
+    JSContext* ctx,
+    ScriptEngine& engine,
+    const std::map<std::string, DynamicValue*>& scriptProperties
+) {
+    JSValue propsObj = JS_NewObject (ctx);
+    for (const auto& [name, dynVal] : scriptProperties) {
+	if (dynVal) {
+	    JS_SetPropertyStr (ctx, propsObj, name.c_str (), engine.dynamicValueToJS (*dynVal));
+	}
+    }
+    return propsObj;
+}
+
+static JSValue jsNoop (JSContext*, JSValueConst, int, JSValueConst*) { return JS_UNDEFINED; }
+static JSValue jsZero (JSContext* ctx, JSValueConst, int, JSValueConst*) { return JS_NewInt32 (ctx, 0); }
+
+static JSValue jsGetTextureAnimation (JSContext* ctx, JSValueConst, int, JSValueConst*) {
+    JSValue animation = JS_NewObject (ctx);
+    JS_SetPropertyStr (ctx, animation, "pause", JS_NewCFunction (ctx, jsNoop, "pause", 0));
+    JS_SetPropertyStr (ctx, animation, "play", JS_NewCFunction (ctx, jsNoop, "play", 0));
+    JS_SetPropertyStr (ctx, animation, "stop", JS_NewCFunction (ctx, jsNoop, "stop", 0));
+    JS_SetPropertyStr (ctx, animation, "setFrame", JS_NewCFunction (ctx, jsNoop, "setFrame", 1));
+    JS_SetPropertyStr (ctx, animation, "getFrame", JS_NewCFunction (ctx, jsZero, "getFrame", 0));
+    return animation;
+}
+
+static void syncLayerObjectProperties (JSContext* ctx, JSValue layer, const Object& object) {
+    JS_SetPropertyStr (ctx, layer, "id", JS_NewInt32 (ctx, object.id));
+    JS_SetPropertyStr (ctx, layer, "name", JS_NewString (ctx, object.name.c_str ()));
+    JS_SetPropertyStr (
+	ctx, layer, "getTextureAnimation", JS_NewCFunction (ctx, jsGetTextureAnimation, "getTextureAnimation", 0)
+    );
+    JS_SetPropertyStr (ctx, layer, "getAnimation", JS_NewCFunction (ctx, jsGetTextureAnimation, "getAnimation", 0));
+
+    for (const char* property : { "origin", "text", "scale", "angles", "visible", "alpha", "color", "parallaxDepth", "pointSize" }) {
+	if (const auto* setting = settingForProperty (object, property); setting && setting->value) {
+	    setObjectPropertyFromDynamicValue (ctx, layer, property, *setting->value);
+	}
+    }
+
+    if (object.is<Sound> ()) {
+	JS_SetPropertyStr (ctx, layer, "volume", JS_NewFloat64 (ctx, 1.0));
+	JS_SetPropertyStr (ctx, layer, "play", JS_NewCFunction (ctx, jsNoop, "play", 0));
+	JS_SetPropertyStr (ctx, layer, "stop", JS_NewCFunction (ctx, jsNoop, "stop", 0));
+    }
+}
+
+static JSValue buildLayerObject (JSContext* ctx, const Object& object) {
+    JSValue layer = JS_NewObject (ctx);
+    syncLayerObjectProperties (ctx, layer, object);
+
+    return layer;
+}
+
+static void installSceneLayers (
+    JSContext* ctx,
+    JSValue global,
+    WallpaperEngine::Render::Wallpapers::CScene* scene,
+    const ScriptBindingContext* bindingContext
+) {
+    JSValue layers = JS_GetPropertyStr (ctx, global, "__layers");
+    if (JS_IsException (layers) || !JS_IsObject (layers)) {
+	JS_FreeValue (ctx, layers);
+	layers = JS_NewObject (ctx);
+    }
+
+    JSValue layerList = JS_NewArray (ctx);
+    JSValue ownerLayer = JS_UNDEFINED;
+
+    if (scene) {
+	uint32_t layerIndex = 0;
+	for (const auto& object : scene->getScene ().objects) {
+	    const std::string id = std::to_string (object->id);
+	    JSValue layer = JS_GetPropertyStr (ctx, layers, id.c_str ());
+	    if ((JS_IsUndefined (layer) || JS_IsException (layer)) && !object->name.empty ()) {
+		JS_FreeValue (ctx, layer);
+		layer = JS_GetPropertyStr (ctx, layers, object->name.c_str ());
+	    }
+
+	    if (JS_IsUndefined (layer) || JS_IsException (layer)) {
+		JS_FreeValue (ctx, layer);
+		layer = buildLayerObject (ctx, *object);
+	    } else {
+		syncLayerObjectProperties (ctx, layer, *object);
+	    }
+
+	    JS_SetPropertyStr (ctx, layers, id.c_str (), JS_DupValue (ctx, layer));
+	    JS_SetPropertyUint32 (ctx, layerList, layerIndex++, JS_DupValue (ctx, layer));
+	    if (!object->name.empty ()) {
+		JS_SetPropertyStr (ctx, layers, object->name.c_str (), JS_DupValue (ctx, layer));
+	    }
+
+	    if (bindingContext
+		&& (object->id == bindingContext->objectId || object->name == bindingContext->objectName)) {
+		ownerLayer = JS_DupValue (ctx, layer);
+	    }
+
+	    JS_FreeValue (ctx, layer);
+	}
+    }
+
+    if (JS_IsUndefined (ownerLayer)) {
+	ownerLayer = JS_NewObject (ctx);
+    }
+
+    JS_SetPropertyStr (ctx, global, "__layers", JS_DupValue (ctx, layers));
+    JS_FreeValue (ctx, layers);
+    JS_SetPropertyStr (ctx, global, "__layerList", layerList);
+    JS_SetPropertyStr (ctx, global, "thisObject", JS_DupValue (ctx, ownerLayer));
+    JS_SetPropertyStr (ctx, global, "thisLayer", ownerLayer);
+}
+
+static void applyLayerProperty (
+    JSContext* ctx,
+    ScriptEngine& engine,
+    JSValue layer,
+    const Object& object,
+    const char* property
+) {
+    auto* setting = settingForProperty (object, property);
+    if (!setting || !setting->value) {
+	return;
+    }
+
+    JSValue jsValue = JS_GetPropertyStr (ctx, layer, property);
+    if (!JS_IsUndefined (jsValue) && !JS_IsException (jsValue)) {
+	auto value = engine.jsToDynamicValue (jsValue, setting->value->getType ());
+	if (value) {
+	    setting->value->update (*value);
+	}
+    }
+    JS_FreeValue (ctx, jsValue);
+}
+
+static void applyLayerUpdates (
+    JSContext* ctx,
+    ScriptEngine& engine,
+    JSValue global,
+    WallpaperEngine::Render::Wallpapers::CScene* scene
+) {
+    if (!scene) {
+	return;
+    }
+
+    JSValue layers = JS_GetPropertyStr (ctx, global, "__layers");
+    if (JS_IsUndefined (layers) || JS_IsException (layers)) {
+	JS_FreeValue (ctx, layers);
+	return;
+    }
+
+    for (const auto& object : scene->getScene ().objects) {
+	JSValue layer = JS_GetPropertyStr (ctx, layers, std::to_string (object->id).c_str ());
+	if (JS_IsUndefined (layer)) {
+	    JS_FreeValue (ctx, layer);
+	    if (!object->name.empty ()) {
+		layer = JS_GetPropertyStr (ctx, layers, object->name.c_str ());
+	    }
+	}
+	if (JS_IsUndefined (layer) || JS_IsException (layer)) {
+	    JS_FreeValue (ctx, layer);
+	    continue;
+	}
+
+	for (const char* property : { "origin", "text", "scale", "angles", "visible", "alpha", "color", "parallaxDepth", "pointSize" }) {
+	    applyLayerProperty (ctx, engine, layer, *object, property);
+	}
+
+	JS_FreeValue (ctx, layer);
+    }
+
+    JS_FreeValue (ctx, layers);
+}
+
+static DynamicValueUniquePtr currentBoundValue (
+    WallpaperEngine::Render::Wallpapers::CScene* scene,
+    const ScriptBindingContext* bindingContext,
+    const DynamicValue& fallback
+) {
+    auto result = std::make_unique<DynamicValue> ();
+    result->update (fallback);
+
+    if (!scene || !bindingContext) {
+	return result;
+    }
+
+    const auto it = std::ranges::find_if (scene->getScene ().objects, [bindingContext] (const auto& object) {
+	return object->id == bindingContext->objectId || object->name == bindingContext->objectName;
+    });
+
+    if (it == scene->getScene ().objects.end ()) {
+	return result;
+    }
+
+    const auto* setting = settingForProperty (**it, bindingContext->propertyName);
+    if (setting && setting->value) {
+	result->update (*setting->value);
+    }
+
+    return result;
+}
+
+void ScriptEngine::refreshMediaState () {
+    auto buildMediaState = [] () -> std::optional<MediaState> {
+	const auto line = processReadFirstLine (
+	    "playerctl",
+	    { "metadata", "--format", "{{status}}\t{{title}}\t{{artist}}\t{{mpris:length}}\t{{position}}\t{{mpris:artUrl}}" }
+	);
+
+	if (!line.has_value ()) {
+	    return std::nullopt;
+	}
+
+	MediaState next;
+	if (line->empty ()) {
+	    next.available = false;
+	    next.playbackState = 0;
+	    next.status = "Stopped";
+	    return next;
+	}
+
+	const auto parts = splitTabs (*line);
+	next.available = true;
+	next.status = parts.size () > 0 ? parts[0] : "";
+	next.title = parts.size () > 1 ? parts[1] : "";
+	next.artist = parts.size () > 2 ? parts[2] : "";
+	next.duration = (parts.size () > 3 ? parseDoubleOrZero (parts[3]) : 0.0) / 1000000.0;
+	next.position = (parts.size () > 4 ? parseDoubleOrZero (parts[4]) : 0.0) / 1000000.0;
+	next.artUrl = parts.size () > 5 ? parts[5] : "";
+
+	if (next.status == "Playing") {
+	    next.playbackState = 1;
+	} else if (next.status == "Paused") {
+	    next.playbackState = 2;
+	} else {
+	    next.playbackState = 0;
+	}
+
+	return next;
+    };
+
+    if (this->m_mediaPollFuture.valid ()
+	&& this->m_mediaPollFuture.wait_for (std::chrono::milliseconds (0)) == std::future_status::ready) {
+	try {
+	    auto next = this->m_mediaPollFuture.get ();
+	    if (next.has_value ()) {
+		this->m_mediaState = std::move (*next);
+		if (std::getenv ("LWE_MEDIA_DEBUG") != nullptr) {
+		    if (!this->m_mediaState.available) {
+			sLog.out ("Media debug: no playerctl media state");
+		    } else {
+			sLog.out (
+			    "Media debug: status=", this->m_mediaState.status,
+			    " title=", this->m_mediaState.title,
+			    " artist=", this->m_mediaState.artist,
+			    " duration=", this->m_mediaState.duration,
+			    " position=", this->m_mediaState.position
+			);
+		    }
+		}
+	    }
+	} catch (const std::exception& e) {
+	    if (std::getenv ("LWE_MEDIA_DEBUG") != nullptr) {
+		sLog.out ("Media debug: playerctl media poll failed: ", e.what ());
+	    }
+	}
+    }
+
+    if (this->m_mediaPollFuture.valid ()) {
+	return;
+    }
+
+    const auto now = std::chrono::steady_clock::now ();
+    if (this->m_lastMediaPoll.time_since_epoch ().count () != 0
+	&& now - this->m_lastMediaPoll < std::chrono::milliseconds (750)) {
+	return;
+    }
+    this->m_lastMediaPoll = now;
+    this->m_mediaPollFuture = std::async (std::launch::async, buildMediaState);
+}
+
+static JSValue makeVec3Object (JSContext* ctx, float x, float y, float z) {
+    JSValue result = JS_NewObject (ctx);
+    JS_SetPropertyStr (ctx, result, "x", JS_NewFloat64 (ctx, x));
+    JS_SetPropertyStr (ctx, result, "y", JS_NewFloat64 (ctx, y));
+    JS_SetPropertyStr (ctx, result, "z", JS_NewFloat64 (ctx, z));
+    return result;
+}
+
+static bool callModuleFunction (JSContext* ctx, JSValue module, const char* name, JSValue event, const char* logContext) {
+    JSValue function = JS_GetPropertyStr (ctx, module, name);
+    if (JS_IsFunction (ctx, function)) {
+	JSValue args[] = { event };
+	JSValue result = JS_Call (ctx, function, JS_UNDEFINED, 1, args);
+	if (JS_IsException (result)) {
+	    logJSException (ctx, logContext);
+	}
+	JS_FreeValue (ctx, result);
+	JS_FreeValue (ctx, function);
+	return true;
+    }
+    JS_FreeValue (ctx, function);
+    return false;
+}
+
+void ScriptEngine::dispatchMediaEvents (JSValue module, const void* bindingKey) {
+    this->refreshMediaState ();
+    JSContext* ctx = this->m_context;
+
+    const std::string propertiesSignature = this->m_mediaState.title + "\n" + this->m_mediaState.artist;
+    if (this->m_lastMediaProperties.find (bindingKey) == this->m_lastMediaProperties.end ()
+	|| this->m_lastMediaProperties[bindingKey] != propertiesSignature) {
+	this->m_lastMediaProperties[bindingKey] = propertiesSignature;
+	JSValue propertiesEvent = JS_NewObject (ctx);
+	JS_SetPropertyStr (ctx, propertiesEvent, "title", JS_NewString (ctx, this->m_mediaState.title.c_str ()));
+	JS_SetPropertyStr (ctx, propertiesEvent, "artist", JS_NewString (ctx, this->m_mediaState.artist.c_str ()));
+	JS_SetPropertyStr (ctx, propertiesEvent, "albumTitle", JS_NewString (ctx, ""));
+	const bool calledProperties
+	    = callModuleFunction (ctx, module, "mediaPropertiesChanged", propertiesEvent, "mediaPropertiesChanged");
+	if (calledProperties && std::getenv ("LWE_MEDIA_DEBUG") != nullptr) {
+	    sLog.out (
+		"Media debug: dispatched properties key=", reinterpret_cast<uintptr_t> (bindingKey),
+		" title=", this->m_mediaState.title,
+		" artist=", this->m_mediaState.artist
+	    );
+	}
+	JS_FreeValue (ctx, propertiesEvent);
+    }
+
+    const std::string playbackSignature = std::to_string (this->m_mediaState.playbackState);
+    if (this->m_lastMediaPlayback[bindingKey] != playbackSignature) {
+	this->m_lastMediaPlayback[bindingKey] = playbackSignature;
+	JSValue event = JS_NewObject (ctx);
+	JS_SetPropertyStr (ctx, event, "state", JS_NewInt32 (ctx, this->m_mediaState.playbackState));
+	callModuleFunction (ctx, module, "mediaPlaybackChanged", event, "mediaPlaybackChanged");
+	JS_FreeValue (ctx, event);
+    }
+
+    const int roundedPosition = static_cast<int> (std::max (0.0, this->m_mediaState.position));
+    const std::string timelineSignature = std::to_string (roundedPosition) + "\n" + std::to_string (this->m_mediaState.duration);
+    if (this->m_lastMediaTimeline.find (bindingKey) == this->m_lastMediaTimeline.end ()
+	|| this->m_lastMediaTimeline[bindingKey] != timelineSignature) {
+	this->m_lastMediaTimeline[bindingKey] = timelineSignature;
+	JSValue timelineEvent = JS_NewObject (ctx);
+	JS_SetPropertyStr (ctx, timelineEvent, "position", JS_NewFloat64 (ctx, this->m_mediaState.position));
+	JS_SetPropertyStr (ctx, timelineEvent, "duration", JS_NewFloat64 (ctx, this->m_mediaState.duration));
+	callModuleFunction (ctx, module, "mediaTimelineChanged", timelineEvent, "mediaTimelineChanged");
+	JS_FreeValue (ctx, timelineEvent);
+    }
+
+    if (this->m_lastMediaThumbnail[bindingKey] != this->m_mediaState.artUrl) {
+	this->m_lastMediaThumbnail[bindingKey] = this->m_mediaState.artUrl;
+	JSValue event = JS_NewObject (ctx);
+	JS_SetPropertyStr (ctx, event, "url", JS_NewString (ctx, this->m_mediaState.artUrl.c_str ()));
+	JS_SetPropertyStr (ctx, event, "primaryColor", makeVec3Object (ctx, 0.12f, 0.12f, 0.12f));
+	JS_SetPropertyStr (ctx, event, "secondaryColor", makeVec3Object (ctx, 0.0f, 0.0f, 0.0f));
+	JS_SetPropertyStr (ctx, event, "tertiaryColor", makeVec3Object (ctx, 0.25f, 0.25f, 0.25f));
+	JS_SetPropertyStr (ctx, event, "highContrastColor", makeVec3Object (ctx, 1.0f, 1.0f, 1.0f));
+	callModuleFunction (ctx, module, "mediaThumbnailChanged", event, "mediaThumbnailChanged");
+	JS_FreeValue (ctx, event);
+    }
+}
+
+void ScriptEngine::updateRuntimeGlobals (JSContext* ctx, JSValue globalObj) const {
+    const auto runtimeSeconds = std::chrono::duration<double> (std::chrono::steady_clock::now () - sStartTime).count ();
+    JSValue engine = JS_GetPropertyStr (ctx, globalObj, "engine");
+    JS_SetPropertyStr (ctx, engine, "runtime", JS_NewFloat64 (ctx, runtimeSeconds));
+    JS_SetPropertyStr (ctx, engine, "frametime", JS_NewFloat64 (ctx, g_Time - g_TimeLast));
+    std::time_t now = std::time (nullptr);
+    std::tm localTime {};
+    localtime_r (&now, &localTime);
+    const auto secondsOfDay = localTime.tm_hour * 3600 + localTime.tm_min * 60 + localTime.tm_sec;
+    JS_SetPropertyStr (ctx, engine, "timeOfDay", JS_NewFloat64 (ctx, static_cast<double> (secondsOfDay) / 86400.0));
+    JS_FreeValue (ctx, engine);
+}
+
+void ScriptEngine::updateSceneInputGlobals (JSContext* ctx, JSValue globalObj, WallpaperEngine::Render::Wallpapers::CScene* scene) {
+    if (scene == nullptr) {
+	return;
+    }
+
+    auto& recorder = scene->getAudioContext ().getRecorder ();
+    recorder.update ();
+    updateAudioArray (ctx, globalObj, "__audio16", recorder.audio16, 16);
+    updateAudioArray (ctx, globalObj, "__audio32", recorder.audio32, 32);
+    updateAudioArray (ctx, globalObj, "__audio64", recorder.audio64, 64);
+
+    JSValue input = JS_GetPropertyStr (ctx, globalObj, "input");
+    if (JS_IsUndefined (input) || JS_IsException (input)) {
+	JS_FreeValue (ctx, input);
+	input = JS_NewObject (ctx);
+    }
+
+    const glm::vec2 mouse = *scene->getMousePosition ();
+    DynamicValue cursorPosition;
+    cursorPosition.update (glm::vec2 (mouse.x, mouse.y));
+    DynamicValue cursorWorldPosition;
+    cursorWorldPosition.update (glm::vec3 (mouse.x * scene->getWidth (), mouse.y * scene->getHeight (), 0.0f));
+    JS_SetPropertyStr (ctx, input, "cursorPosition", this->dynamicValueToJS (cursorPosition));
+    JS_SetPropertyStr (ctx, input, "cursorWorldPosition", this->dynamicValueToJS (cursorWorldPosition));
+    JS_SetPropertyStr (ctx, globalObj, "input", input);
+}
+
+void ScriptEngine::callModuleWithProps (JSContext* ctx, JSValue module, const char* name, JSValue propsObj) const {
+    JSValue function = JS_GetPropertyStr (ctx, module, name);
+    if (JS_IsFunction (ctx, function)) {
+	JSValue args[] = { propsObj };
+	JSValue result = JS_Call (ctx, function, JS_UNDEFINED, 1, args);
+	if (JS_IsException (result)) {
+	    logJSException (ctx, name);
+	}
+	JS_FreeValue (ctx, result);
+    }
+    JS_FreeValue (ctx, function);
+}
+
+void ScriptEngine::initializeModuleIfNeeded (
+    JSContext* ctx, JSValue module, const void* bindingKey, const DynamicValue& currentValue, bool hasScene
+) {
+    if (!hasScene || this->m_initializedModules.contains (bindingKey)) {
+	return;
+    }
+
+    JSValue init = JS_GetPropertyStr (ctx, module, "init");
+    bool initOk = true;
+    if (JS_IsFunction (ctx, init)) {
+	JSValue current = this->dynamicValueToJS (currentValue);
+	JSValue args[] = { current };
+	JSValue initResult = JS_Call (ctx, init, JS_UNDEFINED, 1, args);
+	if (JS_IsException (initResult)) {
+	    logJSException (ctx, "init");
+	    initOk = false;
+	}
+	JS_FreeValue (ctx, initResult);
+	JS_FreeValue (ctx, current);
+    }
+    JS_FreeValue (ctx, init);
+    if (initOk) {
+	this->m_initializedModules.insert (bindingKey);
+    }
+}
+
+void ScriptEngine::runIntervals (JSContext* ctx, JSValue globalObj, const std::string& bindingKeyString) const {
+    JSValue runIntervals = JS_GetPropertyStr (ctx, globalObj, "__weRunIntervals");
+    if (JS_IsFunction (ctx, runIntervals)) {
+	JSValue args[] = { JS_NewString (ctx, bindingKeyString.c_str ()) };
+	JSValue intervalResult = JS_Call (ctx, runIntervals, JS_UNDEFINED, 1, args);
+	if (JS_IsException (intervalResult)) {
+	    logJSException (ctx, "setInterval");
+	}
+	JS_FreeValue (ctx, args[0]);
+	JS_FreeValue (ctx, intervalResult);
+    }
+    JS_FreeValue (ctx, runIntervals);
+}
+
+DynamicValueUniquePtr ScriptEngine::fallbackTextValue (const std::string& scriptSource) const {
+    auto fallback = std::make_unique<DynamicValue> ();
+    if (scriptSource.find ("event.title") != std::string::npos) {
+	fallback->update (shortenMediaText (this->m_mediaState.title, 32));
+    } else if (scriptSource.find ("event.artist") != std::string::npos) {
+	fallback->update (shortenMediaText (this->m_mediaState.artist, 28));
+    } else if (scriptSource.find ("event.position") != std::string::npos || scriptSource.find ("displayPosition") != std::string::npos) {
+	fallback->update (formatMediaTime (this->m_mediaState.position));
+    } else if (scriptSource.find ("event.duration") != std::string::npos) {
+	fallback->update (formatMediaTime (this->m_mediaState.duration));
+    }
+    return fallback;
+}
+
+void ScriptEngine::applyTextFallback (DynamicValue& value, const std::string& scriptSource) const {
+    auto fallback = this->fallbackTextValue (scriptSource);
+    if (fallback->getType () != DynamicValue::Null) {
+	value.update (*fallback);
+    }
+}
+
+void ScriptEngine::logTextEvaluationDebug (const ScriptBindingContext* bindingContext) const {
+    if (
+	std::getenv ("LWE_MEDIA_DEBUG") == nullptr || bindingContext == nullptr
+	|| bindingContext->propertyName != "text"
+    ) {
+	return;
+    }
+
+    sLog.out (
+	"Media debug: evaluating text object=", bindingContext->objectId,
+	" name=", bindingContext->objectName
+    );
+}
+
+void ScriptEngine::logTextResultDebug (const ScriptBindingContext* bindingContext, const DynamicValueUniquePtr& dynResult) const {
+    if (
+	std::getenv ("LWE_MEDIA_DEBUG") == nullptr || bindingContext == nullptr || !dynResult
+	|| bindingContext->propertyName != "text"
+    ) {
+	return;
+    }
+
+    sLog.out (
+	"Media debug: text result object=", bindingContext->objectId,
+	" name=", bindingContext->objectName,
+	" type=", static_cast<int> (dynResult->getType ()),
+	" value=", dynResult->getType () == DynamicValue::String ? dynResult->getString () : "<non-string>"
+    );
+}
+
+JSValue ScriptEngine::callUpdate (JSContext* ctx, JSValue module, const DynamicValue& currentValue) const {
+    JSValue update = JS_GetPropertyStr (ctx, module, "update");
+    JSValue current = this->dynamicValueToJS (currentValue);
+    JSValue result = JS_UNDEFINED;
+    if (JS_IsFunction (ctx, update)) {
+	JSValue args[] = { current };
+	result = JS_Call (ctx, update, JS_UNDEFINED, 1, args);
+    }
+    JS_FreeValue (ctx, update);
+    JS_FreeValue (ctx, current);
+    return result;
+}
+
+DynamicValueUniquePtr ScriptEngine::resolveUndefinedResult (
+    const std::string& scriptSource,
+    const DynamicValue& currentValue,
+    WallpaperEngine::Render::Wallpapers::CScene* scene,
+    const ScriptBindingContext* bindingContext
+) const {
+    if (bindingContext != nullptr && bindingContext->propertyName == "text") {
+	auto fallback = this->fallbackTextValue (scriptSource);
+	if (fallback->getType () != DynamicValue::Null) {
+	    return fallback;
+	}
+    }
+    return currentBoundValue (scene, bindingContext, currentValue);
+}
+
+DynamicValueUniquePtr ScriptEngine::resolveEvaluationResult (
+    JSContext* ctx,
+    JSValue result,
+    const std::string& scriptSource,
+    const DynamicValue& currentValue,
+    WallpaperEngine::Render::Wallpapers::CScene* scene,
+    const ScriptBindingContext* bindingContext
+) const {
+    if (JS_IsException (result)) {
+	logJSException (ctx, "update");
+	JS_FreeValue (ctx, result);
+	auto fallback = std::make_unique<DynamicValue> ();
+	fallback->update (currentValue);
+	return fallback;
+    }
+
+    if (JS_IsUndefined (result)) {
+	JS_FreeValue (ctx, result);
+	return this->resolveUndefinedResult (scriptSource, currentValue, scene, bindingContext);
+    }
+
+    auto dynResult = this->jsToDynamicValue (result, currentValue.getType ());
+    JS_FreeValue (ctx, result);
+    if (
+	bindingContext != nullptr && bindingContext->propertyName == "text" && dynResult
+	&& (
+	    dynResult->getType () != DynamicValue::String
+	    || dynResult->getString ().empty ()
+	)
+    ) {
+	this->applyTextFallback (*dynResult, scriptSource);
+    }
+    this->logTextResultDebug (bindingContext, dynResult);
+    return dynResult;
+}
+
+// ---------------------------------------------------------------------------
+// Layer-script API (Phase 2)
+// ---------------------------------------------------------------------------
+
+void ScriptEngine::ensureLayerRegistry () {
+    if (this->m_layerRegistryReady || !this->m_context) {
+	return;
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JS_SetPropertyStr (ctx, globalObj, "__textLayers", JS_NewObject (ctx));
+    JS_FreeValue (ctx, globalObj);
+    this->m_layerRegistryReady = true;
+}
+
+ScriptLayerHandle ScriptEngine::createLayerScript (
+    const std::string& scriptSource,
+    const std::map<std::string, DynamicValue*>& initialScriptProps,
+    const std::string& initialText
+) {
+    if (!this->m_context) {
+	sLog.error ("ScriptEngine: No JS context available");
+	return kInvalidLayerHandle;
+    }
+    this->ensureLayerRegistry ();
+
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+
+    // Seed initial scriptProperties and text as temporary globals the IIFE reads.
+    JSValue seedProps = JS_NewObject (ctx);
+    for (const auto& [name, dynVal] : initialScriptProps) {
+	if (dynVal) {
+	    JS_SetPropertyStr (ctx, seedProps, name.c_str (), this->dynamicValueToJS (*dynVal));
+	}
+    }
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedProps", seedProps);
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedText", JS_NewString (ctx, initialText.c_str ()));
+
+    const ScriptLayerHandle id = this->m_nextLayerId++;
+
+    // Same stripping logic as evaluate(): WE scripts come as ES6 modules but
+    // QuickJS is easier to drive as plain script evaluation.
+    std::string body = scriptSource;
+    size_t pos;
+    while ((pos = body.find ("'use strict';")) != std::string::npos) {
+	body.erase (pos, 13);
+    }
+    while ((pos = body.find ("\"use strict\";")) != std::string::npos) {
+	body.erase (pos, 13);
+    }
+    while ((pos = body.find ("export ")) != std::string::npos) {
+	body.erase (pos, 7);
+    }
+
+    // The IIFE gives every layer its own closure for top-level vars and
+    // functions, so two layers that both define `function update()` or a
+    // top-level `var scriptProperties` don't clobber each other. Lifecycle
+	    // hooks are captured into globalThis.__textLayers[id] so tick/destroy can
+    // reach them later. `typeof init === 'function'` is safe even when
+    // `init` was never declared — bare-identifier `typeof` never throws.
+    std::ostringstream wrapper;
+    wrapper << "(function() {\n"
+	    << "  var __id = " << id << ";\n"
+	    << "  var __props = Object.assign({}, globalThis.__layerSeedProps || {});\n"
+	    << "  var thisLayer = { text: String(globalThis.__layerSeedText || '') };\n"
+	    << "  var thisScene = {\n"
+	    << "    get time()        { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
+	    << "    get currentTime() { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
+	    << "    get dt()          { var c = globalThis.__sceneCtx; return c ? c.dt   : 0; },\n"
+	    << "    get fps()         { var c = globalThis.__sceneCtx; return c ? c.fps  : 60; },\n"
+	    << "  };\n"
+	    // Minimal WE `engine` shim. Real Wallpaper Engine exposes a broad API
+	    // (media events, audio buffer, user input); we provide just enough for
+	    // the common built-in text scripts to run without ReferenceError.
+	    // `frametime` is the per-frame delta in seconds (what InsertFPS reads).
+	    << "  var engine = {\n"
+	    << "    get frametime() { var c = globalThis.__sceneCtx; return c ? c.dt : 0; },\n"
+	    << "    get time()      { var c = globalThis.__sceneCtx; return c ? c.time : 0; },\n"
+	    << "  };\n"
+	    << "  function createScriptProperties() {\n"
+	    << "    var builder = {\n"
+	    << "      addSlider:   function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addCheckbox: function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addCombo:    function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addColor:    function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      addText:     function(o){ if (!(o.name in __props)) __props[o.name] = o.value; return builder; },\n"
+	    << "      finish:      function(){ return __props; }\n"
+	    << "    };\n"
+	    << "    return builder;\n"
+	    << "  }\n"
+	    << body << "\n"
+	    // `_tick` wraps the user's `update()` so both WE text conventions work:
+	    //   A) `export function update() { thisLayer.text = …; }` (mutates in place)
+	    //   B) `export function update(value) { …; return value; }` (returns new text)
+	    // We pass the current text in, and if the return value is a string we
+	    // adopt it as the new `thisLayer.text`. Non-string / undefined return
+	    // leaves `thisLayer.text` as whatever the function assigned itself.
+	    << "  globalThis.__textLayers[__id] = {\n"
+	    << "    thisLayer: thisLayer,\n"
+	    << "    thisScene: thisScene,\n"
+	    << "    _init:    (typeof init    === 'function') ? init    : null,\n"
+	    << "    _destroy: (typeof destroy === 'function') ? destroy : null,\n"
+	    << "    _tick:    (typeof update  === 'function')\n"
+	    << "              ? function() {\n"
+	    << "                  var r = update(thisLayer.text);\n"
+	    << "                  if (typeof r === 'string') thisLayer.text = r;\n"
+	    << "                }\n"
+	    << "              : null,\n"
+	    << "    _scriptProperties: (typeof scriptProperties !== 'undefined') ? scriptProperties : __props\n"
+	    << "  };\n"
+	    << "})();\n";
+
+    const std::string evalScript = wrapper.str ();
+    JSValue result = JS_Eval (ctx, evalScript.c_str (), evalScript.size (), "<layer-script>", JS_EVAL_TYPE_GLOBAL);
+
+    // Unset seeds so they don't leak into the next createLayerScript call.
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedProps", JS_UNDEFINED);
+    JS_SetPropertyStr (ctx, globalObj, "__layerSeedText", JS_UNDEFINED);
+    JS_FreeValue (ctx, globalObj);
+
+    if (JS_IsException (result)) {
+	logJSException (ctx, "createLayerScript");
+	JS_FreeValue (ctx, result);
+	return kInvalidLayerHandle;
+    }
+    JS_FreeValue (ctx, result);
+
+    this->m_layerInitialized[id] = false;
+    return id;
+}
+
+void ScriptEngine::tickLayer (ScriptLayerHandle handle, double time, double deltaTime, double fps) {
+    if (!this->m_context || handle == kInvalidLayerHandle) {
+	return;
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+
+    JSValue sceneCtx = JS_NewObject (ctx);
+    JS_SetPropertyStr (ctx, sceneCtx, "time", JS_NewFloat64 (ctx, time));
+    JS_SetPropertyStr (ctx, sceneCtx, "dt",   JS_NewFloat64 (ctx, deltaTime));
+    JS_SetPropertyStr (ctx, sceneCtx, "fps",  JS_NewFloat64 (ctx, fps));
+    JS_SetPropertyStr (ctx, globalObj, "__sceneCtx", sceneCtx);
+
+    JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__textLayers");
+    JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
+    JS_FreeValue (ctx, layers);
+
+    if (JS_IsUndefined (layerObj) || JS_IsNull (layerObj)) {
+	JS_FreeValue (ctx, layerObj);
+	JS_FreeValue (ctx, globalObj);
+	return;
+    }
+
+    auto callHook = [&] (const char* prop, const char* tag) {
+	JSValue fn = JS_GetPropertyStr (ctx, layerObj, prop);
+	if (JS_IsFunction (ctx, fn)) {
+	    JSValue ret = JS_Call (ctx, fn, layerObj, 0, nullptr);
+	    if (JS_IsException (ret)) {
+		logJSException (ctx, tag);
+	    }
+	    JS_FreeValue (ctx, ret);
+	}
+	JS_FreeValue (ctx, fn);
+    };
+
+    auto it = this->m_layerInitialized.find (handle);
+    if (it != this->m_layerInitialized.end () && !it->second) {
+	callHook ("_init", "layer.init");
+	it->second = true;
+    }
+    callHook ("_tick", "layer.update");
+
+    JS_FreeValue (ctx, layerObj);
+    JS_FreeValue (ctx, globalObj);
+}
+
+std::string ScriptEngine::layerText (ScriptLayerHandle handle) {
+    if (!this->m_context || handle == kInvalidLayerHandle) {
+	return {};
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__textLayers");
+    JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
+
+    std::string result;
+    if (!JS_IsUndefined (layerObj) && !JS_IsNull (layerObj)) {
+	JSValue thisLayer = JS_GetPropertyStr (ctx, layerObj, "thisLayer");
+	JSValue textVal = JS_GetPropertyStr (ctx, thisLayer, "text");
+	if (!JS_IsUndefined (textVal) && !JS_IsNull (textVal)) {
+	    const char* cstr = JS_ToCString (ctx, textVal);
+	    if (cstr) {
+		result.assign (cstr);
+		JS_FreeCString (ctx, cstr);
+	    }
+	}
+	JS_FreeValue (ctx, textVal);
+	JS_FreeValue (ctx, thisLayer);
+    }
+
+    JS_FreeValue (ctx, layerObj);
+    JS_FreeValue (ctx, layers);
+    JS_FreeValue (ctx, globalObj);
+    return result;
+}
+
+void ScriptEngine::destroyLayer (ScriptLayerHandle handle) {
+    if (!this->m_context || handle == kInvalidLayerHandle) {
+	return;
+    }
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    JSValue layers = JS_GetPropertyStr (ctx, globalObj, "__textLayers");
+    JSValue layerObj = JS_GetPropertyUint32 (ctx, layers, static_cast<uint32_t> (handle));
+
+    if (!JS_IsUndefined (layerObj) && !JS_IsNull (layerObj)) {
+	JSValue fn = JS_GetPropertyStr (ctx, layerObj, "_destroy");
+	if (JS_IsFunction (ctx, fn)) {
+	    JSValue ret = JS_Call (ctx, fn, layerObj, 0, nullptr);
+	    if (JS_IsException (ret)) {
+		logJSException (ctx, "layer.destroy");
+	    }
+	    JS_FreeValue (ctx, ret);
+	}
+	JS_FreeValue (ctx, fn);
+    }
+    JS_FreeValue (ctx, layerObj);
+    JS_FreeValue (ctx, layers);
+    JS_FreeValue (ctx, globalObj);
+
+    // Remove the entry from globalThis.__textLayers so GC can reclaim its closures.
+    const std::string delScript = "delete globalThis.__textLayers[" + std::to_string (handle) + "];";
+    JSValue delResult = JS_Eval (ctx, delScript.c_str (), delScript.size (), "<layer-destroy>", JS_EVAL_TYPE_GLOBAL);
+    if (JS_IsException (delResult)) {
+	logJSException (ctx, "layer.destroy.delete");
+    }
+    JS_FreeValue (ctx, delResult);
+
+    this->m_layerInitialized.erase (handle);
+}
+
+DynamicValueUniquePtr ScriptEngine::evaluate (
+    const void* bindingKey,
+    const std::string& scriptSource,
+    const std::map<std::string, DynamicValue*>& scriptProperties,
+    const DynamicValue& currentValue,
+    WallpaperEngine::Render::Wallpapers::CScene* scene,
+    const ScriptBindingContext* bindingContext
+) {
+    if (!this->m_context) {
+	sLog.error ("ScriptEngine: No JS context available");
+	auto fallback = std::make_unique<DynamicValue> ();
+	fallback->update (currentValue);
+	return fallback;
+    }
+
+    this->installBuiltins ();
+
+    JSContext* ctx = this->m_context;
+    JSValue globalObj = JS_GetGlobalObject (ctx);
+    const std::string bindingKeyString = std::to_string (reinterpret_cast<uintptr_t> (bindingKey));
+    JS_SetPropertyStr (ctx, globalObj, "__currentBindingKey", JS_NewString (ctx, bindingKeyString.c_str ()));
+
+    this->updateRuntimeGlobals (ctx, globalObj);
+    this->updateSceneInputGlobals (ctx, globalObj, scene);
+
+    JSValue propsObj = buildScriptPropertiesObject (ctx, *this, scriptProperties);
+    JS_SetPropertyStr (ctx, globalObj, "__scriptProps", JS_DupValue (ctx, propsObj));
+    installSceneLayers (ctx, globalObj, scene, bindingContext);
+
+    JSValue module = this->ensureModule (bindingKey, scriptSource);
+    this->logTextEvaluationDebug (bindingContext);
+
+    this->callModuleWithProps (ctx, module, "setScriptProperties", propsObj);
+    this->initializeModuleIfNeeded (ctx, module, bindingKey, currentValue, scene != nullptr);
+    this->callModuleWithProps (ctx, module, "applyUserProperties", propsObj);
+
+    this->dispatchMediaEvents (module, bindingKey);
+    this->runIntervals (ctx, globalObj, bindingKeyString);
+
+    JSValue result = this->callUpdate (ctx, module, currentValue);
+    if (!JS_IsException (result)) {
+	applyLayerUpdates (ctx, *this, globalObj, scene);
+    }
+
+    JS_SetPropertyStr (ctx, globalObj, "__scriptProps", JS_UNDEFINED);
+    JS_FreeValue (ctx, globalObj);
+    JS_FreeValue (ctx, propsObj);
+
+    return this->resolveEvaluationResult (ctx, result, scriptSource, currentValue, scene, bindingContext);
+}

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

@@ -0,0 +1,176 @@
+#pragma once
+
+#include <chrono>
+#include <future>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <unordered_set>
+
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
+#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
+#include "WallpaperEngine/Data/Model/Types.h"
+
+extern "C" {
+#include "quickjs.h"
+}
+
+namespace WallpaperEngine::Render::Wallpapers {
+class CScene;
+}
+
+namespace WallpaperEngine::Scripting {
+using namespace WallpaperEngine::Data::Model;
+
+// Opaque handle returned by createLayerScript. 0 means invalid / not created.
+using ScriptLayerHandle = int;
+static constexpr ScriptLayerHandle kInvalidLayerHandle = 0;
+
+class ScriptEngine {
+public:
+    static ScriptEngine& instance ();
+
+    ~ScriptEngine ();
+    ScriptEngine (const ScriptEngine&) = delete;
+    ScriptEngine& operator= (const ScriptEngine&) = delete;
+
+    /**
+     * Evaluate a WallpaperEngine script's update() function.
+     *
+     * @param scriptSource The full JS script text (ES6 module with export function update(value))
+     * @param scriptProperties Map of property name to current DynamicValue*
+     * @param currentValue The current value to pass to update()
+     * @return The modified value from update(), or a copy of currentValue on error
+     */
+    DynamicValueUniquePtr evaluate (
+	const void* bindingKey,
+	const std::string& scriptSource,
+	const std::map<std::string, DynamicValue*>& scriptProperties,
+	const DynamicValue& currentValue,
+	WallpaperEngine::Render::Wallpapers::CScene* scene = nullptr,
+	const ScriptBindingContext* bindingContext = nullptr
+    );
+
+    // -------------------------------------------------------------------
+    // Layer-script API (Phase 2 — dynamic text)
+    // -------------------------------------------------------------------
+    //
+    // Wallpaper Engine text-object scripts follow a lifecycle pattern that
+    // cannot be evaluated with the simple `update(value) -> value` contract
+    // above. They typically look like:
+    //
+    //   'use strict';
+    //   export var scriptProperties = createScriptProperties()…finish();
+    //   export function init()   { /* subscribe to events, cache data    */ }
+    //   export function update() { thisLayer.text = computeCurrentText(); }
+    //
+    // The script mutates a `thisLayer` object in place instead of returning
+    // a value, and lifecycle functions are optional. The API below keeps
+    // per-layer state alive across frames so `init()` runs once and
+    // `update()` re-runs every tick.
+
+    /**
+     * Create a persistent "layer script" from a WE text-object script.
+     *
+     * @param scriptSource The full JS script text.
+     * @param initialScriptProps Initial values for scriptProperties entries.
+     *        Ownership stays with the caller; we only snapshot current values.
+     * @param initialText Initial value of `thisLayer.text` (usually the
+     *        static placeholder carried in the JSON).
+     * @return A positive handle, or kInvalidLayerHandle if evaluation failed.
+     */
+    ScriptLayerHandle createLayerScript (
+	const std::string& scriptSource,
+	const std::map<std::string, DynamicValue*>& initialScriptProps,
+	const std::string& initialText
+    );
+
+    /**
+     * Advance a layer by one frame.
+     *
+     * On the first call, invokes `init()` (if defined) before `update()`.
+     * Updates a `thisScene` context visible to the script (time, fps).
+     * Silently no-ops if the handle is invalid.
+     */
+    void tickLayer (ScriptLayerHandle handle, double time, double deltaTime, double fps);
+
+    /**
+     * Read the current value of `thisLayer.text` for the given layer.
+     * Returns an empty string if the handle is invalid.
+     */
+    std::string layerText (ScriptLayerHandle handle);
+
+    /**
+     * Tear down a layer: invokes `destroy()` (if defined) and frees state.
+     */
+    void destroyLayer (ScriptLayerHandle handle);
+
+    JSValue dynamicValueToJS (const DynamicValue& value) const;
+    DynamicValueUniquePtr jsToDynamicValue (JSValue val, DynamicValue::UnderlyingType hint) const;
+    void releaseBinding (const void* bindingKey);
+
+private:
+    ScriptEngine ();
+
+    JSValue ensureModule (const void* bindingKey, const std::string& scriptSource);
+    void installBuiltins ();
+    void refreshMediaState ();
+    void dispatchMediaEvents (JSValue module, const void* bindingKey);
+    void updateRuntimeGlobals (JSContext* ctx, JSValue globalObj) const;
+    void updateSceneInputGlobals (JSContext* ctx, JSValue globalObj, WallpaperEngine::Render::Wallpapers::CScene* scene);
+    void callModuleWithProps (JSContext* ctx, JSValue module, const char* name, JSValue propsObj) const;
+    void initializeModuleIfNeeded (JSContext* ctx, JSValue module, const void* bindingKey, const DynamicValue& currentValue, bool hasScene);
+    void runIntervals (JSContext* ctx, JSValue globalObj, const std::string& bindingKeyString) const;
+    DynamicValueUniquePtr fallbackTextValue (const std::string& scriptSource) const;
+    void applyTextFallback (DynamicValue& value, const std::string& scriptSource) const;
+    void logTextEvaluationDebug (const ScriptBindingContext* bindingContext) const;
+    void logTextResultDebug (const ScriptBindingContext* bindingContext, const DynamicValueUniquePtr& dynResult) const;
+    JSValue callUpdate (JSContext* ctx, JSValue module, const DynamicValue& currentValue) const;
+    DynamicValueUniquePtr resolveUndefinedResult (
+	const std::string& scriptSource,
+	const DynamicValue& currentValue,
+	WallpaperEngine::Render::Wallpapers::CScene* scene,
+	const ScriptBindingContext* bindingContext
+    ) const;
+    DynamicValueUniquePtr resolveEvaluationResult (
+	JSContext* ctx,
+	JSValue result,
+	const std::string& scriptSource,
+	const DynamicValue& currentValue,
+	WallpaperEngine::Render::Wallpapers::CScene* scene,
+	const ScriptBindingContext* bindingContext
+    ) const;
+
+    struct MediaState {
+	int playbackState = 0;
+	std::string status = "Stopped";
+	std::string title;
+	std::string artist;
+	std::string artUrl;
+	double duration = 0.0;
+	double position = 0.0;
+	bool available = false;
+    };
+
+    // Installs globalThis.__layers and related helpers. Called lazily.
+    void ensureLayerRegistry ();
+
+    JSRuntime* m_runtime = nullptr;
+    JSContext* m_context = nullptr;
+    ScriptLayerHandle m_nextLayerId = 1;
+    bool m_layerRegistryReady = false;
+    std::map<ScriptLayerHandle, bool> m_layerInitialized;
+    std::unordered_map<const void*, JSValue> m_modules = {};
+    std::unordered_set<const void*> m_initializedModules = {};
+    std::unordered_map<const void*, std::string> m_lastMediaProperties = {};
+    std::unordered_map<const void*, std::string> m_lastMediaPlayback = {};
+    std::unordered_map<const void*, std::string> m_lastMediaTimeline = {};
+    std::unordered_map<const void*, std::string> m_lastMediaThumbnail = {};
+    std::chrono::steady_clock::time_point m_lastMediaPoll = {};
+    std::future<std::optional<MediaState>> m_mediaPollFuture = {};
+    MediaState m_mediaState = {};
+    bool m_builtinsInstalled = false;
+};
+} // namespace WallpaperEngine::Scripting

+ 33 - 0
src/WallpaperEngine/Testing/Cases/PropertyParser.cpp

@@ -0,0 +1,33 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Data/Parsers/PropertyParser.h"
+#include "WallpaperEngine/Data/Model/Property.h"
+
+using WallpaperEngine::Data::JSON::JSON;
+using WallpaperEngine::Data::Model::DynamicValue;
+using WallpaperEngine::Data::Parsers::PropertyParser;
+
+TEST_CASE ("Bool properties without a value default to false") {
+    const JSON propertyData = {
+	{ "type", "bool" },
+	{ "text", "Enabled" },
+    };
+
+    const auto property = PropertyParser::parse (propertyData, "enabled");
+
+    REQUIRE (property != nullptr);
+    CHECK (property->getType () == DynamicValue::Boolean);
+    CHECK_FALSE (property->getBool ());
+}
+
+TEST_CASE ("Directory properties are parsed as file-like properties") {
+    const JSON propertyData = {
+	{ "type", "directory" },
+	{ "text", "Folder" },
+    };
+
+    const auto property = PropertyParser::parse (propertyData, "folder");
+
+    REQUIRE (property != nullptr);
+    CHECK (property->dump ().find ("folder - file") != std::string::npos);
+}

+ 296 - 0
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp

@@ -0,0 +1,296 @@
+#include "GLPlayer.h"
+
+#include "WallpaperEngine/Logging/Log.h"
+
+#include <mpv/render_gl.h>
+#include <mpv/stream_cb.h>
+
+using namespace WallpaperEngine::VideoPlayback::MPV;
+
+void* get_proc_address (void* ctx, const char* name) {
+    return static_cast<GLPlayer*> (ctx)->getContext ().getDriver ().getProcAddress (name);
+}
+
+GLPlayer::GLPlayer (
+    RenderContext& context, GLuint outputTexture, const std::filesystem::path& file, const int64_t baseWidth,
+    const int64_t baseHeight, const GLuint fbo
+) : ContextAware (context), m_outputTexture (outputTexture), m_width (baseWidth), m_height (baseHeight) {
+    this->m_fbo = fbo;
+    this->m_doWeOwnFramebuffer = this->m_fbo == GL_NONE;
+
+    this->prepareGL ();
+    this->setSource (file);
+}
+
+GLPlayer::GLPlayer (
+    RenderContext& context, const GLuint outputTexture, MemoryStreamProtocolUniquePtr stream, const int64_t baseWidth,
+    const int64_t baseHeight, const GLuint fbo
+) : ContextAware (context), m_outputTexture (outputTexture), m_width (baseWidth), m_height (baseHeight) {
+    this->m_fbo = fbo;
+    this->m_doWeOwnFramebuffer = this->m_fbo == GL_NONE;
+
+    this->prepareGL ();
+    this->setSource (std::move (stream));
+}
+
+GLPlayer::~GLPlayer () {
+    // clean up any kept resources
+    this->stop ();
+
+    // only clean up framebuffer if we own it
+    if (this->m_doWeOwnFramebuffer) {
+	// free gl resources too
+	glDeleteFramebuffers (1, &this->m_fbo);
+    }
+}
+
+void GLPlayer::incrementUsageCount () {
+    this->m_usageCount++;
+
+    if (this->m_usageCount == 1) {
+	this->play ();
+    }
+}
+
+void GLPlayer::decrementUsageCount () {
+    if (this->m_usageCount == 0) {
+	sLog.exception ("GLPlayer usage count would underflow");
+    }
+
+    this->m_usageCount--;
+
+    if (this->m_usageCount == 0) {
+	this->stop ();
+    }
+}
+
+void GLPlayer::setUntimed () {
+    if (this->m_handle) {
+	sLog.exception ("Cannot set untimed mode after playback has started");
+    }
+
+    this->m_untimed = true;
+}
+
+void GLPlayer::clearUntimed () {
+    if (this->m_handle) {
+	sLog.exception ("Cannot set untimed mode after playback has started");
+    }
+
+    this->m_untimed = false;
+}
+
+void GLPlayer::setMuted () {
+    this->m_muted = true;
+
+    if (this->m_handle) {
+	mpv_set_property_string (this->m_handle, "mute", "yes");
+    }
+}
+
+void GLPlayer::clearMuted () {
+    this->m_muted = false;
+
+    if (this->m_handle) {
+	mpv_set_property_string (this->m_handle, "mute", "no");
+    }
+}
+
+void GLPlayer::setVolume (double volume) {
+    this->m_volume = volume;
+
+    if (this->m_handle) {
+	mpv_set_property (this->m_handle, "volume", MPV_FORMAT_DOUBLE, &this->m_volume);
+    }
+}
+
+void GLPlayer::setPaused () {
+    this->m_paused = true;
+
+    if (this->m_handle) {
+	mpv_set_property_string (this->m_handle, "pause", "yes");
+    }
+}
+
+void GLPlayer::clearPaused () {
+    this->m_paused = false;
+
+    if (this->m_handle) {
+	mpv_set_property_string (this->m_handle, "pause", "no");
+    }
+}
+
+void GLPlayer::render () const {
+    // rendering should only happen if the texture is in use
+    if (this->m_handle == nullptr) {
+	return;
+    }
+
+    // read all the events available
+    while (true) {
+	const mpv_event* event = mpv_wait_event (this->m_handle, 0);
+
+	if (event == nullptr || event->event_id == MPV_EVENT_NONE) {
+	    break;
+	}
+
+	if (event->event_id != MPV_EVENT_VIDEO_RECONFIG) {
+	    continue;
+	}
+
+	int64_t width, height;
+
+	if (mpv_get_property (this->m_handle, "dwidth", MPV_FORMAT_INT64, &width) < 0) {
+	    continue;
+	}
+
+	if (mpv_get_property (this->m_handle, "dheight", MPV_FORMAT_INT64, &height) < 0) {
+	    continue;
+	}
+
+	if (width < 0 || height < 0) {
+	    continue;
+	}
+
+	this->m_width = width;
+	this->m_height = height;
+	// reconfigure the texture
+	glBindTexture (GL_TEXTURE_2D, this->m_outputTexture);
+	glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, this->m_width, this->m_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    }
+
+    // render the next
+    glViewport (0, 0, this->m_width, this->m_height);
+
+    mpv_opengl_fbo fbo { static_cast<int> (this->m_fbo), static_cast<int> (this->m_width),
+			 static_cast<int> (this->m_height), GL_RGBA8 };
+
+    // no need to flip as it'll be handled by the wallpaper rendering code
+    int flip_y = 0;
+
+    mpv_render_param params[] = { { MPV_RENDER_PARAM_OPENGL_FBO, &fbo },
+				  { MPV_RENDER_PARAM_FLIP_Y, &flip_y },
+				  { MPV_RENDER_PARAM_INVALID, nullptr } };
+
+    mpv_render_context_render (this->m_renderContext, params);
+}
+
+int GLPlayer::getWidth () const { return this->m_width; }
+int GLPlayer::getHeight () const { return this->m_height; }
+
+void GLPlayer::prepareGL () {
+    if (!this->m_doWeOwnFramebuffer || this->m_fbo != GL_NONE) {
+	return;
+    }
+
+    glGenFramebuffers (1, &this->m_fbo);
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_fbo);
+    glBindTexture (GL_TEXTURE_2D, this->m_outputTexture);
+    // reset texture's contents
+    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, this->m_width, this->m_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    constexpr GLenum drawBuffers[1] = { GL_COLOR_ATTACHMENT0 };
+    glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->m_outputTexture, 0);
+    glDrawBuffers (1, drawBuffers);
+
+    // ensure first framebuffer is okay
+    if (glCheckFramebufferStatus (GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
+	sLog.exception ("Framebuffers are not properly set");
+    }
+
+    // clear the framebuffer
+    glClear (GL_COLOR_BUFFER_BIT);
+}
+
+void GLPlayer::init () {
+    this->m_handle = mpv_create ();
+
+    if (this->m_handle == nullptr) {
+	sLog.exception ("Cannot create mpv context for video texture");
+    }
+
+    // setup mpv options for playback
+    mpv_set_option_string (this->m_handle, "terminal", "yes");
+#if NDEBUG
+    mpv_set_option_string (this->m_handle, "msg-level", "all=status,statusline=no");
+#else
+    mpv_set_option_string (this->m_handle, "msg-level", "all=v");
+#endif
+    mpv_set_option_string (this->m_handle, "input-cursor", "no");
+    mpv_set_option_string (this->m_handle, "cursor-autohide", "no");
+    mpv_set_option_string (this->m_handle, "config", "no");
+    mpv_set_option_string (this->m_handle, "fbo-format", "rgba8");
+    mpv_set_option_string (this->m_handle, "vo", "libmpv");
+    mpv_set_option_string (this->m_handle, "profile", "fast");
+    mpv_set_option_string (this->m_handle, "untimed", this->m_untimed ? "yes" : "no");
+
+    if (mpv_initialize (this->m_handle) < 0) {
+	sLog.exception ("Could not initialize mpv context");
+    }
+
+    // ensure video is muted and plays in a loop
+    mpv_set_property_string (this->m_handle, "hwdec", "auto");
+    mpv_set_property_string (this->m_handle, "loop", "inf");
+    mpv_set_property (this->m_handle, "volume", MPV_FORMAT_DOUBLE, &this->m_volume);
+
+    // initialize gl context for mpv
+    mpv_opengl_init_params gl_init_params { get_proc_address, this };
+    mpv_render_param params[] { { MPV_RENDER_PARAM_API_TYPE, const_cast<char*> (MPV_RENDER_API_TYPE_OPENGL) },
+				{ MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params },
+				{ MPV_RENDER_PARAM_INVALID, nullptr } };
+
+    if (mpv_render_context_create (&this->m_renderContext, this->m_handle, params) < 0) {
+	sLog.exception ("Failed to initialize MPV's GL context");
+    }
+
+    // mute the video if required
+    mpv_set_property_string (this->m_handle, "mute", this->m_muted ? "yes" : "no");
+    // ensure play/pause status is respected too
+    mpv_set_property_string (this->m_handle, "pause", this->m_paused ? "yes" : "no");
+}
+
+void GLPlayer::setSource (const std::filesystem::path& file) { this->m_file = file; }
+
+void GLPlayer::setSource (MemoryStreamProtocolUniquePtr source) { this->m_stream = std::move (source); }
+
+void GLPlayer::play () {
+    if (this->m_handle != nullptr) {
+	sLog.exception ("Cannot play the same GLPlayer twice");
+    }
+
+    if (!this->m_file.has_value () && !this->m_stream.has_value ()) {
+	sLog.exception ("Cannot play a GLPlayer without a source");
+    }
+
+    this->init ();
+
+    if (this->m_file.has_value ()) {
+	// build the path to the video file
+	const char* command[] = { "loadfile", this->m_file.value ().c_str (), nullptr };
+
+	if (mpv_command (this->m_handle, command) < 0) {
+	    sLog.exception ("Cannot load video to play");
+	}
+    } else if (this->m_stream) {
+	this->m_stream.value ()->registerReadCallback (this->m_handle);
+
+	// start playing the video
+	const char* command[] = { "loadfile", "buffer://", nullptr };
+
+	if (mpv_command (this->m_handle, command) < 0) {
+	    sLog.exception ("Cannot load video texture to play");
+	}
+    }
+}
+
+void GLPlayer::stop () {
+    // clean up mpv and get it ready to start again at some point
+    if (this->m_renderContext) {
+	mpv_render_context_free (this->m_renderContext);
+	this->m_renderContext = nullptr;
+    }
+
+    if (this->m_handle) {
+	mpv_terminate_destroy (this->m_handle);
+	this->m_handle = nullptr;
+    }
+}

+ 81 - 0
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h

@@ -0,0 +1,81 @@
+#pragma once
+
+#include "MemoryStreamProtocol.h"
+#include "WallpaperEngine/Render/RenderContext.h"
+
+#include <GL/glew.h>
+#include <mpv/client.h>
+#include <mpv/render.h>
+#include <string>
+
+namespace WallpaperEngine::VideoPlayback::MPV {
+class GLPlayer : public Helpers::ContextAware {
+    friend struct MemoryStreamProtocol;
+
+public:
+    GLPlayer (
+	RenderContext& context, GLuint outputTexture, const std::filesystem::path& file, int64_t baseWidth,
+	int64_t baseHeight, GLuint fbo = GL_NONE
+    );
+    GLPlayer (
+	RenderContext& context, GLuint outputTexture, MemoryStreamProtocolUniquePtr stream, int64_t baseWidth,
+	int64_t baseHeight, GLuint fbo = GL_NONE
+    );
+    ~GLPlayer () override;
+
+    /**
+     * Increments the usage count of the player
+     *
+     * Directly controls playback, only started when at least one thing is using it
+     * Initializes mpv if needed and starts playback
+     */
+    void incrementUsageCount ();
+    /**
+     * Decrements the usage count of the player
+     *
+     * Directly controls playback, only stopped when nothing is using it
+     * De-initializes mpv if needed
+     */
+    void decrementUsageCount ();
+
+    void setUntimed ();
+    void clearUntimed ();
+    void setMuted ();
+    void clearMuted ();
+    void setVolume (double volume);
+    void setPaused ();
+    void clearPaused ();
+
+    void render () const;
+
+    int getWidth () const;
+    int getHeight () const;
+
+private:
+    void prepareGL ();
+    void init ();
+    void play ();
+    void setSource (MemoryStreamProtocolUniquePtr source);
+    void setSource (const std::filesystem::path& file);
+    void stop ();
+
+protected:
+    bool m_doWeOwnFramebuffer;
+    GLuint m_outputTexture;
+    GLuint m_fbo = GL_NONE;
+    mutable int64_t m_width;
+    mutable int64_t m_height;
+    mpv_handle* m_handle = nullptr;
+    mpv_render_context* m_renderContext = nullptr;
+    double m_volume = 0.0f;
+    bool m_muted = false;
+    bool m_untimed = false;
+    bool m_paused = false;
+    std::optional<std::filesystem::path> m_file;
+    std::optional<MemoryStreamProtocolUniquePtr> m_stream;
+    uint32_t m_usageCount = 0;
+};
+
+using GLPlayerUniquePtr = std::unique_ptr<GLPlayer>;
+using GLPlayerSharedPtr = std::shared_ptr<GLPlayer>;
+};

+ 54 - 0
src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.cpp

@@ -0,0 +1,54 @@
+#include "MemoryStreamProtocol.h"
+
+#include "GLPlayer.h"
+
+#include <mpv/stream_cb.h>
+
+using namespace WallpaperEngine::VideoPlayback::MPV;
+
+int64_t mem_seek (void* cookie, const int64_t offset) {
+    const auto stream = static_cast<MemoryStreamProtocol*> (cookie);
+
+    // sometimes the stream can get to fail state depending on what mpv did
+    // seeking usually means we can ignore that and try seek
+    // if the seek fails the failbit will be set again
+    if (stream->fail ()) {
+	stream->clear ();
+    }
+
+    stream->seekg (offset, std::ios_base::beg);
+
+    return stream->tellg ();
+}
+
+int64_t mem_read (void* cookie, char* buf, uint64_t bytes) {
+    return static_cast<MemoryStreamProtocol*> (cookie)->read (buf, bytes).gcount ();
+}
+
+int64_t mem_size (void* cookie) { return static_cast<MemoryStreamProtocol*> (cookie)->m_size; }
+
+void mem_close (void* cookie) {
+    // seek to the beginning again for the next play
+    mem_seek (cookie, 0);
+    // closing does nothing else because the life is managed
+    // by whatever owns it
+
+    // this also allows starting playback in another instance no problem,
+    // but two instances won't be able to use the same stream
+}
+
+int mem_open (void* userdata, char* uri, struct mpv_stream_cb_info* info) {
+    info->cookie = userdata;
+    info->read_fn = mem_read;
+    info->seek_fn = mem_seek;
+    info->close_fn = mem_close;
+    info->size_fn = mem_size;
+
+    return 0;
+}
+
+void MemoryStreamProtocol::registerReadCallback (mpv_handle* handle) {
+    if (mpv_stream_cb_add_ro (handle, "buffer", this, mem_open) < 0) {
+	sLog.exception ("Cannot register memory stream protocol for mpv");
+    }
+}

+ 24 - 0
src/WallpaperEngine/VideoPlayback/MPV/MemoryStreamProtocol.h

@@ -0,0 +1,24 @@
+#pragma once
+#include "WallpaperEngine/Data/Utils/MemoryStream.h"
+
+#include <mpv/client.h>
+
+#include <cstring>
+#include <memory>
+
+namespace WallpaperEngine::VideoPlayback::MPV {
+class GLPlayer;
+struct MemoryStreamProtocol : Data::Utils::MemoryStream {
+    explicit MemoryStreamProtocol (const char* pointer, const size_t size) :
+	MemoryStream (std::unique_ptr<char[]> (new char[size]), size), m_size (size) {
+	memcpy (this->m_buffer.get (), pointer, size);
+    }
+
+    void registerReadCallback (mpv_handle* handle);
+
+    size_t m_size;
+};
+
+using MemoryStreamProtocolUniquePtr = std::unique_ptr<MemoryStreamProtocol>;
+using MemoryStreamProtocolSharedPtr = std::shared_ptr<MemoryStreamProtocol>;
+};