Browse Source

Merge pull request #1 from Almamu/main

master merge
WallpaperEngineLover 4 months ago
parent
commit
360eb8b24a
76 changed files with 6553 additions and 639 deletions
  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
       - name: Install dependencies
         if: matrix.os == 'ubuntu-24.04'
         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
       - 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.
         # 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
     - name: Install dependencies
       if: matrix.os == 'ubuntu-22.04'
       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
     - name: Install dependencies
       if: matrix.os == 'ubuntu-24.04'
       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
     - 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.
       # 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
       - name: Install dependencies
         if: matrix.os == 'ubuntu-22.04'
         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
       - name: Install dependencies
         if: matrix.os == 'ubuntu-24.04'
         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
       - 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.
         # 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
     - name: Install dependencies
       if: matrix.os == 'ubuntu-22.04'
       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
     - name: Install dependencies
       if: matrix.os == 'ubuntu-24.04'
       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
     - 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.
       # 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-*
 cmake-build-*
 .idea
 .idea
+.cache/
 build/
 build/
 .vscode/
 .vscode/
+.zed/
 third_party/
 third_party/
 packaging/archlinux/*
 packaging/archlinux/*
 !packaging/archlinux/PKGBUILD
 !packaging/archlinux/PKGBUILD
 *-protocol.o
 *-protocol.o
 *-protocol.c
 *-protocol.c
-*-protocol.h
+*-protocol.h
+AGENTS.md

+ 4 - 1
.gitmodules

@@ -28,4 +28,7 @@
 	branch = master
 	branch = master
 [submodule "src/External/Catch2"]
 [submodule "src/External/Catch2"]
 	path = 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(X11)
 find_package(OpenGL REQUIRED)
 find_package(OpenGL REQUIRED)
 find_package(GLEW REQUIRED)
 find_package(GLEW REQUIRED)
+
 find_package(GLUT REQUIRED)
 find_package(GLUT REQUIRED)
 find_package(ZLIB REQUIRED)
 find_package(ZLIB REQUIRED)
 find_package(SDL2 REQUIRED)
 find_package(SDL2 REQUIRED)
@@ -34,9 +35,11 @@ find_package(MPV REQUIRED)
 find_package(LZ4 REQUIRED)
 find_package(LZ4 REQUIRED)
 find_package(FFMPEG REQUIRED)
 find_package(FFMPEG REQUIRED)
 find_package(PulseAudio REQUIRED)
 find_package(PulseAudio REQUIRED)
+find_package(Freetype REQUIRED)
 
 
 set(ENABLE_OPT OFF)
 set(ENABLE_OPT OFF)
 set(SPIRV_CROSS_FORCE_PIC ON)
 set(SPIRV_CROSS_FORCE_PIC ON)
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
 set(KISSFFT_TEST OFF)
 set(KISSFFT_TEST OFF)
 set(KISSFFT_TOOLS 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=hidden)
 list(REMOVE_ITEM CEF_COMPILER_FLAGS -fvisibility-inlines-hidden)
 list(REMOVE_ITEM CEF_COMPILER_FLAGS -fvisibility-inlines-hidden)
 list(APPEND CEF_CXX_COMPILER_FLAGS -std=c++20)
 list(APPEND CEF_CXX_COMPILER_FLAGS -std=c++20)
-list(APPEND CEF_COMPILER_FLAGS -rdynamic)
 list(APPEND CEF_EXE_LINKER_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
 # 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)
 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(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
 set(TARGET_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/output)
 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(${CEF_LIBCEF_DLL_WRAPPER_PATH} libcef_dll_wrapper)
 add_subdirectory(src/External/glslang-WallpaperEngine glslang)
 add_subdirectory(src/External/glslang-WallpaperEngine glslang)
 add_subdirectory(src/External/SPIRV-Cross-WallpaperEngine spirv-cross)
 add_subdirectory(src/External/SPIRV-Cross-WallpaperEngine spirv-cross)
 add_subdirectory(src/External/kissfft kissfft)
 add_subdirectory(src/External/kissfft kissfft)
 add_subdirectory(src/External/argparse argparse)
 add_subdirectory(src/External/argparse argparse)
+set(QJS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
+add_subdirectory(src/External/quickjs quickjs)
 if(BUILD_TESTING)
 if(BUILD_TESTING)
     add_subdirectory(src/External/Catch2)
     add_subdirectory(src/External/Catch2)
 endif()
 endif()
@@ -156,17 +167,21 @@ if(WAYLAND_SUPPORT_FOUND)
 
 
     message(STATUS "Building protocols...")
     message(STATUS "Building protocols...")
     add_custom_command(OUTPUT ${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.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
     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
     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
     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
     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)
         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
     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)
         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_SUPPORT_INCLUDE_DIRS})
     include_directories(${WAYLAND_OUTPUT_DIR})
     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.c
         ${WAYLAND_OUTPUT_DIR}/wlr-foreign-toplevel-management-unstable-v1-protocol.h
         ${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.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()
 endif()
 
 
 if(X11_FOUND)
 if(X11_FOUND)
@@ -395,6 +412,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Objects/CSound.cpp
     src/WallpaperEngine/Render/Objects/CSound.cpp
     src/WallpaperEngine/Render/Objects/CParticle.h
     src/WallpaperEngine/Render/Objects/CParticle.h
     src/WallpaperEngine/Render/Objects/CParticle.cpp
     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.h
     src/WallpaperEngine/Render/CFBO.cpp
     src/WallpaperEngine/Render/CFBO.cpp
@@ -462,7 +481,16 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Builders/VectorBuilder.h
     src/WallpaperEngine/Data/Builders/VectorBuilder.h
     src/WallpaperEngine/Data/Dumpers/StringPrinter.cpp
     src/WallpaperEngine/Data/Dumpers/StringPrinter.cpp
     src/WallpaperEngine/Data/Dumpers/StringPrinter.h
     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)
 if(BUILD_TESTING)
     add_executable(
     add_executable(
@@ -477,19 +505,19 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Input/TestingMouseInput.h
         src/WallpaperEngine/Testing/Input/TestingMouseInput.h
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.cpp
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.cpp
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.h
         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()
 endif()
 
 
-add_executable(
-    linux-wallpaperengine
-    src/main.cpp
+add_library(
+    linux-wallpaperengine-lib SHARED
 
 
     ${COMMON_SOURCES}
     ${COMMON_SOURCES}
     ${WAYLAND_SOURCES}
     ${WAYLAND_SOURCES}
     ${X11_SOURCES}
     ${X11_SOURCES}
     ${DEMOMODE_SOURCES})
     ${DEMOMODE_SOURCES})
 
 
-target_link_libraries (linux-wallpaperengine PUBLIC
+target_link_libraries (linux-wallpaperengine-lib PUBLIC
     ${OPENGL_LIBRARIES}
     ${OPENGL_LIBRARIES}
     GLEW::GLEW
     GLEW::GLEW
     ${GLUT_LIBRARIES}
     ${GLUT_LIBRARIES}
@@ -499,6 +527,7 @@ target_link_libraries (linux-wallpaperengine PUBLIC
     ${FFMPEG_LIBRARIES}
     ${FFMPEG_LIBRARIES}
     ${MPV_LIBRARY}
     ${MPV_LIBRARY}
     ${PULSEAUDIO_LIBRARY}
     ${PULSEAUDIO_LIBRARY}
+    Freetype::Freetype
     ${WAYLAND_LIBRARIES}
     ${WAYLAND_LIBRARIES}
     ${X11_LIBRARIES}
     ${X11_LIBRARIES}
     kissfft
     kissfft
@@ -508,7 +537,8 @@ target_link_libraries (linux-wallpaperengine PUBLIC
     glfw
     glfw
     libcef_lib
     libcef_lib
     libcef_dll_wrapper
     libcef_dll_wrapper
-    argparse)
+    argparse
+    qjs)
 
 
 if (BUILD_TESTING)
 if (BUILD_TESTING)
     target_link_libraries (tests PRIVATE
     target_link_libraries (tests PRIVATE
@@ -522,6 +552,7 @@ if (BUILD_TESTING)
         ${FFMPEG_LIBRARIES}
         ${FFMPEG_LIBRARIES}
         ${MPV_LIBRARY}
         ${MPV_LIBRARY}
         ${PULSEAUDIO_LIBRARY}
         ${PULSEAUDIO_LIBRARY}
+        Freetype::Freetype
         ${WAYLAND_LIBRARIES}
         ${WAYLAND_LIBRARIES}
         ${X11_LIBRARIES}
         ${X11_LIBRARIES}
         kissfft
         kissfft
@@ -531,11 +562,12 @@ if (BUILD_TESTING)
         glfw
         glfw
         libcef_lib
         libcef_lib
         libcef_dll_wrapper
         libcef_dll_wrapper
-        argparse)
+        argparse
+        qjs)
 endif()
 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)
 if (BUILD_TESTING)
     # tests should give as much output as possible
     # tests should give as much output as possible
@@ -544,29 +576,71 @@ if (BUILD_TESTING)
 endif()
 endif()
 
 
 if(X11_SUPPORT_FOUND)
 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
     # make sure some of the X11 functions we'll use are available
     check_function_exists(XSetIOErrorExitHandler HAVE_XSETIOERROREXITHANDLER)
     check_function_exists(XSetIOErrorExitHandler HAVE_XSETIOERROREXITHANDLER)
 
 
     if(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()
 endif()
 endif()
 
 
 if(WAYLAND_SUPPORT_FOUND)
 if(WAYLAND_SUPPORT_FOUND)
-    target_compile_definitions(linux-wallpaperengine PUBLIC ENABLE_WAYLAND)
+    target_compile_definitions(linux-wallpaperengine-lib PUBLIC ENABLE_WAYLAND)
 endif()
 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
 # remove the vulkan lib as chromium includes a broken libvulkan.so.1 with it
 file(REMOVE "${CEF_BINARY_DIR}/libvulkan.so.1")
 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
 # 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/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>
     <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" />
     <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://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/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/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"><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://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>
 </p>
 
 
 # 🖼️ Linux Wallpaper Engine
 # 🖼️ Linux Wallpaper Engine
@@ -40,13 +41,13 @@ Install the required dependencies on Ubuntu/Debian-based systems:
 ### Ubuntu 22.04
 ### Ubuntu 22.04
 ```bash
 ```bash
 sudo apt-get update
 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
 ### Ubuntu 24.04
 ```bash
 ```bash
 sudo apt-get update
 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
 ### Alt linux
@@ -60,7 +61,7 @@ Install the required dependencies on RHEL/Fedora-based systems:
 ### Fedora 42
 ### Fedora 42
 ```bash
 ```bash
 sudo dnf update
 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.
 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!
 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
 ### 🔧 Common Options
 
 
 | Option | Description |
 | 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 |
 | `--fps <val>` | Limit frame rate |
 | `--window <XxYxWxH>` | Run in windowed mode with custom size/position |
 | `--window <XxYxWxH>` | Run in windowed mode with custom size/position |
 | `--screen-root <screen>` | Set as background for specific screen |
 | `--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` |
 | `--scaling <mode>` | Wallpaper scaling: `stretch`, `fit`, `fill`, or `default` |
 | `--clamping <mode>` | Set texture clamping: `clamp`, `border`, `repeat` |
 | `--clamping <mode>` | Set texture clamping: `clamp`, `border`, `repeat` |
 | `--assets-dir <path>` | Set custom path for assets |
 | `--assets-dir <path>` | Set custom path for assets |
@@ -209,6 +215,12 @@ linux-wallpaperengine \
   --scaling fill --screen-root HDMI-1 --bg 2667198602
   --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
 #### Run in a window
 ```bash
 ```bash
 linux-wallpaperengine --window 0x0x1280x720 1845706469
 linux-wallpaperengine --window 0x0x1280x720 1845706469
@@ -291,7 +303,7 @@ linux-wallpaperengine --set-property bloom=1 2370927443
 
 
 ## 🧪 Wayland & X11 Support
 ## 🧪 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`).
 - **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.
 > ⚠ 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"
 url="https://github.com/Almamu/linux-wallpaperengine"
 license=('GPL-3.0-only')
 license=('GPL-3.0-only')
 depends=('lz4' 'ffmpeg' 'mpv' 'glfw' 'glew' 'freeglut' 'libpulse' 'libcups' 'at-spi2-core' 'nss' 'libxcomposite' 'libxdamage' 'nspr')
 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")
 provides=("linux-wallpaperengine")
 source=("${pkgname}::git+https://github.com/Almamu/linux-wallpaperengine.git#branch=main")
 source=("${pkgname}::git+https://github.com/Almamu/linux-wallpaperengine.git#branch=main")
 sha512sums=('SKIP')
 sha512sums=('SKIP')
-optdepends=(
-    'xorg-xrandr: support for X11'
-    'wayland-protocols: support for wayland')
 
 
 pkgver() {
 pkgver() {
     cd "$pkgname"
     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 <cstring>
 #include <fstream>
 #include <fstream>
 #include <iostream>
 #include <iostream>
+#include <sstream>
+#include <optional>
+#include <stdexcept>
 #include <string_view>
 #include <string_view>
 
 
 #include <argparse/argparse.hpp>
 #include <argparse/argparse.hpp>
@@ -296,6 +299,11 @@ void ApplicationContext::loadSettingsFromArgv () {
 		!= this->settings.general.screenBackgrounds.end ()) {
 		!= this->settings.general.screenBackgrounds.end ()) {
 		sLog.exception ("Cannot specify the same screen more than once: ", value);
 		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) {
 	    if (this->settings.render.mode == EXPLICIT_WINDOW) {
 		sLog.exception ("Cannot run in both background and window mode");
 		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;
 	    this->settings.general.screenClamps[lastScreen] = this->settings.render.window.clamp;
 	})
 	})
 	.append ();
 	.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")
     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 {
 	.action ([this, &lastScreen] (const std::string& value) -> void {
 	    this->settings.general.screenBackgrounds[lastScreen] = translateBackground (value);
 	    this->settings.general.screenBackgrounds[lastScreen] = translateBackground (value);
 	    // set the default background to the last one used
 	    // set the default background to the last one used
 	    this->settings.general.defaultBackground = translateBackground (value);
 	    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 ();
 	.append ();
     backgroundGroup.add_argument ("--playlist")
     backgroundGroup.add_argument ("--playlist")
@@ -342,8 +402,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.append ();
 	.append ();
     backgroundGroup.add_argument ("--scaling")
     backgroundGroup.add_argument ("--scaling")
 	.help (
 	.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")
 	.choices ("stretch", "fit", "fill", "default")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
 	.action ([this, &lastScreen] (const std::string& value) -> void {
@@ -363,6 +423,10 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenScalings[lastScreen] = mode;
 		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 {
 	    } else {
 		this->settings.render.window.scalingMode = mode;
 		this->settings.render.window.scalingMode = mode;
 	    }
 	    }
@@ -370,8 +434,8 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.append ();
 	.append ();
     backgroundGroup.add_argument ("--clamp")
     backgroundGroup.add_argument ("--clamp")
 	.help (
 	.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")
 	.choices ("clamp", "border", "repeat")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
 	.action ([this, &lastScreen] (const std::string& value) -> void {
@@ -389,9 +453,37 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenClamps[lastScreen] = flags;
 		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 {
 	    } else {
 		this->settings.render.window.clamp = flags;
 		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");
     auto& performanceGroup = program.add_group ("Performance options");
@@ -509,6 +601,44 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.flag ()
 	.store_into (this->settings.general.dumpStructure);
 	.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 (
     program.add_epilog (
 	"Usage examples:\n"
 	"Usage examples:\n"
 	"  linux-wallpaperengine --screen-root HDMI-1 --bg 2317494988 --scaling fill --clamp border\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"
 	"    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"
 	"  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"
 	"    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 {
     try {

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

@@ -40,6 +40,18 @@ public:
 	EXPLICIT_WINDOW = 2,
 	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 {
     struct PlaylistSettings {
 	uint32_t delayMinutes = 60;
 	uint32_t delayMinutes = 60;
 	std::string mode = "timer";
 	std::string mode = "timer";
@@ -54,6 +66,14 @@ public:
 	PlaylistSettings settings;
 	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 {
     struct {
 	/**
 	/**
 	 * General settings
 	 * General settings
@@ -81,6 +101,8 @@ public:
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
 	    /** Playlist used in window mode */
 	    /** Playlist used in window mode */
 	    std::optional<PlaylistDefinition> defaultPlaylist;
 	    std::optional<PlaylistDefinition> defaultPlaylist;
+	    /** Span groups: multiple monitors sharing one stretched wallpaper */
+	    std::vector<SpanGroup> spanGroups;
 	} general;
 	} general;
 
 
 	/**
 	/**
@@ -103,6 +125,15 @@ public:
 	     * Example: "firefox" will match "org.mozilla.firefox".
 	     * Example: "firefox" will match "org.mozilla.firefox".
 	     */
 	     */
 	    std::vector<std::string> fullscreenPauseIgnoreAppIds;
 	    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 {
 	    struct {
 		/** The window size used in explicit window */
 		/** The window size used in explicit window */
@@ -110,6 +141,11 @@ public:
 		TextureFlags clamp;
 		TextureFlags clamp;
 		WallpaperEngine::Render::WallpaperState::TextureUVsScaling scalingMode;
 		WallpaperEngine::Render::WallpaperState::TextureUVsScaling scalingMode;
 	    } window;
 	    } window;
+
+	    struct {
+		/** Which wlr-layer-shell layer to use for desktop backgrounds */
+		WAYLAND_LAYER layer;
+	    } wayland;
 	} render;
 	} render;
 
 
 	/**
 	/**
@@ -159,6 +195,7 @@ public:
             .screenClamps = {},
             .screenClamps = {},
             .screenPlaylists = {},
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
             .defaultPlaylist = std::nullopt,
+            .spanGroups = {},
         },
         },
         .render = {
         .render = {
             .mode = NORMAL_WINDOW,
             .mode = NORMAL_WINDOW,
@@ -166,11 +203,22 @@ public:
             .pauseOnFullscreen = true,
             .pauseOnFullscreen = true,
             .pauseOnFullscreenOnlyWhenActive = false,
             .pauseOnFullscreenOnlyWhenActive = false,
             .fullscreenPauseIgnoreAppIds = {},
             .fullscreenPauseIgnoreAppIds = {},
+            .debug = {
+                .baseOnly = false,
+                .noSolidFinal = false,
+	                .passLog = false,
+	                .objectFilter = std::nullopt,
+	                .skipObjects = {},
+	                .skipEffects = {},
+	            },
             .window = {
             .window = {
                 .geometry = {},
                 .geometry = {},
                 .clamp = TextureFlags_ClampUVs,
                 .clamp = TextureFlags_ClampUVs,
                 .scalingMode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs,
                 .scalingMode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs,
             },
             },
+            .wayland = {
+                .layer = WAYLAND_LAYER_BOTTOM,
+            },
         },
         },
         .audio = {
         .audio = {
             .enabled = true,
             .enabled = true,

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

@@ -21,6 +21,7 @@
 #endif /* DEMOMODE */
 #endif /* DEMOMODE */
 
 
 #include <algorithm>
 #include <algorithm>
+#include <climits>
 #include <numeric>
 #include <numeric>
 #include <unistd.h>
 #include <unistd.h>
 #define STB_IMAGE_WRITE_IMPLEMENTATION
 #define STB_IMAGE_WRITE_IMPLEMENTATION
@@ -182,13 +183,33 @@ void WallpaperApplication::loadBackgrounds () {
     }
     }
 
 
     for (const auto& [screen, path] : this->m_context.settings.general.screenBackgrounds) {
     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 ()) {
 	if (path.empty ()) {
 	    this->m_backgrounds[screen] = this->loadBackground (this->m_context.settings.general.defaultBackground);
 	    this->m_backgrounds[screen] = this->loadBackground (this->m_context.settings.general.defaultBackground);
 	} else {
 	} else {
 	    this->m_backgrounds[screen] = this->loadBackground (path);
 	    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) {
 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
     // this allows taking screenshots after a background changes
     // useful for playlists
     // useful for playlists
     if (this->m_context.settings.screenshot.take) {
     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));
     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 ();
     const std::string extStr = extension.string ();
 
 
     // Offload pixel processing and saving to a background thread to avoid hitches
     // 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 };
 	auto* bitmap = new uint8_t[width * height * 3] { 0 };
 
 
 	for (const auto& capture : captures) {
 	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 y = 0; y < capture.vpHeight; y++) {
 		for (int x = 0; x < capture.vpWidth; x++) {
 		for (int x = 0; x < capture.vpWidth; x++) {
 		    // interpolate within the UV range to get source coordinates
 		    // 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
 		    // convert UV to pixel coordinates in the source buffer
 		    const int srcX = std::clamp (static_cast<int> (u * capture.readWidth), 0, capture.readWidth - 1);
 		    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);
     m_renderContext = std::make_unique<WallpaperEngine::Render::RenderContext> (*m_videoDriver, *this);
     // create a new background for each screen
     // 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) {
     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 scalingIt = this->m_context.settings.general.screenScalings.find (background);
 	const auto clampIt = this->m_context.settings.general.screenClamps.find (background);
 	const auto clampIt = this->m_context.settings.general.screenClamps.find (background);
 	const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
 	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 () {
 void WallpaperApplication::setupOpenGLDebugging () {
@@ -717,15 +806,12 @@ void WallpaperApplication::setupOpenGLDebugging () {
 #endif
 #endif
 }
 }
 
 
-void WallpaperApplication::show () {
+void WallpaperApplication::setup () {
     this->setupOutput ();
     this->setupOutput ();
     this->setupAudio ();
     this->setupAudio ();
     this->prepareOutputs ();
     this->prepareOutputs ();
     this->setupOpenGLDebugging ();
     this->setupOpenGLDebugging ();
 
 
-    static time_t seconds;
-    static struct tm* timeinfo;
-
     if (this->m_context.settings.general.dumpStructure) {
     if (this->m_context.settings.general.dumpStructure) {
 	auto prettyPrinter = Data::Dumpers::StringPrinter ();
 	auto prettyPrinter = Data::Dumpers::StringPrinter ();
 
 
@@ -748,66 +834,17 @@ void WallpaperApplication::show () {
     bool initialized = false;
     bool initialized = false;
     int frame = 0;
     int frame = 0;
 #endif /* DEMOMODE */
 #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);
 		usleep (FULLSCREEN_CHECK_WAIT_TIME);
-	    }
+		if (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning) {
+			return;
+		}
 	    m_renderContext->setPause (false);
 	    m_renderContext->setPause (false);
 
 
 	    // account for paused duration in playlist timers
 	    // account for paused duration in playlist timers
@@ -822,29 +859,96 @@ void WallpaperApplication::show () {
 	    }
 	    }
 
 
 	    this->m_isPaused = false;
 	    this->m_isPaused = false;
+	} else {
+		// update g_Daytime
+		time (&seconds);
+		timeinfo = localtime (&seconds);
+		g_Daytime = static_cast<float>((timeinfo->tm_hour * 60) + timeinfo->tm_min) / (24.0f * 60.0f);
+
+		// keep track of the previous frame's time
+		g_TimeLast = g_Time;
+		// calculate the current time value
+		g_Time = m_videoDriver->getRenderTime ();
+		// update audio recorder
+		m_audioDriver->update ();
+		// update 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 ();
 	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) {
 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 {
 const WallpaperEngine::Render::Drivers::Output::Output& WallpaperApplication::getOutput () const {
     return this->m_renderContext->getOutput ();
     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>
 #include <set>
 
 
 namespace WallpaperEngine::Application {
 namespace WallpaperEngine::Application {
+
 using namespace WallpaperEngine::Assets;
 using namespace WallpaperEngine::Assets;
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::Data::Model;
 /**
 /**
@@ -31,6 +32,18 @@ class WallpaperApplication {
 public:
 public:
     explicit WallpaperApplication (ApplicationContext& context);
     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
      * Shows the application until it's closed
      */
      */
@@ -57,6 +70,15 @@ public:
      * Gets the output
      * Gets the output
      */
      */
     [[nodiscard]] const WallpaperEngine::Render::Drivers::Output::Output& getOutput () const;
     [[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:
 private:
     /**
     /**
@@ -152,5 +174,6 @@ private:
     bool m_screenShotTaken = false;
     bool m_screenShotTaken = false;
     uint32_t m_nextFrameScreenshot = 0;
     uint32_t m_nextFrameScreenshot = 0;
     std::chrono::steady_clock::time_point m_pauseStart {};
     std::chrono::steady_clock::time_point m_pauseStart {};
+    GLuint m_destinationFramebuffer = 0;
 };
 };
 } // namespace WallpaperEngine::Application
 } // namespace WallpaperEngine::Application

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

@@ -173,8 +173,6 @@ private:
     int m_audioStream = NO_AUDIO_STREAM;
     int m_audioStream = NO_AUDIO_STREAM;
     /** File data pointer */
     /** File data pointer */
     ReadStreamSharedPtr m_buffer = nullptr;
     ReadStreamSharedPtr m_buffer = nullptr;
-    /** The length of the file data pointer */
-    uint32_t m_length = 0;
 
 
     struct MyAVPacketList {
     struct MyAVPacketList {
 	AVPacket* packet;
 	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) {
 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);
     auto* recorder = static_cast<PulseAudioPlaybackRecorder::PulseAudioData*> (userdata);
 
 
     pa_sample_spec spec;
     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) {
 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
     // 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) {
 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
 		// 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;
 		break;
 	    }
 	    }
@@ -196,6 +207,7 @@ PulseAudioPlaybackRecorder::~PulseAudioPlaybackRecorder () {
     free (this->m_captureData.kisscfg);
     free (this->m_captureData.kisscfg);
 
 
     pa_context_disconnect (this->m_context);
     pa_context_disconnect (this->m_context);
+    pa_context_unref (this->m_context);
     pa_mainloop_free (this->m_mainloop);
     pa_mainloop_free (this->m_mainloop);
 }
 }
 
 

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

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

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

@@ -38,7 +38,7 @@ public:
 	const char* third = second ? strchr (second + 1, ' ') : nullptr;
 	const char* third = second ? strchr (second + 1, ' ') : nullptr;
 
 
 	if (first == nullptr) {
 	if (first == nullptr) {
-	    sLog.exception ("Invalid vector format: " + str + " (too few values, expected: 2, 3 or 4)");
+	    return 0;
 	}
 	}
 
 
 	if (second == nullptr) {
 	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 (bool value) { this->DynamicValue::update (value); }
 
 
+DynamicValue::DynamicValue (const std::string& value) { this->DynamicValue::update (value); }
+
 DynamicValue::~DynamicValue () {
 DynamicValue::~DynamicValue () {
     if (this->m_aliveFlag) {
     if (this->m_aliveFlag) {
 	*this->m_aliveFlag = false;
 	*this->m_aliveFlag = false;
@@ -240,7 +242,17 @@ void DynamicValue::update (const std::string& newValue) {
     this->m_type = UnderlyingType::String;
     this->m_type = UnderlyingType::String;
 
 
     if (this->m_condition.has_value ()) {
     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 ();
     this->propagate ();
@@ -256,11 +268,21 @@ void DynamicValue::update (const DynamicValue& other) {
     this->m_float = other.getFloat ();
     this->m_float = other.getFloat ();
     this->m_int = other.getInt ();
     this->m_int = other.getInt ();
     this->m_bool = other.getBool ();
     this->m_bool = other.getBool ();
+    this->m_string = other.getString ();
     this->m_type = other.getType ();
     this->m_type = other.getType ();
 
 
     if (this->m_condition.has_value () && other.getType () == UnderlyingType::String) {
     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 ();
     this->propagate ();

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

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

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

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include <map>
 #include <optional>
 #include <optional>
 #include <string>
 #include <string>
 #include <utility>
 #include <utility>
@@ -22,6 +23,13 @@ struct ObjectData {
     int id;
     int id;
     std::string name;
     std::string name;
     std::vector<int> dependencies;
     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 {
 struct ImageAnimationLayer {
     int id;
     int id;
-    float rate;
+    UserSettingUniquePtr rate;
     UserSettingUniquePtr visible;
     UserSettingUniquePtr visible;
-    float blend;
-    int animation;
+    UserSettingUniquePtr blend;
+    UserSettingUniquePtr animation;
 };
 };
 
 
 struct ImageData {
 struct ImageData {
-    /** The point of origin of the image */
-    UserSettingUniquePtr origin;
     /** The scale of the image */
     /** The scale of the image */
     UserSettingUniquePtr scale;
     UserSettingUniquePtr scale;
     /** The rotation of the image */
     /** The rotation of the image */
@@ -104,11 +110,11 @@ struct ImageData {
     /** The size of the image in pixels */
     /** The size of the image in pixels */
     glm::vec2 size;
     glm::vec2 size;
     /** Parallax depth used for parallax scrolling */
     /** Parallax depth used for parallax scrolling */
-    glm::vec2 parallaxDepth;
+    UserSettingUniquePtr parallaxDepth;
     /** The color blending mode for this image */
     /** The color blending mode for this image */
-    int colorBlendMode;
+    UserSettingUniquePtr colorBlendMode;
     /** The brightness of the image */
     /** The brightness of the image */
-    float brightness;
+    UserSettingUniquePtr brightness;
     /** The material in use for this image */
     /** The material in use for this image */
     ModelUniquePtr model;
     ModelUniquePtr model;
     /** The effects applied to this image after the material is rendered */
     /** The effects applied to this image after the material is rendered */
@@ -537,13 +543,12 @@ struct ParticleInstanceOverride {
 
 
 struct ParticleData {
 struct ParticleData {
     /** Position and transformation */
     /** Position and transformation */
-    UserSettingUniquePtr origin;
     UserSettingUniquePtr scale;
     UserSettingUniquePtr scale;
     UserSettingUniquePtr angles;
     UserSettingUniquePtr angles;
     UserSettingUniquePtr visible;
     UserSettingUniquePtr visible;
 
 
     /** Parallax depth */
     /** Parallax depth */
-    glm::vec2 parallaxDepth;
+    UserSettingUniquePtr parallaxDepth;
 
 
     /** Reference to particle definition file */
     /** Reference to particle definition file */
     std::string particleFile;
     std::string particleFile;
@@ -576,4 +581,45 @@ public:
 	Object (std::move (data)), ParticleData (std::move (particleData)) { };
 	Object (std::move (data)), ParticleData (std::move (particleData)) { };
     ~Particle () override = default;
     ~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 ImageEffect;
 struct ImageEffectPassOverride;
 struct ImageEffectPassOverride;
 class Particle;
 class Particle;
+class Text;
 class DynamicValue;
 class DynamicValue;
 class Property;
 class Property;
 class PropertySlider;
 class PropertySlider;
@@ -49,6 +50,7 @@ using ObjectUniquePtr = std::unique_ptr<Object>;
 using SoundUniquePtr = std::unique_ptr<Sound>;
 using SoundUniquePtr = std::unique_ptr<Sound>;
 using ImageUniquePtr = std::unique_ptr<Image>;
 using ImageUniquePtr = std::unique_ptr<Image>;
 using ParticleUniquePtr = std::unique_ptr<Particle>;
 using ParticleUniquePtr = std::unique_ptr<Particle>;
+using TextUniquePtr = std::unique_ptr<Text>;
 using MaterialUniquePtr = std::unique_ptr<Material>;
 using MaterialUniquePtr = std::unique_ptr<Material>;
 using MaterialPassUniquePtr = std::unique_ptr<MaterialPass>;
 using MaterialPassUniquePtr = std::unique_ptr<MaterialPass>;
 using EffectUniquePtr = std::unique_ptr<Effect>;
 using EffectUniquePtr = std::unique_ptr<Effect>;

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

@@ -4,8 +4,10 @@
 #include "ModelParser.h"
 #include "ModelParser.h"
 
 
 #include "ShaderConstantParser.h"
 #include "ShaderConstantParser.h"
+#include "UserSettingParser.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Project.h"
+#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Logging/Log.h"
 
 
 #include <glm/gtc/constants.hpp>
 #include <glm/gtc/constants.hpp>
@@ -14,12 +16,108 @@
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Model;
 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) {
 ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     const auto imageIt = it.find ("image");
     const auto imageIt = it.find ("image");
     const auto soundIt = it.find ("sound");
     const auto soundIt = it.find ("sound");
     const auto particleIt = it.find ("particle");
     const auto particleIt = it.find ("particle");
     const auto textIt = it.find ("text");
     const auto textIt = it.find ("text");
     const auto lightIt = it.find ("light");
     const auto lightIt = it.find ("light");
+    // use shape to refer to VolumeLight
+    const auto shapeIt = it.find ("shape");
 
 
     // Parse base object data
     // Parse base object data
     // Some particle objects have numeric 'name' fields, so handle type mismatches gracefully
     // 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"),
 	    .id = it.require<int> ("id", "Object must have an id"),
 	    .name = it.require<std::string> ("name", "Object must have a name"),
 	    .name = it.require<std::string> ("name", "Object must have a name"),
 	    .dependencies = parseDependencies (it),
 	    .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) {
     } catch (const std::exception& e) {
 	sLog.error ("Error parsing object base data: ", e.what ());
 	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> ());
 		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 ()) {
     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 ()) {
     } else if (soundIt != it.end () && soundIt->is_array ()) {
-	return parseSound (it, basedata);
+	return parseSound (it, std::move (basedata));
     } else if (particleIt != it.end ()) {
     } else if (particleIt != it.end ()) {
-	return parseParticle (it, project, basedata);
+	return parseParticle (it, project, std::move (basedata));
     } else if (textIt != it.end ()) {
     } 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 ()) {
     } else if (lightIt != it.end ()) {
 	sLog.error ("Light objects are not supported yet");
 	sLog.error ("Light objects are not supported yet");
+    } else if (shapeIt != it.end ()) {
+	sLog.error ("VolumeLight objects are not supported yet");
     } else {
     } 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) {
 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
 ImageUniquePtr
 ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image) {
 ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image) {
     const auto& properties = project.properties;
     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> (
     auto result = std::make_unique<Image> (
 	std::move (base),
 	std::move (base),
 	ImageData {
 	ImageData {
-	    .origin = it.user ("origin", properties, glm::vec3 (0.0f)),
 	    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 	    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 	    .angles = it.user ("angles", properties, glm::vec3 (0.0)),
 	    .angles = it.user ("angles", properties, glm::vec3 (0.0)),
 	    .visible = it.user ("visible", properties, true),
 	    .visible = it.user ("visible", properties, true),
 	    .alpha = it.user ("alpha", properties, 1.0f),
 	    .alpha = it.user ("alpha", properties, 1.0f),
 	    .color = it.user ("color", properties, glm::vec4 (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),
 	    .model = ModelParser::load (project, image),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .animationLayers = animationLayers.has_value () ? parseAnimationLayers (*animationLayers, project)
 	    .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));
 	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;
     return result;
 }
 }
 
 
@@ -201,9 +405,17 @@ TextureMap ObjectParser::parseTextureMap (const JSON& it) {
 	textureIndex++;
 	textureIndex++;
 
 
 	if (cur.is_null ()) {
 	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 {
 	} 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) {
 ImageAnimationLayerUniquePtr ObjectParser::parseAnimationLayer (const JSON& it, const Project& project) {
+    const auto& properties = project.properties;
+
     return std::make_unique<ImageAnimationLayer> (ImageAnimationLayer {
     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> (
 	    return std::make_unique<Particle> (
 		std::move (base),
 		std::move (base),
 		ParticleData {
 		ParticleData {
-		    .origin = it.user ("origin", properties, glm::vec3 (0.0f)),
 		    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		    .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		    .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		    .visible = it.user ("visible", properties, true),
 		    .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 = "sequence",
 		    .animationMode = "sequence",
 		    .sequenceMultiplier = 1.0f,
 		    .sequenceMultiplier = 1.0f,
@@ -505,14 +718,13 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    flags = flagsIt->get<uint32_t> ();
 	    flags = flagsIt->get<uint32_t> ();
 	}
 	}
 
 
-	return std::make_unique<Particle> (
+	auto result = std::make_unique<Particle> (
 	    std::move (base),
 	    std::move (base),
 	    ParticleData {
 	    ParticleData {
-		.origin = it.user ("origin", properties, glm::vec3 (0.0f)),
 		.scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		.scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		.angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		.angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		.visible = it.user ("visible", properties, true),
 		.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,
 		.particleFile = particleFile,
 		.animationMode = animationMode,
 		.animationMode = animationMode,
 		.sequenceMultiplier = sequenceMultiplier,
 		.sequenceMultiplier = sequenceMultiplier,
@@ -529,6 +741,11 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		.instanceOverride = std::move (instanceOverride),
 		.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) {
     } catch (nlohmann::json::exception& e) {
 	sLog.error ("Error parsing particle '", base.name, "': ", e.what ());
 	sLog.error ("Error parsing particle '", base.name, "': ", e.what ());
 	sLog.error ("Particle JSON: ", it.dump ());
 	sLog.error ("Particle JSON: ", it.dump ());
@@ -889,4 +1106,4 @@ ParticleInstanceOverride ObjectParser::parseParticleInstanceOverride (const JSON
 	.color = it.user ("color", properties, glm::vec3 (1.0f)),
 	.color = it.user ("color", properties, glm::vec3 (1.0f)),
 	.colorn = it.user ("colorn", 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
     static ImageUniquePtr
     parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
     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 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 std::vector<ImageEffectUniquePtr> parseEffects (const JSON& it, const Project& project);
     static ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static std::vector<ImageEffectPassOverrideUniquePtr>
     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") {
     if (type == "scenetexture") {
 	return parseSceneTexture (it, name);
 	return parseSceneTexture (it, name);
     }
     }
-    if (type == "file") {
+    if (type == "file" || type == "directory") {
 	return parseFile (it, name);
 	return parseFile (it, name);
     }
     }
     if (type == "textinput") {
     if (type == "textinput") {
 	return parseTextInput (it, name);
 	return parseTextInput (it, name);
     }
     }
+    if (type == "usershortcut") {
+	return parseTextInput (it, name);
+    }
 
 
     if (type.has_value () && type != "group") {
     if (type.has_value () && type != "group") {
 	// show the error and ignore this property
 	// show the error and ignore this property
@@ -92,7 +95,7 @@ PropertySharedPtr PropertyParser::parseBoolean (const JSON& it, const std::strin
 	    .name = name,
 	    .name = name,
 	    .text = it.optional<std::string> ("text", ""),
 	    .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 ()
 	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;
     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) {
 FrameSharedPtr TextureParser::parseFrame (const BinaryReader& file) {
     auto result = std::make_shared<Frame> ();
     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
     // image is animated, keep parsing the rest of the image info
     file.next (magic, 9);
     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;
 	header.animatedVersion = AnimatedVersion_TEXS0002;
     } else if (strncmp (magic, "TEXS0003", 9) == 0) {
     } else if (strncmp (magic, "TEXS0003", 9) == 0) {
 	header.animatedVersion = AnimatedVersion_TEXS0003;
 	header.animatedVersion = AnimatedVersion_TEXS0003;
@@ -211,11 +228,15 @@ void TextureParser::parseAnimations (Texture& header, const BinaryReader& file)
     }
     }
 
 
     while (frameCount-- > 0) {
     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.gifWidth = (*header.frames.begin ())->width1;
 	header.gifHeight = (*header.frames.begin ())->height1;
 	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 MipmapSharedPtr parseMipmap (const BinaryReader& file, const Texture& header);
     static FrameSharedPtr parseFrame (const BinaryReader& file);
     static FrameSharedPtr parseFrame (const BinaryReader& file);
+    static FrameSharedPtr parseFrameV1 (const BinaryReader& file);
 
 
 private:
 private:
     static void parseTextureHeader (Texture& header, const BinaryReader& file);
     static void parseTextureHeader (Texture& header, const BinaryReader& file);

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

@@ -1,7 +1,9 @@
 #include "UserSettingParser.h"
 #include "UserSettingParser.h"
 
 
 #include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Data/Model/UserSetting.h"
 #include "WallpaperEngine/Data/Model/UserSetting.h"
+#include "WallpaperEngine/Logging/Log.h"
 
 
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Builders;
 using namespace WallpaperEngine::Data::Builders;
@@ -13,14 +15,17 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
     auto valueIt = data;
     auto valueIt = data;
     std::string content = data.dump ();
     std::string content = data.dump ();
 
 
+    std::optional<std::string> scriptSource;
+    std::optional<json> scriptPropsJson;
+
     if (data.is_object ()) {
     if (data.is_object ()) {
 	const auto user = data.optional ("user");
 	const auto user = data.optional ("user");
 	const auto script = data.optional ("script");
 	const auto script = data.optional ("script");
 	valueIt = data.require ("value", "User setting must have a value");
 	valueIt = data.require ("value", "User setting must have a value");
 
 
-	// TODO: PARSE SCRIPT VALUES
 	if (script.has_value () && !script->is_null ()) {
 	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 ()) {
 	if (user.has_value () && !user->is_null ()) {
@@ -49,12 +54,33 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	std::string str = valueIt;
 	std::string str = valueIt;
 
 
 	// TODO: VALIDATE THIS IS RIGHT?
 	// 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));
 	    value->update (static_cast<glm::vec2> (valueIt));
 	} else if (size == 3) {
 	} else if (size == 3) {
 	    value->update (static_cast<glm::vec3> (valueIt));
 	    value->update (static_cast<glm::vec3> (valueIt));
-	} else {
+	} else if (size == 4) {
 	    value->update (static_cast<glm::vec4> (valueIt));
 	    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 ()) {
     } else if (valueIt.is_number_integer ()) {
 	value->update (valueIt.get<int> ());
 	value->update (valueIt.get<int> ());
@@ -67,6 +93,24 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	value->update ();
 	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
     // 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)
     // Connect to property if one is specified (this allows property overrides to propagate)
     if (property != nullptr) {
     if (property != nullptr) {
@@ -82,4 +126,4 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
 	.property = property,
 	.property = property,
 	.condition = condition,
 	.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"),
                     .up = camera.require <glm::vec3> ("up", "Camera must have an up position"),
                 },
                 },
                 .projection = {
                 .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),
                     .isAuto = projection.optional ("auto", false),
                     .nearz = camera.optional <float> ("nearz", 0.0f),
                     .nearz = camera.optional <float> ("nearz", 0.0f),
                     .farz = camera.optional <float> ("farz", 1000.0f),
                     .farz = camera.optional <float> ("farz", 1000.0f),

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

@@ -1,6 +1,7 @@
 #pragma once
 #pragma once
 
 
 #include <iostream>
 #include <iostream>
+#include <memory>
 
 
 namespace WallpaperEngine::Data::Utils {
 namespace WallpaperEngine::Data::Utils {
 struct MemoryStream : std::istream, private std::streambuf {
 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 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 "WaylandMouseInput.h"
 #include "WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h"
 #include "WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h"
 #include <glm/common.hpp>
 #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;
 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) :
 WaylandMouseInput::WaylandMouseInput (const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& driver) :
     m_waylandDriver (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 {
 glm::dvec2 WaylandMouseInput::position () const {
     if (!this->m_waylandDriver.getApp ().getContext ().settings.mouse.enabled) {
     if (!this->m_waylandDriver.getApp ().getContext ().settings.mouse.enabled) {
 	return { 0, 0 };
 	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 {
 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;
     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 {
 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;
     return MouseClickStatus::Released;
-}
+}

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

@@ -5,6 +5,8 @@
 #include "WallpaperEngine/Input/MouseInput.h"
 #include "WallpaperEngine/Input/MouseInput.h"
 
 
 #include <glm/vec2.hpp>
 #include <glm/vec2.hpp>
+#include <chrono>
+#include <optional>
 
 
 namespace WallpaperEngine::Render::Drivers {
 namespace WallpaperEngine::Render::Drivers {
 class WaylandOpenGLDriver;
 class WaylandOpenGLDriver;
@@ -39,13 +41,16 @@ public:
     [[nodiscard]] MouseClickStatus rightClick () const override;
     [[nodiscard]] MouseClickStatus rightClick () const override;
 
 
 private:
 private:
+    [[nodiscard]] std::optional<glm::dvec2> queryHyprlandCursorPosition () const;
+
     /**
     /**
      * Wayland: Driver
      * Wayland: Driver
      */
      */
     const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& m_waylandDriver;
     const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& m_waylandDriver;
 
 
     glm::dvec2 m_pos = {};
     glm::dvec2 m_pos = {};
+    std::chrono::steady_clock::time_point m_lastHyprlandQuery = {};
 };
 };
 } // namespace WallpaperEngine::Input::Drivers
 } // 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");
 	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);
     glClear (GL_COLOR_BUFFER_BIT);
+    glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
 
 
     this->m_resolution = { textureWidth, textureHeight, realWidth, realHeight };
     this->m_resolution = { textureWidth, textureHeight, realWidth, realHeight };
 
 
@@ -122,4 +127,8 @@ uint32_t CFBO::getSpritesheetFrames () const {
 
 
 float CFBO::getSpritesheetDuration () const {
 float CFBO::getSpritesheetDuration () const {
     return 0.0f; // FBOs don't have spritesheets
     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]] uint32_t getSpritesheetFrames () const override;
     [[nodiscard]] float getSpritesheetDuration () const override;
     [[nodiscard]] float getSpritesheetDuration () const override;
 
 
+    void incrementUsageCount () const override;
+    void decrementUsageCount () const override;
+    void update () const override;
+
 private:
 private:
     GLuint m_framebuffer = GL_NONE;
     GLuint m_framebuffer = GL_NONE;
     GLuint m_depthbuffer = 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) :
 CObject::CObject (Wallpapers::CScene& scene, const Object& object) :
     Helpers::ContextAware (scene), m_scene (scene), m_object (object) { }
     Helpers::ContextAware (scene), m_scene (scene), m_object (object) { }
 
 
+void CObject::render () { }
+
 Wallpapers::CScene& CObject::getScene () const { return this->m_scene; }
 Wallpapers::CScene& CObject::getScene () const { return this->m_scene; }
 
 
 const AssetLocator& CObject::getAssetLocator () const { return this->getScene ().getAssetLocator (); }
 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); }
     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]] Wallpapers::CScene& getScene () const;
     [[nodiscard]] const AssetLocator& getAssetLocator () const;
     [[nodiscard]] const AssetLocator& getAssetLocator () const;
     [[nodiscard]] int getId () const;
     [[nodiscard]] int getId () const;
-
-    virtual ~CObject () override = default;
-
-protected:
-    CObject (Wallpapers::CScene& scene, const Object& object);
+    [[nodiscard]] const Object& getObject () const;
 
 
 private:
 private:
     Wallpapers::CScene& m_scene;
     Wallpapers::CScene& m_scene;

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

@@ -1,23 +1,50 @@
 #include "CTexture.h"
 #include "CTexture.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Logging/Log.h"
 
 
-#include <cstring>
 #include <lz4.h>
 #include <lz4.h>
-#include <string>
 
 
 #define STB_IMAGE_IMPLEMENTATION
 #define STB_IMAGE_IMPLEMENTATION
+#include "RenderContext.h"
+
 #include <stb_image.h>
 #include <stb_image.h>
 
 
 using namespace WallpaperEngine::Render;
 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
     // ensure the header is parsed
     this->setupResolution ();
     this->setupResolution ();
     const GLint internalFormat = this->setupInternalFormat ();
     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
     // allocate texture ids list
     this->m_textureID = new GLuint[this->m_header->imageCount];
     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);
     glGenTextures (this->m_header->imageCount, this->m_textureID);
 
 
     for (const auto& [index, mipmaps] : this->m_header->images) {
     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;
 	    const uint32_t bufferSize = mipmap->uncompressedSize;
 	    GLenum textureFormat = GL_RGBA;
 	    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) {
 	    if (this->m_header->freeImageFormat != FIF_UNKNOWN) {
 		int fileChannels;
 		int fileChannels;
 
 
@@ -84,8 +107,13 @@ CTexture::CTexture (TextureUniquePtr header) : m_header (std::move (header)) {
 }
 }
 
 
 CTexture::~CTexture () {
 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;
     delete[] this->m_textureID;
@@ -137,6 +165,7 @@ GLint CTexture::setupInternalFormat () const {
 }
 }
 
 
 void CTexture::setupOpenGLParameters (const uint32_t textureID) const {
 void CTexture::setupOpenGLParameters (const uint32_t textureID) const {
+    // TODO: LABEL ELEMENTS TOO
     // bind the texture to assign information to it
     // bind the texture to assign information to it
     glBindTexture (GL_TEXTURE_2D, this->m_textureID[textureID]);
     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; }
 uint32_t CTexture::getSpritesheetFrames () const { return this->getHeader ().spritesheetFrames; }
 
 
 float CTexture::getSpritesheetDuration () const { return this->getHeader ().spritesheetDuration; }
 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
 #pragma once
 
 
+#include "Helpers/ContextAware.h"
 #include "TextureProvider.h"
 #include "TextureProvider.h"
 #include "WallpaperEngine/Data/Assets/Texture.h"
 #include "WallpaperEngine/Data/Assets/Texture.h"
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
 
 
 #include <GL/glew.h>
 #include <GL/glew.h>
 #include <glm/vec4.hpp>
 #include <glm/vec4.hpp>
 #include <memory>
 #include <memory>
+#include <mpv/client.h>
+#include <mpv/render.h>
+#include <mpv/render_gl.h>
 #include <vector>
 #include <vector>
 
 
 namespace WallpaperEngine::Render {
 namespace WallpaperEngine::Render {
+class RenderContext;
 using namespace WallpaperEngine::Data::Assets;
 using namespace WallpaperEngine::Data::Assets;
-
+using namespace WallpaperEngine::VideoPlayback::MPV;
 /**
 /**
  * A normal texture file in WallpaperEngine's format
  * A normal texture file in WallpaperEngine's format
  */
  */
-class CTexture final : public TextureProvider {
+class CTexture final : public TextureProvider, public Helpers::ContextAware {
 public:
 public:
-    explicit CTexture (TextureUniquePtr header);
+    explicit CTexture (RenderContext& context, TextureUniquePtr header);
     ~CTexture () override;
     ~CTexture () override;
 
 
     [[nodiscard]] GLuint getTextureID (uint32_t imageIndex) const override;
     [[nodiscard]] GLuint getTextureID (uint32_t imageIndex) const override;
@@ -34,6 +40,25 @@ public:
     [[nodiscard]] uint32_t getSpritesheetFrames () const override;
     [[nodiscard]] uint32_t getSpritesheetFrames () const override;
     [[nodiscard]] float getSpritesheetDuration () 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:
 private:
     /**
     /**
      * @return The texture header
      * @return The texture header
@@ -51,7 +76,7 @@ private:
     /**
     /**
      * Prepares openGL parameters for loading texture data
      * Prepares openGL parameters for loading texture data
      */
      */
-    void setupOpenGLParameters (const uint32_t textureID) const;
+    void setupOpenGLParameters (uint32_t textureID) const;
 
 
     /** The texture header */
     /** The texture header */
     TextureUniquePtr m_header;
     TextureUniquePtr m_header;
@@ -59,5 +84,7 @@ private:
     GLuint* m_textureID = nullptr;
     GLuint* m_textureID = nullptr;
     /** Resolution vector of the texture */
     /** Resolution vector of the texture */
     glm::vec4 m_resolution {};
     glm::vec4 m_resolution {};
+    /** The video player in use */
+    GLPlayerUniquePtr m_player;
 };
 };
 } // namespace WallpaperEngine::Assets
 } // namespace WallpaperEngine::Assets

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

@@ -7,9 +7,6 @@
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 
 
-#include <glm/glm.hpp>
-#include <glm/gtc/type_ptr.hpp>
-
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render;
 
 
 CWallpaper::CWallpaper (
 CWallpaper::CWallpaper (
@@ -185,6 +182,12 @@ void CWallpaper::setupShaders () {
 
 
 void CWallpaper::setDestinationFramebuffer (GLuint framebuffer) { this->m_destFramebuffer = framebuffer; }
 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) {
 void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
     // update UVs if something has changed, otherwise use old values
     // update UVs if something has changed, otherwise use old values
     if (this->m_state.hasChanged (viewport, vflip, this->getWidth (), this->getHeight ())) {
     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
 #if !NDEBUG
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene");
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene");
 #endif /* !NDEBUG */
 #endif /* !NDEBUG */
-    this->renderFrame (viewport);
+    if (needsSceneRender) {
+	this->renderFrame (sceneViewport);
+	this->m_lastRenderedFrame = currentFrame;
+    }
 #if !NDEBUG
 #if !NDEBUG
     glPopDebugGroup ();
     glPopDebugGroup ();
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene to output");
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, "Rendering scene to output");
 #endif /* !NDEBUG */
 #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[] = {
     const GLfloat texCoords[] = {
 	ustart, vstart, uend, vstart, ustart, vend, ustart, vend, uend, vstart, uend, vend,
 	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 <GL/glew.h>
 #include <GLFW/glfw3.h>
 #include <GLFW/glfw3.h>
+#include <optional>
 
 
 #include "WallpaperEngine/Audio/AudioContext.h"
 #include "WallpaperEngine/Audio/AudioContext.h"
 
 
@@ -36,6 +37,12 @@ class CWallpaper : public Helpers::ContextAware, public FBOProvider {
     friend class WallpaperEngine::Application::WallpaperApplication;
     friend class WallpaperEngine::Application::WallpaperApplication;
 
 
 public:
 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 {
     template <class T> [[nodiscard]] const T* as () const {
 	if (is<T> ()) {
 	if (is<T> ()) {
 	    return static_cast<const T*> (this);
 	    return static_cast<const T*> (this);
@@ -59,7 +66,8 @@ public:
     /**
     /**
      * Performs a render pass of the wallpaper
      * 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
      * Pause the renderer
@@ -114,6 +122,16 @@ public:
      */
      */
     void setDestinationFramebuffer (GLuint framebuffer);
     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
      * @return The width of this wallpaper
      */
      */
@@ -163,6 +181,8 @@ protected:
     /** The FBO used for scene output */
     /** The FBO used for scene output */
     std::shared_ptr<const CFBO> m_sceneFBO = nullptr;
     std::shared_ptr<const CFBO> m_sceneFBO = nullptr;
 
 
+    GLuint m_vaoBuffer = GL_NONE;
+
 private:
 private:
     /** The texture used for the scene output */
     /** The texture used for the scene output */
     GLuint m_texCoordBuffer = GL_NONE;
     GLuint m_texCoordBuffer = GL_NONE;
@@ -172,7 +192,6 @@ private:
     GLint g_Texture0 = GL_NONE;
     GLint g_Texture0 = GL_NONE;
     GLint a_Position = GL_NONE;
     GLint a_Position = GL_NONE;
     GLint a_TexCoord = GL_NONE;
     GLint a_TexCoord = GL_NONE;
-    GLuint m_vaoBuffer = GL_NONE;
     /** The framebuffer to draw the background to */
     /** The framebuffer to draw the background to */
     GLuint m_destFramebuffer = GL_NONE;
     GLuint m_destFramebuffer = GL_NONE;
     /** Setups OpenGL's shaders for this wallpaper backbuffer */
     /** Setups OpenGL's shaders for this wallpaper backbuffer */
@@ -183,5 +202,9 @@ private:
     AudioContext& m_audioContext;
     AudioContext& m_audioContext;
     /** Current Wallpaper state */
     /** Current Wallpaper state */
     WallpaperState m_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
 } // 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);
 	    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; }
 uint32_t GLFWWindowOutput::getImageBufferSize () const { return 0; }
 
 
 void GLFWWindowOutput::updateRender () const {
 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_fullWidth = this->m_driver.getFramebufferSize ().x;
     this->m_fullHeight = this->m_driver.getFramebufferSize ().y;
     this->m_fullHeight = this->m_driver.getFramebufferSize ().y;
 
 

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

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include <glm/vec2.hpp>
 #include <glm/vec4.hpp>
 #include <glm/vec4.hpp>
 #include <string>
 #include <string>
 
 
@@ -11,6 +12,10 @@ public:
 
 
     glm::ivec4 viewport;
     glm::ivec4 viewport;
     std::string name;
     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 */
     /** Whether this viewport is single in the framebuffer or shares space with more viewports */
     bool single;
     bool single;

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

@@ -6,6 +6,7 @@
 #define static
 #define static
 extern "C" {
 extern "C" {
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
+#include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 #include "xdg-shell-protocol.h"
 }
 }
 #undef class
 #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) {
 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);
     const auto viewport = static_cast<WaylandOutputViewport*> (data);
     viewport->size = { w, h };
     viewport->size = { w, h };
+    viewport->logicalSize = { w, h };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
     viewport->resize ();
     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,
     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
     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) {
 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);
     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->size = { width, height };
     viewport->viewport = { 0, 0, viewport->size.x * viewport->scale, viewport->size.y * viewport->scale };
     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,
     .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 (
 WaylandOutputViewport::WaylandOutputViewport (
     WaylandOpenGLDriver* driver, uint32_t waylandName, struct wl_registry* registry
     WaylandOpenGLDriver* driver, uint32_t waylandName, struct wl_registry* registry
 ) : OutputViewport ({ 0, 0, 0, 0 }, "", true), size ({ 0, 0 }), waylandName (waylandName), m_driver (driver) {
 ) : 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);
     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 () {
 void WaylandOutputViewport::setupLS () {
     surface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
     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 (
     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"
 	"linux-wallpaperengine"
     );
     );
 
 
@@ -125,7 +190,17 @@ void WaylandOutputViewport::setupLS () {
     }
     }
 
 
     wl_region* region = wl_compositor_create_region (m_driver->getWaylandContext ()->compositor);
     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_size (layerSurface, 0, 0);
     zwlr_layer_surface_v1_set_anchor (
     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_add_listener (layerSurface, &layerSurfaceListener, this);
     zwlr_layer_surface_v1_set_exclusive_zone (layerSurface, -1);
     zwlr_layer_surface_v1_set_exclusive_zone (layerSurface, -1);
     wl_surface_set_input_region (surface, region);
     wl_surface_set_input_region (surface, region);
+    wl_region_destroy (region);
     wl_surface_commit (surface);
     wl_surface_commit (surface);
     wl_display_roundtrip (m_driver->getWaylandContext ()->display);
     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);
     wl_egl_window_resize (this->eglWindow, this->size.x * this->scale, this->size.y * this->scale, 0, 0);
 
 
     this->getDriver ()->getOutput ().reset ();
     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_shell_v1;
 struct zwlr_layer_surface_v1;
 struct zwlr_layer_surface_v1;
+struct zxdg_output_v1;
+struct zxdg_output_manager_v1;
 
 
 namespace WallpaperEngine::Render::Drivers {
 namespace WallpaperEngine::Render::Drivers {
 class WaylandOpenGLDriver;
 class WaylandOpenGLDriver;
@@ -34,6 +36,7 @@ namespace Output {
 
 
 	wl_output* output = nullptr;
 	wl_output* output = nullptr;
 	glm::ivec2 size = {};
 	glm::ivec2 size = {};
+	glm::ivec2 position = {};
 	uint32_t waylandName;
 	uint32_t waylandName;
 	int scale = 1;
 	int scale = 1;
 	bool initialized = false;
 	bool initialized = false;
@@ -50,8 +53,11 @@ namespace Output {
 	wl_cursor* pointer = nullptr;
 	wl_cursor* pointer = nullptr;
 	wl_surface* cursorSurface = nullptr;
 	wl_surface* cursorSurface = nullptr;
 	bool callbackInitialized = false;
 	bool callbackInitialized = false;
+	bool hasXdgLogicalPosition = false;
+	zxdg_output_v1* xdgOutput = nullptr;
 
 
 	void setupLS ();
 	void setupLS ();
+	void setupXdgOutput (zxdg_output_manager_v1* manager);
 
 
 	/**
 	/**
 	 * Activates output's context for drawing
 	 * Activates output's context for drawing
@@ -73,4 +79,4 @@ namespace Output {
     };
     };
 } // namespace Output
 } // namespace Output
 } // namespace WallpaperEngine::Render::Drivers
 } // 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 () {
 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 ();
     this->m_viewports.clear ();
 
 
     // free all the resources we've got
     // free all the resources we've got
@@ -101,6 +102,13 @@ void X11Output::loadScreenInfo () {
 	return;
 	return;
     }
     }
 
 
+    discoverOutputs (screenResources);
+    XRRFreeScreenResources (screenResources);
+    validateOutputs ();
+    initX11Background ();
+}
+
+void X11Output::discoverOutputs (XRRScreenResources* screenResources) {
     for (int i = 0; i < screenResources->noutput; i++) {
     for (int i = 0; i < screenResources->noutput; i++) {
 	const XRROutputInfo* info = XRRGetOutputInfo (this->m_display, screenResources, screenResources->outputs[i]);
 	const XRROutputInfo* info = XRRGetOutputInfo (this->m_display, screenResources, screenResources->outputs[i]);
 
 
@@ -116,37 +124,66 @@ void X11Output::loadScreenInfo () {
 	    continue;
 	    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
 	// 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 (
 	    sLog.out (
 		"Found requested screen: ", info->name, " -> ", crtc->x, "x", crtc->y, ":", crtc->width, "x",
 		"Found requested screen: ", info->name, " -> ", crtc->x, "x", crtc->y, ":", crtc->width, "x",
 		crtc->height
 		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);
 	XRRFreeCrtcInfo (crtc);
     }
     }
+}
 
 
-    XRRFreeScreenResources (screenResources);
-
+void X11Output::validateOutputs () const {
     bool any = false;
     bool any = false;
 
 
     for (const auto& o : this->m_screens) {
     for (const auto& o : this->m_screens) {
 	const auto cur = this->m_context.settings.general.screenBackgrounds.find (o->name);
 	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) {
     if (!any) {
@@ -165,7 +202,9 @@ void X11Output::loadScreenInfo () {
 
 
 	sLog.exception ("Cannot continue...");
 	sLog.exception ("Cannot continue...");
     }
     }
+}
 
 
+void X11Output::initX11Background () {
     // create pixmap so we can draw things in there
     // 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_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);
     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 <string>
 
 
 #include <X11/Xlib.h>
 #include <X11/Xlib.h>
+#include <X11/extensions/Xrandr.h>
 
 
 #include "Output.h"
 #include "Output.h"
 #include "WallpaperEngine/Render/Drivers/VideoDriver.h"
 #include "WallpaperEngine/Render/Drivers/VideoDriver.h"
@@ -26,6 +27,9 @@ public:
 
 
 private:
 private:
     void loadScreenInfo ();
     void loadScreenInfo ();
+    void discoverOutputs (XRRScreenResources* screenResources);
+    void validateOutputs () const;
+    void initX11Background ();
     void free ();
     void free ();
 
 
     Display* m_display = nullptr;
     Display* m_display = nullptr;

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

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

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

@@ -8,6 +8,7 @@
 #define static
 #define static
 extern "C" {
 extern "C" {
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
+#include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 #include "xdg-shell-protocol.h"
 #include <linux/input-event-codes.h>
 #include <linux/input-event-codes.h>
 }
 }
@@ -15,6 +16,7 @@ extern "C" {
 #undef namespace
 #undef namespace
 #undef static
 #undef static
 
 
+#include <algorithm>
 #include <string.h>
 #include <string.h>
 #include <unistd.h>
 #include <unistd.h>
 
 
@@ -119,6 +121,10 @@ handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const cha
 	driver->getWaylandContext ()->seat
 	driver->getWaylandContext ()->seat
 	    = static_cast<wl_seat*> (wl_registry_bind (registry, name, &wl_seat_interface, 1));
 	    = static_cast<wl_seat*> (wl_registry_bind (registry, name, &wl_seat_interface, 1));
 	wl_seat_add_listener (driver->getWaylandContext ()->seat, &seatListener, driver);
 	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);
 	zwlr_layer_surface_v1_destroy (viewport->layerSurface);
     }
     }
 
 
+    if (viewport->xdgOutput) {
+	zxdg_output_v1_destroy (viewport->xdgOutput);
+	viewport->xdgOutput = nullptr;
+    }
+
     if (viewport->surface) {
     if (viewport->surface) {
 	wl_surface_destroy (viewport->surface);
 	wl_surface_destroy (viewport->surface);
     }
     }
@@ -257,6 +268,13 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app) :
 WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app) :
     VideoDriver (app, m_mouseInput), m_output (context, *this), m_requestedExit (false), m_frameCounter (0),
     VideoDriver (app, m_mouseInput), m_output (context, *this), m_requestedExit (false), m_frameCounter (0),
     m_context (context), m_mouseInput (*this) {
     m_context (context), m_mouseInput (*this) {
+    initWaylandRegistry ();
+    initEGL ();
+    setupOutputLayerSurfaces ();
+    initGLEW ();
+}
+
+void WaylandOpenGLDriver::initWaylandRegistry () {
     m_waylandContext.display = wl_display_connect (nullptr);
     m_waylandContext.display = wl_display_connect (nullptr);
 
 
     if (!m_waylandContext.display) {
     if (!m_waylandContext.display) {
@@ -274,12 +292,39 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
 	sLog.exception ("Failed to bind to required interfaces");
 	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;
     bool any = false;
 
 
     for (const auto& o : this->m_screens) {
     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;
 	    continue;
 	}
 	}
 
 
@@ -297,19 +342,42 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
 
 
 	sLog.error ("Requested: ");
 	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);
 	    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...");
 	sLog.exception ("Cannot continue...");
     }
     }
+}
 
 
+void WaylandOpenGLDriver::initGLEW () {
+    glewExperimental = GL_TRUE;
     if (const GLenum result = glewInit (); result != GLEW_OK) {
     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 () {
 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
     // stop EGL
     eglMakeCurrent (EGL_NO_DISPLAY, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
     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);
 	    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_shell_v1;
 struct zwlr_layer_surface_v1;
 struct zwlr_layer_surface_v1;
+struct zxdg_output_manager_v1;
 
 
 namespace WallpaperEngine::Render::Drivers {
 namespace WallpaperEngine::Render::Drivers {
 using namespace WallpaperEngine::Application;
 using namespace WallpaperEngine::Application;
@@ -54,6 +55,7 @@ public:
 	wl_shm* shm = nullptr;
 	wl_shm* shm = nullptr;
 	zwlr_layer_shell_v1* layerShell = nullptr;
 	zwlr_layer_shell_v1* layerShell = nullptr;
 	wl_seat* seat = nullptr;
 	wl_seat* seat = nullptr;
+	zxdg_output_manager_v1* xdgOutputManager = nullptr;
     };
     };
 
 
     explicit WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
     explicit WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
@@ -91,7 +93,10 @@ private:
     WaylandContext m_waylandContext = {};
     WaylandContext m_waylandContext = {};
     mutable bool m_requestedExit;
     mutable bool m_requestedExit;
 
 
+    void initWaylandRegistry ();
+    void setupOutputLayerSurfaces ();
     void initEGL ();
     void initEGL ();
+    void initGLEW ();
     void finishEGL () const;
     void finishEGL () const;
 
 
     uint32_t m_frameCounter = 0;
     uint32_t m_frameCounter = 0;

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

@@ -2,11 +2,21 @@
 
 
 #include "CRenderable.h"
 #include "CRenderable.h"
 
 
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <iterator>
+#include <optional>
 #include <sstream>
 #include <sstream>
 
 
+#include <glm/gtc/matrix_transform.hpp>
+
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Object.h"
+#include "WallpaperEngine/Data/Model/UserSetting.h"
 #include "WallpaperEngine/Data/Parsers/MaterialParser.h"
 #include "WallpaperEngine/Data/Parsers/MaterialParser.h"
+#include "WallpaperEngine/Logging/Log.h"
 
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render::Objects;
 using namespace WallpaperEngine::Render::Objects;
@@ -14,6 +24,152 @@ using namespace WallpaperEngine::Render::Objects::Effects;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Builders;
 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) :
 CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     CRenderable (scene, image, *image.model->material), m_sceneSpacePosition (GL_NONE), m_copySpacePosition (GL_NONE),
     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 (),
     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_width = static_cast<float> (scene.getWidth ());
     auto scene_height = static_cast<float> (scene.getHeight ());
     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::vec2 size = this->getSize ();
-    glm::vec3 scale = this->getImage ().scale->value->getVec3 ();
+    glm::vec3 scale = transform.scale;
 
 
     this->detectTexture ();
     this->detectTexture ();
 
 
@@ -65,6 +221,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 
 
 	// TODO: CHANGE ALIGNMENT TOO?
 	// TODO: CHANGE ALIGNMENT TOO?
     }
     }
+    this->m_size = size;
 
 
     glm::vec2 scaledSize = size * glm::vec2 (scale);
     glm::vec2 scaledSize = size * glm::vec2 (scale);
 
 
@@ -154,10 +311,16 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     GLfloat realY = 0.0;
     GLfloat realY = 0.0;
 
 
     if (this->getImage ().model->passthrough) {
     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) {
 	if (this->getImage ().model->fullscreen) {
 	    realX = -1.0;
 	    realX = -1.0;
@@ -199,18 +362,35 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texcoordPass);
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texcoordPass);
     glBufferData (GL_ARRAY_BUFFER, sizeof (texcoordPass), texcoordPass, GL_STATIC_DRAW);
     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->m_modelViewProjectionScreen
 	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt ();
 	= 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_modelViewProjectionCopyInverse = glm::inverse (this->m_modelViewProjectionCopy);
     this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
     this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
     this->m_viewProjectionMatrix = glm::mat4 (1.0);
     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 () {
 CImage::~CImage () {
+    this->m_texture->decrementUsageCount ();
+
     // delete passes first as they depend on the image's data
     // delete passes first as they depend on the image's data
     for (auto* pass : this->m_passes) {
     for (auto* pass : this->m_passes) {
 	delete pass;
 	delete pass;
@@ -224,6 +404,177 @@ CImage::~CImage () {
     glDeleteBuffers (1, &this->m_passSpacePosition);
     glDeleteBuffers (1, &this->m_passSpacePosition);
     glDeleteBuffers (1, &this->m_texcoordCopy);
     glDeleteBuffers (1, &this->m_texcoordCopy);
     glDeleteBuffers (1, &this->m_texcoordPass);
     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 () {
 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
     // TODO: CHECK ORDER OF THINGS, 2419444134'S ID 27 DEPENDS ON 104'S COMPOSITE_A WHEN OUR LAST RENDER IS ON
     // COMPOSITE_B
     // COMPOSITE_B
     // TODO: SUPPORT PASSTHROUGH (IT'S A SHADER)
     // 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
     // copy pass to the composite layer
     for (const auto& cur : this->getImage ().model->material->passes) {
     for (const auto& cur : this->getImage ().model->material->passes) {
 	this->m_passes.push_back (
 	this->m_passes.push_back (
@@ -248,9 +616,14 @@ void CImage::setup () {
     }
     }
 
 
     // prepare the passes list
     // prepare the passes list
-    if (!this->getImage ().effects.empty ()) {
+    if (!debug.baseOnly && !this->getImage ().effects.empty ()) {
 	// generate the effects used by this material
 	// generate the effects used by this material
 	for (const auto& cur : this->m_image.effects) {
 	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
 	    // 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...
 	    // visible but affect the output of the image...
 	    if (!cur->visible->value->getBool ()) {
 	    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
     // 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
 	this->m_materials.colorBlending.material
 	    = MaterialParser::load (this->getScene ().getScene ().project, "materials/util/effectpassthrough.json");
 	    = MaterialParser::load (this->getScene ().getScene ().project, "materials/util/effectpassthrough.json");
 	this->m_materials.colorBlending.override = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
 	this->m_materials.colorBlending.override = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
             .id = -1,
             .id = -1,
             .combos = {
             .combos = {
-                {"BLENDMODE", this->m_image.colorBlendMode},
+                {"BLENDMODE", this->m_image.colorBlendMode->value->getInt()},
             },
             },
             .constants = {},
             .constants = {},
             .textures = {},
             .textures = {},
@@ -374,6 +774,8 @@ void CImage::setupPasses () {
     auto cur = this->m_passes.begin ();
     auto cur = this->m_passes.begin ();
     auto end = this->m_passes.end ();
     auto end = this->m_passes.end ();
     bool first = true;
     bool first = true;
+    bool inTargetEffectSequence = false;
+    std::shared_ptr<const TextureProvider> effectInput = nullptr;
 
 
     for (; cur != end; ++cur) {
     for (; cur != end; ++cur) {
 	// TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
 	// 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
 	// TODO: WHICH ONE IS THE LAST + A FEW OTHER THINGS
 	Effects::CPass* pass = *cur;
 	Effects::CPass* pass = *cur;
 	std::shared_ptr<const CFBO> prevDrawTo = drawTo;
 	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
 	const glm::mat4* inverseProjection
-	    = (first) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
+	    = (isFirstPass) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
 	first = false;
 	first = false;
 
 
+	if (isFirstPass && this->m_hasPuppetMesh) {
+	    pass->setBlendingMode (BlendingMode_Translucent);
+	    this->setupPuppetGeometryCallback (pass);
+	}
+
 	pass->setModelMatrix (&this->m_modelMatrix);
 	pass->setModelMatrix (&this->m_modelMatrix);
 	pass->setViewProjectionMatrix (&this->m_viewProjectionMatrix);
 	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
 	// 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
 	// 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
 	    // TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
 	    spacePosition = this->getSceneSpacePosition ();
 	    spacePosition = this->getSceneSpacePosition ();
 	    drawTo = this->getScene ().getFBO ();
 	    drawTo = this->getScene ().getFBO ();
@@ -415,20 +814,67 @@ void CImage::setupPasses () {
 
 
 	pass->setDestination (drawTo);
 	pass->setDestination (drawTo);
 	pass->setInput (asInput);
 	pass->setInput (asInput);
+	pass->setPreviousInput (inTargetEffectSequence ? effectInput : nullptr);
 	pass->setPosition (spacePosition);
 	pass->setPosition (spacePosition);
 	pass->setTexCoord (texcoord);
 	pass->setTexCoord (texcoord);
 	pass->setModelViewProjectionMatrix (projection);
 	pass->setModelViewProjectionMatrix (projection);
 	pass->setModelViewProjectionMatrixInverse (inverseProjection);
 	pass->setModelViewProjectionMatrixInverse (inverseProjection);
 
 
 	texcoord = this->getTexCoordPass ();
 	texcoord = this->getTexCoordPass ();
-	drawTo = prevDrawTo;
 
 
-	if (!pass->getTarget ().has_value ()) {
+	if (writesToTarget) {
+	    asInput = drawTo;
+	    drawTo = prevDrawTo;
+	} else {
+	    drawTo = prevDrawTo;
 	    this->pinpongFramebuffer (&drawTo, &asInput);
 	    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) {
 void CImage::pinpongFramebuffer (std::shared_ptr<const CFBO>* drawTo, std::shared_ptr<const TextureProvider>* asInput) {
     // temporarily store FBOs used
     // temporarily store FBOs used
     std::shared_ptr<const CFBO> currentMainFBO = this->m_currentMainFBO;
     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 () {
 void CImage::render () {
     // do not try to render something that did not initialize successfully
     // do not try to render something that did not initialize successfully
-    // non-visible materials do need to be rendered
     if (!this->m_initialized) {
     if (!this->m_initialized) {
 	return;
 	return;
     }
     }
 
 
+    if (!this->getImage ().visible->value->getBool ()) {
+	return;
+    }
+
     glColorMask (true, true, true, true);
     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
 #if !NDEBUG
     std::string str = "Rendering ";
     std::string str = "Rendering ";
@@ -489,7 +935,7 @@ void CImage::render () {
 #endif /* DEBUG */
 #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 (); }
 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 (); }
 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; }
 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 "../TextureProvider.h"
 
 
 #include <glm/vec3.hpp>
 #include <glm/vec3.hpp>
+#include <vector>
 
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render;
@@ -63,12 +64,42 @@ protected:
 
 
     void updateScreenSpacePosition ();
     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:
 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_sceneSpacePosition;
     GLuint m_copySpacePosition;
     GLuint m_copySpacePosition;
     GLuint m_passSpacePosition;
     GLuint m_passSpacePosition;
     GLuint m_texcoordCopy;
     GLuint m_texcoordCopy;
     GLuint m_texcoordPass;
     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_modelViewProjectionScreen = {};
     glm::mat4 m_modelViewProjectionPass = {};
     glm::mat4 m_modelViewProjectionPass = {};
@@ -94,6 +125,8 @@ private:
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
 
 
     glm::vec4 m_pos = {};
     glm::vec4 m_pos = {};
+    glm::vec3 m_sceneCenter = {};
+    glm::vec2 m_size = {};
 
 
     bool m_initialized = false;
     bool m_initialized = false;
 
 
@@ -102,6 +135,8 @@ private:
 	    MaterialUniquePtr material;
 	    MaterialUniquePtr material;
 	    ImageEffectPassOverrideUniquePtr override;
 	    ImageEffectPassOverrideUniquePtr override;
 	} colorBlending;
 	} colorBlending;
+	std::vector<MaterialUniquePtr> compatibilityMaterials = {};
+	std::vector<ImageEffectPassOverrideUniquePtr> compatibilityOverrides = {};
     } m_materials;
     } m_materials;
 };
 };
 } // namespace WallpaperEngine::Render::Objects
 } // 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
     // Update control points with mouse position
-    const glm::vec2* mousePos = getScene ().getMousePosition ();
+    const glm::vec2* mousePos = getScene ().getMousePositionNormalized ();
     if (mousePos) {
     if (mousePos) {
 
 
 	for (auto& cp : m_controlPoints) {
 	for (auto& cp : m_controlPoints) {
@@ -413,16 +413,14 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
     bool randomPeriodicEmission = (emitter.flags & 4) != 0;
     bool randomPeriodicEmission = (emitter.flags & 4) != 0;
 
 
     return [this, emitter, transformedEmitterOrigin, controlPointIndex, rate, flippedDirections, limitOnePerFrame,
     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
 	    instantaneousEmitted
 	    = false] (std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
 	    = false] (std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
 	if (count >= particles.size ()) {
 	if (count >= particles.size ()) {
 	    return;
 	    return;
 	}
 	}
 
 
-	elapsedTime += dt;
-
 	// Handle delay
 	// Handle delay
 	if (delayTimer > 0.0f) {
 	if (delayTimer > 0.0f) {
 	    delayTimer -= dt;
 	    delayTimer -= dt;
@@ -561,12 +559,11 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
     }
     }
 
 
     bool limitOnePerFrame = (emitter.flags & 2) != 0;
     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 ()) {
 	if (count >= particles.size ()) {
 	    return;
 	    return;
 	}
 	}
@@ -1328,7 +1325,7 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
     bool maintainDistance = (flags & 2) != 0;
     bool maintainDistance = (flags & 2) != 0;
     bool ringShape = (flags & 4) != 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,
 	    speedOuterValue, centerForceValue, ringRadiusValue, ringWidthValue, ringPullDistanceValue,
 	    ringPullForceValue, audioMode, infiniteAxis, maintainDistance, ringShape, speedOverride] (
 	    ringPullForceValue, audioMode, infiniteAxis, maintainDistance, ringShape, speedOverride] (
 	       std::vector<ParticleInstance>& particles, uint32_t count,
 	       std::vector<ParticleInstance>& particles, uint32_t count,
@@ -1469,7 +1466,7 @@ OperatorFunc CParticle::createControlPointAttractOperator (const ControlPointAtt
     DynamicValue* thresholdValue = op.threshold->value.get ();
     DynamicValue* thresholdValue = op.threshold->value.get ();
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->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,
 	       std::vector<ParticleInstance>& particles, uint32_t count,
 	       const std::vector<ControlPointData>& controlPoints, float currentTime, float dt
 	       const std::vector<ControlPointData>& controlPoints, float currentTime, float dt
 	   ) {
 	   ) {
@@ -1840,12 +1837,13 @@ void CParticle::setupParticleUniforms () {
 }
 }
 
 
 void CParticle::updateMatrices () {
 void CParticle::updateMatrices () {
-    // Build model matrix from particle object transform
     glm::vec3 scale = m_particle.scale->value->getVec3 ();
     glm::vec3 scale = m_particle.scale->value->getVec3 ();
     glm::vec3 angles = m_particle.angles->value->getVec3 ();
     glm::vec3 angles = m_particle.angles->value->getVec3 ();
 
 
     m_modelMatrix = glm::mat4 (1.0f);
     m_modelMatrix = glm::mat4 (1.0f);
     m_modelMatrix = glm::translate (m_modelMatrix, m_transformedOrigin);
     m_modelMatrix = glm::translate (m_modelMatrix, m_transformedOrigin);
+    this->applyParallaxToModelMatrix ();
+
     // Negate X and Z rotations to account for Y-flipped coordinate system
     // 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.z, glm::vec3 (0, 0, 1));
     m_modelMatrix = glm::rotate (m_modelMatrix, angles.y, glm::vec3 (0, 1, 0));
     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_modelMatrix = glm::scale (m_modelMatrix, scale);
     m_modelMatrixInverse = glm::inverse (m_modelMatrix);
     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) {
     if ((m_particle.flags & 4) != 0) {
 	// Perspective particles use a dedicated perspective projection
 	// Perspective particles use a dedicated perspective projection
 	float width = getScene ().getCamera ().getWidth ();
 	float width = getScene ().getCamera ().getWidth ();
@@ -1880,22 +1917,11 @@ void CParticle::updateMatrices () {
 	// so the cross product produces a visible XY perpendicular direction.
 	// so the cross product produces a visible XY perpendicular direction.
 	m_eyePosition = glm::vec3 (0.0f, 0.0f, 1000.0f);
 	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);
     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) {
     if (m_spritesheetFrames > 0 && m_spritesheetCols > 0 && m_spritesheetRows > 0) {
 	float frameWidth = 1.0f / static_cast<float> (m_spritesheetCols);
 	float frameWidth = 1.0f / static_cast<float> (m_spritesheetCols);
 	float frameHeight = 1.0f / static_cast<float> (m_spritesheetRows);
 	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 setupGeometryCallbacks ();
     void setupParticleUniforms ();
     void setupParticleUniforms ();
     void updateMatrices ();
     void updateMatrices ();
+    void applyParallaxToModelMatrix ();
+    void updateParticleViewProjection ();
+    void updateParticleRenderVars ();
 
 
 private:
 private:
     const Particle& m_particle;
     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 TextureMap DEFAULT_BINDS = {};
 const ImageEffectPassOverride DEFAULT_OVERRIDE = {};
 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 (
 CPass::CPass (
     CRenderable& renderable, std::shared_ptr<const FBOProvider> fboProvider, const MaterialPass& pass,
     CRenderable& renderable, std::shared_ptr<const FBOProvider> fboProvider, const MaterialPass& pass,
     std::optional<std::reference_wrapper<const ImageEffectPassOverride>> override,
     std::optional<std::reference_wrapper<const ImageEffectPassOverride>> override,
@@ -47,17 +57,22 @@ CPass::CPass (
 
 
 CPass::~CPass () {
 CPass::~CPass () {
     // destroy shader programs
     // 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 (
 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
     // a bind named "previous" is just another way of telling it to use whatever texture there was already
     if (it->second == "previous") {
     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
     // 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) {
     switch (this->m_pass.depthtest) {
 	case DepthtestMode_Enabled:
 	case DepthtestMode_Enabled:
 	    glEnable (GL_DEPTH_TEST);
 	    glEnable (GL_DEPTH_TEST);
+	    glDepthFunc (GL_LEQUAL);
 	    break;
 	    break;
 	case DepthtestMode_Disabled:
 	case DepthtestMode_Disabled:
 	default:
 	default:
@@ -158,65 +174,98 @@ void CPass::setupRenderTexture () {
     // use the shader we have registered
     // use the shader we have registered
     glUseProgram (this->m_programID);
     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 () {
 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->setupRenderFramebuffer ();
     this->setupRenderTexture ();
     this->setupRenderTexture ();
     this->setupRenderUniforms ();
     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::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) {
 void CPass::setModelViewProjectionMatrix (const glm::mat4* projection) {
     this->m_modelViewProjectionMatrix = projection;
     this->m_modelViewProjectionMatrix = projection;
 }
 }
@@ -461,9 +555,14 @@ void CPass::setupShaders () {
     const std::string& shaderName
     const std::string& shaderName
 	= this->m_override.shaderOverride.has_value () ? this->m_override.shaderOverride.value () : this->m_pass.shader;
 	= 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_shader = new Render::Shaders::Shader (
 	this->m_renderable.getAssetLocator (), shaderName, this->m_combos, this->m_override.combos,
 	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]
     const auto [vertex, fragment]
@@ -564,11 +663,18 @@ void CPass::setupTextureUniforms () {
     }
     }
 
 
     for (const auto& [index, textureName] : this->m_pass.textures) {
     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 {
 	try {
 	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
 	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
 		this->m_textures[index] = this->resolveFBO (textureName);
 		this->m_textures[index] = this->resolveFBO (textureName);
@@ -576,16 +682,12 @@ void CPass::setupTextureUniforms () {
 		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
 		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
 	    }
 	    }
 	} catch (std::runtime_error& ex) {
 	} 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
     // override any texture
     for (const auto& [index, textureName] : this->m_override.textures) {
     for (const auto& [index, textureName] : this->m_override.textures) {
-	if (index == 0) {
-	    continue;
-	}
-
 	try {
 	try {
 	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
 	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
 		this->m_textures[index] = this->resolveFBO (textureName);
 		this->m_textures[index] = this->resolveFBO (textureName);
@@ -619,7 +721,9 @@ void CPass::setupTextureUniforms () {
     this->addUniform ("g_Texture5", 5);
     this->addUniform ("g_Texture5", 5);
     this->addUniform ("g_Texture6", 6);
     this->addUniform ("g_Texture6", 6);
     this->addUniform ("g_Texture7", 7);
     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) {
     for (const auto& [textureIndex, expectedTexture] : this->m_textures) {
 	std::ostringstream namestream;
 	std::ostringstream namestream;
@@ -629,6 +733,8 @@ void CPass::setupTextureUniforms () {
 	texture = this->resolveTexture (expectedTexture, textureIndex, texture);
 	texture = this->resolveTexture (expectedTexture, textureIndex, texture);
 	this->addUniform (namestream.str (), texture->getResolution ());
 	this->addUniform (namestream.str (), texture->getResolution ());
     }
     }
+
+    this->addUniform ("g_Texture0Resolution", &this->m_texture0Resolution);
 }
 }
 
 
 void CPass::setupUniforms () {
 void CPass::setupUniforms () {
@@ -648,15 +754,18 @@ void CPass::setupUniforms () {
     this->addUniform ("g_Alpha", renderable.getAlpha ());
     this->addUniform ("g_Alpha", renderable.getAlpha ());
     this->addUniform ("g_Color", renderable.getColor ());
     this->addUniform ("g_Color", renderable.getColor ());
     this->addUniform ("g_Color4", renderable.getColor4 ());
     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
     // add some external variables
     this->addUniform ("g_Time", &g_Time);
     this->addUniform ("g_Time", &g_Time);
     this->addUniform ("g_Daytime", &g_Daytime);
     this->addUniform ("g_Daytime", &g_Daytime);
     // add model-view-projection matrix
     // add model-view-projection matrix
     this->addUniform ("g_ModelViewProjectionMatrixInverse", &this->m_modelViewProjectionMatrixInverse);
     this->addUniform ("g_ModelViewProjectionMatrixInverse", &this->m_modelViewProjectionMatrixInverse);
     this->addUniform ("g_ModelViewProjectionMatrix", &this->m_modelViewProjectionMatrix);
     this->addUniform ("g_ModelViewProjectionMatrix", &this->m_modelViewProjectionMatrix);
+    this->addUniform ("g_EffectModelViewProjectionMatrix", &this->m_modelViewProjectionMatrix);
     this->addUniform ("g_ModelMatrix", &this->m_modelMatrix);
     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_NormalModelMatrix", glm::identity<glm::mat3> ());
     this->addUniform ("g_ViewProjectionMatrix", &this->m_viewProjectionMatrix);
     this->addUniform ("g_ViewProjectionMatrix", &this->m_viewProjectionMatrix);
     this->addUniform ("g_PointerPosition", scene.getMousePosition ());
     this->addUniform ("g_PointerPosition", scene.getMousePosition ());
@@ -792,15 +901,15 @@ void CPass::addUniform (ShaderVariable* value) {
 
 
 void CPass::addUniform (const ShaderVariable* value, const DynamicValue* setting) {
 void CPass::addUniform (const ShaderVariable* value, const DynamicValue* setting) {
     if (value->is<ShaderVariableFloat> ()) {
     if (value->is<ShaderVariableFloat> ()) {
-	this->addUniform (value->getName (), setting->getFloat ());
+	this->addUniform (value->getName (), &setting->getFloat ());
     } else if (value->is<ShaderVariableInteger> ()) {
     } else if (value->is<ShaderVariableInteger> ()) {
-	this->addUniform (value->getName (), setting->getInt ());
+	this->addUniform (value->getName (), &setting->getInt ());
     } else if (value->is<ShaderVariableVector2> ()) {
     } else if (value->is<ShaderVariableVector2> ()) {
-	this->addUniform (value->getName (), setting->getVec2 ());
+	this->addUniform (value->getName (), &setting->getVec2 ());
     } else if (value->is<ShaderVariableVector3> ()) {
     } else if (value->is<ShaderVariableVector3> ()) {
-	this->addUniform (value->getName (), setting->getVec3 ());
+	this->addUniform (value->getName (), &setting->getVec3 ());
     } else if (value->is<ShaderVariableVector4> ()) {
     } else if (value->is<ShaderVariableVector4> ()) {
-	this->addUniform (value->getName (), setting->getVec4 ());
+	this->addUniform (value->getName (), &setting->getVec4 ());
     } else {
     } else {
 	sLog.error ("Cannot convert setting dynamic value  to ", value->getName (), ". Using default value");
 	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 ();
     ~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 setTexCoord (GLuint texcoord);
     void setPosition (GLuint position);
     void setPosition (GLuint position);
     void setModelViewProjectionMatrix (const glm::mat4* projection);
     void setModelViewProjectionMatrix (const glm::mat4* projection);
@@ -110,6 +111,12 @@ private:
 	const GLuint* value;
 	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);
     static GLuint compileShader (const char* shader, GLuint type);
     void setupShaders ();
     void setupShaders ();
     void setupShaderVariables ();
     void setupShaderVariables ();
@@ -145,6 +152,11 @@ private:
 
 
     void setupRenderFramebuffer () const;
     void setupRenderFramebuffer () const;
     void setupRenderTexture ();
     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 setupRenderUniforms ();
     void setupRenderReferenceUniforms ();
     void setupRenderReferenceUniforms ();
     void setupRenderAttributes () const;
     void setupRenderAttributes () const;
@@ -180,8 +192,10 @@ private:
 
 
     Render::Shaders::Shader* m_shader = nullptr;
     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;
     GLuint m_programID;
 
 

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

@@ -24,7 +24,8 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
 
 
     // render the background
     // render the background
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
     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
 #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) {
 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);
     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 {
 const std::map<std::string, std::shared_ptr<CWallpaper>>& RenderContext::getWallpapers () const {
     return this->m_wallpapers;
     return this->m_wallpapers;
 }
 }
+
 } // namespace WallpaperEngine::Render
 } // 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,
 	GLSLContext::UnitType_Fragment, filename, assetLocator.fragmentShader (filename), assetLocator, constants,
 	textures, overrideTextures, combos, overrideCombos
 	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
     // link shaders between them
     this->m_vertex.linkToUnit (&this->m_fragment);
     this->m_vertex.linkToUnit (&this->m_fragment);
     this->m_fragment.linkToUnit (&this->m_vertex);
     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
      * The combos the shader should be generated with
      */
      */
     const ComboMap& m_combos;
     const ComboMap& m_combos;
-    /**
-     * The overriden combos
-     */
-    const ComboMap& m_overrideCombos;
     /**
     /**
      * The list of textures the pass knows about
      * The list of textures the pass knows about
      */
      */
     const TextureMap m_passTextures;
     const TextureMap m_passTextures;
-    /**
-     * The list of the override textures
-     */
-    const TextureMap& m_overrideTextures;
 };
 };
 } // namespace WallpaperEngine::Render::Shaders
 } // namespace WallpaperEngine::Render::Shaders

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

@@ -1,6 +1,7 @@
 #include "ShaderUnit.h"
 #include "ShaderUnit.h"
 
 
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include <exception>
 #include <regex>
 #include <regex>
 #include <stack>
 #include <stack>
 #include <string>
 #include <string>
@@ -34,10 +35,16 @@
 	  "#define CAST3(x) (vec3(x))\n"                                                                               \
 	  "#define CAST3(x) (vec3(x))\n"                                                                               \
 	  "#define CAST4(x) (vec4(x))\n"                                                                               \
 	  "#define CAST4(x) (vec4(x))\n"                                                                               \
 	  "#define CAST3X3(x) (mat3(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 saturate(x) (clamp(x, 0.0, 1.0))\n"                                                                 \
 	  "#define texSample2D texture\n"                                                                              \
 	  "#define texSample2D texture\n"                                                                              \
 	  "#define texSample2DLod textureLod\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 atan2 atan\n"                                                                                       \
 	  "#define fmod(x, y) ((x)-(y)*trunc((x)/(y)))\n"                                                              \
 	  "#define fmod(x, y) ((x)-(y)*trunc((x)/(y)))\n"                                                              \
 	  "#define ddx dFdx\n"                                                                                         \
 	  "#define ddx dFdx\n"                                                                                         \
@@ -71,9 +78,9 @@ void ShaderUnit::preprocess () {
     this->m_preprocessed = this->m_content;
     this->m_preprocessed = this->m_content;
     this->m_includes = "";
     this->m_includes = "";
 
 
-    this->preprocessVariables ();
     this->preprocessIncludes ();
     this->preprocessIncludes ();
     this->preprocessRequires ();
     this->preprocessRequires ();
+    this->preprocessVariables ();
 
 
     // replace gl_FragColor with the equivalent
     // replace gl_FragColor with the equivalent
     const std::string from = "gl_FragColor";
     const std::string from = "gl_FragColor";
@@ -87,9 +94,6 @@ void ShaderUnit::preprocess () {
 }
 }
 
 
 void ShaderUnit::preprocessVariables () {
 void ShaderUnit::preprocessVariables () {
-    this->m_preprocessed = this->m_content;
-    this->m_includes = "";
-
     size_t start = 0, end = 0;
     size_t start = 0, end = 0;
     while ((end = this->m_preprocessed.find ('\n', start)) != std::string::npos) {
     while ((end = this->m_preprocessed.find ('\n', start)) != std::string::npos) {
 	// Extract a line from the string
 	// Extract a line from the string
@@ -101,10 +105,12 @@ void ShaderUnit::preprocessVariables () {
 
 
 	if (combo != std::string::npos) {
 	if (combo != std::string::npos) {
 	    this->parseComboConfiguration (line.substr (combo + strlen ("// [COMBO] ")), 0);
 	    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
 	    // 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);
 	    const size_t last_space = line.find_last_of (' ', semicolon);
 
 
@@ -258,8 +264,7 @@ void ShaderUnit::preprocessIncludes () {
 	size_t current = 0;
 	size_t current = 0;
 
 
 	while (
 	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 ();
 	    current += match.position ();
 
 
 	    // if it's opening an #ifdef keep track of the start of the block
 	    // if it's opening an #ifdef keep track of the start of the block
@@ -307,22 +312,142 @@ void ShaderUnit::preprocessIncludes () {
 
 
 void ShaderUnit::preprocessRequires () {
 void ShaderUnit::preprocessRequires () {
     size_t start = 0, end = 0;
     size_t start = 0, end = 0;
-    // comment out requires
+
     while ((start = this->m_preprocessed.find ("#require", end)) != std::string::npos) {
     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);
 	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, "//");
 	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) {
 void ShaderUnit::parseComboConfiguration (const std::string& content, const int defaultValue) {
     // TODO: SUPPORT REQUIRES SO WE PROPERLY FOLLOW THE REQUIRED CHAIN
     // 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");
     const auto combo = data.require<std::string> ("combo", "cannot parse combo information");
     // ignore type as it seems to be used only on the editor
     // ignore type as it seems to be used only on the editor
     // const auto type = data.find ("type");
     // const auto type = data.find ("type");
@@ -357,7 +482,13 @@ void ShaderUnit::parseComboConfiguration (const std::string& content, const int
 void ShaderUnit::parseParameterConfiguration (
 void ShaderUnit::parseParameterConfiguration (
     const std::string& type, const std::string& name, const std::string& content
     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 material = data.optional ("material");
     const auto defvalue = data.optional ("default");
     const auto defvalue = data.optional ("default");
     // auto range = data.find ("range");
     // auto range = data.find ("range");
@@ -402,7 +533,6 @@ void ShaderUnit::parseParameterConfiguration (
 	// if needed
 	// if needed
 	const auto textureName = data.find ("default");
 	const auto textureName = data.find ("default");
 	// TODO: CREATE TEXTURE WITH THE GIVEN COLOR
 	// TODO: CREATE TEXTURE WITH THE GIVEN COLOR
-	const auto paintDefaultColor = data.find ("paintdefaultcolor");
 	// extract the texture number from the name
 	// extract the texture number from the name
 	const char value = name.at (std::string ("g_Texture").length ());
 	const char value = name.at (std::string ("g_Texture").length ());
 	const auto requireany = data.find ("requireany");
 	const auto requireany = data.find ("requireany");
@@ -584,11 +714,12 @@ const std::string& ShaderUnit::compile () {
     }
     }
 
 
     // this should be the rest of the shader
     // 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
     // the pass itself handles shader compilation, the unit doesn't have enough information for this step
     return this->m_final;
     return this->m_final;
 }
 }
 
 
 const std::vector<Variables::ShaderVariable*>& ShaderUnit::getParameters () const { return this->m_parameters; }
 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 ();
     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 ();
     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
      * 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/Model/Project.h"
 #include "WallpaperEngine/Data/Parsers/TextureParser.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::Render;
 using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::Data::Parsers;
 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) { }
 TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (context) { }
 
 
 std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string& filename) {
 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 ()) {
     if (const auto found = this->m_textureCache.find (filename); found != this->m_textureCache.end ()) {
 	return found->second;
 	return found->second;
     }
     }
@@ -27,7 +419,6 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	    auto stream = BinaryReader (contents);
 	    auto stream = BinaryReader (contents);
 
 
 	    // Create metadata loader lambda that captures the assetLocator
 	    // 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
 	    // so we need to construct the full path here
 	    auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
 	    auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
 		std::filesystem::path fullPath = std::filesystem::path ("materials") / metaFilename;
 		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 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);
 	    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) {
 void TextureCache::store (const std::string& name, std::shared_ptr<const TextureProvider> texture) {
     this->m_textureCache.insert_or_assign (name, 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
      * @return Duration of spritesheet animation in seconds
      */
      */
     [[nodiscard]] virtual float getSpritesheetDuration () const = 0;
     [[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
 } // 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/CImage.h"
 #include "WallpaperEngine/Render/Objects/CParticle.h"
 #include "WallpaperEngine/Render/Objects/CParticle.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
+#include "WallpaperEngine/Render/Objects/CText.h"
 
 
 #include "WallpaperEngine/Render/WallpaperState.h"
 #include "WallpaperEngine/Render/WallpaperState.h"
 
 
@@ -10,6 +11,8 @@
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Parsers/ObjectParser.h"
 #include "WallpaperEngine/Data/Parsers/ObjectParser.h"
 
 
+#include <ranges>
+
 extern float g_Time;
 extern float g_Time;
 extern float g_TimeLast;
 extern float g_TimeLast;
 
 
@@ -20,6 +23,37 @@ using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Render::Wallpapers;
 using namespace WallpaperEngine::Render::Wallpapers;
 using JSON = WallpaperEngine::Data::JSON::JSON;
 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 (
 CScene::CScene (
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
@@ -35,7 +69,33 @@ CScene::CScene (
 
 
     // detect size if the orthogonal project is auto
     // detect size if the orthogonal project is auto
     if (scene->camera.projection.isAuto) {
     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 };
     this->m_parallaxDisplacement = { 0, 0 };
@@ -70,6 +130,8 @@ CScene::CScene (
 	this->addObjectToRenderOrder (*object);
 	this->addObjectToRenderOrder (*object);
     }
     }
 
 
+    this->collectScriptedValues ();
+
     // create extra framebuffers for the bloom effect
     // create extra framebuffers for the bloom effect
     this->_rt_4FrameBuffer = this->create (
     this->_rt_4FrameBuffer = this->create (
 	"_rt_4FrameBuffer", TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0, { sceneWidth / 4, sceneHeight / 4 },
 	"_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) {
     if (renderObject != nullptr) {
 	this->m_objects.emplace (renderObject->getId (), renderObject);
 	this->m_objects.emplace (renderObject->getId (), renderObject);
     }
     }
@@ -208,6 +257,33 @@ Render::CObject* CScene::createObject (const Object& object) {
     return renderObject;
     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) {
 void CScene::addObjectToRenderOrder (const Object& object) {
     const auto obj = this->m_objects.find (object.id);
     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; }
 Camera& CScene::getCamera () const { return *this->m_camera; }
 
 
 void CScene::renderFrame (const glm::ivec4& viewport) {
 void CScene::renderFrame (const glm::ivec4& viewport) {
     // ensure the virtual mouse position is up to date
     // ensure the virtual mouse position is up to date
     this->updateMouse (viewport);
     this->updateMouse (viewport);
 
 
+    this->updateScriptedValues ();
+
     // update the parallax position if required
     // update the parallax position if required
     if (this->getScene ().camera.parallax.enabled->value->getBool ()
     if (this->getScene ().camera.parallax.enabled->value->getBool ()
 	&& !this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
 	&& !this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
 	const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
 	const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
 	const float amount = this->getScene ().camera.parallax.amount->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
 	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
     // use the scene's framebuffer by default
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
     // ensure we render over the whole framebuffer
     // 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);
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
 
     for (const auto& cur : this->m_objectsByRenderOrder) {
     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 ();
 	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);
     double mouseX = glm::clamp ((position.x - viewport.x) / viewport.z, 0.0, 1.0);
     // Normalize Y coordinate (OpenGL convention: 0=bottom, 1=top)
     // 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)
     // 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
     // Account for UV cropping when using fill/fit scaling modes
     // The scene may be rendered larger than viewport and cropped via UVs
     // 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
     // Map mouse position from viewport space to scene UV space
     // UVs define what portion of the scene texture is visible
     // 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);
     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 (); }
 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::getMousePosition () const { return &this->m_mousePosition; }
 
 
 const glm::vec2* CScene::getMousePositionLast () const { return &this->m_mousePositionLast; }
 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 glm::vec2* CScene::getParallaxDisplacement () const { return &this->m_parallaxDisplacement; }
 
 
 const std::vector<CObject*>& CScene::getObjectsByRenderOrder () const { return this->m_objectsByRenderOrder; }
 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/Render/Camera.h"
 
 
+#include "WallpaperEngine/Data/Model/ScriptedDynamicValue.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
 
 
 namespace WallpaperEngine::Render {
 namespace WallpaperEngine::Render {
@@ -28,11 +29,20 @@ public:
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () 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* getMousePosition () const;
     const glm::vec2* getMousePositionLast () const;
     const glm::vec2* getMousePositionLast () const;
+    const glm::vec2* getMousePositionNormalized () const;
     const glm::vec2* getParallaxDisplacement () const;
     const glm::vec2* getParallaxDisplacement () const;
 
 
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
+    [[nodiscard]] const CObject* getObject (int id) const;
 
 
 protected:
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
     void renderFrame (const glm::ivec4& viewport) override;
@@ -42,15 +52,21 @@ protected:
 
 
 private:
 private:
     Render::CObject* createObject (const Object& object);
     Render::CObject* createObject (const Object& object);
+    Render::CObject* dispatchObjectType (const Object& object);
     void addObjectToRenderOrder (const Object& object);
     void addObjectToRenderOrder (const Object& object);
+    void collectScriptedValues ();
+    void registerScriptedValue (const UserSettingUniquePtr& setting);
+    void updateScriptedValues ();
 
 
     std::unique_ptr<Camera> m_camera;
     std::unique_ptr<Camera> m_camera;
     ObjectUniquePtr m_bloomObjectData;
     ObjectUniquePtr m_bloomObjectData;
     CObject* m_bloomObject = nullptr;
     CObject* m_bloomObject = nullptr;
     std::map<int, CObject*> m_objects = {};
     std::map<int, CObject*> m_objects = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
+    std::vector<ScriptedDynamicValue*> m_scriptedValues = {};
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
     glm::vec2 m_mousePositionLast = {};
+    glm::vec2 m_mousePositionNormalized = {};
     glm::vec2 m_parallaxDisplacement = {};
     glm::vec2 m_parallaxDisplacement = {};
     std::shared_ptr<const CFBO> _rt_4FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_4FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_8FrameBuffer = 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 "CVideo.h"
-#include "WallpaperEngine/Logging/Log.h"
-
-#include <GL/glew.h>
 
 
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
 
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render::Wallpapers;
 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 (
 CVideo::CVideo (
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, 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
     // setup framebuffers
     this->setupFramebuffers ();
     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) {
 void CVideo::renderFrame (const glm::ivec4& viewport) {
     // ensure the video's audio follows audio detection rules
     // 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->getAudioContext ().getDriver ().getAudioDetector ().anythingPlaying ()) {
 	this->m_muted = !this->m_muted;
 	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) {
 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/Audio/AudioStream.h"
 #include "WallpaperEngine/Render/CWallpaper.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 {
 namespace WallpaperEngine::Render::Wallpapers {
+using namespace WallpaperEngine::VideoPlayback::MPV;
+
 class CVideo final : public CWallpaper {
 class CVideo final : public CWallpaper {
 public:
 public:
     CVideo (
     CVideo (
@@ -15,13 +16,12 @@ public:
 
 
     ~CVideo () override;
     ~CVideo () override;
 
 
-    const Video& getVideo () const;
+    const Data::Model::Video& getVideo () const;
 
 
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () const override;
     [[nodiscard]] int getHeight () const override;
 
 
     void setPause (bool newState) override;
     void setPause (bool newState) override;
-    void setSize (int width, int height);
 
 
 protected:
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
     void renderFrame (const glm::ivec4& viewport) override;
@@ -29,12 +29,8 @@ protected:
     friend class CWallpaper;
     friend class CWallpaper;
 
 
 private:
 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;
     bool m_muted = false;
 };
 };
 } // namespace WallpaperEngine::Render::Wallpapers
 } // 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>;
+};