Parcourir la source

tons of fixes

UwU il y a 5 jours
Parent
commit
560d446385
83 fichiers modifiés avec 4027 ajouts et 506 suppressions
  1. 9 0
      CMakeLists.txt
  2. 2 0
      docs/investigations/puppet-and-renderer-notes.md
  3. 40 5
      src/WallpaperEngine/Application/ApplicationContext.cpp
  4. 2 0
      src/WallpaperEngine/Application/ApplicationContext.h
  5. 124 13
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  6. 4 1
      src/WallpaperEngine/Application/WallpaperApplication.h
  7. 48 9
      src/WallpaperEngine/Audio/AudioStream.cpp
  8. 20 3
      src/WallpaperEngine/Audio/AudioStream.h
  9. 26 1
      src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.cpp
  10. 25 1
      src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h
  11. 24 9
      src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp
  12. 6 0
      src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.h
  13. 4 4
      src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.cpp
  14. 56 0
      src/WallpaperEngine/Audio/SpectrumNormalizer.cpp
  15. 24 0
      src/WallpaperEngine/Audio/SpectrumNormalizer.h
  16. 28 2
      src/WallpaperEngine/Data/Builders/VectorBuilder.h
  17. 1 1
      src/WallpaperEngine/Data/Model/Object.h
  18. 25 1
      src/WallpaperEngine/Data/Model/Property.h
  19. 177 0
      src/WallpaperEngine/Data/Parsers/Localization.cpp
  20. 41 0
      src/WallpaperEngine/Data/Parsers/Localization.h
  21. 11 4
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  22. 9 0
      src/WallpaperEngine/Data/Parsers/ProjectParser.cpp
  23. 7 4
      src/WallpaperEngine/Data/Parsers/PropertyParser.cpp
  24. 19 1
      src/WallpaperEngine/Input/Drivers/KDECursorInput.cpp
  25. 19 0
      src/WallpaperEngine/Media/ThumbnailPalette.cpp
  26. 6 0
      src/WallpaperEngine/Media/ThumbnailPalette.h
  27. 1 1
      src/WallpaperEngine/Render/CTexture.cpp
  28. 1 0
      src/WallpaperEngine/Render/CTexture.h
  29. 4 4
      src/WallpaperEngine/Render/CWallpaper.cpp
  30. 3 0
      src/WallpaperEngine/Render/CWallpaper.h
  31. 13 2
      src/WallpaperEngine/Render/Camera.cpp
  32. 11 1
      src/WallpaperEngine/Render/Camera.h
  33. 14 1
      src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.cpp
  34. 1 0
      src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.h
  35. 4 0
      src/WallpaperEngine/Render/Drivers/VideoDriver.h
  36. 5 1
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
  37. 1 0
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h
  38. 105 23
      src/WallpaperEngine/Render/Objects/CImage.cpp
  39. 18 0
      src/WallpaperEngine/Render/Objects/CImage.h
  40. 63 39
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  41. 4 0
      src/WallpaperEngine/Render/Objects/CParticle.h
  42. 10 13
      src/WallpaperEngine/Render/Objects/CText.cpp
  43. 83 69
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  44. 11 0
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  45. 95 0
      src/WallpaperEngine/Render/RenderContext.cpp
  46. 2 0
      src/WallpaperEngine/Render/RenderContext.h
  47. 192 7
      src/WallpaperEngine/Render/TextureCache.cpp
  48. 5 0
      src/WallpaperEngine/Render/TextureProvider.h
  49. 286 164
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  50. 37 2
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  51. 20 1
      src/WallpaperEngine/Render/Wallpapers/CSplat.cpp
  52. 209 34
      src/WallpaperEngine/Render/Wallpapers/CWeb.cpp
  53. 27 2
      src/WallpaperEngine/Render/Wallpapers/CWeb.h
  54. 272 2
      src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
  55. 5 1
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
  56. 34 0
      src/WallpaperEngine/Scripting/EngineObject.cpp
  57. 15 4
      src/WallpaperEngine/Scripting/InputObject.cpp
  58. 113 14
      src/WallpaperEngine/Scripting/ScriptEngine.cpp
  59. 27 0
      src/WallpaperEngine/Scripting/ScriptEngine.h
  60. 7 4
      src/WallpaperEngine/Scripting/ScriptableObject.h
  61. 96 0
      src/WallpaperEngine/Testing/Cases/Localization.cpp
  62. 50 1
      src/WallpaperEngine/Testing/Cases/PropertyParser.cpp
  63. 54 0
      src/WallpaperEngine/Testing/Cases/SpectrumListeners.cpp
  64. 95 0
      src/WallpaperEngine/Testing/Cases/SpectrumNormalizer.cpp
  65. 16 0
      src/WallpaperEngine/Testing/Cases/TextInputProperty.cpp
  66. 6 1
      src/WallpaperEngine/Testing/Render/TestingOpenGLDriver.cpp
  67. 140 11
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp
  68. 23 0
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
  69. 2 0
      src/WallpaperEngine/WebBrowser/CEF/BrowserApp.cpp
  70. 92 18
      src/WallpaperEngine/WebBrowser/CEF/BrowserClient.cpp
  71. 4 0
      src/WallpaperEngine/WebBrowser/CEF/BrowserClient.h
  72. 323 0
      src/WallpaperEngine/WebBrowser/CEF/PageBridge.cpp
  73. 53 0
      src/WallpaperEngine/WebBrowser/CEF/PageBridge.h
  74. 9 8
      src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.cpp
  75. 2 0
      src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h
  76. 312 0
      src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.cpp
  77. 7 1
      src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.h
  78. 116 4
      src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.cpp
  79. 8 0
      src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.h
  80. 78 0
      src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.cpp
  81. 68 12
      src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h
  82. 14 2
      src/WallpaperEngine/WebBrowser/WebBrowserContext.cpp
  83. 4 0
      src/WallpaperEngine/WebBrowser/WebBrowserContext.h

+ 9 - 0
CMakeLists.txt

@@ -350,6 +350,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp
     src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.h
     src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.cpp
+    src/WallpaperEngine/Audio/SpectrumNormalizer.cpp
+    src/WallpaperEngine/Audio/SpectrumNormalizer.h
     src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h
 
     src/WallpaperEngine/Audio/Drivers/Detectors/PulseAudioPlayingDetector.cpp
@@ -474,6 +476,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.cpp
     src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h
     src/WallpaperEngine/WebBrowser/CEF/BrowserClient.cpp
+    src/WallpaperEngine/WebBrowser/CEF/PageBridge.cpp
+    src/WallpaperEngine/WebBrowser/CEF/PageBridge.h
     src/WallpaperEngine/WebBrowser/CEF/BrowserClient.h
     src/WallpaperEngine/WebBrowser/CEF/BrowserApp.cpp
     src/WallpaperEngine/WebBrowser/CEF/BrowserApp.h
@@ -512,6 +516,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp
     src/WallpaperEngine/Data/Parsers/EffectParser.cpp
     src/WallpaperEngine/Data/Parsers/EffectParser.h
+    src/WallpaperEngine/Data/Parsers/Localization.cpp
+    src/WallpaperEngine/Data/Parsers/Localization.h
     src/WallpaperEngine/Data/Parsers/ProjectParser.cpp
     src/WallpaperEngine/Data/Parsers/ProjectParser.h
     src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp
@@ -595,6 +601,9 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.h
         src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp
         src/WallpaperEngine/Testing/Cases/PropertyParser.cpp
+        src/WallpaperEngine/Testing/Cases/SpectrumListeners.cpp
+        src/WallpaperEngine/Testing/Cases/SpectrumNormalizer.cpp
+        src/WallpaperEngine/Testing/Cases/Localization.cpp
         src/WallpaperEngine/Testing/Cases/ScalingModes.cpp
         src/WallpaperEngine/Testing/Cases/CornerColor.cpp
         src/WallpaperEngine/Testing/Cases/JsonVectorParsing.cpp

+ 2 - 0
docs/investigations/puppet-and-renderer-notes.md

@@ -121,6 +121,8 @@ Verified via Python simulation against every wallpaper touched this session befo
 
 A wallpaper (`1746890525`, a single giant multi-effect puppet - fire/pulse/waterwaves/reflection/shake/shine/depthparallax, 13 passes) was briefly reported as "half looks like lava" during testing, but turned out non-reproducible - the user was hotswapping heavily at the time, which lines up with the use-after-free below rather than this paint-order change (this wallpaper has only one qualifying `Image`, and it's excluded for being a puppet, so the containment rule is a structural no-op for it either way). Before it was confirmed transient, `g_TextureNResolution` uniform binding was checked directly via a one-shot diagnostic log in `CPass.cpp` (`TEMP-DIAG texture resolution uniform for object 13`, left in place) and found correct (1:1 ratios everywhere) - ruling out a UV/padding bug in the fire effect's flow-map sampling as the cause, in case this resurfaces.
 
+**Removed (2026-09-22).** The heuristic broke `3425834410` (Beatrice): its background has transparent eye holes and two layers are authored *under* it on purpose - a soft, blurred eye-white layer (id 26, 538x636) and an iris layer (id 21, 296x388, `iris_movement` + `pulse` effects). Both qualified as "contained plain translucent" objects, so both were moved after the background (bg, 21, 26): the blurred whites got painted over the face and covered the irises, so no eyes showed. Real WE draws array order (confirmed in IDA), and the asagi ring it was written for turned out to be the `CPass.cpp` intermediate-FBO double premultiply (Normal blending into a cleared FBO), fixed separately. With the heuristic gone, asagi renders without a ring in the sandbox and Beatrice's eyes show through the holes as in its `preview.gif`. Paint order is now plain array order / `sortorder` only.
+
 ## Hotswap use-after-free crash (2026-08-10)
 
 Real crash (SIGSEGV + coredump) reported on real hardware: `CSound::applyEffectiveVolume()` <- `CScene::setAudioPolicy()` <- `WallpaperApplication::applyAudioPolicy()` <- `checkHotswapRequest()`, alongside a `filesystem_error: Cannot find requested file ... [/models/bar.json]` for a different, apparently-incomplete workshop item (`2352180009`) hotswapped around the same time.

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

@@ -22,6 +22,23 @@
 #define APP_DIRECTORY "wallpaper_engine"
 
 using namespace WallpaperEngine::Application;
+
+namespace {
+// CEF and --web-host children re-run the whole argument parsing, the parent already reported all of it
+bool isHelperProcess (int argc, char** argv, bool webHost) {
+    if (webHost) {
+	return true;
+    }
+
+    for (int i = 1; i < argc; i++) {
+	if (std::string_view (argv[i]).starts_with ("--type=")) {
+	    return true;
+	}
+    }
+
+    return false;
+}
+} // namespace
 using WallpaperEngine::Data::JSON::JSON;
 
 std::filesystem::path ApplicationContext::resolvePlaylistItemPath (const std::string& raw) const {
@@ -821,6 +838,16 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.action ([this] (const std::string& value) -> void { this->settings.general.disableParticles = true; });
 
+    configurationGroup.add_argument ("--expand-canvas")
+	.help (
+	    "Grows a scene's render canvas so image layers that extend past the camera's projection (a tall or wide "
+	    "picture the author only meant to be revealed by parallax) are shown in full instead of cropped. Layout "
+	    "stays centered on the original canvas, so it only ever adds room around it. Combine with "
+	    "--disable-parallax and --scaling fit to see the whole picture"
+	)
+	.flag ()
+	.action ([this] (const std::string& value) -> void { this->settings.general.expandCanvas = true; });
+
     configurationGroup.add_argument ("--disable-animations")
 	.help ("Freezes all scene animation (scripts, particles, effects and puppet meshes) at its current frame")
 	.flag ()
@@ -1042,7 +1069,9 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 	    if (onKDE) {
 		this->settings.render.wayland.layer = WAYLAND_LAYER_BACKGROUND;
-		sLog.out ("KDE detected: using --layer background to prevent 'show desktop' from hiding the wallpaper (use --layer bottom to override)");
+
+		if (!isHelperProcess (this->m_argc, this->m_argv, this->settings.general.webHost))
+		    sLog.out ("KDE detected: using --layer background to prevent 'show desktop' from hiding the wallpaper (use --layer bottom to override)");
 	    }
 	}
 
@@ -1068,7 +1097,10 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    bufferStream << " ";
 	}
 
-	std::cout << buffer.str () << std::endl;
+	if (!isHelperProcess (this->m_argc, this->m_argv, this->settings.general.webHost)) {
+	    std::cout << buffer.str () << std::endl;
+	}
+
 	this->validateAssets ();
 	this->validateScreenshot ();
 
@@ -1104,9 +1136,12 @@ std::filesystem::path ApplicationContext::translateBackground (const std::string
 
 void ApplicationContext::validateAssets () {
     if (!this->settings.general.assets.empty ()) {
-	sLog.out (
-	    "Using wallpaper engine's assets at ", this->settings.general.assets, " based on --assets-dir parameter"
-	);
+	if (!isHelperProcess (this->m_argc, this->m_argv, this->settings.general.webHost)) {
+	    sLog.out (
+		"Using wallpaper engine's assets at ", this->settings.general.assets, " based on --assets-dir parameter"
+	    );
+	}
+
 	return;
     }
 

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

@@ -110,6 +110,8 @@ public:
 	    bool onlyListEffects;
 	    bool dumpStructure;
 	    bool disableParticles;
+	    /** Grows a scene's render canvas to fit every image layer that extends past it, see --expand-canvas */
+	    bool expandCanvas;
 	    /** Objects/layers to force-hide, matched by id or name */
 	    std::vector<std::string> disabledObjects;
 	    /** Objects/layers to force-show, matched by id or name */

+ 124 - 13
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -23,6 +23,7 @@
 #include "WallpaperEngine/Media/DBusMediaSource.h"
 
 #include "WallpaperEngine/WebBrowser/CEF/BrowserClient.h"
+#include "WallpaperEngine/WebBrowser/CEF/PageBridge.h"
 #include "WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h"
 #include "WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h"
 #include "include/cef_browser.h"
@@ -309,7 +310,9 @@ WallpaperApplication::ProjectSource WallpaperApplication::openProjectSource (con
     return { std::move (baseContainer), std::move (baseJson), json.optional ("preset") };
 }
 
-void WallpaperApplication::applyPreset (const Project& project, const WallpaperEngine::Data::JSON::JSON& preset) {
+void WallpaperApplication::applyPreset (
+    const Project& project, const WallpaperEngine::Data::JSON::JSON& preset, const std::filesystem::path& presetDir
+) {
     for (const auto& entry : preset.items ()) {
 	const auto property = project.properties.find (entry.key ());
 
@@ -329,6 +332,21 @@ void WallpaperApplication::applyPreset (const Project& project, const WallpaperE
 	    text = value.dump ();
 	}
 
+	// file and directory properties hold paths relative to the preset folder, pages expect an absolute one
+	if (const auto* file = dynamic_cast<const PropertyFile*> (property->second.get ());
+	    file != nullptr && !text.empty () && std::filesystem::path (text).is_relative ()) {
+	    const auto candidate = presetDir / text;
+
+	    if (file->isDirectory () ? std::filesystem::is_directory (candidate)
+				     : std::filesystem::is_regular_file (candidate)) {
+		text = candidate.string ();
+	    }
+	}
+
+	if (auto* combo = dynamic_cast<PropertyCombo*> (property->second.get ()); combo != nullptr) {
+	    combo->allowValue (text);
+	}
+
 	try {
 	    property->second->update (text, DynamicValue::UpdateSource::User);
 	} catch (const std::exception& e) {
@@ -354,7 +372,13 @@ ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
     auto project = WallpaperEngine::Data::Parsers::ProjectParser::parse (json, std::move (container));
 
     if (preset.has_value ()) {
-	applyPreset (*project, *preset);
+	std::filesystem::path presetDir = bg;
+
+	if (!presetDir.has_filename ()) {
+	    presetDir = presetDir.parent_path ();
+	}
+
+	applyPreset (*project, *preset, presetDir);
     }
 
     return project;
@@ -633,6 +657,8 @@ struct HotswapRequest {
     bool propertiesProvided = false;
     std::map<std::string, std::string> properties;
     std::optional<int> volume;
+    /** frame rate limit, see --fps */
+    std::optional<int> fps;
     /** "on"/"off"/"toggle" (also "1"/"0"/"true"/"false" for on/off) */
     std::optional<std::string> xray;
     /** "stretch"/"fit"/"fill"/"center"/"default" */
@@ -643,6 +669,8 @@ struct HotswapRequest {
     std::optional<std::string> offset;
     /** "on"/"off"/"toggle" (also "1"/"0"/"true"/"false" for on/off) */
     std::optional<std::string> disableParallax;
+    /** "on"/"off" (also "1"/"0"/"true"/"false"), see --expand-canvas. Baked in at scene build, so it reloads */
+    std::optional<std::string> expandCanvas;
     /** hex RGB/RGBA color, e.g. "000000" or "#1a1a1aff" */
     std::optional<std::string> cornerColor;
     /** floating-point playback speed multiplier, e.g. "0.5" */
@@ -722,6 +750,12 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 	    } catch (const std::exception&) {
 		sLog.error ("Hotswap: ignoring invalid volume value: ", value);
 	    }
+	} else if (key == "fps") {
+	    try {
+		request.fps = std::stoi (value);
+	    } catch (const std::exception&) {
+		sLog.error ("Hotswap: ignoring invalid fps value: ", value);
+	    }
 	} else if (key == "xray") {
 	    request.xray = value;
 	} else if (key == "scaling") {
@@ -732,6 +766,8 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 	    request.offset = value;
 	} else if (key == "disable-parallax") {
 	    request.disableParallax = value;
+	} else if (key == "expand-canvas") {
+	    request.expandCanvas = value;
 	} else if (key == "corner-color") {
 	    request.cornerColor = value;
 	} else if (key == "speed") {
@@ -795,17 +831,37 @@ void WallpaperApplication::checkHotswapRequest () {
 
     if (!request.path.has_value () && !request.layersProvided && !request.volume.has_value ()
 	&& !request.xray.has_value () && !request.scaling.has_value () && !request.zoom.has_value ()
-	&& !request.offset.has_value () && !request.disableParallax.has_value () && !request.cornerColor.has_value ()
+	&& !request.offset.has_value () && !request.disableParallax.has_value () && !request.expandCanvas.has_value ()
+	&& !request.cornerColor.has_value ()
 	&& !request.speed.has_value () && !request.audioScreen.has_value () && !request.ambientVolume.has_value ()
 	&& !request.propertiesProvided && !request.audioSensitivityProvided && !request.soundVolumeProvided) {
 	sLog.error ("Hotswap requested but control file was empty");
 	return;
     }
 
+    // overrides belong to the wallpaper they were set for, object ids and generic property names
+    // ("newproperty1") collide across wallpapers, so a new wallpaper starts without the old ones and
+    // only gets whatever this same request carries for it
+    if (request.path.has_value ()) {
+	auto& general = this->m_context.settings.general;
+
+	general.disabledObjects.clear ();
+	general.enabledObjects.clear ();
+	general.disabledEffects.clear ();
+	general.enabledEffects.clear ();
+	general.properties.clear ();
+	general.audioSensitivity.clear ();
+	general.soundVolume.clear ();
+    }
+
     if (request.volume.has_value ()) {
 	this->applyVolumeHotswap (*request.volume);
     }
 
+    if (request.fps.has_value ()) {
+	this->applyFpsHotswap (*request.fps);
+    }
+
     if (request.xray.has_value ()) {
 	this->applyXrayHotswap (*request.xray);
     }
@@ -846,6 +902,20 @@ void WallpaperApplication::checkHotswapRequest () {
 	this->applySoundVolumeHotswap (request.soundVolume);
     }
 
+    bool expandCanvasChanged = false;
+
+    if (request.expandCanvas.has_value ()) {
+	const auto& value = *request.expandCanvas;
+	const bool enable = value == "on" || value == "1" || value == "true";
+
+	if (!enable && value != "off" && value != "0" && value != "false") {
+	    sLog.error ("Hotswap: ignoring invalid expand-canvas value: ", value);
+	} else {
+	    expandCanvasChanged = this->m_context.settings.general.expandCanvas != enable;
+	    this->m_context.settings.general.expandCanvas = enable;
+	}
+    }
+
     if (request.layersProvided) {
 	this->m_context.settings.general.disabledObjects = request.disabledObjects;
 	this->m_context.settings.general.enabledObjects = request.enabledObjects;
@@ -869,9 +939,9 @@ void WallpaperApplication::checkHotswapRequest () {
 
     // volume/xray/speed-only requests are pure live setters with nothing to reload. Properties
     // and audio sensitivity, like layers, are baked into the scene graph at parse time, so they
-    // need the same reload.
+    // need the same reload, as does the expanded canvas (it sizes the scene's framebuffers).
     if (!request.path.has_value () && !request.layersProvided && !request.propertiesProvided
-	&& !request.audioSensitivityProvided) {
+	&& !request.audioSensitivityProvided && !expandCanvasChanged) {
 	return;
     }
 
@@ -1036,6 +1106,14 @@ void WallpaperApplication::applyVolumeHotswap (int volume) {
     sLog.out ("Hotswap: applied volume ", volume, " live");
 }
 
+void WallpaperApplication::applyFpsHotswap (int fps) {
+    fps = std::max (1, fps);
+
+    this->m_context.settings.render.maximumFPS = fps;
+
+    sLog.out ("Hotswap: applied fps limit ", fps, " live");
+}
+
 void WallpaperApplication::applyXrayHotswap (const std::string& value) {
     bool newState;
 
@@ -1613,8 +1691,12 @@ void WallpaperApplication::runWebHost () {
 
     CefWindowInfo windowInfo;
     windowInfo.SetAsWindowless (0);
+    // frames are produced when the main process asks for them (see the loop below), so they line up with what it
+    // presents instead of drifting against a free running 60Hz timer
+    windowInfo.external_begin_frame_enabled = true;
 
     CefBrowserSettings browserSettings;
+    // only a ceiling while external begin frames are on, the main process decides when a frame is produced
     browserSettings.windowless_frame_rate = std::max (60, this->m_context.settings.render.maximumFPS);
 
     const CefRefPtr<CEF::SharedMemoryRenderHandler> renderHandler = new CEF::SharedMemoryRenderHandler (shm);
@@ -1635,16 +1717,35 @@ void WallpaperApplication::runWebHost () {
 	return;
     }
 
+    // a windowless browser is treated as backgrounded by default, which freezes the page's own timers and rAF
+    browser->GetHost ()->WasHidden (false);
+
     shm->helperReady.store (true, std::memory_order_release);
 
     Input::MouseClickStatus lastLeft = Input::Released;
     Input::MouseClickStatus lastRight = Input::Released;
+    uint32_t lastFrameRequest = shm->frameRequestSeq.load (std::memory_order_acquire);
+    auto lastBeginFrame = std::chrono::steady_clock::now ();
+    int lastMouseX = -1;
+    int lastMouseY = -1;
     uint32_t lastDesiredWidth = shm->desiredWidth.load (std::memory_order_relaxed);
     uint32_t lastDesiredHeight = shm->desiredHeight.load (std::memory_order_relaxed);
     bool lastAudioMuted = shm->audioMuted.load (std::memory_order_relaxed);
     browser->GetHost ()->SetAudioMuted (lastAudioMuted);
+    CEF::PageBridge bridge (browser, *shm, project.properties, *client);
 
     while (!shm->quitRequested.load (std::memory_order_acquire)) {
+	// The heartbeat keeps the page alive (timers, videos, first paint) when the main process isn't rendering, for
+	// example while the surface is fully covered and the compositor stops sending frame callbacks.
+	const auto now = std::chrono::steady_clock::now ();
+	const uint32_t frameRequest = shm->frameRequestSeq.load (std::memory_order_acquire);
+
+	if (frameRequest != lastFrameRequest || now - lastBeginFrame > std::chrono::milliseconds (100)) {
+	    lastFrameRequest = frameRequest;
+	    lastBeginFrame = now;
+	    browser->GetHost ()->SendExternalBeginFrame ();
+	}
+
 	CefDoMessageLoopWork ();
 
 	const uint32_t desiredWidth = shm->desiredWidth.load (std::memory_order_relaxed);
@@ -1663,10 +1764,18 @@ void WallpaperApplication::runWebHost () {
 	    browser->GetHost ()->SetAudioMuted (lastAudioMuted);
 	}
 
+	bridge.update ();
+
 	CefMouseEvent evt;
 	evt.x = static_cast<int> (shm->mouseX.load (std::memory_order_relaxed));
 	evt.y = static_cast<int> (shm->mouseY.load (std::memory_order_relaxed));
-	browser->GetHost ()->SendMouseMoveEvent (evt, false);
+
+	// every event wakes the page's input handling, so only tell it when the pointer actually moved
+	if (evt.x != lastMouseX || evt.y != lastMouseY) {
+	    lastMouseX = evt.x;
+	    lastMouseY = evt.y;
+	    browser->GetHost ()->SendMouseMoveEvent (evt, false);
+	}
 
 	const auto left = static_cast<Input::MouseClickStatus> (shm->leftClick.load (std::memory_order_relaxed));
 	const auto right = static_cast<Input::MouseClickStatus> (shm->rightClick.load (std::memory_order_relaxed));
@@ -1685,7 +1794,9 @@ void WallpaperApplication::runWebHost () {
 	    lastRight = right;
 	}
 
-	std::this_thread::sleep_for (std::chrono::milliseconds (4));
+	// Short, this is also how often input and audio get a chance to be handed to the page and how soon a painted
+	// frame gets picked up. A frame request from the main process cuts the wait short.
+	shm->waitForFrameRequest (lastFrameRequest, 1);
     }
 
     browser->GetHost ()->CloseBrowser (true);
@@ -1737,8 +1848,8 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 
 	glFinish ();
 
-	const int readWidth = wallpaper->getWidth ();
-	const int readHeight = wallpaper->getHeight ();
+	const int readWidth = wallpaper->getCanvasWidth ();
+	const int readHeight = wallpaper->getCanvasHeight ();
 	const auto bufferSize = readWidth * readHeight * 3;
 	auto* buffer = new uint8_t[bufferSize];
 
@@ -2012,8 +2123,8 @@ void WallpaperApplication::setup () {
 	sLog.exception ("Demo mode only supports one background");
     }
 
-    int width = this->m_renderContext->getWallpapers ().begin ()->second->getWidth ();
-    int height = this->m_renderContext->getWallpapers ().begin ()->second->getHeight ();
+    int width = this->m_renderContext->getWallpapers ().begin ()->second->getCanvasWidth ();
+    int height = this->m_renderContext->getWallpapers ().begin ()->second->getCanvasHeight ();
     std::vector<uint8_t> pixels (width * height * 3);
     bool initialized = false;
     int frame = 0;
@@ -2072,8 +2183,8 @@ void WallpaperApplication::render () {
 	// wait a full render cycle before starting, giving video/web decoders time to set up
 	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 ();
+		width = this->m_renderContext->getWallpapers ().begin ()->second->getCanvasWidth ();
+		height = this->m_renderContext->getWallpapers ().begin ()->second->getCanvasHeight ();
 		pixels.reserve (width * height * 3);
 		init_encoder ("output.webm", width, height);
 		initialized = true;

+ 4 - 1
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -67,7 +67,9 @@ private:
     /** overlay is an extra folder mounted after bg, used for preset-owned files (e.g. splat data) */
     AssetLocatorUniquePtr setupAssetLocator (const std::string& bg, const std::filesystem::path& overlay = {}) const;
     ProjectSource openProjectSource (const std::string& path) const;
-    static void applyPreset (const Project& project, const WallpaperEngine::Data::JSON::JSON& preset);
+    static void applyPreset (
+	const Project& project, const WallpaperEngine::Data::JSON::JSON& preset, const std::filesystem::path& presetDir
+    );
     void initializeSubsystems ();
     void loadBackgrounds ();
     [[nodiscard]] ProjectUniquePtr loadBackground (const std::string& bg);
@@ -129,6 +131,7 @@ private:
      * without touching the loaded projects. volume is 0-128, matching --volume.
      */
     void applyVolumeHotswap (int volume);
+    void applyFpsHotswap (int fps);
 
     /**
      * Pushes a full-xray toggle live to the renderer, without touching the loaded projects.

+ 48 - 9
src/WallpaperEngine/Audio/AudioStream.cpp

@@ -31,14 +31,15 @@ int audio_read_thread (void* arg) {
 	ret = av_read_frame (stream->getFormatContext (), packet);
 
 	if (ret == AVERROR_EOF) {
-	    avformat_seek_file (stream->getFormatContext (), stream->getAudioStream (), 0, 0, 0, ~AVSEEK_FLAG_FRAME);
-	    avcodec_flush_buffers (stream->getContext ());
-
-	    // ensure the thread is not killed if audio has to be looped
-	    if (stream->isRepeat ()) {
-		ret = 0;
+	    // a one-shot stream is done, rewinding/flushing would only race the decoder still draining the queue
+	    if (!stream->isRepeat ()) {
+		break;
 	    }
 
+	    avformat_seek_file (stream->getFormatContext (), stream->getAudioStream (), 0, 0, 0, ~AVSEEK_FLAG_FRAME);
+	    stream->flushCodec ();
+	    ret = 0;
+
 	    continue;
 	}
 
@@ -51,6 +52,8 @@ int audio_read_thread (void* arg) {
     }
 
     SDL_DestroyMutex (waitMutex);
+    av_packet_free (&packet);
+    stream->markReaderFinished ();
 
     return 0;
 }
@@ -162,6 +165,10 @@ AudioStream::~AudioStream () {
 
     delete this->m_queue;
 
+    if (this->m_codecMutex != nullptr) {
+	SDL_DestroyMutex (this->m_codecMutex);
+    }
+
     if (this->m_formatContext != nullptr) {
 	avformat_free_context (this->m_formatContext);
     }
@@ -275,6 +282,8 @@ void AudioStream::initialize () {
 	sLog.exception ("Failed to initialize the resampling context.");
     }
 
+    this->m_codecMutex = SDL_CreateMutex ();
+
     this->m_queue->mutex = SDL_CreateMutex ();
     this->m_queue->cond = SDL_CreateCond ();
     this->m_queue->wait = SDL_CreateCond ();
@@ -337,8 +346,23 @@ bool AudioStream::doQueue (AVPacket* pkt) {
     return true;
 }
 
-void AudioStream::dequeuePacket () {
+void AudioStream::flushCodec () {
+    SDL_LockMutex (this->m_codecMutex);
+    avcodec_flush_buffers (this->m_context);
+    SDL_UnlockMutex (this->m_codecMutex);
+}
+
+void AudioStream::markReaderFinished () {
+    this->m_readerFinished = true;
+
+    SDL_LockMutex (this->m_queue->mutex);
+    SDL_CondSignal (this->m_queue->cond);
+    SDL_UnlockMutex (this->m_queue->mutex);
+}
+
+bool AudioStream::dequeuePacket () {
     MyAVPacketList entry {};
+    bool gotPacket = false;
 
     SDL_LockMutex (this->m_queue->mutex);
 
@@ -361,13 +385,21 @@ void AudioStream::dequeuePacket () {
 
 	    av_packet_move_ref (this->m_decodePacket, entry.packet);
 	    av_packet_free (&entry.packet);
+	    gotPacket = true;
+	    break;
+	}
+
+	// the reader is gone and the queue is drained, nobody will ever signal us again
+	if (this->m_readerFinished || !this->m_initialized) {
 	    break;
 	}
 
-	SDL_CondWait (this->m_queue->cond, this->m_queue->mutex);
+	SDL_CondWaitTimeout (this->m_queue->cond, this->m_queue->mutex, 20);
     }
 
     SDL_UnlockMutex (this->m_queue->mutex);
+
+    return gotPacket;
 }
 
 AVCodecContext* AudioStream::getContext () const { return this->m_context; }
@@ -527,6 +559,8 @@ int AudioStream::decodeFrame (uint8_t* audioBuffer, const int bufferSize) {
     while (this->m_audioContext.getApplicationContext ().state.general.keepRunning) {
 	while (this->m_audioPacketSize > 0 && this->m_audioContext.getApplicationContext ().state.general.keepRunning) {
 	    int got_frame = 0;
+
+	    SDL_LockMutex (this->m_codecMutex);
 	    int ret = avcodec_receive_frame (this->getContext (), this->m_decodeFrame);
 
 	    if (ret == 0) {
@@ -538,6 +572,8 @@ int AudioStream::decodeFrame (uint8_t* audioBuffer, const int bufferSize) {
 	    if (ret == 0) {
 		ret = avcodec_send_packet (this->getContext (), this->m_decodePacket);
 	    }
+	    SDL_UnlockMutex (this->m_codecMutex);
+
 	    if (ret < 0 && ret != AVERROR (EAGAIN)) {
 		return -1;
 	    }
@@ -565,7 +601,10 @@ int AudioStream::decodeFrame (uint8_t* audioBuffer, const int bufferSize) {
 	    av_packet_unref (this->m_decodePacket);
 	}
 
-	this->dequeuePacket ();
+	if (!this->dequeuePacket ()) {
+	    this->m_audioPacketSize = 0;
+	    return -1;
+	}
 
 	this->m_audioPacketSize = this->m_decodePacket->size;
     }

+ 20 - 3
src/WallpaperEngine/Audio/AudioStream.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <atomic>
 #include <string>
 
 extern "C" {
@@ -48,11 +49,23 @@ public:
     /**
      * Gets the next packet in the queue
      *
-     * WARNING: BLOCKS UNTIL SOME DATA IS READ FROM IT
+     * WARNING: BLOCKS UNTIL SOME DATA IS READ FROM IT, unless the file reader is done (a non-repeating
+     * stream that reached its end) or the stream was stopped
      *
-     * @return
+     * @return true if a packet was fetched, false if there is nothing left to play
+     */
+    bool dequeuePacket ();
+
+    /**
+     * Called by the file reader thread when it will not queue any more packets
+     */
+    void markReaderFinished ();
+
+    /**
+     * Flushes the decoder's internal state (called by the reader thread when a repeating stream loops).
+     * Locks against decodeFrame() so the reset can't land mid-decode on another thread.
      */
-    void dequeuePacket ();
+    void flushCodec ();
 
     /**
      * @return The audio context in use for this audio stream
@@ -165,6 +178,8 @@ private:
     bool m_initialized = false;
     /** Repeat enabled? */
     bool m_repeat = false;
+    /** Set once the reader thread has exited, so nothing waits for packets that will never come */
+    std::atomic<bool> m_readerFinished = false;
     /** The codec context that contains the original audio format information */
     AVCodecContext* m_context = nullptr;
     /** The format context that controls how data is read off the file */
@@ -184,6 +199,8 @@ private:
     AVFrame* m_decodeFrame = nullptr;
     /** Bytes left to decode from m_decodePacket, carried between decodeFrame() calls */
     int m_audioPacketSize = 0;
+    /** Guards m_context: avcodec_flush_buffers() on the reader thread races send/receive on the decode thread otherwise */
+    SDL_mutex* m_codecMutex = nullptr;
 
     /**
      * Packet queue information

+ 26 - 1
src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.cpp

@@ -1,6 +1,31 @@
 #include "PlaybackRecorder.h"
 
+#include <ranges>
+
 namespace WallpaperEngine::Audio::Drivers::Recorders {
 void PlaybackRecorder::update () { }
 
-} // namespace WallpaperEngine::Audio::Drivers::Recorders
+int PlaybackRecorder::addSpectrumListener (SpectrumListener listener) {
+    std::lock_guard guard (this->m_listenersMutex);
+
+    const int id = ++this->m_nextListenerId;
+    this->m_listeners.emplace (id, std::move (listener));
+
+    return id;
+}
+
+void PlaybackRecorder::removeSpectrumListener (int id) {
+    std::lock_guard guard (this->m_listenersMutex);
+
+    this->m_listeners.erase (id);
+}
+
+void PlaybackRecorder::notifySpectrumListeners (const float* audio64) {
+    std::lock_guard guard (this->m_listenersMutex);
+
+    for (const auto& listener : this->m_listeners | std::views::values) {
+	listener (audio64);
+    }
+}
+
+} // namespace WallpaperEngine::Audio::Drivers::Recorders

+ 25 - 1
src/WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h

@@ -1,8 +1,15 @@
 #pragma once
 
+#include <functional>
+#include <map>
+#include <mutex>
+
 namespace WallpaperEngine::Audio::Drivers::Recorders {
 class PlaybackRecorder {
 public:
+    /** Called with the 64 band spectrum every time a fresh one has been computed, from the recorder's capture thread */
+    using SpectrumListener = std::function<void (const float* audio64)>;
+
     virtual ~PlaybackRecorder () = default;
 
     virtual void update ();
@@ -11,8 +18,25 @@ public:
     virtual void lock () const { }
     virtual void unlock () const { }
 
+    /**
+     * Registers a callback to be pushed every new spectrum instead of polling audio64 once per rendered frame,
+     * which at low --fps values adds up to a whole frame of latency. Does nothing for recorders that don't capture.
+     *
+     * @return An id for removeSpectrumListener. After that call returns the callback is guaranteed not to be running
+     */
+    int addSpectrumListener (SpectrumListener listener);
+    void removeSpectrumListener (int id);
+
     float audio16[16] = { 0 };
     float audio32[32] = { 0 };
     float audio64[64] = { 0 };
+
+protected:
+    void notifySpectrumListeners (const float* audio64);
+
+private:
+    std::mutex m_listenersMutex;
+    std::map<int, SpectrumListener> m_listeners;
+    int m_nextListenerId = 0;
 };
-} // namespace WallpaperEngine::Audio::Drivers::Recorders
+} // namespace WallpaperEngine::Audio::Drivers::Recorders

+ 24 - 9
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp

@@ -298,15 +298,28 @@ void PulseAudioPlaybackRecorder::processFrame () {
 
 	// Written directly (no smoothing here) - the wallpaper's own script already smooths this
 	// via its "smoothing" scriptproperty; an extra pass here would just double up on that.
-	bands64[band] = fmax (
-	    0.0f, fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 63.0f) * 1.0f - 0.5f)))
-	);
-	bands32[band >> 1] = fmax (
-	    0.0f, fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 31.0f) * 1.0f - 0.5f)))
-	);
-	bands16[band >> 2] = fmax (
-	    0.0f, fmin (1.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 15.0f) * 1.0f - 0.5f)))
-	);
+	bands64[band] = fmax (0.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 63.0f) * 1.0f - 0.5f)));
+	bands32[band >> 1] = fmax (0.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 31.0f) * 1.0f - 0.5f)));
+	bands16[band >> 2] = fmax (0.0f, f1 * static_cast<float> (2.0f - pow (M_E, (1.0f - band / 15.0f) * 1.0f - 0.5f)));
+    }
+
+    // The levels above are log scaled with no upper bound, and clamping them to 1 made every band of anything but
+    // quiet music sit at the top. Fit them to the loudest recent band instead, the same way for all resolutions
+    // so they stay consistent with each other.
+    const auto now = std::chrono::steady_clock::now ();
+    const float dt = std::chrono::duration<float> (now - this->m_lastFrame).count ();
+    this->m_lastFrame = now;
+
+    this->m_normalizer.update (bands64, 64, dt);
+
+    for (float& band : bands64) {
+	band = this->m_normalizer.apply (band);
+    }
+    for (float& band : bands32) {
+	band = this->m_normalizer.apply (band);
+    }
+    for (float& band : bands16) {
+	band = this->m_normalizer.apply (band);
     }
 
     this->lock ();
@@ -315,6 +328,8 @@ void PulseAudioPlaybackRecorder::processFrame () {
     memcpy (this->audio16, bands16, sizeof (bands16));
     this->unlock ();
 
+    this->notifySpectrumListeners (bands64);
+
     static int diagnosticCounter = 0;
     if (++diagnosticCounter >= 100) {
 	diagnosticCounter = 0;

+ 6 - 0
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.h

@@ -1,9 +1,11 @@
 #pragma once
 
 #include "PlaybackRecorder.h"
+#include "WallpaperEngine/Audio/SpectrumNormalizer.h"
 #include "kiss_fftr.h"
 #include <SDL.h>
 #include <atomic>
+#include <chrono>
 #include <pulse/pulseaudio.h>
 
 #define WAVE_BUFFER_SIZE 1024
@@ -42,6 +44,10 @@ private:
     pa_context* m_context;
     PulseAudioData m_captureData;
 
+    // only ever touched from the capture thread
+    WallpaperEngine::Audio::SpectrumNormalizer m_normalizer;
+    std::chrono::steady_clock::time_point m_lastFrame = std::chrono::steady_clock::now ();
+
     float m_audioFFTbuffer[WAVE_BUFFER_SIZE] = { 0.0f };
     kiss_fft_cpx m_FFTinfo[WAVE_BUFFER_SIZE / 2 + 1] = { { .r = 0.0f, .i = 0.0f } };
 

+ 4 - 4
src/WallpaperEngine/Audio/Drivers/SDLAudioDriver.cpp

@@ -144,13 +144,13 @@ void SDLAudioDriver::removeStream (int streamId) {
 }
 
 void SDLAudioDriver::setStreamVolume (int streamId, int volume) {
-    SDL_LockMutex (this->m_streamListMutex);
-
+    // no stream list lock here: every sound calls this each frame, and the audio callback holds that lock
+    // while it waits on packets for all streams, which stalled the render thread for up to ~200ms at a time.
+    // The map is only modified by addStream/removeStream on the render thread, the same
+    // thread calling this, and the callback only reads it, so the lookup is safe and the volume is atomic
     if (const auto it = this->m_streams.find (streamId); it != this->m_streams.end ()) {
 	it->second->volume.store (volume, std::memory_order_relaxed);
     }
-
-    SDL_UnlockMutex (this->m_streamListMutex);
 }
 
 const std::map<int, SDLAudioBuffer*>& SDLAudioDriver::getStreams () { return this->m_streams; }

+ 56 - 0
src/WallpaperEngine/Audio/SpectrumNormalizer.cpp

@@ -0,0 +1,56 @@
+#include "SpectrumNormalizer.h"
+
+#include <algorithm>
+#include <array>
+#include <cmath>
+#include <functional>
+
+using namespace WallpaperEngine::Audio;
+
+namespace {
+// levels are 0.35 * log10(power) scaled, so one unit is a bit under 30dB
+constexpr float WINDOW = 1.2f;
+// below this a band is never shown, capture noise and dither sit around here
+constexpr float GATE = 0.2f;
+// the smallest span that still gets stretched to fill the range, so faint sounds fill it without noise blowing up
+constexpr float MIN_SPAN = 0.35f;
+// how quickly the reference rises to a louder peak, per second (as an exponential rate)
+constexpr float ATTACK_RATE = 25.0f;
+constexpr float RELEASE_SECONDS = 3.0f;
+} // namespace
+
+void SpectrumNormalizer::update (const float* levels, std::size_t count, float dt) {
+    dt = std::clamp (dt, 0.0f, 0.25f);
+    count = std::min<std::size_t> (count, 64);
+
+    if (count == 0) {
+	return;
+    }
+
+    std::array<float, 64> sorted {};
+    std::copy_n (levels, count, sorted.begin ());
+
+    // average of the loudest fifth of the bands
+    const std::size_t top = std::max<std::size_t> (1, count / 5);
+    std::partial_sort (sorted.begin (), sorted.begin () + top, sorted.begin () + count, std::greater<> ());
+
+    float peak = 0.0f;
+    for (std::size_t i = 0; i < top; i++) {
+	peak += sorted[i];
+    }
+    peak /= static_cast<float> (top);
+
+    if (peak > this->m_reference) {
+	this->m_reference += (peak - this->m_reference) * (1.0f - std::exp (-ATTACK_RATE * dt));
+    } else {
+	// falls back to the current peak rather than to zero, so a steady level settles right at it
+	this->m_reference = peak + (this->m_reference - peak) * std::exp (-dt / RELEASE_SECONDS);
+    }
+}
+
+float SpectrumNormalizer::apply (float level) const {
+    const float high = std::max (this->m_reference, GATE + MIN_SPAN);
+    const float low = std::max (high - WINDOW, GATE);
+
+    return std::clamp ((level - low) / (high - low), 0.0f, 1.0f);
+}

+ 24 - 0
src/WallpaperEngine/Audio/SpectrumNormalizer.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <cstddef>
+
+namespace WallpaperEngine::Audio {
+/**
+ * Turns the recorder's unbounded log scaled band levels into 0-1 values that keep the shape of the spectrum at any
+ * volume. The top of the range follows the loudest fifth of the bands (rising quickly, falling back over a few
+ * seconds), and levels below a gate are never shown so silence and capture noise stay flat.
+ */
+class SpectrumNormalizer {
+public:
+    /** Feeds the band levels of a frame (at most 64), dt being the time since the previous one */
+    void update (const float* levels, std::size_t count, float dt);
+
+    /** Maps one band level of the frame last given to update() into 0-1 */
+    [[nodiscard]] float apply (float level) const;
+
+    [[nodiscard]] float getReference () const { return this->m_reference; }
+
+private:
+    float m_reference = 0.0f;
+};
+} // namespace WallpaperEngine::Audio

+ 28 - 2
src/WallpaperEngine/Data/Builders/VectorBuilder.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <algorithm>
 #include <cstring>
 #include <glm/detail/qualifier.hpp>
 #include <glm/detail/type_vec1.hpp>
@@ -15,12 +16,35 @@ class VectorBuilder {
     /** Calls the proper std::strto* function based on the incoming type */
     template <typename type> static type convert (const char* str);
 
+    /** Some scenes write vectors as "x,y,z" or pad them with extra spaces ("x  y"), reduce them to "x y z" */
+    static std::string normalizeSeparators (const std::string& str) {
+	std::string result;
+	result.reserve (str.size ());
+
+	for (const char c : str) {
+	    const bool separator = c == ',' || c == ' ' || c == '\t';
+
+	    if (!separator) {
+		result += c;
+	    } else if (!result.empty () && result.back () != ' ') {
+		result += ' ';
+	    }
+	}
+
+	if (!result.empty () && result.back () == ' ') {
+	    result.pop_back ();
+	}
+
+	return result;
+    }
+
 public:
     /**
      * Returns the vector size (1-4) encoded in a space-separated string.
      * TODO: move/rename, doesn't really belong here
      */
-    static int preparseSize (const std::string& str) {
+    static int preparseSize (const std::string& input) {
+	const auto str = normalizeSeparators (input);
 	const char* p = str.c_str ();
 	const char* first = strchr (p, ' ');
 	const char* second = first ? strchr (first + 1, ' ') : nullptr;
@@ -43,9 +67,11 @@ public:
 
     /** Parses a space-separated string into a glm::vec using std::strto* functions */
     template <int length, typename type, glm::qualifier qualifier>
-    [[nodiscard]] static glm::vec<length, type, qualifier> parse (const std::string& str) {
+    [[nodiscard]] static glm::vec<length, type, qualifier> parse (const std::string& input) {
 	static_assert (length >= 1 && length <= 4, "Invalid vector length");
 
+	const auto str = normalizeSeparators (input);
+
 	const char* p = str.c_str ();
 
 	const char* first = strchr (p, ' ');

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

@@ -527,7 +527,7 @@ struct ParticleData {
     std::string animationMode;
     float sequenceMultiplier;
     uint32_t maxCount;
-    uint32_t startTime;
+    float startTime;
     uint32_t flags;
 
     ModelUniquePtr material;

+ 25 - 1
src/WallpaperEngine/Data/Model/Property.h

@@ -5,7 +5,9 @@
 #include "DynamicValue.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include <functional>
 #include <map>
+#include <ranges>
 #include <string>
 #include <utility>
 
@@ -112,6 +114,19 @@ public:
 	this->PropertyCombo::update (value, UpdateSource::Initialization);
     }
 
+    /** Replaces every option label, used to swap the localization keys projects store for readable text */
+    void relabel (const std::function<std::string (const std::string&)>& translate) {
+	for (auto& label : this->values | std::views::values) {
+	    label = translate (label);
+	}
+    }
+
+    /**
+     * Presets can carry combo values the wallpaper never listed as an option (a 30 second slideshow interval stored
+     * as 0.5, say), Wallpaper Engine hands those to the page as they are so this makes room for one
+     */
+    void allowValue (const std::string& value) { this->values.emplace (value, value); }
+
     using Property::update;
     void update (const std::string& value, UpdateSource source) override {
 	if (this->values.contains (value) == false) {
@@ -182,10 +197,17 @@ private:
 
 class PropertyFile final : public Property {
 public:
-    explicit PropertyFile (PropertyData data, const std::string& value) : Property (std::move (data)) {
+    /** fileType is what the project restricts the property to ("video", or empty for images), only relevant for directories */
+    explicit PropertyFile (
+	PropertyData data, const std::string& value, bool directory = false, std::string fileType = ""
+    ) : Property (std::move (data)), m_directory (directory), m_fileType (std::move (fileType)) {
 	this->PropertyFile::update (value, UpdateSource::Initialization);
     }
 
+    /** Directories are handed to web wallpapers as a list of the files inside, files as the path itself */
+    [[nodiscard]] bool isDirectory () const { return this->m_directory; }
+    [[nodiscard]] const std::string& getFileType () const { return this->m_fileType; }
+
     void update (const std::string& value, UpdateSource source) override { this->DynamicValue::update (value, source); }
 
     [[nodiscard]] std::string dump () const override {
@@ -199,6 +221,8 @@ public:
     }
 
 private:
+    bool m_directory;
+    std::string m_fileType;
     std::string m_value;
 };
 

+ 177 - 0
src/WallpaperEngine/Data/Parsers/Localization.cpp

@@ -0,0 +1,177 @@
+#include "Localization.h"
+
+#include <algorithm>
+#include <cctype>
+#include <cstdlib>
+#include <regex>
+
+using namespace WallpaperEngine::Data::Parsers;
+
+namespace {
+std::string lower (std::string value) {
+    std::ranges::transform (value, value.begin (), [] (unsigned char c) { return std::tolower (c); });
+
+    return value;
+}
+
+void replaceAll (std::string& text, const std::string& from, const std::string& to) {
+    for (std::size_t at = text.find (from); at != std::string::npos; at = text.find (from, at + to.size ())) {
+	text.replace (at, from.size (), to);
+    }
+}
+} // namespace
+
+Localization::Localization (const std::optional<JSON>& general, std::string locale) {
+    if (general.has_value ()) {
+	if (const auto localization = general->optional ("localization"); localization.has_value ()) {
+	    for (const auto& language : localization->items ()) {
+		if (!language.value ().is_object ()) {
+		    continue;
+		}
+
+		Table table;
+
+		for (const auto& entry : language.value ().items ()) {
+		    if (entry.value ().is_string ()) {
+			table.emplace (entry.key (), entry.value ().template get<std::string> ());
+		    }
+		}
+
+		this->m_tables.emplace (lower (language.key ()), std::move (table));
+	    }
+	}
+    }
+
+    if (this->m_tables.empty ()) {
+	return;
+    }
+
+    locale = lower (locale);
+    const std::string language = locale.substr (0, locale.find ('-'));
+
+    // the exact language, then any variant of it (zh-chs for zh_CN), then English, then whatever the project has
+    const auto exact = this->m_tables.find (locale);
+    const auto variant = std::ranges::find_if (this->m_tables, [&] (const auto& entry) {
+	return entry.first.starts_with (language + "-") || entry.first == language;
+    });
+    const auto english = this->m_tables.find ("en-us");
+    const auto anyEnglish = std::ranges::find_if (this->m_tables, [] (const auto& entry) {
+	return entry.first.starts_with ("en");
+    });
+
+    if (english != this->m_tables.end ()) {
+	this->m_english = &english->second;
+    } else if (anyEnglish != this->m_tables.end ()) {
+	this->m_english = &anyEnglish->second;
+    }
+
+    if (exact != this->m_tables.end ()) {
+	this->m_preferred = &exact->second;
+    } else if (variant != this->m_tables.end ()) {
+	this->m_preferred = &variant->second;
+    } else if (this->m_english != nullptr) {
+	this->m_preferred = this->m_english;
+    } else {
+	this->m_preferred = &this->m_tables.begin ()->second;
+    }
+
+    if (this->m_english == nullptr) {
+	this->m_english = this->m_preferred;
+    }
+}
+
+std::string Localization::resolve (const std::string& text) const {
+    if (text.empty ()) {
+	return "";
+    }
+
+    for (const Table* table : { this->m_preferred, this->m_english }) {
+	if (table == nullptr) {
+	    continue;
+	}
+
+	if (const auto found = table->find (text); found != table->end ()) {
+	    if (const auto cleaned = clean (found->second); !cleaned.empty ()) {
+		return cleaned;
+	    }
+	}
+    }
+
+    std::string cleaned = clean (text);
+
+    // a key nobody translated: "ui_music_type_0" reads better as "Music type 0" than as it is
+    if (cleaned.starts_with ("ui_") && cleaned.find (' ') == std::string::npos) {
+	cleaned = cleaned.substr (3);
+	std::ranges::replace (cleaned, '_', ' ');
+
+	if (!cleaned.empty ()) {
+	    cleaned[0] = static_cast<char> (std::toupper (static_cast<unsigned char> (cleaned[0])));
+	}
+    }
+
+    return cleaned;
+}
+
+std::string Localization::clean (const std::string& text) {
+    static const std::regex smallPrint (R"(<small[^>]*>.*?</small>)", std::regex::icase);
+    static const std::regex lineBreak (R"(<br\s*/?>)", std::regex::icase);
+    static const std::regex tag (R"(<[^>]*>)");
+
+    std::string result = std::regex_replace (text, smallPrint, "");
+    result = std::regex_replace (result, lineBreak, " ");
+    result = std::regex_replace (result, tag, " ");
+
+    replaceAll (result, "&nbsp;", " ");
+    replaceAll (result, "&lt;", "<");
+    replaceAll (result, "&gt;", ">");
+    replaceAll (result, "&quot;", "\"");
+    replaceAll (result, "&#39;", "'");
+    replaceAll (result, "&amp;", "&");
+
+    // collapse whitespace, including the newlines some strings carry
+    std::string collapsed;
+    bool space = true;
+
+    for (const char c : result) {
+	if (std::isspace (static_cast<unsigned char> (c))) {
+	    if (!space) {
+		collapsed += ' ';
+	    }
+
+	    space = true;
+	} else {
+	    collapsed += c;
+	    space = false;
+	}
+    }
+
+    if (!collapsed.empty () && collapsed.back () == ' ') {
+	collapsed.pop_back ();
+    }
+
+    return collapsed;
+}
+
+std::string Localization::systemLocale () {
+    for (const char* variable : { "LC_ALL", "LC_MESSAGES", "LANG" }) {
+	const char* value = std::getenv (variable);
+
+	if (value == nullptr || *value == '\0') {
+	    continue;
+	}
+
+	std::string locale = value;
+
+	if (locale == "C" || locale == "POSIX") {
+	    break;
+	}
+
+	// "de_DE.UTF-8@euro" -> "de-de"
+	locale = locale.substr (0, locale.find_first_of (".@"));
+	std::ranges::replace (locale, '_', '-');
+
+	return lower (locale);
+    }
+
+    return "en-us";
+}

+ 41 - 0
src/WallpaperEngine/Data/Parsers/Localization.h

@@ -0,0 +1,41 @@
+#pragma once
+
+#include <map>
+#include <optional>
+#include <string>
+
+#include "WallpaperEngine/Data/JSON.h"
+
+namespace WallpaperEngine::Data::Parsers {
+using JSON = WallpaperEngine::Data::JSON::JSON;
+
+/**
+ * Resolves the texts of a project's properties. Workshop projects only store keys like "ui_background_mode_img" in
+ * their properties and keep the actual strings, one table per language, under general.localization.
+ */
+class Localization {
+public:
+    /** locale is something like "de-de", by default the one the user's environment asks for */
+    explicit Localization (const std::optional<JSON>& general, std::string locale = systemLocale ());
+
+    /**
+     * @return The readable text for a property text or option label: looked up in the user's language, then in
+     * English, with the markup Wallpaper Engine's own UI renders (<small>, <hr>...) removed. Keys that are in no table
+     * are made readable as far as that's possible.
+     */
+    [[nodiscard]] std::string resolve (const std::string& text) const;
+
+    /** Drops markup and the parts meant as small print, and tidies whitespace */
+    [[nodiscard]] static std::string clean (const std::string& text);
+
+    /** The locale of the environment (LC_ALL, LC_MESSAGES, LANG) in the form the tables use, "en-us" if unset */
+    [[nodiscard]] static std::string systemLocale ();
+
+private:
+    using Table = std::map<std::string, std::string>;
+
+    std::map<std::string, Table> m_tables;
+    const Table* m_preferred = nullptr;
+    const Table* m_english = nullptr;
+};
+} // namespace WallpaperEngine::Data::Parsers

+ 11 - 4
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -319,7 +319,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		    .animationMode = "sequence",
 		    .sequenceMultiplier = 1.0f,
 		    .maxCount = 100,
-		    .startTime = 0,
+		    .startTime = 0.0f,
 		    .flags = 0,
 		    .material = nullptr,
 		    .emitters = {},
@@ -487,7 +487,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 
 	float sequenceMultiplier = 1.0f;
 	uint32_t maxCount = 100;
-	uint32_t startTime = 0;
+	float startTime = 0.0f;
 	uint32_t flags = 0;
 
 	const auto seqMultIt = particleJson.find ("sequencemultiplier");
@@ -502,7 +502,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 
 	const auto startTimeIt = particleJson.find ("starttime");
 	if (startTimeIt != particleJson.end () && startTimeIt->is_number ()) {
-	    startTime = startTimeIt->get<uint32_t> ();
+	    startTime = startTimeIt->get<float> ();
 	}
 
 	const auto flagsIt = particleJson.find ("flags");
@@ -867,7 +867,7 @@ ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& p
 }
 
 ParticleInstanceOverride ObjectParser::parseParticleInstanceOverride (const JSON& it, const Properties& properties) {
-    return ParticleInstanceOverride {
+    auto result = ParticleInstanceOverride {
 	.enabled = it.user ("enabled", properties, true),
 	.alpha = it.user ("alpha", properties, 1.0f),
 	.size = it.user ("size", properties, 1.0f),
@@ -878,4 +878,11 @@ ParticleInstanceOverride ObjectParser::parseParticleInstanceOverride (const JSON
 	.color = it.user ("color", properties, glm::vec3 (1.0f)),
 	.colorn = it.user ("colorn", properties, glm::vec3 (1.0f)),
     };
+
+    // WE converts the legacy 0-255 "color" into colorn on load (replacing any colorn) and only reads colorn after
+    if (it.optional ("color").has_value ()) {
+	result.colorn = Builders::UserSettingBuilder::fromValue (result.color->value->getVec3 () / 255.0f);
+    }
+
+    return result;
 }

+ 9 - 0
src/WallpaperEngine/Data/Parsers/ProjectParser.cpp

@@ -5,7 +5,9 @@
 
 #include "WallpaperParser.h"
 
+#include "Localization.h"
 #include "PropertyParser.h"
+#include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/FileSystem/Container.h"
 
@@ -73,6 +75,7 @@ Properties ProjectParser::parseProperties (const std::optional<JSON>& data) {
     }
 
     Properties result = {};
+    const Localization localization (data);
 
     for (const auto& cur : properties.value ().items ()) {
 	const auto& property = PropertyParser::parse (cur.value (), cur.key ());
@@ -82,6 +85,12 @@ Properties ProjectParser::parseProperties (const std::optional<JSON>& data) {
 	    continue;
 	}
 
+	property->text = localization.resolve (property->text);
+
+	if (auto* combo = dynamic_cast<PropertyCombo*> (property.get ()); combo != nullptr) {
+	    combo->relabel ([&localization] (const std::string& label) { return localization.resolve (label); });
+	}
+
 	result.emplace (cur.key (), property);
     }
 

+ 7 - 4
src/WallpaperEngine/Data/Parsers/PropertyParser.cpp

@@ -20,7 +20,8 @@ PropertySharedPtr PropertyParser::parse (const JSON& it, const std::string& name
     if (type == "combo") {
 	return parseCombo (it, name);
     }
-    if (type == "text") {
+    // "txt", "test" and "label" are what some workshop items call their UI-only description lines
+    if (type == "text" || type == "txt" || type == "test" || type == "label") {
 	return parseText (it, name);
     }
     if (type == "scenetexture") {
@@ -136,18 +137,20 @@ PropertySharedPtr PropertyParser::parseFile (const JSON& it, const std::string&
 	    .name = name,
 	    .text = it.optional<std::string> ("text", ""),
 	},
-	it.optional<std::string> ("value", "")
+	it.optional<std::string> ("value", ""),
+	it.optional<std::string> ("type", "") == "directory",
+	it.optional<std::string> ("fileType", "")
     );
 }
 
 PropertySharedPtr PropertyParser::parseTextInput (const JSON& it, const std::string& name) {
-    const auto value = it.require ("value", "Property must have a value");
+    const auto value = it.optional ("value");
 
     return std::make_shared<PropertyTextInput> (
 	PropertyData {
 	    .name = name,
 	    .text = it.optional<std::string> ("text", ""),
 	},
-	value.is_string () ? value.get<std::string> () : value.dump ()
+	!value.has_value () ? "" : value->is_string () ? value->get<std::string> () : value->dump ()
     );
 }

+ 19 - 1
src/WallpaperEngine/Input/Drivers/KDECursorInput.cpp

@@ -244,7 +244,25 @@ bool KDECursorInput::handleMethodCall (DBusMessage* message) {
 
 std::optional<glm::dvec2> KDECursorInput::position () {
     if (m_connection != nullptr) {
-	if (!dbus_connection_read_write_dispatch (m_connection, 0)) {
+	// KWin sends one call per cursor move, far more often than frames are drawn;
+	// read_write_dispatch only handles a single message and a read pulls in ~2KB at most,
+	// so keep reading and dispatching until the socket has nothing left
+	bool connected = true;
+	for (int reads = 0; connected && reads < 64; reads++) {
+	    connected = dbus_connection_read_write (m_connection, 0);
+
+	    bool dispatched = false;
+	    while (connected && dbus_connection_get_dispatch_status (m_connection) == DBUS_DISPATCH_DATA_REMAINS) {
+		dbus_connection_dispatch (m_connection);
+		dispatched = true;
+	    }
+
+	    if (!dispatched) {
+		break;
+	    }
+	}
+
+	if (!connected) {
 	    sLog.error ("KDE cursor input: DBus connection dropped unexpectedly");
 	    m_scriptLoaded = false;
 	    dbus_connection_unregister_object_path (m_connection, kObjectPath);

+ 19 - 0
src/WallpaperEngine/Media/ThumbnailPalette.cpp

@@ -1,5 +1,7 @@
 #include "ThumbnailPalette.h"
 
+#include "stb_image.h"
+
 #include <algorithm>
 #include <array>
 #include <cmath>
@@ -127,3 +129,20 @@ ThumbnailPalette WallpaperEngine::Media::computeThumbnailPalette (const uint8_t*
 
     return palette;
 }
+
+
+ThumbnailPalette WallpaperEngine::Media::loadThumbnailPalette (const std::string& path) {
+    int width = 0;
+    int height = 0;
+    int channels = 0;
+    auto* pixels = stbi_load (path.c_str (), &width, &height, &channels, 4);
+
+    if (pixels == nullptr) {
+	return {};
+    }
+
+    const auto palette = computeThumbnailPalette (pixels, static_cast<size_t> (width), static_cast<size_t> (height));
+    stbi_image_free (pixels);
+
+    return palette;
+}

+ 6 - 0
src/WallpaperEngine/Media/ThumbnailPalette.h

@@ -2,6 +2,7 @@
 
 #include <cstddef>
 #include <cstdint>
+#include <string>
 
 #include <glm/vec3.hpp>
 #include <glm/geometric.hpp>
@@ -25,4 +26,9 @@ struct ThumbnailPalette {
  * from each other, most common first.
  */
 ThumbnailPalette computeThumbnailPalette (const uint8_t* rgba, size_t width, size_t height);
+
+/**
+ * Decodes the image at the given path and returns its palette, or the default one if it cannot be read.
+ */
+ThumbnailPalette loadThumbnailPalette (const std::string& path);
 } // namespace WallpaperEngine::Media

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

@@ -44,7 +44,7 @@ CTexture::CTexture (RenderContext& context, TextureUniquePtr header) :
 	);
 	this->m_player->setMuted ();
 	this->m_player->setVolume (0.0f);
-	this->m_player->setUntimed ();
+	this->m_player->disableAudio ();
 	return;
     }
 

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

@@ -46,6 +46,7 @@ public:
     void decrementUsageCount () const override;
     void update () const override;
     bool isReady () const override;
+    [[nodiscard]] GLPlayer* getPlayer () const override { return this->m_player.get (); }
 
 private:
     [[nodiscard]] const Texture& getHeader () const;

+ 4 - 4
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -172,8 +172,8 @@ const CWallpaper::SpanInfo* CWallpaper::getSpanInfo () const {
 }
 
 void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
-    if (this->m_state.hasChanged (viewport, vflip, this->getWidth (), this->getHeight ())) {
-	this->m_state.updateState (viewport, vflip, this->getWidth (), this->getHeight ());
+    if (this->m_state.hasChanged (viewport, vflip, this->getCanvasWidth (), this->getCanvasHeight ())) {
+	this->m_state.updateState (viewport, vflip, this->getCanvasWidth (), this->getCanvasHeight ());
     }
 }
 
@@ -302,8 +302,8 @@ void CWallpaper::setPause (bool newState) { }
 void CWallpaper::setAudioPolicy (bool muted, std::optional<int> ambientVolume) { }
 
 void CWallpaper::setupFramebuffers () {
-    const uint32_t width = this->getWidth ();
-    const uint32_t height = this->getHeight ();
+    const uint32_t width = this->getCanvasWidth ();
+    const uint32_t height = this->getCanvasHeight ();
     const uint32_t clamp = this->m_state.getClampingMode ();
 
     this->m_sceneFBO = this->create (

+ 3 - 0
src/WallpaperEngine/Render/CWallpaper.h

@@ -92,6 +92,9 @@ public:
 
     [[nodiscard]] virtual int getWidth () const = 0;
     [[nodiscard]] virtual int getHeight () const = 0;
+    /** Size of the framebuffer the wallpaper renders into, only a scene can make this bigger than getWidth/getHeight */
+    [[nodiscard]] virtual int getCanvasWidth () const { return this->getWidth (); }
+    [[nodiscard]] virtual int getCanvasHeight () const { return this->getHeight (); }
 
     // maxRenderSize is only meaningful for Web wallpapers (see CWeb) - the largest resolution this
     // wallpaper instance will ever be asked to render at: a single screen's size normally, or a span

+ 13 - 2
src/WallpaperEngine/Render/Camera.cpp

@@ -32,20 +32,31 @@ float Camera::getWidth () const { return this->m_width; }
 
 float Camera::getHeight () const { return this->m_height; }
 
+float Camera::getCanvasWidth () const { return this->m_canvasWidth; }
+
+float Camera::getCanvasHeight () const { return this->m_canvasHeight; }
+
 float Camera::getFov () const { return this->m_camera.projection.fov->value->getFloat (); }
 
 float Camera::getNearZ () const { return this->m_camera.projection.nearz->value->getFloat (); }
 
 float Camera::getFarZ () const { return this->m_camera.projection.farz->value->getFloat (); }
 
-void Camera::setOrthogonalProjection (const float width, const float height) {
+void Camera::setOrthogonalProjection (
+    const float width, const float height, const float canvasWidth, const float canvasHeight
+) {
     this->m_width = width;
     this->m_height = height;
+    this->m_canvasWidth = canvasWidth > 0.0f ? canvasWidth : width;
+    this->m_canvasHeight = canvasHeight > 0.0f ? canvasHeight : height;
 
     float nearz = this->m_camera.projection.nearz->value->getFloat ();
     float farz = this->m_camera.projection.farz->value->getFloat ();
 
-    this->m_projection = glm::ortho<float> (-width / 2.0, width / 2.0, -height / 2.0, height / 2.0, nearz, farz);
+    this->m_projection = glm::ortho<float> (
+	-this->m_canvasWidth / 2.0, this->m_canvasWidth / 2.0, -this->m_canvasHeight / 2.0, this->m_canvasHeight / 2.0,
+	nearz, farz
+    );
     this->m_projection = glm::translate (this->m_projection, this->getEye ());
     this->m_isOrthogonal = true;
 }

+ 11 - 1
src/WallpaperEngine/Render/Camera.h

@@ -19,7 +19,13 @@ public:
     Camera (Wallpapers::CScene& scene, const SceneData::Camera& camera);
     ~Camera ();
 
-    void setOrthogonalProjection (const float width, const float height);
+    /**
+     * @param width Layout width, what object positions are computed against
+     * @param height Layout height
+     * @param canvasWidth What actually gets rendered, centered on the layout (0 = same as the layout width)
+     * @param canvasHeight Same as canvasWidth, for the height
+     */
+    void setOrthogonalProjection (float width, float height, float canvasWidth = 0.0f, float canvasHeight = 0.0f);
 
     [[nodiscard]] const glm::vec3& getCenter () const;
     [[nodiscard]] const glm::vec3& getEye () const;
@@ -30,6 +36,8 @@ public:
     [[nodiscard]] bool isOrthogonal () const;
     [[nodiscard]] float getWidth () const;
     [[nodiscard]] float getHeight () const;
+    [[nodiscard]] float getCanvasWidth () const;
+    [[nodiscard]] float getCanvasHeight () const;
     [[nodiscard]] float getFov () const;
     [[nodiscard]] float getNearZ () const;
     [[nodiscard]] float getFarZ () const;
@@ -37,6 +45,8 @@ public:
 private:
     float m_width;
     float m_height;
+    float m_canvasWidth = 0.0f;
+    float m_canvasHeight = 0.0f;
     bool m_isOrthogonal = false;
     glm::mat4 m_projection = {};
     glm::mat4 m_lookat = {};

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

@@ -1,6 +1,7 @@
 #include "GLFWOpenGLDriver.h"
 #include "VideoFactories.h"
 #include "WallpaperEngine/Logging/Log.h"
+
 #include "WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.h"
 #ifdef ENABLE_X11
 #include "WallpaperEngine/Render/Drivers/Output/X11Output.h"
@@ -14,6 +15,8 @@
 #include <cstdlib>
 #include <unistd.h>
 
+#include <algorithm>
+
 using namespace WallpaperEngine::Render::Drivers;
 
 void CustomGLFWErrorHandler (int errorCode, const char* reason) { sLog.error ("GLFW error ", errorCode, ": ", reason); }
@@ -105,7 +108,9 @@ glm::ivec2 GLFWOpenGLDriver::getFramebufferSize () const {
 uint32_t GLFWOpenGLDriver::getFrameCounter () const { return this->m_frameCounter; }
 
 void GLFWOpenGLDriver::dispatchEventQueue () {
-    static float startTime, endTime, minimumTime = 1.0f / this->m_context.settings.render.maximumFPS;
+    static float startTime, endTime;
+    // read every frame, --fps can change with a hotswap
+    const float minimumTime = 1.0f / std::max (1, this->m_context.settings.render.maximumFPS);
     startTime = this->getRenderTime ();
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
@@ -151,6 +156,14 @@ void* GLFWOpenGLDriver::getProcAddress (const char* name) const {
     return reinterpret_cast<void*> (glfwGetProcAddress (name));
 }
 
+void* GLFWOpenGLDriver::getX11Display () const {
+    if (glfwGetPlatform () != GLFW_PLATFORM_X11) {
+	return nullptr;
+    }
+
+    return glfwGetX11Display ();
+}
+
 GLFWwindow* GLFWOpenGLDriver::getWindow () const { return this->m_window; }
 
 __attribute__ ((constructor)) void registerGLFWOpenGLDriver () {

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

@@ -32,6 +32,7 @@ public:
     [[nodiscard]] uint32_t getFrameCounter () const override;
     void dispatchEventQueue () override;
     [[nodiscard]] void* getProcAddress (const char* name) const override;
+    [[nodiscard]] void* getX11Display () const override;
 
     GLFWwindow* getWindow () const;
 

+ 4 - 0
src/WallpaperEngine/Render/Drivers/VideoDriver.h

@@ -36,6 +36,10 @@ public:
     [[nodiscard]] virtual glm::ivec2 getFramebufferSize () const = 0;
     [[nodiscard]] virtual uint32_t getFrameCounter () const = 0;
     [[nodiscard]] virtual void* getProcAddress (const char* name) const = 0;
+    /** The wl_display, if rendering goes through Wayland (mpv needs it for zero-copy hwdec) */
+    [[nodiscard]] virtual void* getWaylandDisplay () const { return nullptr; }
+    /** The X11 Display, if rendering goes through X11 (mpv needs it for zero-copy hwdec) */
+    [[nodiscard]] virtual void* getX11Display () const { return nullptr; }
     virtual void dispatchEventQueue () = 0;
     [[nodiscard]] WallpaperApplication& getApp () const;
     [[nodiscard]] Input::InputContext& getInputContext ();

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

@@ -444,7 +444,9 @@ void WaylandOpenGLDriver::dispatchEventQueue () {
     // implemented, as those will likely require a different processing rate
     // TODO: write a non-blocking version of this once particle simulation starts working, otherwise
     // wl_display_dispatch will block if no surfaces are being drawn
-    static float startTime, endTime, minimumTime = 1.0f / this->m_context.settings.render.maximumFPS;
+    static float startTime, endTime;
+    // read every frame, --fps can change with a hotswap
+    const float minimumTime = 1.0f / std::max (1, this->m_context.settings.render.maximumFPS);
     startTime = this->getRenderTime ();
 
     if (wl_display_dispatch (m_waylandContext.display) == -1) {
@@ -490,6 +492,8 @@ void* WaylandOpenGLDriver::getProcAddress (const char* name) const {
     return reinterpret_cast<void*> (eglGetProcAddress (name));
 }
 
+void* WaylandOpenGLDriver::getWaylandDisplay () const { return this->m_waylandContext.display; }
+
 WaylandOpenGLDriver::WaylandContext* WaylandOpenGLDriver::getWaylandContext () { return &this->m_waylandContext; }
 
 Output::WaylandOutputViewport* WaylandOpenGLDriver::surfaceToViewport (const wl_surface* surface) const {

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

@@ -78,6 +78,7 @@ public:
     uint32_t getFrameCounter () const override;
     void dispatchEventQueue () override;
     [[nodiscard]] void* getProcAddress (const char* name) const override;
+    [[nodiscard]] void* getWaylandDisplay () const override;
 
     void onLayerClose (Output::WaylandOutputViewport*);
     Output::WaylandOutputViewport* surfaceToViewport (const wl_surface*) const;

+ 105 - 23
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -876,15 +876,22 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 
     this->detectTexture ();
 
+    const bool placeholderTexture = this->m_texture == nullptr;
+
     if (this->m_texture == nullptr) {
 	if (this->m_image.model->solidlayer && size.x == 0.0f && size.y == 0.0f) {
-	    size.x = scene_width;
-	    size.y = scene_height;
+	    size.x = static_cast<float> (scene.getCanvasWidth ());
+	    size.y = static_cast<float> (scene.getCanvasHeight ());
 	}
 	// TODO: create a dummy texture of correct size, fbo constructors should be enough, but this should be
 	// properly handled
+	// solid layers are often declared far larger than the scene, nothing samples
+	// these buffers past the canvas so only the layout size has to stay as declared
+	const glm::vec2 placeholderSize = { std::min (size.x, static_cast<float> (scene.getCanvasWidth ())),
+					    std::min (size.y, static_cast<float> (scene.getCanvasHeight ())) };
+
 	this->m_texture = std::make_shared<CFBO> (
-	    "", TextureFormat_ARGB8888, TextureFlags_NoFlags, 1, size.x, size.y, size.x, size.y
+	    "", TextureFormat_ARGB8888, TextureFlags_NoFlags, 1, size.x, size.y, placeholderSize.x, placeholderSize.y
 	);
     }
 
@@ -903,13 +910,20 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     // fullscreen layers should use the whole projection's size
     // TODO: WHAT SHOULD AUTOSIZE DO?
     if (this->getImage ().model->fullscreen) {
-	size = { scene_width, scene_height };
+	size = { static_cast<float> (scene.getCanvasWidth ()), static_cast<float> (scene.getCanvasHeight ()) };
 	origin = { scene_width / 2, scene_height / 2, 0 };
 
 	// TODO: CHANGE ALIGNMENT TOO?
     }
     this->m_size = size;
 
+    // taken after the texture/model/fullscreen fallbacks above, unsized layers would otherwise get 0x0 buffers
+    glm::vec2 bufferSize = size;
+
+    if (placeholderTexture) {
+	bufferSize = glm::min (bufferSize, glm::vec2 (scene.getCanvasWidth (), scene.getCanvasHeight ()));
+    }
+
     glm::vec2 scaledSize = size * glm::vec2 (scale);
 
     this->m_pos.x = origin.x - (scaledSize.x / 2);
@@ -954,10 +968,10 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 	: this->m_texture->getFlags ();
 
     this->m_currentMainFBO = this->m_mainFBO = scene.create (
-	nameA.str (), TextureFormat_ARGB8888, compositeFlags, 1, { size.x, size.y }, { size.x, size.y }
+	nameA.str (), TextureFormat_ARGB8888, compositeFlags, 1, { bufferSize.x, bufferSize.y }, { bufferSize.x, bufferSize.y }
     );
     this->m_currentSubFBO = this->m_subFBO = scene.create (
-	nameB.str (), TextureFormat_ARGB8888, compositeFlags, 1, { size.x, size.y }, { size.x, size.y }
+	nameB.str (), TextureFormat_ARGB8888, compositeFlags, 1, { bufferSize.x, bufferSize.y }, { bufferSize.x, bufferSize.y }
     );
 
     GLfloat sceneSpacePosition[] = { this->m_pos.x, this->m_pos.y, 0.0f, this->m_pos.x, this->m_pos.w, 0.0f,
@@ -1062,6 +1076,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt ();
     // must match m_modelViewProjectionScreen - updateScreenSpacePosition() may skip recomputing it
     this->m_modelViewProjectionScreenInverse = glm::inverse (this->m_modelViewProjectionScreen);
+    this->updateEffectTextureProjection ();
 
     if (this->getImage ().model->passthrough) {
 	this->m_modelViewProjectionCopy = this->m_modelViewProjectionScreen;
@@ -1076,6 +1091,38 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     this->m_texture->incrementUsageCount ();
 }
 
+void CImage::updateTextures () const {
+    this->getTexture ()->update ();
+
+    for (const auto* pass : this->m_passes) {
+	pass->updatePlaybackTextures ();
+    }
+}
+
+bool CImage::containsScenePoint (const glm::vec2& point) const {
+    // m_pos is stored centered on the scene with y pointing down, x/z are left/right and y/w bottom/top
+    const float x = point.x - static_cast<float> (this->getScene ().getWidth ()) / 2.0f;
+    const float y = static_cast<float> (this->getScene ().getHeight ()) / 2.0f - point.y;
+
+    return x >= std::min (this->m_pos.x, this->m_pos.z) && x <= std::max (this->m_pos.x, this->m_pos.z)
+	&& y >= std::min (this->m_pos.y, this->m_pos.w) && y <= std::max (this->m_pos.y, this->m_pos.w);
+}
+
+glm::vec2 CImage::getSceneCenter () const {
+    return { (this->m_pos.x + this->m_pos.z) / 2.0f + static_cast<float> (this->getScene ().getWidth ()) / 2.0f,
+	     static_cast<float> (this->getScene ().getHeight ()) / 2.0f - (this->m_pos.y + this->m_pos.w) / 2.0f };
+}
+
+void CImage::refreshScenePosition () {
+    const auto sceneWidth = static_cast<float> (this->getScene ().getWidth ());
+    const auto sceneHeight = static_cast<float> (this->getScene ().getHeight ());
+    const auto transform = this->resolveTransform (this->getImage ());
+    glm::vec3 origin = transform.origin;
+    const glm::vec2 size = this->resolveGeometrySize (sceneWidth, sceneHeight, origin);
+
+    this->updateScenePosition (origin, size, transform.scale, sceneWidth, sceneHeight);
+}
+
 CImage::~CImage () {
     this->m_texture->decrementUsageCount ();
 
@@ -2155,6 +2202,7 @@ void CImage::setupPasses () {
 
 	pass->setModelMatrix (&this->m_modelMatrix);
 	pass->setViewProjectionMatrix (&this->m_viewProjectionMatrix);
+	pass->setEffectTextureProjectionMatrix (&this->m_effectTextureProjection, &this->m_effectTextureProjectionInverse);
 
 	writesToTarget = this->configurePassTarget (pass, drawTo, asInput, effectInput, inTargetEffectSequence);
 	// TODO: PROPERLY CHECK IF THIS IS ALL THAT'S NEEDED
@@ -2173,7 +2221,9 @@ void CImage::setupPasses () {
 	    // callback always binds m_puppetSpacePosition itself, ignoring whatever spacePosition holds.
 	    spacePosition = this->getSceneSpacePosition ();
 	    projection = &this->m_modelViewProjectionScreen;
-	    inverseProjection = &this->m_modelViewProjectionScreenInverse;
+	    // WE's final pass inverse lands in the layer's local space (origin at its center, unscaled
+	    // pixels); older shaders like the bundled xray.vert unproject the pointer through it
+	    inverseProjection = &this->m_objectSpaceProjectionInverse;
 
 	    if (this->m_hasPuppetMesh) {
 		GLfloat bufDump[18] = {};
@@ -2291,7 +2341,10 @@ void CImage::render () {
 
     const auto& appContext = this->getScene ().getContext ().getApp ().getContext ();
     const auto visibility = appContext.resolveObjectVisibility (this->getId (), this->getObject ().name);
-    if (!visibility.value_or (this->getImage ().visible->value->getBool ())) {
+    // a hidden layer another object reads through _rt_imageLayerComposite_<id> (xray's "bloody" twins) still
+    // has to fill that FBO every frame, shouldRenderFinalPass() keeps it off the screen
+    if (!visibility.value_or (this->getImage ().visible->value->getBool ())
+	&& (visibility.has_value () || !this->m_isDependency)) {
 	return;
     }
 
@@ -2339,15 +2392,19 @@ void CImage::render () {
 
 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->getAlpha (); }
 
-const float& CImage::getAlpha () const { return this->m_image.alpha->value->getFloat (); }
+const float& CImage::getAlpha () const {
+    // some scenes store out-of-range alpha (e.g. 222) - it feeds mix() in blend modes, so it must stay in 0..1
+    m_alphaCache = glm::clamp (this->m_image.alpha->value->getFloat (), 0.0f, 1.0f);
+    return m_alphaCache;
+}
 
 const glm::vec3& CImage::getColor () const { return this->m_image.color->value->getVec3 (); }
 
 const glm::vec4& CImage::getColor4 () const {
     // "version" 2 materials take color and alpha together through g_Color4
-    m_color4Cache = glm::vec4 (this->m_image.color->value->getVec3 (), this->m_image.alpha->value->getFloat ());
+    m_color4Cache = glm::vec4 (this->m_image.color->value->getVec3 (), this->getAlpha ());
     return m_color4Cache;
 }
 
@@ -2368,7 +2425,8 @@ glm::vec2 CImage::resolveGeometrySize (float sceneWidth, float sceneHeight, glm:
     }
 
     if (this->getImage ().model->fullscreen) {
-	size = { sceneWidth, sceneHeight };
+	size = { static_cast<float> (this->getScene ().getCanvasWidth ()),
+		 static_cast<float> (this->getScene ().getCanvasHeight ()) };
 	origin = { sceneWidth / 2.0f, sceneHeight / 2.0f, 0.0f };
     }
 
@@ -2508,8 +2566,8 @@ CImage::ResolvedTransform CImage::updateGeometryBuffers () {
 
 namespace {
 // keeps an edge pair (e.g. m_pos.x/.z) from sliding past the viewport once `offset` is added to both,
-// so the image never uncovers ground it doesn't have pixels for; if the image is too small to fully
-// cover the viewport on this axis to begin with, there's no safe offset, so movement is frozen at 0
+// so the image never uncovers ground it doesn't have pixels for; an image too small to cover the viewport
+// on this axis has no ground to uncover, it is an object sitting on the scene and moves freely
 float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExtent) {
     const float low = std::min (edgeA, edgeB);
     const float high = std::max (edgeA, edgeB);
@@ -2518,7 +2576,7 @@ float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExte
     const float minOffset = half - high;
 
     if (minOffset > maxOffset)
-	return 0.0f;
+	return offset;
 
     return std::clamp (offset, minOffset, maxOffset);
 }
@@ -2565,12 +2623,9 @@ void CImage::updateScreenSpacePosition () {
 
     // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
     if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
-	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;
+	const glm::vec2 offset = this->getScene ().getParallaxOffset (this->getImage ());
+	float x = offset.x;
+	float y = offset.y;
 
 	// a texture that isn't UV-clamped tiles/repeats instead of showing black past its edges (GL_REPEAT,
 	// see CTexture.cpp), so sliding it further is harmless and exempt from the clamp; scene.json's own
@@ -2580,8 +2635,8 @@ void CImage::updateScreenSpacePosition () {
 
 	if (this->getScene ().getContext ().getApp ().getContext ().settings.mouse.clampParallaxToImageSize
 	    && !textureTiles) {
-	    const float sceneWidth = static_cast<float> (this->getScene ().getWidth ());
-	    const float sceneHeight = static_cast<float> (this->getScene ().getHeight ());
+	    const float sceneWidth = static_cast<float> (this->getScene ().getCanvasWidth ());
+	    const float sceneHeight = static_cast<float> (this->getScene ().getCanvasHeight ());
 	    x = clampParallaxAxis (x, this->m_pos.x, this->m_pos.z, sceneWidth);
 	    y = clampParallaxAxis (y, this->m_pos.y, this->m_pos.w, sceneHeight);
 	}
@@ -2594,14 +2649,41 @@ void CImage::updateScreenSpacePosition () {
 	this->m_modelViewProjectionScreenInverse = glm::inverse (mvp);
     }
     this->m_modelViewProjectionScreen = mvp;
+    this->updateEffectTextureProjection ();
     if (this->getImage ().model->passthrough) {
 	this->m_modelViewProjectionCopy = this->m_modelViewProjectionScreen;
 	this->m_modelViewProjectionCopyInverse = this->m_modelViewProjectionScreenInverse;
     }
 }
 
+void CImage::updateEffectTextureProjection () {
+    // the final quad puts texcoord (0, 0) at (m_pos.x, m_pos.w), which is the layer's local (-1, +1) corner;
+    // the scene FBO is y-flipped against the screen the pointer position is measured on, hence the flip
+    const glm::vec3 center ((this->m_pos.x + this->m_pos.z) / 2.0f, (this->m_pos.y + this->m_pos.w) / 2.0f, 0.0f);
+    const glm::vec3 halfSize ((this->m_pos.z - this->m_pos.x) / 2.0f, (this->m_pos.w - this->m_pos.y) / 2.0f, 1.0f);
+    const glm::mat4 projection = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f))
+	* this->m_modelViewProjectionScreen * glm::scale (glm::translate (glm::mat4 (1.0f), center), halfSize);
+
+    if (projection == this->m_effectTextureProjection) {
+	return;
+    }
+
+    this->m_effectTextureProjection = projection;
+    // a zero-sized layer has no inverse, keep the last usable one instead of feeding NaNs to the shader
+    if (halfSize.x != 0.0f && halfSize.y != 0.0f) {
+	this->m_effectTextureProjectionInverse = glm::inverse (projection);
+    }
+
+    const glm::vec2 size = this->getSize ();
+    this->m_objectSpaceProjectionInverse
+	= glm::scale (glm::mat4 (1.0f), glm::vec3 (size.x / 2.0f, size.y / 2.0f, 1.0f))
+	* this->m_effectTextureProjectionInverse;
+}
+
 const Image& CImage::getImage () const { return this->m_image; }
 
+void CImage::markAsDependency () { this->m_isDependency = true; }
+
 glm::vec2 CImage::getSize () const {
     if (this->m_texture == nullptr) {
 	return this->getImage ().size;

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

@@ -77,8 +77,18 @@ public:
     void setup () override;
     void render () override;
 
+    /** Refreshes the image's own texture plus any video a pass pulled in from a user texture slot */
+    void updateTextures () const;
+    /** Whether a point in scene coordinates (origin bottom-left, y up) lies inside the layer's on-screen box, rotation ignored */
+    [[nodiscard]] bool containsScenePoint (const glm::vec2& point) const;
+    /** Center of the layer's on-screen box in scene coordinates (origin bottom-left, y up) */
+    [[nodiscard]] glm::vec2 getSceneCenter () const;
+    /** Moves the on-screen box to the current origin/scale without touching GL */
+    void refreshScenePosition ();
     [[nodiscard]] const Image& getImage () const;
     [[nodiscard]] glm::vec2 getSize () const;
+    /** Another object samples this layer's composite FBO, so its passes run even while it's hidden */
+    void markAsDependency ();
 
     [[nodiscard]] GLuint getSceneSpacePosition () const;
     [[nodiscard]] GLuint getCopySpacePosition () const;
@@ -116,6 +126,7 @@ protected:
     void setupPasses ();
 
     void updateScreenSpacePosition ();
+    void updateEffectTextureProjection ();
 
     struct ResolvedTransform {
 	glm::vec3 origin;
@@ -206,6 +217,11 @@ private:
     glm::mat4 m_modelViewProjectionScreenInverse = {};
     glm::mat4 m_modelViewProjectionPassInverse = {};
     glm::mat4 m_modelViewProjectionCopyInverse = {};
+    /** Maps the layer quad's own -1..1 space (+y = texture top) to screen clip space, effects like xray and
+     *  cursorripple use its inverse to bring g_PointerPosition into the layer's texture space */
+    glm::mat4 m_effectTextureProjection = glm::mat4 (1.0);
+    glm::mat4 m_effectTextureProjectionInverse = glm::mat4 (1.0);
+    glm::mat4 m_objectSpaceProjectionInverse = glm::mat4 (1.0);
 
     glm::mat4 m_modelMatrix = {};
     glm::mat4 m_viewProjectionMatrix = {};
@@ -217,6 +233,7 @@ private:
 
     const Image& m_image;
     mutable glm::vec4 m_color4Cache {};
+    mutable float m_alphaCache = 1.0f;
 
     std::vector<Effects::CPass*> m_passes = {};
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
@@ -230,6 +247,7 @@ private:
     glm::vec2 m_lastUploadedGeometrySize = glm::vec2 (std::numeric_limits<float>::quiet_NaN ());
 
     bool m_initialized = false;
+    bool m_isDependency = false;
 
     struct {
 	struct {

+ 63 - 39
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -176,12 +176,37 @@ void CParticle::render () {
 	return;
     }
 
+    syncTransformedOrigin ();
+
+    // stop() drops every particle, and a later play() starts emitting from scratch
+    const auto playback = this->getPlayback ();
+    if (playback == Playback::Stopped) {
+	m_particleCount = 0;
+    } else if (m_lastPlayback == Playback::Stopped) {
+	m_emitters.clear ();
+	setupEmitters ();
+    }
+    m_lastPlayback = playback;
+
     const float currentTime = m_hasMouseControlPoint ? g_RealTime : g_Time;
 
     // Initialize time on first render to avoid a huge dt spike, and skip the update
     // that frame to avoid an initial burst
     if (m_time == 0.0) {
 	m_time = currentTime;
+	// "starttime" prewarms the system so it starts already populated instead of every
+	// particle visibly leaving the emitter at once
+	if (!m_prewarmed && m_particle.startTime > 0.0f && playback == Playback::Playing) {
+	    m_prewarmed = true;
+	    constexpr float step = 1.0f / 30.0f;
+	    m_time = currentTime - m_particle.startTime;
+	    for (float left = m_particle.startTime; left > 0.0f; left -= step) {
+		const float dt = std::min (step, left);
+		m_time += dt;
+		update (dt);
+	    }
+	    m_time = currentTime;
+	}
 	if (m_useRopeRenderer) {
 	    renderRope ();
 	} else {
@@ -193,7 +218,7 @@ void CParticle::render () {
     float dt = currentTime - static_cast<float> (m_time);
     m_time = currentTime;
 
-    if (dt > 0.0f && this->isPlaying ()) {
+    if (dt > 0.0f && playback != Playback::Stopped) {
 	// Cap dt to prevent simulation instability across different FPS
 	dt = std::min (dt, 0.1f);
 	update (dt);
@@ -208,29 +233,40 @@ void CParticle::render () {
     }
 }
 
-void CParticle::update (float dt) {
-    float screenWidth = static_cast<float> (getScene ().getWidth ());
-    float screenHeight = static_cast<float> (getScene ().getHeight ());
+bool CParticle::isPlaying () const {
+    const auto playback = this->getPlayback ();
 
-    if (screenWidth != m_lastScreenWidth || screenHeight != m_lastScreenHeight) {
-	// Resolution changed - recalculate transformed origin
-	glm::vec3 origin = m_particle.origin->value->getVec3 ();
-	origin.x -= screenWidth / 2.0f;
-	origin.y = screenHeight / 2.0f - origin.y;
-	m_transformedOrigin = origin;
-
-	// Update world-space control points that aren't mouse-linked
-	for (size_t i = 0; i < m_controlPoints.size (); i++) {
-	    auto& cp = m_controlPoints[i];
-	    if (!cp.linkMouse && cp.worldSpace) {
-		cp.position = cp.offset - m_transformedOrigin;
-	    }
-	}
+    return playback == Playback::Playing || (playback == Playback::Paused && m_particleCount > 0);
+}
 
-	m_lastScreenWidth = screenWidth;
-	m_lastScreenHeight = screenHeight;
+// scripts can move the system every frame (e.g. an origin that follows the cursor)
+void CParticle::syncTransformedOrigin () {
+    const float screenWidth = static_cast<float> (getScene ().getWidth ());
+    const float screenHeight = static_cast<float> (getScene ().getHeight ());
+
+    glm::vec3 origin = m_particle.origin->value->getVec3 ();
+    origin.x -= screenWidth / 2.0f;
+    origin.y = screenHeight / 2.0f - origin.y;
+
+    if (origin == m_transformedOrigin && screenWidth == m_lastScreenWidth && screenHeight == m_lastScreenHeight) {
+	return;
     }
 
+    m_transformedOrigin = origin;
+    m_lastScreenWidth = screenWidth;
+    m_lastScreenHeight = screenHeight;
+
+    for (auto& cp : m_controlPoints) {
+	if (!cp.linkMouse && cp.worldSpace) {
+	    cp.position = cp.offset - m_transformedOrigin;
+	}
+    }
+}
+
+void CParticle::update (float dt) {
+    float screenWidth = static_cast<float> (getScene ().getWidth ());
+    float screenHeight = static_cast<float> (getScene ().getHeight ());
+
     const glm::vec2* mousePos = getScene ().getMousePositionNormalized ();
     if (mousePos) {
 
@@ -251,8 +287,11 @@ void CParticle::update (float dt) {
 	}
     }
 
-    for (auto& emitter : m_emitters) {
-	emitter (m_particles, m_particleCount, dt);
+    // pause() stops emission but keeps simulating what is already alive
+    if (this->getPlayback () == Playback::Playing) {
+	for (auto& emitter : m_emitters) {
+	    emitter (m_particles, m_particleCount, dt);
+	}
     }
 
     for (uint32_t i = 0; i < m_particleCount; i++) {
@@ -1780,23 +1819,8 @@ void CParticle::applyParallaxToModelMatrix () {
 	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,
-    };
+    const glm::vec2 offset = getScene ().getParallaxOffset (m_particle);
+    const glm::vec3 parallaxOffset { offset.x, offset.y, 0.0f };
     m_modelMatrix = glm::translate (m_modelMatrix, parallaxOffset);
 }
 

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

@@ -104,6 +104,7 @@ public:
     [[nodiscard]] const glm::vec3& getColor () const override;
     [[nodiscard]] const glm::vec4& getColor4 () const override;
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
+    [[nodiscard]] bool isPlaying () const override;
 
 protected:
     void setupEmitters ();
@@ -143,6 +144,7 @@ protected:
     void setupGeometryCallbacks ();
     void setupParticleUniforms ();
     void updateMatrices ();
+    void syncTransformedOrigin ();
     void applyParallaxToModelMatrix ();
     void updateParticleViewProjection ();
     void updateParticleRenderVars ();
@@ -170,6 +172,7 @@ private:
     std::vector<float> m_cumulativeArcLength;
 
     double m_time { 0.0 };
+    bool m_prewarmed { false };
 
     // Mouse-linked systems run on unscaled real time so cursor trails track 1:1 regardless of --speed
     bool m_hasMouseControlPoint { false };
@@ -238,5 +241,6 @@ private:
     std::mt19937 m_rng;
 
     bool m_initialized { false };
+    Playback m_lastPlayback { Playback::Playing };
 };
 } // namespace WallpaperEngine::Render::Objects

+ 10 - 13
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -244,8 +244,8 @@ const Material& compositeMaterial () {
 }
 
 // Mirrors CImage.cpp's clampParallaxAxis: keeps an edge pair from sliding past the viewport
-// once `offset` is added to both, freezing movement at 0 if the box is too small to fully
-// cover the viewport on this axis to begin with.
+// once `offset` is added to both; a box too small to cover the viewport on this axis has no
+// ground to uncover and moves freely.
 float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExtent) {
     const float low = std::min (edgeA, edgeB);
     const float high = std::max (edgeA, edgeB);
@@ -254,7 +254,7 @@ float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExte
     const float minOffset = half - high;
 
     if (minOffset > maxOffset)
-	return 0.0f;
+	return offset;
 
     return std::clamp (offset, minOffset, maxOffset);
 }
@@ -1010,22 +1010,19 @@ void CText::render () {
     glm::vec2 parallaxOffset = { 0.0f, 0.0f };
     // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
     if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
-	const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
-	const glm::vec2 depth = m_text.parallaxDepth->value->getVec2 ();
-	const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
-	const float referenceSize = static_cast<float> (this->getScene ().getWidth ());
-	parallaxOffset.x = (depth.x + parallaxAmount) * displacement->x * referenceSize;
-	parallaxOffset.y = (depth.y + parallaxAmount) * displacement->y * referenceSize;
+	parallaxOffset = this->getScene ().getParallaxOffset (m_text);
 
 	// mirrors CImage's parallax clamp, or a text layer drifts past its edges while a same-depth
 	// CImage backing panel freezes, visibly separating the two
 	if (this->getScene ().getContext ().getApp ().getContext ().settings.mouse.clampParallaxToImageSize) {
 	    const float baseX = origin.x + offsetX - scene_w * 0.5f;
 	    const float baseY = scene_h * 0.5f - (origin.y + offsetY);
-	    parallaxOffset.x
-		= clampParallaxAxis (parallaxOffset.x, baseX - scaledHalfWidth, baseX + scaledHalfWidth, scene_w);
-	    parallaxOffset.y
-		= clampParallaxAxis (parallaxOffset.y, baseY - scaledHalfHeight, baseY + scaledHalfHeight, scene_h);
+	    parallaxOffset.x = clampParallaxAxis (
+		parallaxOffset.x, baseX - scaledHalfWidth, baseX + scaledHalfWidth, getScene ().getCanvasWidth ()
+	    );
+	    parallaxOffset.y = clampParallaxAxis (
+		parallaxOffset.y, baseY - scaledHalfHeight, baseY + scaledHalfHeight, getScene ().getCanvasHeight ()
+	    );
 	}
     }
 

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

@@ -70,6 +70,8 @@ CPass::UniformEntry::~UniformEntry () {
 
 const TextureMap DEFAULT_BINDS = {};
 const ImageEffectPassOverride DEFAULT_OVERRIDE = {};
+// objects that don't provide their own layer-to-screen mapping (text, particles) keep the old identity
+const glm::mat4 IDENTITY_MATRIX = glm::mat4 (1.0);
 
 namespace {
 std::string textureSizeLabel (const std::shared_ptr<const TextureProvider>& texture) {
@@ -122,11 +124,17 @@ CPass::CPass (
     m_pass (pass), m_binds (binds.has_value () ? binds.value ().get () : DEFAULT_BINDS),
     m_override (override.has_value () ? override.value ().get () : DEFAULT_OVERRIDE), m_target (target),
     m_blendingmode (pass.blending), m_vao (GL_NONE) {
+    this->m_effectTextureProjectionMatrix = &IDENTITY_MATRIX;
+    this->m_effectTextureProjectionMatrixInverse = &IDENTITY_MATRIX;
     this->setupShaders ();
     glGenVertexArrays (1, &m_vao);
 }
 
 CPass::~CPass () {
+    for (const auto& texture : this->m_playbackTextures) {
+	texture->decrementUsageCount ();
+    }
+
     for (const auto& value : this->m_uniforms | std::views::values) {
 	delete value;
     }
@@ -191,6 +199,23 @@ std::shared_ptr<const TextureProvider> CPass::resolveTexture (
     return this->resolveFBO (it->second);
 }
 
+void CPass::trackPlayback (const std::shared_ptr<const TextureProvider>& texture) {
+    // the renderable already counts its own texture, and frame buffers have nothing to play back
+    if (texture == nullptr || texture == this->m_renderable.getTexture ()
+	|| std::ranges::find (this->m_playbackTextures, texture) != this->m_playbackTextures.end ()) {
+	return;
+    }
+
+    texture->incrementUsageCount ();
+    this->m_playbackTextures.push_back (texture);
+}
+
+void CPass::updatePlaybackTextures () const {
+    for (const auto& texture : this->m_playbackTextures) {
+	texture->update ();
+    }
+}
+
 std::optional<std::string> CPass::resolveUserTextureName (const std::string& propertyName) const {
     const auto& properties = this->m_renderable.getScene ().getScene ().project.properties;
     const auto it = properties.find (propertyName);
@@ -406,13 +431,8 @@ void CPass::bindTextureUnit (int index, const std::shared_ptr<const TextureProvi
 }
 
 void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const TextureProvider>& texture0) const {
-    static int diagCallCount = 0;
-    const bool diagTarget
-	= this->m_renderable.getId () == 21 && this->m_pass.shader == "effects/shake" && diagCallCount++ < 6;
     for (auto [index, chain] : this->m_textures) {
 	auto expectedTexture = chain->texture;
-	const auto* requestedChainHead = chain->texture.get ();
-	int chainSteps = 0;
 
 	do {
 	    if (expectedTexture == nullptr) {
@@ -431,27 +451,14 @@ void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const
 
 	    chain = chain->next;
 	    expectedTexture = chain == nullptr ? nullptr : chain->texture;
-	    chainSteps++;
 	} while (chain != nullptr);
 
 	if (expectedTexture == nullptr && this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
 	    expectedTexture = this->m_previousInput;
-	    chainSteps = -1;
 	}
 
 	if (expectedTexture == nullptr) {
 	    expectedTexture = this->m_input;
-	    chainSteps = -2;
-	}
-
-	if (diagTarget) {
-	    sLog.out (
-		"TEMP-DIAG texture-binding-fix bind slot=", index, " requestedTexturePtr=", (const void*) requestedChainHead,
-		" requestedReady=", (requestedChainHead != nullptr && requestedChainHead->isReady ()),
-		" finalTexturePtr=", (const void*) expectedTexture.get (), " matchesRequested=",
-		(expectedTexture.get () == requestedChainHead), " fellBackVia=", chainSteps, " (0=direct hit, >0=chain fallback, -1=previousInput, -2=input)",
-		" finalTextureID=", (expectedTexture != nullptr ? expectedTexture->getTextureID (index == 0 ? currentTexture : 0) : 0)
-	    );
 	}
 
 	this->bindTextureUnit (index, expectedTexture, index == 0 ? currentTexture : 0);
@@ -639,36 +646,6 @@ void CPass::render () {
     this->setupRenderUniforms ();
     this->setupRenderReferenceUniforms ();
 
-    if (this->m_renderable.getId () == 21 && this->m_pass.shader == "effects/shake") {
-	static int diagCallCount = 0;
-	if (diagCallCount++ % 5 == 0) {
-	    const GLint boundsLoc = glGetUniformLocation (this->m_programID, "g_Bounds");
-	    const GLint ampLoc = glGetUniformLocation (this->m_programID, "g_Amp");
-	    const GLint speedLoc = glGetUniformLocation (this->m_programID, "g_Speed");
-	    const GLint timeLoc = glGetUniformLocation (this->m_programID, "g_Time");
-	    GLfloat bounds[2] = { -1, -1 };
-	    GLfloat amp = -1, speed = -1, time = -1;
-	    if (boundsLoc != -1) glGetUniformfv (this->m_programID, boundsLoc, bounds);
-	    if (ampLoc != -1) glGetUniformfv (this->m_programID, ampLoc, &amp);
-	    if (speedLoc != -1) glGetUniformfv (this->m_programID, speedLoc, &speed);
-	    if (timeLoc != -1) glGetUniformfv (this->m_programID, timeLoc, &time);
-
-	    constexpr float kHalfPi = 1.5707963267948966f;
-	    const float t = speed * time;
-	    const float fracVal = t / kHalfPi - std::floor (t / kHalfPi);
-	    const float raw = std::sin (fracVal * kHalfPi) * 0.498f + 0.5f;
-	    const float remapped = (bounds[1] - bounds[0]) != 0
-		? std::clamp ((raw - bounds[0]) * (1.0f / (bounds[1] - bounds[0])), 0.0f, 1.0f)
-		: -1.0f;
-
-	    sLog.out (
-		"TEMP-DIAG blink pulse (GPU-readback) programID=", this->m_programID, " g_Time(GPU)=", time,
-		" g_Amp(GPU)=", amp, " g_Speed(GPU)=", speed, " g_Bounds(GPU)=(", bounds[0], ",", bounds[1],
-		") -> rawOffset=", raw, " remappedOffset=", remapped
-	    );
-	}
-    }
-
     this->setupRenderAttributes ();
     this->renderGeometry ();
     this->cleanupRenderSetup ();
@@ -698,6 +675,11 @@ void CPass::setModelMatrix (const glm::mat4* model) { this->m_modelMatrix = mode
 
 void CPass::setViewProjectionMatrix (const glm::mat4* viewProjection) { this->m_viewProjectionMatrix = viewProjection; }
 
+void CPass::setEffectTextureProjectionMatrix (const glm::mat4* projection, const glm::mat4* inverse) {
+    this->m_effectTextureProjectionMatrix = projection;
+    this->m_effectTextureProjectionMatrixInverse = inverse;
+}
+
 void CPass::setBlendingMode (BlendingMode blendingmode) { this->m_blendingmode = blendingmode; }
 
 BlendingMode CPass::getBlendingMode () const { return this->m_blendingmode; }
@@ -882,23 +864,12 @@ void CPass::setupShaders () {
     // bind each g_TextureN sampler to unit N explicitly, the translated GLSL collapses every layout(binding) to 0
     {
 	glUseProgram (this->m_programID);
-	const bool diagTarget = this->m_renderable.getId () == 21 && shaderName == "effects/shake";
-	if (diagTarget) {
-	    sLog.out ("TEMP-DIAG texture-binding-fix: programID=", this->m_programID, " shader=", shaderName);
-	}
 	for (int index = 0; index <= 9; index++) {
 	    const std::string name = "g_Texture" + std::to_string (index);
 	    const GLint loc = glGetUniformLocation (this->m_programID, name.c_str ());
 	    if (loc != -1) {
 		glUniform1i (loc, index);
 	    }
-	    if (diagTarget) {
-		const GLenum err = glGetError ();
-		sLog.out (
-		    "TEMP-DIAG   ", name, " location=", loc, (loc != -1 ? " -> bound to unit " : " (not present)"),
-		    (loc != -1 ? std::to_string (index) : std::string ()), " glGetError=", err
-		);
-	    }
 	}
     }
 
@@ -954,6 +925,7 @@ void CPass::setupTextureUniforms () {
 	    });
 
 	    this->m_textures[index] = chain;
+
 	} catch (std::runtime_error& ex) {
 	    sLog.error (
 		"Cannot resolve texture '", textureName, "' (index=", index, ", object id=",
@@ -977,6 +949,10 @@ void CPass::setupTextureUniforms () {
 	    });
 
 	    this->m_textures[index] = chain;
+
+	    if (textureName.find ("_rt_") != 0 && textureName.find ("_alias_") != 0) {
+		this->trackPlayback (texture);
+	    }
 	} catch (std::runtime_error& ex) {
 	    sLog.error (
 		"Cannot resolve texture '", textureName, "' (index=", index, ", object id=",
@@ -1009,6 +985,9 @@ void CPass::setupTextureUniforms () {
 	    });
 
 	    this->m_textures[index] = chain;
+	    if (textureName.find ("_rt_") != 0 && textureName.find ("_alias_") != 0) {
+		this->trackPlayback (texture);
+	    }
 	} catch (std::runtime_error& ex) {
 	    sLog.error (
 		"Cannot resolve user texture '", textureName, "' (index=", index, ", object id=",
@@ -1033,6 +1012,10 @@ void CPass::setupTextureUniforms () {
 	    });
 
 	    this->m_textures[index] = chain;
+
+	    if (textureName.find ("_rt_") != 0 && textureName.find ("_alias_") != 0) {
+		this->trackPlayback (texture);
+	    }
 	} catch (std::runtime_error& ex) {
 	    sLog.error (
 		"Cannot resolve texture '", textureName, "' (index=", index, ", object id=",
@@ -1063,6 +1046,10 @@ void CPass::setupTextureUniforms () {
 	    });
 
 	    this->m_textures[index] = chain;
+
+	    if (textureName.find ("_rt_") != 0 && textureName.find ("_alias_") != 0) {
+		this->trackPlayback (texture);
+	    }
 	} catch (std::runtime_error& ex) {
 	    sLog.error (
 		"Cannot resolve user texture '", textureName, "' (index=", index, ", object id=",
@@ -1104,13 +1091,6 @@ void CPass::setupTextureUniforms () {
 	texture = this->resolveTexture (expectedTexture->texture, textureIndex, texture);
 	const glm::vec4* res = texture->getResolution ();
 
-	if (this->m_renderable.getId () == 13) {
-	    sLog.out (
-		"TEMP-DIAG texture resolution uniform for object 13: name=", namestream.str (), " index=", textureIndex,
-		" res=(", res->x, ",", res->y, ",", res->z, ",", res->w, ") shader=", this->m_pass.shader
-	    );
-	}
-
 	this->addUniform (namestream.str (), res);
     }
 
@@ -1150,9 +1130,9 @@ void CPass::setupUniforms () {
     this->addUniform ("g_ViewProjectionMatrix", &this->m_viewProjectionMatrix);
     this->addUniform ("g_PointerPosition", scene.getMousePosition ());
     this->addUniform ("g_PointerPositionLast", scene.getMousePositionLast ());
-    this->addUniform ("g_ParallaxPosition", scene.getMousePosition ());
-    this->addUniform ("g_EffectTextureProjectionMatrix", glm::mat4 (1.0));
-    this->addUniform ("g_EffectTextureProjectionMatrixInverse", glm::mat4 (1.0));
+    this->addUniform ("g_ParallaxPosition", scene.getParallaxPosition ());
+    this->addUniform ("g_EffectTextureProjectionMatrix", &this->m_effectTextureProjectionMatrix);
+    this->addUniform ("g_EffectTextureProjectionMatrixInverse", &this->m_effectTextureProjectionMatrixInverse);
     this->addUniform ("g_TexelSize", glm::vec2 (1.0 / scene.getWidth (), 1.0 / scene.getHeight ()));
     this->addUniform ("g_TexelSizeHalf", glm::vec2 (0.5 / scene.getWidth (), 0.5 / scene.getHeight ()));
     this->addUniform ("g_AudioSpectrum16Left", recorder.audio16, 16);
@@ -1350,7 +1330,41 @@ void CPass::addUniform (const std::string& name, const glm::vec4 value) {
     this->addUniform (name, UniformType::Vector4, value);
 }
 
+GLenum CPass::getDeclaredUniformType (const std::string& name) const {
+    GLint count = 0;
+    glGetProgramiv (this->m_programID, GL_ACTIVE_UNIFORMS, &count);
+
+    for (GLint index = 0; index < count; index++) {
+	char buffer[256];
+	GLsizei length = 0;
+	GLint size = 0;
+	GLenum type = GL_NONE;
+
+	glGetActiveUniform (this->m_programID, index, sizeof (buffer), &length, &size, &type, buffer);
+
+	if (name == buffer) {
+	    return type;
+	}
+    }
+
+    return GL_NONE;
+}
+
 void CPass::addUniform (const std::string& name, const glm::vec4* value) {
+    // resolution uniforms are always kept as a vec4 (texture size + real size), but shaders often declare
+    // them as a vec2 and glUniform4 on that is a GL error that leaves the uniform at 0 (color_key_plus
+    // then divides by a zero resolution and the whole layer comes out NaN)
+    switch (this->getDeclaredUniformType (name)) {
+	case GL_FLOAT_VEC2:
+	    this->addUniform (name, UniformType::Vector2, reinterpret_cast<const glm::vec2*> (value), 1);
+	    return;
+	case GL_FLOAT_VEC3:
+	    this->addUniform (name, UniformType::Vector3, reinterpret_cast<const glm::vec3*> (value), 1);
+	    return;
+	default:
+	    break;
+    }
+
     this->addUniform (name, UniformType::Vector4, value, 1);
 }
 

+ 11 - 0
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -32,6 +32,8 @@ public:
     ~CPass ();
 
     void render ();
+    /** Advances video textures this pass pulls in from user/material slots, the image's own texture is updated by the scene */
+    void updatePlaybackTextures () const;
 
     void setDestination (std::shared_ptr<const CFBO> drawTo);
     void setInput (std::shared_ptr<const TextureProvider> input);
@@ -42,6 +44,7 @@ public:
     void setModelViewProjectionMatrixInverse (const glm::mat4* projection);
     void setModelMatrix (const glm::mat4* model);
     void setViewProjectionMatrix (const glm::mat4* viewProjection);
+    void setEffectTextureProjectionMatrix (const glm::mat4* projection, const glm::mat4* inverse);
     void setBlendingMode (BlendingMode blendingmode);
     [[nodiscard]] BlendingMode getBlendingMode () const;
     [[nodiscard]] std::shared_ptr<const CFBO> resolveFBO (const std::string& name) const;
@@ -131,6 +134,8 @@ private:
     static GLuint compileShader (const char* shader, GLuint type);
     void setupShaders ();
     void setupShaderVariables ();
+    /** GL type the linked program declares for a uniform (GL_NONE if it has no such active uniform) */
+    [[nodiscard]] GLenum getDeclaredUniformType (const std::string& name) const;
     void setupUniforms ();
     void setupTextureUniforms ();
     void setupAttributes ();
@@ -206,6 +211,8 @@ private:
     const glm::mat4* m_modelViewProjectionMatrixInverse;
     const glm::mat4* m_modelMatrix;
     const glm::mat4* m_viewProjectionMatrix;
+    const glm::mat4* m_effectTextureProjectionMatrix;
+    const glm::mat4* m_effectTextureProjectionMatrixInverse;
 
     // full xray support: 0.0/1.0 fed to the g_XrayFullReveal uniform injected by patchXrayFullRevealBypass(),
     // updated each frame from state.xray.fullReveal (see render()); m_xrayFullRevealPatched records whether
@@ -214,6 +221,10 @@ private:
     bool m_xrayFullRevealPatched = false;
 
     std::map<int, std::shared_ptr<TextureChainEntry>> m_textures = {};
+    /** Textures that got their usage count bumped by this pass (starts video playback), released on destruction */
+    std::vector<std::shared_ptr<const TextureProvider>> m_playbackTextures = {};
+
+    void trackPlayback (const std::shared_ptr<const TextureProvider>& texture);
 
     Render::Shaders::Shader* m_shader = nullptr;
 

+ 95 - 0
src/WallpaperEngine/Render/RenderContext.cpp

@@ -6,6 +6,39 @@
 #include "RenderContext.h"
 
 #include "WallpaperEngine/Data/Model/Project.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
+
+#include <chrono>
+#include <cstdlib>
+#include <unordered_map>
+
+namespace {
+using Clock = std::chrono::steady_clock;
+
+double millisSince (const Clock::time_point start) {
+    return std::chrono::duration<double, std::milli> (Clock::now () - start).count ();
+}
+
+// LWE_FRAME_STATS=1 logs per-output frame timings every 2 seconds. It adds a glFinish after each step so
+// GPU work is attributed to the step that queued it, which costs some throughput while enabled
+struct FrameStats {
+    Clock::time_point windowStart = Clock::now ();
+    Clock::time_point lastFrame {};
+    int frames = 0;
+    double maxInterval = 0.0;
+    double scene = 0.0, sceneMax = 0.0;
+    double video = 0.0, videoMax = 0.0;
+    double swap = 0.0, swapMax = 0.0;
+};
+
+bool frameStatsEnabled () {
+    static const bool enabled = std::getenv ("LWE_FRAME_STATS") != nullptr;
+    return enabled;
+}
+
+std::unordered_map<std::string, FrameStats> s_frameStats;
+}
 
 namespace WallpaperEngine::Render {
 RenderContext::RenderContext (
@@ -15,6 +48,11 @@ RenderContext::RenderContext (
     m_textureCache (std::make_unique<TextureCache> (*this)) { }
 
 void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
+    if (frameStatsEnabled ()) {
+	this->renderWithStats (viewport);
+	return;
+    }
+
     viewport->makeCurrent ();
 
 #if !NDEBUG
@@ -36,6 +74,63 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
     viewport->swapOutput ();
 }
 
+void RenderContext::renderWithStats (Drivers::Output::OutputViewport* viewport) {
+    auto& stats = s_frameStats[viewport->name];
+    const auto frameStart = Clock::now ();
+
+    if (stats.lastFrame != Clock::time_point {}) {
+	stats.maxInterval = std::max (stats.maxInterval, std::chrono::duration<double, std::milli> (frameStart - stats.lastFrame).count ());
+    }
+
+    stats.lastFrame = frameStart;
+
+    viewport->makeCurrent ();
+    glFinish ();
+    VideoPlayback::MPV::GLPlayer::s_statsMillis = 0.0;
+
+    const auto sceneStart = Clock::now ();
+
+    if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
+	ref->second->render (
+	    viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition, viewport->logicalSize
+	);
+    }
+
+    glFinish ();
+
+    const double video = VideoPlayback::MPV::GLPlayer::s_statsMillis;
+    const double scene = millisSince (sceneStart) - video;
+    const auto swapStart = Clock::now ();
+
+    viewport->swapOutput ();
+
+    const double swap = millisSince (swapStart);
+
+    stats.frames++;
+    stats.scene += scene;
+    stats.sceneMax = std::max (stats.sceneMax, scene);
+    stats.video += video;
+    stats.videoMax = std::max (stats.videoMax, video);
+    stats.swap += swap;
+    stats.swapMax = std::max (stats.swapMax, swap);
+
+    const double window = millisSince (stats.windowStart);
+
+    if (window < 2000.0) {
+	return;
+    }
+
+    const double n = stats.frames;
+
+    sLog.out (
+	"FRAME-STATS ", viewport->name, ": ", n * 1000.0 / window, " fps, worst gap ", stats.maxInterval,
+	"ms | scene avg ", stats.scene / n, " max ", stats.sceneMax, " | video avg ", stats.video / n, " max ",
+	stats.videoMax, " | swap avg ", stats.swap / n, " max ", stats.swapMax, " (ms)"
+    );
+
+    stats = FrameStats { .lastFrame = stats.lastFrame };
+}
+
 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);

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

@@ -52,6 +52,8 @@ namespace Render {
 	[[nodiscard]] Media::MediaSource& getMediaSource () const;
 
     private:
+	void renderWithStats (Drivers::Output::OutputViewport* viewport);
+
 	Drivers::VideoDriver& m_driver;
 	std::map<std::string, std::shared_ptr<CWallpaper>> m_wallpapers = {};
 	WallpaperApplication& m_app;

+ 192 - 7
src/WallpaperEngine/Render/TextureCache.cpp

@@ -5,21 +5,192 @@
 
 #include "CTexture.h"
 #include "WallpaperEngine/Assets/AssetLoadException.h"
+#include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Render/Helpers/ContextAware.h"
 
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Parsers/TextureParser.h"
 
 #include <algorithm>
+#include <cctype>
 #include <chrono>
+#include <cstring>
 #include <filesystem>
 #include <fstream>
+#include <iterator>
+
+#include <stb_image.h>
+
+extern "C" {
+#include <libavformat/avformat.h>
+#include <libavutil/mem.h>
+}
 
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Assets;
 
+namespace {
+struct MemoryReader {
+    const uint8_t* data;
+    size_t size;
+    size_t position;
+};
+
+int memoryRead (void* opaque, uint8_t* buffer, int length) {
+    auto* reader = static_cast<MemoryReader*> (opaque);
+
+    if (reader->position >= reader->size) {
+	return AVERROR_EOF;
+    }
+
+    const auto count = std::min (static_cast<size_t> (length), reader->size - reader->position);
+
+    memcpy (buffer, reader->data + reader->position, count);
+    reader->position += count;
+
+    return static_cast<int> (count);
+}
+
+int64_t memorySeek (void* opaque, int64_t offset, int whence) {
+    auto* reader = static_cast<MemoryReader*> (opaque);
+
+    if (whence == AVSEEK_SIZE) {
+	return static_cast<int64_t> (reader->size);
+    }
+
+    int64_t target = offset;
+
+    if ((whence & ~AVSEEK_FORCE) == SEEK_CUR) {
+	target += static_cast<int64_t> (reader->position);
+    } else if ((whence & ~AVSEEK_FORCE) == SEEK_END) {
+	target += static_cast<int64_t> (reader->size);
+    }
+
+    if (target < 0 || target > static_cast<int64_t> (reader->size)) {
+	return -1;
+    }
+
+    reader->position = static_cast<size_t> (target);
+
+    return target;
+}
+
+/** Reads the video stream dimensions of an in-memory container, {0, 0} if it cannot be probed */
+std::pair<uint32_t, uint32_t> probeVideoSize (const uint8_t* data, size_t size) {
+    MemoryReader reader { data, size, 0 };
+    constexpr int ioBufferSize = 32768;
+
+    auto* ioBuffer = static_cast<uint8_t*> (av_malloc (ioBufferSize));
+    AVIOContext* io = avio_alloc_context (ioBuffer, ioBufferSize, 0, &reader, memoryRead, nullptr, memorySeek);
+    AVFormatContext* format = avformat_alloc_context ();
+
+    format->pb = io;
+
+    std::pair<uint32_t, uint32_t> result { 0, 0 };
+
+    // on failure avformat_open_input frees the format context itself but leaves the io context alone
+    if (avformat_open_input (&format, nullptr, nullptr, nullptr) == 0) {
+	if (avformat_find_stream_info (format, nullptr) >= 0) {
+	    for (unsigned int i = 0; i < format->nb_streams; i++) {
+		const auto* params = format->streams[i]->codecpar;
+
+		if (params->codec_type == AVMEDIA_TYPE_VIDEO && params->width > 0 && params->height > 0) {
+		    result = { static_cast<uint32_t> (params->width), static_cast<uint32_t> (params->height) };
+		    break;
+		}
+	    }
+	}
+
+	avformat_close_input (&format);
+    }
+
+    av_freep (&io->buffer);
+    avio_context_free (&io);
+
+    return result;
+}
+
+std::string lowerExtension (const std::string& filename) {
+    auto extension = std::filesystem::path (filename).extension ().string ();
+
+    std::ranges::transform (extension, extension.begin (), [] (unsigned char c) { return std::tolower (c); });
+
+    return extension;
+}
+
+bool isRawImageExtension (const std::string& extension) {
+    return extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".bmp"
+	|| extension == ".tga" || extension == ".gif";
+}
+
+bool isRawVideoExtension (const std::string& extension) {
+    return extension == ".mp4" || extension == ".webm" || extension == ".mkv" || extension == ".mov"
+	|| extension == ".m4v" || extension == ".avi";
+}
+
+/**
+ * Wraps a plain image/video file (what "scenetexture" properties hold once the user picks a file, as opposed
+ * to the .tex assets shipped inside the scene) into the same header the .tex parser would have produced
+ */
+TextureUniquePtr buildRawTexture (const std::string& filename, ReadStream& stream) {
+    const auto extension = lowerExtension (filename);
+    const bool video = isRawVideoExtension (extension);
+
+    const std::string contents ((std::istreambuf_iterator<char> (stream)), std::istreambuf_iterator<char> ());
+
+    if (contents.empty ()) {
+	sLog.exception ("Cannot load ", filename, ": file is empty");
+    }
+
+    int width = 0;
+    int height = 0;
+
+    if (video) {
+	const auto [videoWidth, videoHeight]
+	    = probeVideoSize (reinterpret_cast<const uint8_t*> (contents.data ()), contents.size ());
+
+	// mpv resizes the output texture once it knows the real size, this is only the initial one
+	width = videoWidth > 0 ? static_cast<int> (videoWidth) : 1920;
+	height = videoHeight > 0 ? static_cast<int> (videoHeight) : 1080;
+    } else {
+	int channels = 0;
+
+	if (!stbi_info_from_memory (
+		reinterpret_cast<const stbi_uc*> (contents.data ()), static_cast<int> (contents.size ()), &width,
+		&height, &channels
+	    )) {
+	    sLog.exception ("Cannot decode image ", filename, ": ", stbi_failure_reason ());
+	}
+    }
+
+    auto mipmap = std::make_shared<Mipmap> ();
+
+    mipmap->width = width;
+    mipmap->height = height;
+    mipmap->uncompressedSize = static_cast<int> (contents.size ());
+    mipmap->uncompressedData = std::make_unique<char[]> (contents.size ());
+    memcpy (mipmap->uncompressedData.get (), contents.data (), contents.size ());
+
+    auto result = std::make_unique<Texture> ();
+
+    result->containerVersion = ContainerVersion_TEXB0003;
+    result->format = TextureFormat_ARGB8888;
+    result->flags = TextureFlags_ClampUVs;
+    result->width = width;
+    result->height = height;
+    result->textureWidth = width;
+    result->textureHeight = height;
+    result->freeImageFormat = video ? FIF_MP4 : FIF_PNG;
+    result->isVideoMp4 = video;
+    result->imageCount = 1;
+    result->images.emplace (0, MipmapList { mipmap });
+
+    return result;
+}
+} // namespace
+
 TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (context) {
     this->m_currentThumbnail = std::make_shared<AlbumTexture> (this->getContext ());
 
@@ -63,15 +234,29 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
     }
 
     const auto load = [&] (const Project& project) {
-	const auto contents = project.assetLocator->texture (filename);
-	auto stream = BinaryReader (contents);
+	TextureUniquePtr parsedTexture;
 
-	auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
-	    std::filesystem::path fullPath = std::filesystem::path ("materials") / metaFilename;
-	    return project.assetLocator->readString (fullPath);
-	};
+	try {
+	    const auto contents = project.assetLocator->texture (filename);
+	    auto stream = BinaryReader (contents);
+
+	    auto metadataLoader = [&project] (const std::string& metaFilename) -> std::string {
+		std::filesystem::path fullPath = std::filesystem::path ("materials") / metaFilename;
+		return project.assetLocator->readString (fullPath);
+	    };
+
+	    parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
+	} catch (AssetLoadException&) {
+	    const auto extension = lowerExtension (filename);
 
-	auto parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
+	    if (!isRawImageExtension (extension) && !isRawVideoExtension (extension)) {
+		throw;
+	    }
+
+	    const auto raw = project.assetLocator->read (filename);
+
+	    parsedTexture = buildRawTexture (filename, *raw);
+	}
 	auto texture = std::make_shared<CTexture> (this->getContext (), std::move (parsedTexture));
 
 #if !NDEBUG

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

@@ -9,6 +9,10 @@
 #include "WallpaperEngine/Data/Assets/Texture.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 
+namespace WallpaperEngine::VideoPlayback::MPV {
+class GLPlayer;
+}
+
 namespace WallpaperEngine::Render {
 using namespace WallpaperEngine::Data::Assets;
 /**
@@ -43,5 +47,6 @@ public:
     /** For video CTextures, playback only stops once usage count reaches zero (de-initializes mpv if needed) */
     virtual void decrementUsageCount () const = 0;
     virtual void update () const = 0;
+    [[nodiscard]] virtual VideoPlayback::MPV::GLPlayer* getPlayer () const { return nullptr; }
 };
 } // namespace WallpaperEngine::Render

+ 286 - 164
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -13,6 +13,9 @@
 #include "WallpaperEngine/Data/Parsers/ObjectParser.h"
 
 #include <algorithm>
+#include <chrono>
+#include <cmath>
+#include <cstdlib>
 #include <ranges>
 
 extern float g_Time;
@@ -46,16 +49,25 @@ CScene::CScene (
 	height = this->getContext ().getOutput ().getFullHeight ();
     }
 
-    this->m_parallaxDisplacement = { 0, 0 };
+    this->m_cameraParallax = { 0, 0 };
+    this->m_parallaxBias = { 0, 0 };
+    this->m_parallaxPosition = { 0.5f, 0.5f };
+
+    float canvasWidth = width;
+    float canvasHeight = height;
+
+    if (this->getContext ().getApp ().getContext ().settings.general.expandCanvas) {
+	this->expandCanvasToContent (*scene, width, height, canvasWidth, canvasHeight);
+    }
 
     // TODO: CONVERSION
-    this->m_camera->setOrthogonalProjection (width, height);
+    this->m_camera->setOrthogonalProjection (width, height, canvasWidth, canvasHeight);
 
     // needed before scene setup below, which creates FBOs
     this->setupFramebuffers ();
 
-    const uint32_t sceneWidth = this->m_camera->getWidth ();
-    const uint32_t sceneHeight = this->m_camera->getHeight ();
+    const uint32_t sceneWidth = this->m_camera->getCanvasWidth ();
+    const uint32_t sceneHeight = this->m_camera->getCanvasHeight ();
 
     this->_rt_shadowAtlas = this->create (
 	"_rt_shadowAtlas", TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0, { sceneWidth, sceneHeight },
@@ -72,152 +84,16 @@ CScene::CScene (
 	this->createObject (*object);
     }
 
-    // EXPERIMENTAL (2026-08-10): id-order and size/footprint-order were both tried and reverted as
-    // defaults for objects with no explicit sortorder (see CLAUDE.md for why each broke a different,
-    // previously-correct wallpaper). This tries a third signal, derived from real structural data instead
-    // of a property proxy: attachment/parent chain depth. A puppet piece riding another via "attachment"
-    // (e.g. hair attached to a head attached to a body) should paint after whatever it's attached to -
-    // that's real information already present in the scene graph, not a guess. Depth is the primary key;
-    // array position remains the tiebreak for equal-depth objects (including the common case of no
-    // parent chain at all, depth 0 for everyone), so this is a no-op for any wallpaper whose objects are
-    // all at the same depth - it only changes anything for wallpapers with real parent/attachment chains.
-    constexpr int kMaxChainDepth = 32;
-    const auto computeChainDepth = [&scene] (const Object* object) {
-	int depth = 0;
-	const Object* current = object;
-	// plain group parents are just layer-tree nesting and keep array order
-	while (current->parent.has_value () && current->attachment.has_value () && depth < kMaxChainDepth) {
-	    const auto it = std::ranges::find_if (
-		scene->objects, [&current] (const auto& o) { return o->id == current->parent.value (); }
-	    );
-	    if (it == scene->objects.end ()) {
-		break;
-	    }
-	    current = it->get ();
-	    depth++;
-	}
-	return depth;
-    };
-
+    // real Wallpaper Engine draws its object list in scene.json array order unless the scene sets
+    // customsortorder (then by each object's sortorder); parent/attachment links don't reorder anything
     std::vector<std::pair<const Object*, int>> objectsByPaintOrder;
     objectsByPaintOrder.reserve (scene->objects.size ());
     for (int index = 0; index < static_cast<int> (scene->objects.size ()); index++) {
 	const Object* object = scene->objects[index].get ();
-	const int fallback = computeChainDepth (object) * 1'000'000 + index;
-	objectsByPaintOrder.emplace_back (object, object->sortOrder.value_or (fallback));
+	objectsByPaintOrder.emplace_back (object, object->sortOrder.value_or (index));
     }
     std::ranges::stable_sort (objectsByPaintOrder, [] (const auto& a, const auto& b) { return a.second < b.second; });
 
-    // EXPERIMENTAL (2026-08-10): fixes a real compositing seam found on a specific wallpaper ("asagi",
-    // 3221531573) without touching the general-purpose sort above. That wallpaper has a full-canvas
-    // background layer whose alpha channel has soft-edged holes cut for several smaller detail overlays
-    // (eyes, cheek highlights, hair strands) to show through - 6 of those 7 detail layers already draw
-    // after (on top of) the background in raw array order, which is correct: a hard-edged detail sprite
-    // fully covers the background's soft hole edge with no visible seam. Exactly one (the eye layer) is
-    // authored the other way around - it draws before/underneath the background - so the background's own
-    // soft hole edge partially alpha-blends its own color over the already-drawn eye at the feather zone,
-    // producing a visible ring. Confirmed by decoding both textures directly (tools/decode_tex.py): the
-    // background has a genuine soft alpha gradient at the hole boundary, the eye layer's own alpha is
-    // pure 0/255 with no soft edge of its own, and its opaque footprint fully contains the hole with
-    // 60-120px of margin on every side - so drawing it last cleanly overwrites the seam either way.
-    //
-    // Two earlier attempts at a *global* default sort key (id-ascending, size-descending - see the
-    // deeper history in the project's CLAUDE.md) each fixed this exact case but broke other, previously-
-    // correct wallpapers, because `id` and declared `size` aren't reliable depth proxies in general - a
-    // puppet's `size` reflects its mesh bounding box, not visual prominence (broke koshini's hair), and
-    // `id` assignment doesn't correlate with paint order at all in some scenes (a 3528590419 object is
-    // the backmost layer despite having the highest id of its whole sibling group). This is deliberately
-    // narrower than either: only reorders a pair when one object's bounding box is *fully contained*
-    // inside another's, and the container is within 10% of the full scene size (i.e. looks like an actual
-    // background, not just a coincidentally-large sprite) - restricted further to plain (non-puppet),
-    // non-utility (models/util/*), fully translucent-blended objects with no parent/attachment chain and
-    // no explicit sortorder, so it can't touch anything the depth-based sort above or an explicit
-    // sortorder already handles. Checked against every wallpaper with qualifying top-level objects tested
-    // this session (mikasa's lens-flare chain, 3528590419's background/audio-bar pair) - the "near
-    // full-scene container" requirement is what keeps this from matching either: a lens flare's glow
-    // sprite is much smaller than the scene, and 3528590419's "Audio bar" is a reactive utility layer
-    // (models/util/composelayer.json), already excluded by the utility-path check.
-    const auto isPlainTranslucentTopLevelImage = [] (const Object* object) -> const Image* {
-	if (object->parent.has_value () || object->attachment.has_value () || object->sortOrder.has_value ()) {
-	    return nullptr;
-	}
-	const auto* image = dynamic_cast<const Image*> (object);
-	if (image == nullptr || image->model == nullptr || image->model->puppet.has_value ()) {
-	    return nullptr;
-	}
-	if (image->model->filename.starts_with ("models/util/")) {
-	    return nullptr;
-	}
-	if (image->model->material == nullptr || image->model->material->passes.empty ()) {
-	    return nullptr;
-	}
-	for (const auto& pass : image->model->material->passes) {
-	    if (pass->blending != BlendingMode_Translucent) {
-		return nullptr;
-	    }
-	}
-	return image;
-    };
-
-    struct ImageBounds {
-	const Object* object;
-	glm::vec2 min;
-	glm::vec2 max;
-    };
-
-    std::vector<ImageBounds> candidates;
-    for (const auto& [object, sortKey] : objectsByPaintOrder) {
-	const Image* image = isPlainTranslucentTopLevelImage (object);
-	if (image == nullptr) {
-	    continue;
-	}
-	const glm::vec3 origin = object->origin->value->getVec3 ();
-	// the declared size alone isn't the on-screen footprint, the object's scale matters too
-	const glm::vec3 scale = image->scale->value->getVec3 ();
-	const glm::vec2 half = image->size * glm::abs (glm::vec2 (scale.x, scale.y)) / 2.0f;
-	candidates.push_back ({ object, { origin.x - half.x, origin.y - half.y }, { origin.x + half.x, origin.y + half.y } });
-    }
-
-    for (const auto& container : candidates) {
-	const glm::vec2 containerSize = container.max - container.min;
-	if (containerSize.x < static_cast<float> (sceneWidth) * 0.9f
-	    || containerSize.y < static_cast<float> (sceneHeight) * 0.9f) {
-	    continue;
-	}
-
-	for (const auto& contained : candidates) {
-	    if (contained.object == container.object) {
-		continue;
-	    }
-	    const glm::vec2 containedSize = contained.max - contained.min;
-	    if (containedSize.x * containedSize.y >= containerSize.x * containerSize.y) {
-		continue;
-	    }
-	    const bool fullyInside = contained.min.x >= container.min.x && contained.min.y >= container.min.y
-		&& contained.max.x <= container.max.x && contained.max.y <= container.max.y;
-	    if (!fullyInside) {
-		continue;
-	    }
-
-	    const auto containedIt = std::ranges::find_if (
-		objectsByPaintOrder, [&contained] (const auto& p) { return p.first == contained.object; }
-	    );
-	    const auto containerIt = std::ranges::find_if (
-		objectsByPaintOrder, [&container] (const auto& p) { return p.first == container.object; }
-	    );
-	    if (containedIt < containerIt) {
-		auto entry = *containedIt;
-		objectsByPaintOrder.erase (containedIt);
-		// containerIt was invalidated by the erase above if it came after containedIt, which it did
-		// (containedIt < containerIt) - re-find it before inserting relative to it
-		const auto refreshedContainerIt = std::ranges::find_if (
-		    objectsByPaintOrder, [&container] (const auto& p) { return p.first == container.object; }
-		);
-		objectsByPaintOrder.insert (std::next (refreshedContainerIt), entry);
-	    }
-	}
-    }
-
     for (const auto& [object, sortKey] : objectsByPaintOrder) {
 	this->addObjectToRenderOrder (*object);
     }
@@ -302,6 +178,32 @@ Render::CObject* CScene::createObject (const Object& object) {
 	return current->second;
     }
 
+    // dependency/parent loops spanning several objects exist in the wild, the object further
+    // up the stack finishes creating itself once this call unwinds
+    if (!this->m_objectsInCreation.insert (object.id).second) {
+	sLog.error ("Dependency or parent cycle through object ", object.id, ", ignoring the back reference");
+	return nullptr;
+    }
+
+    try {
+	this->createObjectDependencies (object);
+    } catch (...) {
+	this->m_objectsInCreation.erase (object.id);
+	throw;
+    }
+
+    this->m_objectsInCreation.erase (object.id);
+
+    renderObject = this->dispatchObjectType (object);
+
+    if (renderObject != nullptr) {
+	this->m_objects.emplace (renderObject->getId (), renderObject);
+    }
+
+    return renderObject;
+}
+
+void CScene::createObjectDependencies (const Object& object) {
     for (const auto& cur : object.dependencies) {
 	// self-dependency is a possibility...
 	if (cur == object.id) {
@@ -329,14 +231,6 @@ Render::CObject* CScene::createObject (const Object& object) {
 
 	this->createObject (**dep);
     }
-
-    renderObject = this->dispatchObjectType (object);
-
-    if (renderObject != nullptr) {
-	this->m_objects.emplace (renderObject->getId (), renderObject);
-    }
-
-    return renderObject;
 }
 
 Render::CObject* CScene::dispatchObjectType (const Object& object) {
@@ -386,6 +280,10 @@ void CScene::addObjectToRenderOrder (const Object& object) {
 	return;
     }
 
+    if (!this->m_objectsInRenderOrderWalk.insert (object.id).second) {
+	return;
+    }
+
     for (const auto& dep : object.dependencies) {
 	// self-dependency is possible
 	if (dep == object.id) {
@@ -396,6 +294,11 @@ void CScene::addObjectToRenderOrder (const Object& object) {
 
 	if (depIt != this->getScene ().objects.end ()) {
 	    this->addObjectToRenderOrder (**depIt);
+
+	    if (const auto created = this->m_objects.find (dep);
+		created != this->m_objects.end () && created->second->is<Objects::CImage> ()) {
+		created->second->as<Objects::CImage> ()->markAsDependency ();
+	    }
 	} else {
 	    sLog.error ("Cannot find dependency ", dep, " for object ", object.id);
 	}
@@ -409,13 +312,43 @@ void CScene::addObjectToRenderOrder (const Object& object) {
     if (renderIt == this->m_objectsByRenderOrder.end ()) {
 	this->m_objectsByRenderOrder.emplace_back (obj->second);
     }
+
+    this->m_objectsInRenderOrderWalk.erase (object.id);
 }
 
 ScriptEngine& CScene::getScriptEngine () const { return *this->m_scriptEngine; }
 Camera& CScene::getCamera () const { return *this->m_camera; }
 
+namespace {
+// with LWE_FRAME_STATS set, any single step of a scene frame slower than this is logged by name
+constexpr double STALL_THRESHOLD_MS = 15.0;
+
+template <typename Step> void timeStep (const std::string& label, Step&& step) {
+    static const bool enabled = std::getenv ("LWE_FRAME_STATS") != nullptr;
+
+    if (!enabled) {
+	step ();
+	return;
+    }
+
+    glFinish ();
+    const auto start = std::chrono::steady_clock::now ();
+    step ();
+    glFinish ();
+    const double ms = std::chrono::duration<double, std::milli> (std::chrono::steady_clock::now () - start).count ();
+
+    if (ms >= STALL_THRESHOLD_MS) {
+	sLog.out ("FRAME-STALL ", label, ": ", ms, "ms");
+    }
+}
+}
+
 void CScene::renderFrame (const glm::ivec4& viewport) {
-    this->updateMouse (viewport);
+    timeStep ("renderFrame total", [&] { this->renderFrameSteps (viewport); });
+}
+
+void CScene::renderFrameSteps (const glm::ivec4& viewport) {
+    timeStep ("updateMouse", [&] { this->updateMouse (viewport); });
 
     if (this->getScene ().camera.parallax.enabled->value->getBool ()) {
 	// the wallpaper's own position rides through the same per-layer depth/clamp mechanism as mouse parallax, halved to match its range
@@ -425,21 +358,32 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 
 	if (this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
 	    // no mouse contribution left to smooth toward, apply position directly
-	    this->m_parallaxDisplacement = positionBias;
+	    this->m_parallaxBias = positionBias;
+	    this->m_cameraParallax = { 0.0f, 0.0f };
 	} else {
 	    const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
 	    const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
-	    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) + positionBias;
-	    this->m_parallaxDisplacement
-		= glm::mix (this->m_parallaxDisplacement, (centeredMouse * amount) * influence, delay);
+	    const float delay = this->getScene ().camera.parallax.delay->value->getFloat ();
+
+	    this->m_parallaxBias = positionBias * amount * influence;
+
+	    // same easing as the real engine: no delay snaps to the mouse, otherwise a fast exponential follow
+	    const glm::vec2 target = (this->m_mousePosition - glm::vec2 (0.5f, 0.5f)) * influence;
+	    if (delay <= 0.0f) {
+		this->m_cameraParallax = target;
+	    } else {
+		const float dt = std::max (g_Time - g_TimeLast, 0.0f);
+		const float follow = std::min (1.0f, (1.0f - delay / 3.0f) * 10.0f * dt);
+		this->m_cameraParallax += (target - this->m_cameraParallax) * follow;
+	    }
 	}
+
+	this->m_parallaxPosition = glm::clamp (glm::vec2 (0.5f) + this->m_cameraParallax, 0.0f, 1.0f);
     }
 
-    this->getScriptEngine ().tick ();
+    // after the tick, so a layer a script moves this frame (e.g. onto input.cursorWorldPosition) is hit tested where it is now
+    timeStep ("script tick", [&] { this->getScriptEngine ().tick (); });
+    timeStep ("cursor events", [&] { this->dispatchCursorEvents (); });
 
     // only image objects need their texture (e.g. video/gif frame) refreshed before drawing
     for (const auto& cur : this->m_objectsByRenderOrder) {
@@ -455,7 +399,7 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 	glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, message.c_str ());
 #endif
 
-	image->getTexture ()->update ();
+	timeStep ("updateTextures " + image->getObject ().name, [&] { image->updateTextures (); });
 
 #if !NDEBUG
 	glPopDebugGroup ();
@@ -487,7 +431,77 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 	    continue;
 	}
 
-	cur->render ();
+	timeStep ("render " + cur->getObject ().name, [&] { cur->render (); });
+    }
+}
+
+void CScene::dispatchCursorEvents () {
+    if (!this->getContext ().getApp ().getContext ().settings.mouse.enabled) {
+	return;
+    }
+
+    const bool down
+	= this->getContext ().getInputContext ().getMouseInput ().leftClick () == Input::MouseClickStatus::Clicked;
+    const bool pressed = down && !this->m_cursorLeftDown;
+    const bool released = !down && this->m_cursorLeftDown;
+    this->m_cursorLeftDown = down;
+
+    const glm::vec2 scenePosition = { this->m_mousePositionNormalized.x * static_cast<float> (this->getWidth ()),
+				      this->m_mousePositionNormalized.y * static_cast<float> (this->getHeight ()) };
+    const bool moved = scenePosition != this->m_cursorLastScenePosition;
+    this->m_cursorLastScenePosition = scenePosition;
+
+    auto& engine = this->getScriptEngine ();
+    // handlers are free to create layers, which would move things around under a live iteration
+    const auto objects = this->m_objectsByRenderOrder;
+
+    for (auto* cur : objects) {
+	if (!cur->is<Objects::CImage> ()) {
+	    continue;
+	}
+
+	auto* image = cur->as<Objects::CImage> ();
+
+	if (!engine.hasCursorHandlers (*image)) {
+	    continue;
+	}
+
+	image->refreshScenePosition ();
+
+	const int id = image->getImage ().id;
+	const bool inside = image->getImage ().visible->value->getBool () && !this->isHiddenByAncestor (*cur)
+	    && image->containsScenePoint (scenePosition);
+	const bool wasInside = this->m_cursorInside.contains (id);
+	const glm::vec2 local = scenePosition - image->getSceneCenter ();
+
+	if (inside && !wasInside) {
+	    this->m_cursorInside.insert (id);
+	    engine.dispatchCursorEvent ("cursorEnter", *image, scenePosition, local);
+	} else if (!inside && wasInside) {
+	    this->m_cursorInside.erase (id);
+	    engine.dispatchCursorEvent ("cursorLeave", *image, scenePosition, local);
+	}
+
+	if (inside && moved) {
+	    engine.dispatchCursorEvent ("cursorMove", *image, scenePosition, local);
+	}
+
+	if (pressed && inside) {
+	    this->m_cursorPressed.insert (id);
+	    engine.dispatchCursorEvent ("cursorDown", *image, scenePosition, local);
+	}
+
+	if (released) {
+	    const bool clicked = this->m_cursorPressed.erase (id) > 0 && inside;
+
+	    if (inside) {
+		engine.dispatchCursorEvent ("cursorUp", *image, scenePosition, local);
+	    }
+
+	    if (clicked) {
+		engine.dispatchCursorEvent ("cursorClick", *image, scenePosition, local);
+	    }
+	}
     }
 }
 
@@ -519,10 +533,63 @@ const Data::Model::Properties& CScene::getUserProperties () const {
 
 const Scene& CScene::getScene () const { return *this->getWallpaperData ().as<Scene> (); }
 
+void CScene::expandCanvasToContent (
+    const Scene& scene, const float width, const float height, float& canvasWidth, float& canvasHeight
+) const {
+    // keeps the expanded canvas within what the GPU is guaranteed to be able to allocate for a framebuffer
+    constexpr float maxCanvasSize = 8192.0f;
+
+    float extentX = width / 2.0f;
+    float extentY = height / 2.0f;
+
+    for (const auto& object : scene.objects) {
+	if (!object->is<Image> ()) {
+	    continue;
+	}
+
+	const auto* image = object->as<Image> ();
+
+	// children are positioned relative to their parent, and a layer with no declared size takes its
+	// texture's, which isn't known this early
+	if (image->parent.has_value () || image->size.x <= 0.0f || image->size.y <= 0.0f
+	    || image->origin == nullptr || image->scale == nullptr) {
+	    continue;
+	}
+
+	const auto visibility = this->getContext ().getApp ().getContext ().resolveObjectVisibility (image->id, image->name);
+
+	if (visibility.has_value () && !visibility.value ()) {
+	    continue;
+	}
+
+	if (image->visible != nullptr && !image->visible->value->getBool ()) {
+	    continue;
+	}
+
+	const glm::vec3 origin = image->origin->value->getVec3 ();
+	const glm::vec3 scale = image->scale->value->getVec3 ();
+
+	// rotation is ignored on purpose, a rotated layer just keeps the extent its unrotated box has
+	extentX = std::max (extentX, std::abs (origin.x - width / 2.0f) + std::abs (scale.x) * image->size.x / 2.0f);
+	extentY = std::max (extentY, std::abs (origin.y - height / 2.0f) + std::abs (scale.y) * image->size.y / 2.0f);
+    }
+
+    canvasWidth = std::min (std::ceil (extentX * 2.0f), std::max (width, maxCanvasSize));
+    canvasHeight = std::min (std::ceil (extentY * 2.0f), std::max (height, maxCanvasSize));
+
+    if (canvasWidth != width || canvasHeight != height) {
+	sLog.out ("Expanded scene canvas from ", width, "x", height, " to ", canvasWidth, "x", canvasHeight);
+    }
+}
+
 int CScene::getWidth () const { return this->m_camera->getWidth (); }
 
 int CScene::getHeight () const { return this->m_camera->getHeight (); }
 
+int CScene::getCanvasWidth () const { return this->m_camera->getCanvasWidth (); }
+
+int CScene::getCanvasHeight () const { return this->m_camera->getCanvasHeight (); }
+
 float CScene::getTime () const { return g_Time; }
 
 float CScene::getDeltaTime () const { return g_Time - g_TimeLast; }
@@ -542,7 +609,62 @@ const glm::vec2* CScene::getMousePositionLast () const { return &this->m_mousePo
 
 const glm::vec2* CScene::getMousePositionNormalized () const { return &this->m_mousePositionNormalized; }
 
-const glm::vec2* CScene::getParallaxDisplacement () const { return &this->m_parallaxDisplacement; }
+const glm::vec2* CScene::getParallaxPosition () const { return &this->m_parallaxPosition; }
+
+glm::vec2 CScene::getParallaxOffset (const Object& object) const {
+    if (!this->getScene ().camera.parallax.enabled->value->getBool ()) {
+	return { 0.0f, 0.0f };
+    }
+
+    const auto depthOf = [] (const Object& candidate) -> std::optional<glm::vec2> {
+	if (candidate.is<Image> ()) {
+	    return candidate.as<Image> ()->parallaxDepth->value->getVec2 ();
+	}
+	if (candidate.is<Text> ()) {
+	    return candidate.as<Text> ()->parallaxDepth->value->getVec2 ();
+	}
+	if (candidate.is<Particle> ()) {
+	    return candidate.as<Particle> ()->parallaxDepth->value->getVec2 ();
+	}
+	return std::nullopt;
+    };
+
+    // topmost ancestor that carries a parallaxDepth, guarded against a malformed parent loop
+    constexpr int maxParentDepth = 32;
+    const Object* anchor = &object;
+    std::optional<glm::vec2> depth = depthOf (object);
+    const Object* current = &object;
+    for (int i = 0; i < maxParentDepth && current->parent.has_value (); ++i) {
+	const CObject* parent = this->getObject (current->parent.value ());
+	if (parent == nullptr) {
+	    break;
+	}
+	current = &parent->getObject ();
+	if (const auto parentDepth = depthOf (*current); parentDepth.has_value ()) {
+	    anchor = current;
+	    depth = parentDepth;
+	}
+    }
+
+    if (!depth.has_value ()) {
+	return { 0.0f, 0.0f };
+    }
+
+    const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
+    const float width = static_cast<float> (this->getWidth ());
+    const float height = static_cast<float> (this->getHeight ());
+    glm::vec2 shift = (*depth + amount) * this->m_parallaxBias * width;
+
+    if (!this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	// real engine: (origin - cameraPosition) * amount * depth, camera sitting at the mouse-driven point of the
+	// scene. Origins are y-up and this space is y-down, hence the flipped Y terms
+	const glm::vec3 origin = anchor->origin->value->getVec3 ();
+	shift.x += (origin.x - width * 0.5f - this->m_cameraParallax.x * width) * amount * depth->x;
+	shift.y -= (origin.y - height * 0.5f + this->m_cameraParallax.y * height) * amount * depth->y;
+    }
+
+    return shift;
+}
 
 const std::vector<CObject*>& CScene::getObjectsByRenderOrder () const { return this->m_objectsByRenderOrder; }
 

+ 37 - 2
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -5,6 +5,8 @@
 #include "WallpaperEngine/Render/CWallpaper.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 
+#include <set>
+
 namespace WallpaperEngine::Render {
 class Camera;
 class CObject;
@@ -32,6 +34,8 @@ public:
 
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () const override;
+    [[nodiscard]] int getCanvasWidth () const override;
+    [[nodiscard]] int getCanvasHeight () const override;
 
     // Used by CText/ScriptEngine; read from the same g_Time/g_TimeLast globals CParticle consumes via extern.
     [[nodiscard]] float getTime () const;
@@ -41,7 +45,15 @@ public:
     const glm::vec2* getMousePosition () const;
     const glm::vec2* getMousePositionLast () const;
     const glm::vec2* getMousePositionNormalized () const;
-    const glm::vec2* getParallaxDisplacement () const;
+    [[nodiscard]] bool isCursorLeftDown () const { return this->m_cursorLeftDown; }
+    /** Position fed to shaders as g_ParallaxPosition: 0.5 +- the smoothed, influence-scaled mouse offset */
+    const glm::vec2* getParallaxPosition () const;
+    /**
+     * Parallax translation of an object in scene units, in the y-down space CImage/CText/CParticle position
+     * themselves in. Like the real engine, the whole parent chain shifts as one rigid group using the topmost
+     * ancestor's origin and parallaxDepth, a child's own depth is ignored.
+     */
+    [[nodiscard]] glm::vec2 getParallaxOffset (const Data::Model::Object& object) const;
 
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
     [[nodiscard]] const CObject* getObject (int id) const;
@@ -64,12 +76,24 @@ public:
 
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
+    void renderFrameSteps (const glm::ivec4& viewport);
     void updateMouse (const glm::ivec4& viewport);
+    /** Hover/press tracking that turns pointer state into cursorEnter/cursorClick/... calls on scripted layers */
+    void dispatchCursorEvents ();
 
     friend class CWallpaper;
 
 private:
+    /**
+     * Grows the render canvas (symmetrically around the layout center, so object positions stay valid) until
+     * every top-level image layer with a declared size fits, see --expand-canvas
+     */
+    void expandCanvasToContent (
+	const Scene& scene, float width, float height, float& canvasWidth, float& canvasHeight
+    ) const;
+
     Render::CObject* createObject (const Object& object);
+    void createObjectDependencies (const Object& object);
     Render::CObject* dispatchObjectType (const Object& object);
     void addObjectToRenderOrder (const Object& object);
 
@@ -78,6 +102,8 @@ private:
     ObjectUniquePtr m_bloomObjectData;
     CObject* m_bloomObject = nullptr;
     std::map<int, CObject*> m_objects = {};
+    std::set<int> m_objectsInCreation = {};
+    std::set<int> m_objectsInRenderOrderWalk = {};
     std::map<int, bool> m_soundPlayRequests = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
     std::vector<DynamicValue*> m_scriptedValues = {};
@@ -88,7 +114,16 @@ private:
     glm::vec2 m_mousePosition = {};
     glm::vec2 m_mousePositionLast = {};
     glm::vec2 m_mousePositionNormalized = {};
-    glm::vec2 m_parallaxDisplacement = {};
+    /** Smoothed camera offset from the scene center as a fraction of the scene size, in mouse coordinates */
+    glm::vec2 m_cameraParallax = {};
+    glm::vec2 m_parallaxPosition = { 0.5f, 0.5f };
+    /** Wallpaper position offset (WallpaperState) riding through the parallax path */
+    glm::vec2 m_parallaxBias = {};
+    bool m_cursorLeftDown = false;
+    glm::vec2 m_cursorLastScenePosition = {};
+    // object ids the pointer is over / that the current press started on
+    std::set<int> m_cursorInside = {};
+    std::set<int> m_cursorPressed = {};
     std::shared_ptr<const CFBO> _rt_4FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_8FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_Bloom = nullptr;

+ 20 - 1
src/WallpaperEngine/Render/Wallpapers/CSplat.cpp

@@ -6,6 +6,9 @@
 #include <cstdio>
 #include <cstring>
 #include <ctime>
+#include <filesystem>
+#include <fstream>
+#include <iterator>
 #include <limits>
 #include <mutex>
 #include <optional>
@@ -490,10 +493,26 @@ void CSplat::loadCloud () {
 	meta = locator.readString (directory + "/meta.json");
     }
 
+    // presets store the folder relative to themselves, which arrives here as an absolute path on disk
+    const bool onDisk = std::filesystem::path (directory).is_absolute ();
+    const auto readFile = [&] (const std::string& name) -> std::string {
+	if (!onDisk) {
+	    return locator.readString (directory + "/" + name);
+	}
+
+	std::ifstream file (std::filesystem::path (directory) / name, std::ios::binary);
+
+	if (!file) {
+	    sLog.exception ("Cannot read ", (std::filesystem::path (directory) / name).string ());
+	}
+
+	return { std::istreambuf_iterator<char> (file), std::istreambuf_iterator<char> () };
+    };
+
     Splat::SplatCloud cloud;
 
     try {
-	cloud = Splat::loadSog (meta, [&] (const std::string& name) { return locator.readString (directory + "/" + name); });
+	cloud = Splat::loadSog (meta, readFile);
     } catch (const std::exception& e) {
 	sLog.exception ("Cannot load the splat cloud from ", directory, ": ", e.what ());
     }

+ 209 - 34
src/WallpaperEngine/Render/Wallpapers/CWeb.cpp

@@ -5,11 +5,18 @@
 #include "WallpaperEngine/Application/WallpaperApplication.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include "WallpaperEngine/Media/ThumbnailPalette.h"
+#include "WallpaperEngine/Render/RenderContext.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 
+#include <atomic>
 #include <chrono>
+#include <cmath>
 #include <csignal>
+#include <cstdlib>
+#include <cstring>
+#include <cctype>
 #include <sys/prctl.h>
 #include <sys/syscall.h>
 #include <sys/wait.h>
@@ -54,6 +61,21 @@ CWeb::CWeb (
     this->m_shm->desiredWidth.store (static_cast<uint32_t> (this->m_width), std::memory_order_relaxed);
     this->m_shm->desiredHeight.store (static_cast<uint32_t> (this->m_height), std::memory_order_relaxed);
 
+    // pushed from the recorder's capture thread, the host process only ever reads these atomics
+    auto* shm = this->m_shm;
+
+    this->m_spectrumListenerId = this->getAudioContext ().getRecorder ().addSpectrumListener (
+	[shm] (const float* audio64) {
+	    for (std::size_t i = 0; i < WebHostSharedMemory::AUDIO_BANDS; i++) {
+		shm->audioBands[i].store (audio64[i], std::memory_order_relaxed);
+	    }
+
+	    shm->audioSeq.fetch_add (1, std::memory_order_release);
+	}
+    );
+
+    this->m_statsEnabled = std::getenv ("LWE_WEB_STATS") != nullptr;
+
     this->spawnHost (resolvedBackgroundPath);
 }
 
@@ -90,6 +112,13 @@ void CWeb::spawnHost (const std::filesystem::path& resolvedBackgroundPath) {
 	    args.emplace_back (appContext.settings.general.assets.string ());
 	}
 
+	// the host reads the wallpaper's project on its own, so it only knows about property values that were passed on
+	// the command line or through a hotswap request if they are handed to it again
+	for (const auto& [name, value] : appContext.settings.general.properties) {
+	    args.emplace_back ("--set-property");
+	    args.emplace_back (name + "=" + value);
+	}
+
 	std::vector<char*> argv;
 	argv.reserve (args.size () + 2);
 	// cosmetic only (what the child sees as its own argv[0]) - the executable actually run is
@@ -151,57 +180,190 @@ void CWeb::renderFrame (const glm::ivec4& viewport) {
     }
 
     this->updateMouse (viewport);
+    this->updateMedia ();
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
     glViewport (0, 0, this->getWidth (), this->getHeight ());
 
-    // Pull the latest frame the host process painted, if there's one we haven't uploaded yet.
-    // Seqlock: frameSeq is even and stable while no write is in progress: odd means the host is
-    // mid-write, and a value that changed across our read means we caught a write in progress and
-    // may have read a torn frame - either way, just try again on a later call rather than block,
-    // bounded so a host that's somehow writing every few microseconds can't stall rendering here.
-    for (int attempt = 0; attempt < 4; attempt++) {
-	const uint32_t seqBefore = this->m_shm->frameSeq.load (std::memory_order_acquire);
+    if ((this->m_shm->frameSlot.load (std::memory_order_acquire) & WebHostSharedMemory::FRAME_DIRTY) == 0) {
+	// nothing new, but the next frame is still due
+	this->requestPageFrame ();
+	return;
+    }
 
-	if (seqBefore % 2 != 0 || seqBefore == this->m_lastUploadedSeq) {
-	    break;
-	}
+    const uint32_t taken = this->m_shm->frameSlot.exchange (this->m_frontSlot, std::memory_order_acq_rel);
+    this->m_frontSlot = taken & WebHostSharedMemory::FRAME_SLOT_MASK;
 
-	const uint32_t frameWidth = this->m_shm->frameWidth.load (std::memory_order_relaxed);
-	const uint32_t frameHeight = this->m_shm->frameHeight.load (std::memory_order_relaxed);
+    const uint32_t frameWidth = this->m_shm->slotWidth[this->m_frontSlot].load (std::memory_order_relaxed);
+    const uint32_t frameHeight = this->m_shm->slotHeight[this->m_frontSlot].load (std::memory_order_relaxed);
 
-	if (frameWidth == 0 || frameHeight == 0) {
-	    break;
+    if (frameWidth == 0 || frameHeight == 0) {
+	this->requestPageFrame ();
+	return;
+    }
+
+    glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
+
+    // only reallocate when the size changed, glTexSubImage2D avoids a driver-side realloc/sync every frame
+    if (frameWidth == this->m_uploadedTextureWidth && frameHeight == this->m_uploadedTextureHeight) {
+	glTexSubImage2D (
+	    GL_TEXTURE_2D, 0, 0, 0, static_cast<GLsizei> (frameWidth), static_cast<GLsizei> (frameHeight),
+	    GL_BGRA_EXT, GL_UNSIGNED_BYTE, this->m_shm->frameBuffer (this->m_frontSlot)
+	);
+    } else {
+	glTexImage2D (
+	    GL_TEXTURE_2D, 0, GL_RGBA, static_cast<GLsizei> (frameWidth), static_cast<GLsizei> (frameHeight), 0,
+	    GL_BGRA_EXT, GL_UNSIGNED_BYTE, this->m_shm->frameBuffer (this->m_frontSlot)
+	);
+	this->m_uploadedTextureWidth = frameWidth;
+	this->m_uploadedTextureHeight = frameHeight;
+    }
+
+    glBindTexture (GL_TEXTURE_2D, 0);
+
+    this->m_statsFrames++;
+
+    // ask for the next frame only after taking this one, so what gets painted is always what the next render shows
+    this->requestPageFrame ();
+}
+
+void CWeb::requestPageFrame () {
+    const auto now = std::chrono::steady_clock::now ();
+
+    if (this->m_lastRender != std::chrono::steady_clock::time_point {}) {
+	const double interval = std::chrono::duration<double> (now - this->m_lastRender).count ();
+
+	// a long gap is a pause or a stall, not the rate we're rendering at
+	if (interval < 0.1) {
+	    this->m_renderInterval = this->m_renderInterval == 0.0 ? interval : this->m_renderInterval * 0.95 + interval * 0.05;
+
+	    // renders per 60Hz frame, only switching once clearly closer to another whole number so a rate sitting
+	    // between two (90Hz) doesn't flip back and forth
+	    const double ratio = 1.0 / (this->m_renderInterval * 60.0);
+
+	    if (std::abs (ratio - this->m_frameRequestDivisor) > 0.65) {
+		this->m_frameRequestDivisor = static_cast<uint32_t> (std::max (1L, std::lround (ratio)));
+	    }
 	}
+    }
+
+    this->m_lastRender = now;
 
-	glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
+    if (this->m_statsEnabled) {
+	this->m_statsRenders++;
 
-	// Only reallocate GPU storage (glTexImage2D) when the size actually changed - the common
-	// case is the same size every frame, and glTexSubImage2D updating pixels in place avoids the
-	// driver-side realloc/sync that a fresh glTexImage2D call triggers, up to 60 times a second.
-	if (frameWidth == this->m_uploadedTextureWidth && frameHeight == this->m_uploadedTextureHeight) {
-	    glTexSubImage2D (
-		GL_TEXTURE_2D, 0, 0, 0, static_cast<GLsizei> (frameWidth), static_cast<GLsizei> (frameHeight),
-		GL_BGRA_EXT, GL_UNSIGNED_BYTE, this->m_shm->frameBuffer ()
+	if (this->m_statsStart == std::chrono::steady_clock::time_point {}) {
+	    this->m_statsStart = now;
+	} else if (const double elapsed = std::chrono::duration<double> (now - this->m_statsStart).count (); elapsed >= 5.0) {
+	    sLog.out (
+		"CWeb host ", this->m_hostPid, ": ", this->m_statsRenders / elapsed, " renders/s, ",
+		this->m_statsFrames / elapsed, " page frames/s, one page frame every ", this->m_frameRequestDivisor,
+		" render(s)"
 	    );
+	    this->m_statsStart = now;
+	    this->m_statsRenders = 0;
+	    this->m_statsFrames = 0;
+	}
+    }
+
+    if (++this->m_rendersSinceRequest < this->m_frameRequestDivisor) {
+	return;
+    }
+
+    this->m_rendersSinceRequest = 0;
+    this->m_shm->requestFrame ();
+}
+
+namespace {
+// truncates to what fits, without leaving half of a multi byte character at the end
+void copyText (char* target, std::size_t capacity, const std::string& text) {
+    std::size_t length = std::min (text.size (), capacity - 1);
+
+    while (length > 0 && length < text.size () && (static_cast<unsigned char> (text[length]) & 0xC0) == 0x80) {
+	length--;
+    }
+
+    std::memcpy (target, text.data (), length);
+    target[length] = '\0';
+}
+
+// MPRIS art URLs are percent encoded file:// URLs, the way they appear on the bus
+std::string coverPathFromUrl (const std::optional<std::string>& url) {
+    if (!url.has_value () || !url->starts_with ("file://")) {
+	return "";
+    }
+
+    std::string path;
+
+    for (std::size_t i = 7; i < url->size (); i++) {
+	if ((*url)[i] == '%' && i + 2 < url->size () && std::isxdigit ((*url)[i + 1]) && std::isxdigit ((*url)[i + 2])) {
+	    path += static_cast<char> (std::stoi (url->substr (i + 1, 2), nullptr, 16));
+	    i += 2;
 	} else {
-	    glTexImage2D (
-		GL_TEXTURE_2D, 0, GL_RGBA, static_cast<GLsizei> (frameWidth), static_cast<GLsizei> (frameHeight), 0,
-		GL_BGRA_EXT, GL_UNSIGNED_BYTE, this->m_shm->frameBuffer ()
-	    );
-	    this->m_uploadedTextureWidth = frameWidth;
-	    this->m_uploadedTextureHeight = frameHeight;
+	    path += (*url)[i];
 	}
+    }
 
-	glBindTexture (GL_TEXTURE_2D, 0);
+    return path;
+}
+} // namespace
 
-	const uint32_t seqAfter = this->m_shm->frameSeq.load (std::memory_order_acquire);
+void CWeb::updateMedia () {
+    const auto& info = this->getContext ().getMediaSource ().getMediaInfo ();
+    const auto& last = this->m_lastMedia;
 
-	if (seqAfter == seqBefore) {
-	    this->m_lastUploadedSeq = seqAfter;
-	    break;
+    if (this->m_mediaPublished && info.title == last.title && info.artist == last.artist && info.album == last.album
+	&& info.url == last.url && info.playbackState == last.playbackState && info.available == last.available
+	&& info.duration == last.duration && info.position == last.position) {
+	return;
+    }
+
+    const bool coverChanged = !this->m_mediaPublished || info.url != last.url;
+    const std::string coverPath = coverPathFromUrl (info.url);
+    Media::ThumbnailPalette palette;
+
+    if (coverChanged) {
+	this->m_coverVersion++;
+
+	if (!coverPath.empty ()) {
+	    palette = Media::loadThumbnailPalette (coverPath);
 	}
-	// torn read, the host wrote a new frame while we were copying - retry
     }
+
+    auto& shm = *this->m_shm;
+    const uint32_t seq = shm.mediaSeq.load (std::memory_order_relaxed);
+
+    shm.mediaSeq.store (seq + 1, std::memory_order_release);
+    std::atomic_thread_fence (std::memory_order_seq_cst);
+
+    shm.mediaState = static_cast<int32_t> (info.playbackState);
+    shm.mediaAvailable = info.available;
+    // MPRIS reports microseconds, pages are given seconds
+    shm.mediaPosition = info.position / 1000000.0;
+    shm.mediaDuration = info.duration / 1000000.0;
+    copyText (shm.mediaTitle, WebHostSharedMemory::MEDIA_TEXT, info.title);
+    copyText (shm.mediaArtist, WebHostSharedMemory::MEDIA_TEXT, info.artist);
+    copyText (shm.mediaAlbum, WebHostSharedMemory::MEDIA_TEXT, info.album);
+
+    if (coverChanged) {
+	shm.mediaCoverVersion = this->m_coverVersion;
+	copyText (shm.mediaCoverPath, WebHostSharedMemory::MEDIA_PATH, coverPath);
+
+	const glm::vec3 colors[5]
+	    = { palette.primary, palette.secondary, palette.tertiary, palette.text, palette.highContrast };
+
+	for (int i = 0; i < 5; i++) {
+	    for (int channel = 0; channel < 3; channel++) {
+		shm.mediaPalette[i][channel]
+		    = static_cast<uint8_t> (std::clamp (colors[i][channel], 0.0f, 1.0f) * 255.0f + 0.5f);
+	    }
+	}
+    }
+
+    std::atomic_thread_fence (std::memory_order_seq_cst);
+    shm.mediaSeq.store (seq + 2, std::memory_order_release);
+
+    this->m_lastMedia = info;
+    this->m_mediaPublished = true;
 }
 
 void CWeb::updateMouse (const glm::ivec4& viewport) {
@@ -222,6 +384,9 @@ void CWeb::updateMouse (const glm::ivec4& viewport) {
 }
 
 CWeb::~CWeb () {
+    // waits for a callback that's running right now, so the shared memory can be unmapped safely below
+    this->getAudioContext ().getRecorder ().removeSpectrumListener (this->m_spectrumListenerId);
+
     this->m_shm->quitRequested.store (true, std::memory_order_release);
 
     // Give the host process a chance to close its CEF browser and shut down cleanly before falling
@@ -229,6 +394,7 @@ CWeb::~CWeb () {
     // directly in this process (500 iterations there too, just pumping CEF's own message loop
     // instead of polling waitpid - there's no message loop to pump here anymore).
     bool exited = false;
+    const auto shutdownStart = std::chrono::steady_clock::now ();
 
     for (int i = 0; i < 500; i++) {
 	int status = 0;
@@ -241,7 +407,16 @@ CWeb::~CWeb () {
 	std::this_thread::sleep_for (std::chrono::milliseconds (10));
     }
 
+    if (this->m_statsEnabled) {
+	sLog.out (
+	    "CWeb host ", this->m_hostPid, exited ? " exited after " : " had to be killed after ",
+	    std::chrono::duration_cast<std::chrono::milliseconds> (std::chrono::steady_clock::now () - shutdownStart).count (),
+	    "ms"
+	);
+    }
+
     if (!exited) {
+	sLog.error ("CWeb: web host ", this->m_hostPid, " did not shut down in time, killing it");
 	kill (this->m_hostPid, SIGKILL);
 	waitpid (this->m_hostPid, nullptr, 0);
     }

+ 27 - 2
src/WallpaperEngine/Render/Wallpapers/CWeb.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <chrono>
 #include <glm/ext.hpp>
 #include <glm/glm.hpp>
 
@@ -12,6 +13,7 @@
 #include <vector>
 
 #include "WallpaperEngine/Audio/AudioStream.h"
+#include "WallpaperEngine/Media/MediaSource.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
 #include "WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h"
 
@@ -45,24 +47,47 @@ public:
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
     void updateMouse (const glm::ivec4& viewport);
+    /** Mirrors the media player state into the shared segment for the host process to forward to the page */
+    void updateMedia ();
     const Web& getWeb () const { return *this->getWallpaperData ().as<Web> (); }
 
     friend class CWallpaper;
 
 private:
     void spawnHost (const std::filesystem::path& resolvedBackgroundPath);
+    // asks the host for the next page frame, at most once per m_frameRequestDivisor renders
+    void requestPageFrame ();
 
     WallpaperEngine::WebBrowser::IPC::WebHostSharedMemory* m_shm = nullptr;
     std::string m_shmName;
     uint32_t m_shmMaxWidth = 0;
     uint32_t m_shmMaxHeight = 0;
-    // seqlock value of the last frame actually uploaded to the GL texture - see renderFrame()
-    uint32_t m_lastUploadedSeq = 0;
+    // slot of the shared triple buffer this side owns, the one uploaded to the GL texture
+    uint32_t m_frontSlot = 2;
     // size of the texture as currently allocated on the GPU, so renderFrame() can tell whether it
     // needs to reallocate storage (glTexImage2D) or can just update pixels in place (glTexSubImage2D)
     uint32_t m_uploadedTextureWidth = 0;
     uint32_t m_uploadedTextureHeight = 0;
+    // Renders come at whatever rate the display and --fps allow, but the page only needs about 60 frames a second:
+    // on a 240Hz display every 4th render asks for one, which keeps the cadence even instead of 4x the paint and copy
+    // work. Derived from the measured render interval.
+    std::chrono::steady_clock::time_point m_lastRender {};
+    double m_renderInterval = 0.0;
+    uint32_t m_frameRequestDivisor = 1;
+    uint32_t m_rendersSinceRequest = 0;
+    // LWE_WEB_STATS=1 logs how many renders and page frames per second this wallpaper gets, to tell a slow page from a
+    // slow engine when web wallpapers degrade
+    bool m_statsEnabled = false;
+    std::chrono::steady_clock::time_point m_statsStart {};
+    uint32_t m_statsRenders = 0;
+    uint32_t m_statsFrames = 0;
     pid_t m_hostPid = -1;
+    // registered with the audio recorder so spectra are pushed to the host as soon as they're computed instead of
+    // being sampled once per rendered frame
+    int m_spectrumListenerId = 0;
+    Media::MediaSource::MediaInfo m_lastMedia {};
+    bool m_mediaPublished = false;
+    uint32_t m_coverVersion = 0;
     bool m_helperFailureLogged = false;
 
     int m_width = 16;

+ 272 - 2
src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp

@@ -1,5 +1,6 @@
 #include "ScriptableObjectAdapter.h"
 
+#include <algorithm>
 #include <cstring>
 #include <utility>
 
@@ -7,6 +8,8 @@
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/Objects/CImage.h"
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 #include "WallpaperEngine/Scripting/ScriptableObject.h"
 
@@ -34,7 +37,9 @@ JSValue scriptableobject_playback_call (
 	return JS_NewBool (ctx, object->isPlaying ());
     }
 
-    object->setPlaying (magic == 0);
+    using Playback = WallpaperEngine::Scripting::ScriptableObject::Playback;
+
+    object->setPlayback (magic == 0 ? Playback::Playing : magic == 1 ? Playback::Paused : Playback::Stopped);
 
     return JS_UNDEFINED;
 }
@@ -95,6 +100,246 @@ JSValue scriptableobject_hierarchy_call (
     return children;
 }
 
+// effect handles keep the effect's address, safe for the same reason as the object address above
+JSValue scriptableeffect_visible_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t effectAddress = 0;
+    int64_t engineAddress = 0;
+    JS_ToInt64 (ctx, &effectAddress, func_data[0]);
+    JS_ToInt64 (ctx, &engineAddress, func_data[1]);
+    auto* effect = reinterpret_cast<ImageEffect*> (static_cast<intptr_t> (effectAddress));
+    auto* engine = reinterpret_cast<WallpaperEngine::Scripting::ScriptEngine*> (static_cast<intptr_t> (engineAddress));
+
+    if (magic == 0) {
+	return JS_NewBool (ctx, effect->visible->value->getBool ());
+    }
+
+    if (argc > 0) {
+	engine->assignJsValue (argv[0], *effect->visible->value);
+    }
+
+    return JS_UNDEFINED;
+}
+
+const std::vector<ImageEffectUniquePtr>* effectsOf (const Object& object) {
+    if (object.is<Image> ()) {
+	return &object.as<Image> ()->effects;
+    }
+
+    if (object.is<Text> ()) {
+	return &object.as<Text> ()->effects;
+    }
+
+    return nullptr;
+}
+
+JSValue scriptableeffect_instantiate (JSContext* ctx, ImageEffect& effect, WallpaperEngine::Scripting::ScriptEngine& engine) {
+    JSValue handle = JS_NewObject (ctx);
+    JSValue data[] = {
+	JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&effect))),
+	JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&engine))),
+    };
+    const JSAtom visible = JS_NewAtom (ctx, "visible");
+
+    JS_DefinePropertyGetSet (
+	ctx, handle, visible, JS_NewCFunctionData (ctx, scriptableeffect_visible_call, 0, 0, 2, data),
+	JS_NewCFunctionData (ctx, scriptableeffect_visible_call, 1, 1, 2, data), JS_PROP_ENUMERABLE
+    );
+    JS_FreeAtom (ctx, visible);
+    JS_SetPropertyStr (ctx, handle, "name", JS_NewString (ctx, effect.name.c_str ()));
+
+    return handle;
+}
+
+JSValue scriptableobject_effect_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t objectAddress = 0;
+    int64_t engineAddress = 0;
+    JS_ToInt64 (ctx, &objectAddress, func_data[0]);
+    JS_ToInt64 (ctx, &engineAddress, func_data[1]);
+    auto* object = reinterpret_cast<WallpaperEngine::Scripting::ScriptableObject*> (static_cast<intptr_t> (objectAddress));
+    auto* engine = reinterpret_cast<WallpaperEngine::Scripting::ScriptEngine*> (static_cast<intptr_t> (engineAddress));
+    const auto* effects = effectsOf (object->getObject ());
+
+    if (magic == 0) {
+	return JS_NewInt32 (ctx, effects == nullptr ? 0 : static_cast<int> (effects->size ()));
+    }
+
+    if (effects == nullptr || argc < 1) {
+	return JS_UNDEFINED;
+    }
+
+    if (JS_IsNumber (argv[0])) {
+	int index = 0;
+	JS_ToInt32 (ctx, &index, argv[0]);
+
+	if (index < 0 || static_cast<size_t> (index) >= effects->size ()) {
+	    return JS_UNDEFINED;
+	}
+
+	return scriptableeffect_instantiate (ctx, *(*effects)[index], *engine);
+    }
+
+    const char* name = JS_ToCString (ctx, argv[0]);
+
+    if (name == nullptr) {
+	return JS_UNDEFINED;
+    }
+
+    ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
+
+    for (const auto& effect : *effects) {
+	if (effect->name == name) {
+	    return scriptableeffect_instantiate (ctx, *effect, *engine);
+	}
+    }
+
+    return JS_UNDEFINED;
+}
+
+// video textures are only ever reached through the player's address, kept alive by the layer's texture for as long as the scene exists
+JSValue videotexture_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t playerAddress = 0;
+    int64_t engineAddress = 0;
+    JS_ToInt64 (ctx, &playerAddress, func_data[0]);
+    JS_ToInt64 (ctx, &engineAddress, func_data[1]);
+    auto* player = reinterpret_cast<WallpaperEngine::VideoPlayback::MPV::GLPlayer*> (static_cast<intptr_t> (playerAddress));
+    auto* engine = reinterpret_cast<WallpaperEngine::Scripting::ScriptEngine*> (static_cast<intptr_t> (engineAddress));
+
+    switch (magic) {
+	case 0:
+	    if (player->hasEnded ()) {
+		player->seek (0.0);
+	    }
+
+	    player->clearPaused ();
+	    return JS_UNDEFINED;
+	case 1:
+	    player->setPaused ();
+	    return JS_UNDEFINED;
+	case 2:
+	    player->setPaused ();
+	    player->seek (0.0);
+	    return JS_UNDEFINED;
+	case 3:
+	    return JS_NewBool (ctx, !player->isPaused ());
+	case 4:
+	    return JS_NewBool (ctx, player->isPaused ());
+	case 5:
+	    return JS_NewBool (ctx, player->isPaused () && player->getPlaybackPosition () < 0.001);
+	case 6:
+	    return JS_NewFloat64 (ctx, player->getPlaybackPosition ());
+	case 7: {
+	    double seconds = 0.0;
+
+	    if (argc > 0 && JS_ToFloat64 (ctx, &seconds, argv[0]) == 0) {
+		player->seek (std::max (seconds, 0.0));
+	    }
+
+	    return JS_UNDEFINED;
+	}
+	case 8:
+	    if (argc > 0) {
+		engine->addVideoEndedCallback (player, argv[0]);
+	    }
+
+	    return JS_UNDEFINED;
+	case 9:
+	    return JS_NewBool (ctx, player->isLooping ());
+	case 10:
+	    if (argc > 0) {
+		player->setLoop (JS_ToBool (ctx, argv[0]) > 0);
+	    }
+
+	    return JS_UNDEFINED;
+	case 11:
+	    return JS_NewFloat64 (ctx, player->getDuration ());
+	case 12:
+	    return JS_NewFloat64 (ctx, player->getSpeed ());
+	case 13: {
+	    double rate = 0.0;
+
+	    if (argc > 0 && JS_ToFloat64 (ctx, &rate, argv[0]) == 0 && rate > 0.0) {
+		player->setSpeed (rate);
+	    }
+
+	    return JS_UNDEFINED;
+	}
+	default:
+	    return JS_UNDEFINED;
+    }
+}
+
+JSValue videotexture_instantiate (
+    JSContext* ctx, WallpaperEngine::VideoPlayback::MPV::GLPlayer& player, WallpaperEngine::Scripting::ScriptEngine& engine
+) {
+    JSValue handle = JS_NewObject (ctx);
+    JSValue data[] = {
+	JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&player))),
+	JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&engine))),
+    };
+    static constexpr struct {
+	const char* name;
+	int magic;
+	int length;
+    } calls[] = { { "play", 0, 0 },	       { "pause", 1, 0 },	     { "stop", 2, 0 },
+		  { "isPlaying", 3, 0 },       { "isPaused", 4, 0 },	     { "isStopped", 5, 0 },
+		  { "getCurrentTime", 6, 0 },  { "setCurrentTime", 7, 1 },   { "addEndedCallback", 8, 1 } };
+
+    for (const auto& call : calls) {
+	JS_SetPropertyStr (
+	    ctx, handle, call.name, JS_NewCFunctionData (ctx, videotexture_call, call.length, call.magic, 2, data)
+	);
+    }
+
+    static constexpr struct {
+	const char* name;
+	int getter;
+	int setter;
+    } properties[] = { { "loop", 9, 10 }, { "duration", 11, 14 }, { "rate", 12, 13 } };
+
+    // "duration" gets an ignoring setter (14) since strict mode scripts throw on a property with none
+    for (const auto& property : properties) {
+	const JSAtom atom = JS_NewAtom (ctx, property.name);
+
+	JS_DefinePropertyGetSet (
+	    ctx, handle, atom, JS_NewCFunctionData (ctx, videotexture_call, 0, property.getter, 2, data),
+	    JS_NewCFunctionData (ctx, videotexture_call, 1, property.setter, 2, data), JS_PROP_ENUMERABLE
+	);
+	JS_FreeAtom (ctx, atom);
+    }
+
+    return handle;
+}
+
+// scripts call this on image layers whose material is a video (mp4 texture), everything else gets null
+JSValue scriptableobject_video_texture_call (
+    JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic, JSValueConst* func_data
+) {
+    int64_t objectAddress = 0;
+    int64_t engineAddress = 0;
+    JS_ToInt64 (ctx, &objectAddress, func_data[0]);
+    JS_ToInt64 (ctx, &engineAddress, func_data[1]);
+    auto* object = reinterpret_cast<WallpaperEngine::Scripting::ScriptableObject*> (static_cast<intptr_t> (objectAddress));
+    auto* engine = reinterpret_cast<WallpaperEngine::Scripting::ScriptEngine*> (static_cast<intptr_t> (engineAddress));
+
+    if (!object->is<WallpaperEngine::Render::Objects::CImage> ()) {
+	return JS_NULL;
+    }
+
+    const auto texture = object->as<WallpaperEngine::Render::Objects::CImage> ()->getTexture ();
+
+    if (texture == nullptr || texture->getPlayer () == nullptr) {
+	return JS_NULL;
+    }
+
+    return videotexture_instantiate (ctx, *texture->getPlayer (), *engine);
+}
+
 JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver) {
     JSClassID classId = 0;
 
@@ -119,7 +364,7 @@ JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSA
     static constexpr struct {
 	const char* name;
 	int magic;
-    } playbackCalls[] = { { "play", 0 }, { "pause", 1 }, { "stop", 1 }, { "isPlaying", 2 } };
+    } playbackCalls[] = { { "play", 0 }, { "pause", 1 }, { "stop", 3 }, { "isPlaying", 2 } };
 
     for (const auto& call : playbackCalls) {
 	if (std::strcmp (name, call.name) == 0) {
@@ -146,6 +391,31 @@ JSValue scriptableobject_property_get (JSContext* ctx, JSValueConst obj_val, JSA
 	}
     }
 
+    static constexpr struct {
+	const char* name;
+	int magic;
+    } effectCalls[] = { { "getEffectCount", 0 }, { "getEffect", 1 } };
+
+    for (const auto& call : effectCalls) {
+	if (std::strcmp (name, call.name) == 0) {
+	    JSValue data[] = {
+		JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->object))),
+		JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->adapter.getEngine ()))),
+	    };
+
+	    return JS_NewCFunctionData (ctx, scriptableobject_effect_call, 1, call.magic, 2, data);
+	}
+    }
+
+    if (std::strcmp (name, "getVideoTexture") == 0) {
+	JSValue data[] = {
+	    JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->object))),
+	    JS_NewInt64 (ctx, static_cast<int64_t> (reinterpret_cast<intptr_t> (&container->adapter.getEngine ()))),
+	};
+
+	return JS_NewCFunctionData (ctx, scriptableobject_video_texture_call, 0, 0, 2, data);
+    }
+
     if (std::strcmp (name, "name") == 0) {
 	return JS_NewString (ctx, container->object.getObject ().name.c_str ());
     }

+ 5 - 1
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -442,8 +442,12 @@ JSValue vector_normalize (JSContext* ctx, JSValueConst this_val, int argc, JSVal
 
     const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
 
+    // glm::normalize of a zero vector is NaN, which poisons whatever property the script returns
+    const auto source = vector_get<components> (container->value);
+    const auto length = glm::length (source);
+
     newContainer->value.update (
-	glm::normalize (vector_get<components> (container->value)), DynamicValue::UpdateSource::Script
+	length > 0.0f ? source / length : glm::vec<components, float> (0.0f), DynamicValue::UpdateSource::Script
     );
 
     return newVector;

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

@@ -44,11 +44,34 @@ JSValue engine_open_user_shortcut (JSContext* ctx, JSValueConst this_val, int ar
     return JS_UNDEFINED;
 }
 
+// scripts only ever hand the result back to layer properties (layer.font = ...), so the path itself is the handle
+JSValue engine_register_asset (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
+    if (argc < 1 || !JS_IsString (argv[0])) {
+	return JS_UNDEFINED;
+    }
+
+    return JS_DupValue (ctx, argv[0]);
+}
+
 // engine.isRunningInEditor() and friends: fixed answers, this is always a plain desktop wallpaper
 JSValue engine_query_flag (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
     return JS_NewBool (ctx, magic != 0);
 }
 
+// the scene's own coordinate space (project width/height), not the monitor resolution
+JSValue engine_get_canvas_size (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
+    const auto it = engineInstances.find (magic);
+
+    if (it == engineInstances.end ()) {
+	return JS_UNDEFINED;
+    }
+
+    const auto& camera = it->second.getScene ().getCamera ();
+    const DynamicValue size (glm::vec2 (camera.getWidth (), camera.getHeight ()));
+
+    return it->second.getEngine ().getAdapters ().vec2->instantiate (const_cast<DynamicValue&> (size), true);
+}
+
 JSValue engine_get_frametime (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
     return JS_NewFloat64 (ctx, g_Time - g_TimeLast);
 }
@@ -275,6 +298,13 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 	JS_NewCFunction (this->m_engine.getContext (), engine_get_daytime, "get", 0),
 	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
     );
+    JS_DefinePropertyGetSet (
+	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "canvasSize"),
+	JS_NewCFunctionMagic (
+	    this->m_engine.getContext (), engine_get_canvas_size, "get", 0, JS_CFUNC_generic_magic, this->m_instanceId
+	),
+	JS_NewCFunction (this->m_engine.getContext (), engine_set_value, "set", 1), JS_PROP_ENUMERABLE
+    );
     JS_DefinePropertyGetSet (
 	this->m_engine.getContext (), this->m_instance, JS_NewAtom (this->m_engine.getContext (), "userProperties"),
 	JS_NewCFunctionMagic (
@@ -311,6 +341,10 @@ EngineObject::EngineObject (ScriptEngine& engine, Render::Wallpapers::CScene& sc
 	),
 	JS_PROP_ENUMERABLE
     );
+    JS_DefinePropertyValueStr (
+	this->m_engine.getContext (), this->m_instance, "registerAsset",
+	JS_NewCFunction (this->m_engine.getContext (), engine_register_asset, "registerAsset", 1), JS_PROP_ENUMERABLE
+    );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "openUserShortcut",
 	JS_NewCFunction (this->m_engine.getContext (), engine_open_user_shortcut, "openUserShortcut", 0),

+ 15 - 4
src/WallpaperEngine/Scripting/InputObject.cpp

@@ -10,8 +10,17 @@ JSValue get_cursor_world_position (JSContext* ctx, JSValueConst this_val, int ar
     JSClassID classId;
     auto* input = static_cast<InputObject*> (JS_GetAnyOpaque (this_val, &classId));
 
-    // TODO: PROPERLY IMPLEMENT THIS
-    return input->getScene ().getScriptEngine ().getAdapters ().vec3->instantiate ();
+    auto& scene = input->getScene ();
+    const auto position = scene.getMousePositionNormalized ();
+
+    // same scene space as layer origins (y up from the bottom), and the point the cursor event hit test uses
+    JSValue result = scene.getScriptEngine ().getAdapters ().vec3->instantiate ();
+
+    JS_SetPropertyStr (ctx, result, "x", JS_NewFloat64 (ctx, position->x * static_cast<float> (scene.getWidth ())));
+    JS_SetPropertyStr (ctx, result, "y", JS_NewFloat64 (ctx, position->y * static_cast<float> (scene.getHeight ())));
+    JS_SetPropertyStr (ctx, result, "z", JS_NewFloat64 (ctx, 0.0));
+
+    return result;
 }
 
 JSValue get_cursor_screen_position (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
@@ -28,8 +37,10 @@ JSValue get_cursor_screen_position (JSContext* ctx, JSValueConst this_val, int a
 }
 
 JSValue get_cursor_left_down (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    // TODO: IMPLEMENT THIS
-    return JS_NewBool (ctx, false);
+    JSClassID classId;
+    auto* input = static_cast<InputObject*> (JS_GetAnyOpaque (this_val, &classId));
+
+    return JS_NewBool (ctx, input->getScene ().isCursorLeftDown ());
 }
 
 JSValue input_set_value (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { return JS_EXCEPTION; }

+ 113 - 14
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -1,6 +1,7 @@
 #include "ScriptEngine.h"
+
+#include "WallpaperEngine/VideoPlayback/MPV/GLPlayer.h"
 #include "WallpaperEngine/Media/ThumbnailPalette.h"
-#include "stb_image.h"
 
 #include "Adapters/ScriptableObjectAdapter.h"
 #include "Modules/ColorModule.h"
@@ -285,6 +286,10 @@ ScriptEngine::~ScriptEngine () {
 	JS_FreeValue (this->m_context, module.thisObject);
     }
 
+    for (const auto& entry : this->m_videoEndedCallbacks) {
+	JS_FreeValue (this->m_context, entry.callback);
+    }
+
     JS_FreeValue (this->m_context, this->m_globalThis);
 
     this->m_consoleObject.reset ();
@@ -1003,9 +1008,115 @@ void ScriptEngine::initializeModule (const std::string& key, LoadedModule& modul
     }
 }
 
+namespace {
+constexpr const char* CURSOR_HANDLERS[] = { "cursorEnter", "cursorLeave", "cursorMove",
+					    "cursorDown",  "cursorUp",	  "cursorClick" };
+}
+
+bool ScriptEngine::hasCursorHandlers (const ScriptableObject& object) {
+    for (auto& module : this->m_scriptModules | std::views::values) {
+	if (module.object != &object || !module.initialized) {
+	    continue;
+	}
+
+	if (module.cursorHandlers < 0) {
+	    module.cursorHandlers = 0;
+
+	    for (const char* handler : CURSOR_HANDLERS) {
+		JSValue function = JS_GetPropertyStr (this->m_context, module.module, handler);
+
+		if (JS_IsFunction (this->m_context, function)) {
+		    module.cursorHandlers = 1;
+		}
+
+		JS_FreeValue (this->m_context, function);
+	    }
+	}
+
+	if (module.cursorHandlers > 0) {
+	    return true;
+	}
+    }
+
+    return false;
+}
+
+void ScriptEngine::dispatchCursorEvent (
+    const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec2& localPosition
+) {
+    const auto makePosition = [this] (const glm::vec2& value) {
+	JSValue result = JS_NewObject (this->m_context);
+
+	JS_SetPropertyStr (this->m_context, result, "x", JS_NewFloat64 (this->m_context, value.x));
+	JS_SetPropertyStr (this->m_context, result, "y", JS_NewFloat64 (this->m_context, value.y));
+	JS_SetPropertyStr (this->m_context, result, "z", JS_NewFloat64 (this->m_context, 0.0));
+
+	return result;
+    };
+
+    for (auto& [key, module] : this->m_scriptModules) {
+	if (module.object != &object || !module.initialized || module.cursorHandlers <= 0) {
+	    continue;
+	}
+
+	this->m_runningModule = &module;
+	this->bindThisLayer (*module.object, &module);
+
+	JSValue event = JS_NewObject (this->m_context);
+	JS_SetPropertyStr (this->m_context, event, "worldPosition", makePosition (worldPosition));
+	JS_SetPropertyStr (this->m_context, event, "localPosition", makePosition (localPosition));
+
+	JSValue args[] = { event };
+	JSValue result = this->call (module.module, 1, args, handler);
+
+	if (JS_IsException (result)) {
+	    logJSException (this->m_context, key.c_str ());
+	}
+
+	JS_FreeValue (this->m_context, result);
+	JS_FreeValue (this->m_context, event);
+    }
+
+    this->m_runningModule = nullptr;
+}
+
+void ScriptEngine::addVideoEndedCallback (VideoPlayback::MPV::GLPlayer* player, JSValueConst callback) {
+    if (player == nullptr || !JS_IsFunction (this->m_context, callback)) {
+	return;
+    }
+
+    this->m_videoEndedCallbacks.push_back ({ player, JS_DupValue (this->m_context, callback) });
+}
+
 void ScriptEngine::tick () {
     this->m_engineObject->tick ();
 
+    // index based, a callback is free to register more callbacks (and reallocate the vector)
+    for (size_t index = 0; index < this->m_videoEndedCallbacks.size (); index++) {
+	auto& entry = this->m_videoEndedCallbacks[index];
+
+	if (!entry.player->hasEnded ()) {
+	    entry.notified = false;
+	    continue;
+	}
+
+	if (entry.notified) {
+	    continue;
+	}
+
+	entry.notified = true;
+
+	JSValue callback = JS_DupValue (this->m_context, entry.callback);
+	JSValue result = JS_Call (this->m_context, callback, JS_UNDEFINED, 0, nullptr);
+
+	if (JS_IsException (result)) {
+	    logJSException (this->m_context, "video ended callback");
+	}
+
+	JS_FreeValue (this->m_context, result);
+	JS_FreeValue (this->m_context, callback);
+    }
+
     // safe to erase retired modules now, nothing is mid-iteration and no queueScript() is on the stack
     for (const auto& key : this->m_retiredScriptKeys) {
 	const auto it = this->m_scriptModules.find (key);
@@ -1105,19 +1216,7 @@ Media::ThumbnailPalette ScriptEngine::thumbnailPaletteFor (const Media::MediaSou
 	return this->m_palette;
     }
 
-    path = path.substr (7);
-
-    int width = 0;
-    int height = 0;
-    int channels = 0;
-    auto* pixels = stbi_load (path.c_str (), &width, &height, &channels, 4);
-
-    if (pixels == nullptr) {
-	return this->m_palette;
-    }
-
-    this->m_palette = Media::computeThumbnailPalette (pixels, static_cast<size_t> (width), static_cast<size_t> (height));
-    stbi_image_free (pixels);
+    this->m_palette = Media::loadThumbnailPalette (path.substr (7));
 
     return this->m_palette;
 }

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

@@ -34,6 +34,10 @@ namespace WallpaperEngine::Render::Wallpapers {
 class CScene;
 }
 
+namespace WallpaperEngine::VideoPlayback::MPV {
+class GLPlayer;
+}
+
 namespace WallpaperEngine::Scripting {
 void logJSException (JSContext* ctx, const char* context);
 
@@ -62,6 +66,8 @@ public:
 	JSValue thisObject = JS_UNDEFINED;
 	// name of the property the script is attached to ("origin", an effect constant, ...)
 	std::string propertyName;
+	// -1 until checked, then whether the module exports any cursor* handler
+	int cursorHandlers = -1;
     };
     struct JSObjectAdapters {
 	std::unique_ptr<Adapters::VectorAdapter<4>> vec4;
@@ -156,6 +162,19 @@ public:
      */
     void destroyLayer (ScriptLayerHandle handle);
 
+    /** Whether any running script on the object exports a cursor handler (cursorEnter, cursorClick, ...) */
+    [[nodiscard]] bool hasCursorHandlers (const ScriptableObject& object);
+    /**
+     * Calls `handler` (cursorEnter/cursorLeave/cursorMove/cursorDown/cursorUp/cursorClick) on every script running on
+     * the object with an event carrying worldPosition (scene coordinates) and localPosition (offset from the layer's center)
+     */
+    void dispatchCursorEvent (
+	const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec2& localPosition
+    );
+
+    /** Calls callback (once per playthrough) when player reaches the end of a non-looping video, for IVideoTexture.addEndedCallback() */
+    void addVideoEndedCallback (VideoPlayback::MPV::GLPlayer* player, JSValueConst callback);
+
     AnimationSystem& getAnimations () { return m_animations; }
     /** Whether a script module is currently running for this property value */
     [[nodiscard]] bool hasScript (const DynamicValue& value) const;
@@ -194,6 +213,14 @@ private:
 
     LoadedModule* m_runningModule = nullptr;
 
+    struct VideoEndedCallback {
+	VideoPlayback::MPV::GLPlayer* player;
+	JSValue callback;
+	// set once the callback ran for the current end, cleared when the video is seeked back
+	bool notified = false;
+    };
+    std::vector<VideoEndedCallback> m_videoEndedCallbacks = {};
+
     ScriptLayerHandle m_nextLayerId = 1;
     bool m_layerRegistryReady = false;
     std::map<ScriptLayerHandle, bool> m_layerInitialized;

+ 7 - 4
src/WallpaperEngine/Scripting/ScriptableObject.h

@@ -25,9 +25,12 @@ public:
 
     const std::map<std::string, PropertyEntry>& getProperties () const;
 
-    /** thisLayer.play()/pause() from scripts, kept here since init() can call it before the derived object exists */
-    void setPlaying (bool playing) { this->m_playing = playing; }
-    [[nodiscard]] bool isPlaying () const { return this->m_playing; }
+    enum class Playback { Playing, Paused, Stopped };
+
+    /** thisLayer.play()/pause()/stop() from scripts, kept here since init() can call it before the derived object exists */
+    void setPlayback (Playback playback) { this->m_playback = playback; }
+    [[nodiscard]] Playback getPlayback () const { return this->m_playback; }
+    [[nodiscard]] virtual bool isPlaying () const { return this->m_playback == Playback::Playing; }
 
 protected:
     void registerProperty (
@@ -38,7 +41,7 @@ protected:
     void registerEffectConstants (const std::vector<ImageEffectUniquePtr>& effects);
 
 private:
-    bool m_playing = true;
+    Playback m_playback = Playback::Playing;
     std::map<std::string, PropertyEntry> m_properties;
 };
 }

+ 96 - 0
src/WallpaperEngine/Testing/Cases/Localization.cpp

@@ -0,0 +1,96 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Data/Parsers/Localization.h"
+#include "WallpaperEngine/Data/Parsers/PropertyParser.h"
+
+using WallpaperEngine::Data::JSON::JSON;
+using WallpaperEngine::Data::Parsers::Localization;
+
+namespace {
+JSON project () {
+    return JSON {
+	{ "localization",
+	  { { "en-us",
+	      { { "ui_background_mode", "Wallpaper Mode" },
+		{ "ui_background_text", "<hr><h4>●  Setting background</h4><hr>" },
+		{ "ui_color__highContrastColor", "High Contrast color <small>Black or white, whichever fits.</small>" },
+		{ "ui_only_english", "Only in English" } } },
+	    { "de-de", { { "ui_background_mode", "Hintergrundmodus" } } },
+	    { "zh-chs", { { "ui_background_mode", "背景模式" } } } } },
+    };
+}
+} // namespace
+
+TEST_CASE ("Property texts are looked up in the localization tables") {
+    const Localization localization (project (), "en-us");
+
+    CHECK (localization.resolve ("ui_background_mode") == "Wallpaper Mode");
+    CHECK (localization.resolve ("") == "");
+}
+
+TEST_CASE ("Markup and small print are dropped from localized texts") {
+    const Localization localization (project (), "en-us");
+
+    CHECK (localization.resolve ("ui_background_text") == "● Setting background");
+    CHECK (localization.resolve ("ui_color__highContrastColor") == "High Contrast color");
+}
+
+TEST_CASE ("The user's language wins, English fills in what it is missing") {
+    const Localization german (project (), "de-de");
+
+    CHECK (german.resolve ("ui_background_mode") == "Hintergrundmodus");
+    CHECK (german.resolve ("ui_only_english") == "Only in English");
+}
+
+TEST_CASE ("A language variant is picked when only the language matches") {
+    const Localization chinese (project (), "zh-cn");
+
+    CHECK (chinese.resolve ("ui_background_mode") == "背景模式");
+}
+
+TEST_CASE ("An unknown language falls back to English") {
+    const Localization localization (project (), "fr-fr");
+
+    CHECK (localization.resolve ("ui_background_mode") == "Wallpaper Mode");
+}
+
+TEST_CASE ("Keys without a translation are made readable, real text is left alone") {
+    const Localization localization (std::nullopt, "en-us");
+
+    CHECK (localization.resolve ("ui_music_type_0") == "Music type 0");
+    CHECK (localization.resolve ("Scale") == "Scale");
+    CHECK (localization.resolve ("Speed &amp; <b>power</b>") == "Speed & power");
+}
+
+TEST_CASE ("Property texts and combo labels of a whole project are localized") {
+    const JSON data = {
+	{ "title", "Test" },
+	{ "type", "web" },
+	{ "file", "index.html" },
+	{ "general",
+	  { { "localization", project ().at ("localization") },
+	    { "properties",
+	      { { "mode",
+		  { { "type", "combo" },
+		    { "text", "ui_background_mode" },
+		    { "value", 1 },
+		    { "options", JSON::array ({ { { "label", "ui_only_english" }, { "value", 1 } } }) } } } } } } },
+    };
+
+    const auto localization = Localization (data.optional ("general"), "en-us");
+    (void) localization;
+
+    // ProjectParser needs an asset locator to build a Project, so the pieces are checked separately above and the
+    // combo relabeling here through the property itself
+    const auto property = WallpaperEngine::Data::Parsers::PropertyParser::parse (
+	data.at ("general").at ("properties").at ("mode"), "mode"
+    );
+    auto* combo = dynamic_cast<WallpaperEngine::Data::Model::PropertyCombo*> (property.get ());
+
+    REQUIRE (combo != nullptr);
+
+    combo->relabel ([&] (const std::string& label) { return localization.resolve (label); });
+
+    CHECK (combo->dump ().find ("Only in English") != std::string::npos);
+}

+ 50 - 1
src/WallpaperEngine/Testing/Cases/PropertyParser.cpp

@@ -30,4 +30,53 @@ TEST_CASE ("Directory properties are parsed as file-like properties") {
 
     REQUIRE (property != nullptr);
     CHECK (property->dump ().find ("folder - file") != std::string::npos);
-}
+}
+TEST_CASE ("Directory properties remember they are directories and what file type they hold") {
+    const auto directory = PropertyParser::parse (
+	JSON { { "type", "directory" }, { "text", "Videos" }, { "fileType", "video" }, { "mode", "fetchall" } }, "vidDir"
+    );
+    const auto file = PropertyParser::parse (JSON { { "type", "file" }, { "text", "Image" } }, "image");
+
+    const auto* asDirectory = dynamic_cast<const WallpaperEngine::Data::Model::PropertyFile*> (directory.get ());
+    const auto* asFile = dynamic_cast<const WallpaperEngine::Data::Model::PropertyFile*> (file.get ());
+
+    REQUIRE (asDirectory != nullptr);
+    REQUIRE (asFile != nullptr);
+    CHECK (asDirectory->isDirectory ());
+    CHECK (asDirectory->getFileType () == "video");
+    CHECK_FALSE (asFile->isDirectory ());
+    CHECK (asFile->getFileType ().empty ());
+}
+
+TEST_CASE ("UI-only description lines with made up types are parsed as plain text") {
+    for (const char* type : { "txt", "test", "label" }) {
+	const JSON propertyData = {
+	    { "type", type },
+	    { "text", "ui_some_tip" },
+	    { "value", "1 0 0" },
+	};
+
+	CHECK (PropertyParser::parse (propertyData, "tip") != nullptr);
+    }
+}
+
+TEST_CASE ("Combo properties can be given a value the wallpaper did not list") {
+    const JSON propertyData = {
+	{ "type", "combo" },
+	{ "text", "Interval" },
+	{ "value", 1 },
+	{ "options", JSON::array ({ { { "label", "1 min" }, { "value", 1 } }, { { "label", "5 min" }, { "value", 5 } } }) },
+    };
+
+    const auto property = PropertyParser::parse (propertyData, "interval");
+    auto* combo = dynamic_cast<WallpaperEngine::Data::Model::PropertyCombo*> (property.get ());
+
+    REQUIRE (combo != nullptr);
+
+    combo->update (std::string ("0.5"), DynamicValue::UpdateSource::User);
+    CHECK (combo->getString () == "1");
+
+    combo->allowValue ("0.5");
+    combo->update (std::string ("0.5"), DynamicValue::UpdateSource::User);
+    CHECK (combo->getString () == "0.5");
+}

+ 54 - 0
src/WallpaperEngine/Testing/Cases/SpectrumListeners.cpp

@@ -0,0 +1,54 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Audio/Drivers/Recorders/PlaybackRecorder.h"
+
+using WallpaperEngine::Audio::Drivers::Recorders::PlaybackRecorder;
+
+namespace {
+// what a capturing recorder does once it has a new spectrum
+class PushingRecorder : public PlaybackRecorder {
+public:
+    void push (const float* bands) { this->notifySpectrumListeners (bands); }
+};
+} // namespace
+
+TEST_CASE ("Spectrum listeners are pushed every new spectrum until removed") {
+    PushingRecorder recorder;
+    float bands[64] = { 0.5f };
+    int calls = 0;
+    float first = 0.0f;
+
+    const int id = recorder.addSpectrumListener ([&] (const float* audio64) {
+	calls++;
+	first = audio64[0];
+    });
+
+    recorder.push (bands);
+    bands[0] = 0.25f;
+    recorder.push (bands);
+
+    CHECK (calls == 2);
+    CHECK (first == 0.25f);
+
+    recorder.removeSpectrumListener (id);
+    recorder.push (bands);
+
+    CHECK (calls == 2);
+}
+
+TEST_CASE ("Several spectrum listeners each get the spectrum") {
+    PushingRecorder recorder;
+    float bands[64] = {};
+    int a = 0;
+    int b = 0;
+
+    recorder.addSpectrumListener ([&] (const float*) { a++; });
+    const int second = recorder.addSpectrumListener ([&] (const float*) { b++; });
+
+    recorder.push (bands);
+    recorder.removeSpectrumListener (second);
+    recorder.push (bands);
+
+    CHECK (a == 2);
+    CHECK (b == 1);
+}

+ 95 - 0
src/WallpaperEngine/Testing/Cases/SpectrumNormalizer.cpp

@@ -0,0 +1,95 @@
+#include <catch2/catch_approx.hpp>
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Audio/SpectrumNormalizer.h"
+
+using Catch::Approx;
+using WallpaperEngine::Audio::SpectrumNormalizer;
+
+namespace {
+// a frame whose loudest fifth of the bands sit at the given level, and the rest well below it
+void settle (SpectrumNormalizer& normalizer, float peak, float seconds) {
+    constexpr float frame = 0.023f;
+    float levels[64] = {};
+
+    for (int i = 0; i < 13; i++) {
+	levels[i] = peak;
+    }
+
+    for (float t = 0.0f; t < seconds; t += frame) {
+	normalizer.update (levels, 64, frame);
+    }
+}
+} // namespace
+
+TEST_CASE ("Silence and capture noise stay flat") {
+    SpectrumNormalizer normalizer;
+
+    settle (normalizer, 0.0f, 2.0f);
+
+    CHECK (normalizer.apply (0.0f) == 0.0f);
+    CHECK (normalizer.apply (0.1f) == 0.0f);
+}
+
+TEST_CASE ("Loud music does not push every band to the top") {
+    SpectrumNormalizer normalizer;
+
+    settle (normalizer, 2.5f, 3.0f);
+
+    CHECK (normalizer.apply (2.5f) == Approx (1.0f).margin (0.02f));
+    // a band a little below the peak is clearly below the top, not locked to it
+    CHECK (normalizer.apply (2.1f) < 0.7f);
+    CHECK (normalizer.apply (2.1f) > 0.2f);
+    CHECK (normalizer.apply (1.0f) == 0.0f);
+}
+
+TEST_CASE ("The same shape looks the same at any volume") {
+    SpectrumNormalizer loud;
+    SpectrumNormalizer louder;
+
+    settle (loud, 2.0f, 3.0f);
+    settle (louder, 3.0f, 3.0f);
+
+    // 0.3 below the peak in both
+    CHECK (loud.apply (1.7f) == Approx (louder.apply (2.7f)).margin (0.02f));
+}
+
+TEST_CASE ("Faint sounds are stretched to fill the range but not below the gate") {
+    SpectrumNormalizer normalizer;
+
+    settle (normalizer, 0.6f, 3.0f);
+
+    CHECK (normalizer.apply (0.6f) == Approx (1.0f).margin (0.02f));
+    CHECK (normalizer.apply (0.15f) == 0.0f);
+}
+
+TEST_CASE ("The reference follows the music back down over a few seconds") {
+    SpectrumNormalizer normalizer;
+
+    settle (normalizer, 3.0f, 2.0f);
+    CHECK (normalizer.getReference () == Approx (3.0f).margin (0.05f));
+
+    settle (normalizer, 1.0f, 0.5f);
+    CHECK (normalizer.getReference () > 2.0f);
+
+    settle (normalizer, 1.0f, 10.0f);
+    CHECK (normalizer.getReference () < 1.2f);
+}
+
+TEST_CASE ("One outlier band does not flatten the rest of the spectrum") {
+    SpectrumNormalizer normalizer;
+    float levels[64];
+
+    for (float& level : levels) {
+	level = 1.5f;
+    }
+
+    levels[10] = 4.0f;
+
+    for (int i = 0; i < 200; i++) {
+	normalizer.update (levels, 64, 0.023f);
+    }
+
+    CHECK (normalizer.getReference () < 2.2f);
+    CHECK (normalizer.apply (1.5f) > 0.5f);
+}

+ 16 - 0
src/WallpaperEngine/Testing/Cases/TextInputProperty.cpp

@@ -20,3 +20,19 @@ TEST_CASE ("Text input properties keep their string value without JSON quotes")
     CHECK (property->getType () == DynamicValue::String);
     CHECK (property->getString () == "HH:mm");
 }
+
+
+TEST_CASE ("Text input properties without a value default to an empty string") {
+    const JSON propertyData = {
+	{ "type", "textinput" },
+	{ "text", "ui_font" },
+	{ "condition", "weather_show.value" },
+	{ "order", 9090 },
+    };
+
+    const auto property = PropertyParser::parse (propertyData, "ui_font");
+
+    REQUIRE (property != nullptr);
+    CHECK (property->getType () == DynamicValue::String);
+    CHECK (property->getString ().empty ());
+}

+ 6 - 1
src/WallpaperEngine/Testing/Render/TestingOpenGLDriver.cpp

@@ -1,8 +1,11 @@
 #include "TestingOpenGLDriver.h"
 
 #include "WallpaperEngine/Logging/Log.h"
+
 #include "WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.h"
 
+#include <algorithm>
+
 using namespace WallpaperEngine::Testing::Render;
 
 void TestingCustomGLFWErrorHandler (int errorCode, const char* reason) {
@@ -89,7 +92,9 @@ glm::ivec2 TestingOpenGLDriver::getFramebufferSize () const {
 
 uint32_t TestingOpenGLDriver::getFrameCounter () const { return this->m_frameCounter; }
 void TestingOpenGLDriver::dispatchEventQueue () {
-    static float startTime, endTime, minimumTime = 1.0f / this->m_context.settings.render.maximumFPS;
+    static float startTime, endTime;
+    // read every frame, --fps can change with a hotswap
+    const float minimumTime = 1.0f / std::max (1, this->m_context.settings.render.maximumFPS);
     startTime = this->getRenderTime ();
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 

+ 140 - 11
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp

@@ -4,11 +4,21 @@
 
 #include <mpv/render_gl.h>
 #include <mpv/stream_cb.h>
+#include <chrono>
+#include <cstdlib>
+#include <string_view>
 #include <vector>
 
 using namespace WallpaperEngine::VideoPlayback::MPV;
 
 std::unordered_map<std::filesystem::path, GLPlayer*> GLPlayer::s_activePlayers;
+double GLPlayer::s_statsMillis = 0.0;
+
+// the synchronous mpv_set_property/mpv_command wait for mpv's core thread, which can take a few hundred ms
+// while it's busy, and these are called from the render thread (scripts pausing/seeking videos on click)
+static void setPropertyAsync (mpv_handle* handle, const char* name, const char* value) {
+    mpv_set_property_async (handle, 0, name, MPV_FORMAT_STRING, &value);
+}
 
 void* get_proc_address (void* ctx, const char* name) {
     return static_cast<GLPlayer*> (ctx)->getContext ().getDriver ().getProcAddress (name);
@@ -72,6 +82,14 @@ void GLPlayer::setUntimed () {
     this->m_untimed = true;
 }
 
+void GLPlayer::disableAudio () {
+    if (this->m_handle) {
+	sLog.exception ("Cannot disable audio after playback has started");
+    }
+
+    this->m_audio = false;
+}
+
 void GLPlayer::clearUntimed () {
     if (this->m_handle) {
 	sLog.exception ("Cannot set untimed mode after playback has started");
@@ -84,7 +102,7 @@ void GLPlayer::setMuted () {
     this->m_muted = true;
 
     if (this->m_handle) {
-	mpv_set_property_string (this->m_handle, "mute", "yes");
+	setPropertyAsync (this->m_handle, "mute", "yes");
     }
 }
 
@@ -92,7 +110,7 @@ void GLPlayer::clearMuted () {
     this->m_muted = false;
 
     if (this->m_handle) {
-	mpv_set_property_string (this->m_handle, "mute", "no");
+	setPropertyAsync (this->m_handle, "mute", "no");
     }
 }
 
@@ -100,7 +118,7 @@ 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);
+	mpv_set_property_async (this->m_handle, 0, "volume", MPV_FORMAT_DOUBLE, &this->m_volume);
     }
 }
 
@@ -108,7 +126,7 @@ void GLPlayer::setSpeed (double speed) {
     this->m_speed = speed;
 
     if (this->m_handle) {
-	mpv_set_property (this->m_handle, "speed", MPV_FORMAT_DOUBLE, &this->m_speed);
+	mpv_set_property_async (this->m_handle, 0, "speed", MPV_FORMAT_DOUBLE, &this->m_speed);
     }
 }
 
@@ -116,7 +134,7 @@ void GLPlayer::setPaused () {
     this->m_paused = true;
 
     if (this->m_handle) {
-	mpv_set_property_string (this->m_handle, "pause", "yes");
+	setPropertyAsync (this->m_handle, "pause", "yes");
     }
 }
 
@@ -124,10 +142,42 @@ void GLPlayer::clearPaused () {
     this->m_paused = false;
 
     if (this->m_handle) {
-	mpv_set_property_string (this->m_handle, "pause", "no");
+	setPropertyAsync (this->m_handle, "pause", "no");
     }
 }
 
+void GLPlayer::setLoop (bool loop) {
+    this->m_loop = loop;
+
+    if (this->m_handle) {
+	setPropertyAsync (this->m_handle, "loop", loop ? "inf" : "no");
+    }
+}
+
+void GLPlayer::seek (const double seconds) {
+    this->m_ended = false;
+
+    if (this->m_handle == nullptr || !this->m_fileLoaded) {
+	this->m_pendingSeek = seconds;
+	return;
+    }
+
+    const std::string target = std::to_string (seconds);
+    const char* command[] = { "seek", target.c_str (), "absolute+exact", nullptr };
+
+    mpv_command_async (this->m_handle, 0, command);
+}
+
+double GLPlayer::getDuration () const {
+    double duration = 0.0;
+
+    if (this->m_handle != nullptr) {
+	mpv_get_property (this->m_handle, "duration", MPV_FORMAT_DOUBLE, &duration);
+    }
+
+    return duration;
+}
+
 void GLPlayer::render () const {
     // only render while actively playing (m_handle is set by usage-count-driven play())
     if (this->m_handle == nullptr) {
@@ -141,6 +191,31 @@ void GLPlayer::render () const {
 	    break;
 	}
 
+	if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
+	    const auto* property = static_cast<const mpv_event_property*> (event->data);
+
+	    if (property->format == MPV_FORMAT_FLAG && std::string_view (property->name) == "eof-reached"
+		&& *static_cast<const int*> (property->data) && !this->m_loop) {
+		this->m_ended = true;
+	    }
+
+	    continue;
+	}
+
+	if (event->event_id == MPV_EVENT_FILE_LOADED) {
+	    this->m_fileLoaded = true;
+
+	    if (this->m_pendingSeek.has_value ()) {
+		const std::string target = std::to_string (*this->m_pendingSeek);
+		const char* command[] = { "seek", target.c_str (), "absolute+exact", nullptr };
+
+		this->m_pendingSeek.reset ();
+		mpv_command_async (this->m_handle, 0, command);
+	    }
+
+	    continue;
+	}
+
 	if (event->event_id != MPV_EVENT_VIDEO_RECONFIG) {
 	    continue;
 	}
@@ -161,10 +236,22 @@ void GLPlayer::render () const {
 
 	this->m_width = width;
 	this->m_height = height;
+	this->m_needsRedraw = true;
 	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);
     }
 
+    // mpv only hands out its next frame once the update flags were collected, without this playback stalls
+    const uint64_t updateFlags = mpv_render_context_update (this->m_renderContext);
+
+    if (this->m_doWeOwnFramebuffer) {
+	if (!(updateFlags & MPV_RENDER_UPDATE_FRAME) && !this->m_needsRedraw) {
+	    return;
+	}
+
+	this->m_needsRedraw = false;
+    }
+
     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),
@@ -172,12 +259,27 @@ void GLPlayer::render () const {
 
     // no need to flip as it'll be handled by the wallpaper rendering code
     int flip_y = 0;
+    // by default mpv sleeps in here until the frame's display time, stalling the whole scene
+    // (~20ms of every 33ms frame per texture video). A texture is just sampled later, so don't wait
+    int blockForTargetTime = this->m_doWeOwnFramebuffer ? 0 : 1;
 
     mpv_render_param params[] = { { MPV_RENDER_PARAM_OPENGL_FBO, &fbo },
 				  { MPV_RENDER_PARAM_FLIP_Y, &flip_y },
+				  { MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &blockForTargetTime },
 				  { MPV_RENDER_PARAM_INVALID, nullptr } };
 
+    static const bool collectStats = std::getenv ("LWE_FRAME_STATS") != nullptr;
+
+    if (!collectStats) {
+	mpv_render_context_render (this->m_renderContext, params);
+	return;
+    }
+
+    glFinish ();
+    const auto start = std::chrono::steady_clock::now ();
     mpv_render_context_render (this->m_renderContext, params);
+    glFinish ();
+    s_statsMillis += std::chrono::duration<double, std::milli> (std::chrono::steady_clock::now () - start).count ();
 }
 
 int GLPlayer::getWidth () const { return this->m_width; }
@@ -214,6 +316,8 @@ void GLPlayer::prepareGL () {
 }
 
 void GLPlayer::init () {
+    this->m_fileLoaded = false;
+    this->m_ended = false;
     this->m_handle = mpv_create ();
 
     if (this->m_handle == nullptr) {
@@ -234,23 +338,48 @@ void GLPlayer::init () {
     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 (!this->m_audio) {
+	mpv_set_option_string (this->m_handle, "aid", "no");
+    }
+    if (this->m_doWeOwnFramebuffer) {
+	// without the blocking wait in render() a frame would otherwise show up to 50ms early
+	mpv_set_option_string (this->m_handle, "video-timing-offset", "0");
+    }
 
     if (mpv_initialize (this->m_handle) < 0) {
 	sLog.exception ("Could not initialize mpv context");
     }
 
+    // keep-open parks a finished video on its last frame instead of unloading it, so a script can still seek it
+    // back to the start. Observed rather than polled: mpv_get_property waits for mpv's core thread, and polling
+    // it every frame stalled the render thread ~160-200ms every few seconds on a paused video
+    mpv_observe_property (this->m_handle, 0, "eof-reached", MPV_FORMAT_FLAG);
+
     mpv_set_property_string (this->m_handle, "hwdec", "auto");
-    mpv_set_property_string (this->m_handle, "loop", "inf");
+    mpv_set_property_string (this->m_handle, "loop", this->m_loop ? "inf" : "no");
+    mpv_set_property_string (this->m_handle, "keep-open", "yes");
     mpv_set_property (this->m_handle, "volume", MPV_FORMAT_DOUBLE, &this->m_volume);
     mpv_set_property (this->m_handle, "speed", MPV_FORMAT_DOUBLE, &this->m_speed);
 
     // 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 } };
+    // without the native display mpv can't import decoded frames into our GL context (VA-API dmabuf interop)
+    // and falls back to a copy mode, every frame going GPU -> RAM -> GPU
+    const auto& driver = this->getContext ().getDriver ();
+    std::vector<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 },
+    };
+
+    if (void* display = driver.getWaylandDisplay ()) {
+	params.push_back ({ MPV_RENDER_PARAM_WL_DISPLAY, display });
+    } else if (void* display = driver.getX11Display ()) {
+	params.push_back ({ MPV_RENDER_PARAM_X11_DISPLAY, display });
+    }
+
+    params.push_back ({ MPV_RENDER_PARAM_INVALID, nullptr });
 
-    if (mpv_render_context_create (&this->m_renderContext, this->m_handle, params) < 0) {
+    if (mpv_render_context_create (&this->m_renderContext, this->m_handle, params.data ()) < 0) {
 	sLog.exception ("Failed to initialize MPV's GL context");
     }
 

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

@@ -6,6 +6,7 @@
 #include <GL/glew.h>
 #include <mpv/client.h>
 #include <mpv/render.h>
+#include <optional>
 #include <string>
 #include <unordered_map>
 
@@ -30,13 +31,25 @@ public:
     void decrementUsageCount ();
 
     void setUntimed ();
+    /** For videos that are only ever a texture. Must be called before playback starts */
+    void disableAudio ();
     void clearUntimed ();
     void setMuted ();
     void clearMuted ();
     void setVolume (double volume);
     void setSpeed (double speed);
+    [[nodiscard]] double getSpeed () const { return this->m_speed; }
     void setPaused ();
     void clearPaused ();
+    void setLoop (bool loop);
+    /** Jumps to a position in seconds, remembered and applied once the file has loaded if playback hasn't got that far */
+    void seek (double seconds);
+    /** Whether a non-looping video has played through to its end since the last seek */
+    [[nodiscard]] bool hasEnded () const { return this->m_ended; }
+    [[nodiscard]] bool isPaused () const { return this->m_paused; }
+    [[nodiscard]] bool isLooping () const { return this->m_loop; }
+    /** Total length in seconds, or 0 while it isn't known yet */
+    double getDuration () const;
 
     void render () const;
 
@@ -46,6 +59,9 @@ public:
     /** Current playback position in seconds, or 0 if playback hasn't started yet */
     double getPlaybackPosition () const;
 
+    /** Time spent in render () since the last reset, only collected while LWE_FRAME_STATS is set */
+    static double s_statsMillis;
+
 private:
     void prepareGL ();
     void init ();
@@ -67,6 +83,13 @@ protected:
     bool m_muted = false;
     bool m_untimed = false;
     bool m_paused = false;
+    bool m_loop = true;
+    bool m_audio = true;
+    // a texture we own only needs redrawing when mpv has a new frame, or after its size changed
+    mutable bool m_needsRedraw = true;
+    mutable bool m_fileLoaded = false;
+    mutable bool m_ended = false;
+    mutable std::optional<double> m_pendingSeek;
     std::optional<std::filesystem::path> m_file;
     std::optional<MemoryStreamProtocolUniquePtr> m_stream;
     uint32_t m_usageCount = 0;

+ 2 - 0
src/WallpaperEngine/WebBrowser/CEF/BrowserApp.cpp

@@ -25,6 +25,8 @@ void BrowserApp::OnBeforeCommandLineProcessing (const CefString& process_type, C
 	"IsolateOrigins,HardwareMediaKeyHandling,WebContentsOcclusion,RendererCodeIntegrityEnabled,site-per-process"
     );
     command_line->AppendSwitch ("--disable-gpu-shader-disk-cache");
+    // no keyring is ever needed, and without this every process tries (and fails) to reach kwalletd
+    command_line->AppendSwitchWithValue ("--password-store", "basic");
     command_line->AppendSwitch ("--disable-site-isolation-trials");
     command_line->AppendSwitch ("--disable-web-security");
     command_line->AppendSwitchWithValue ("--remote-allow-origins", "*");

+ 92 - 18
src/WallpaperEngine/WebBrowser/CEF/BrowserClient.cpp

@@ -1,10 +1,58 @@
 #include "BrowserClient.h"
+#include <charconv>
+#include <cstdlib>
 #include <iostream>
+#include <mutex>
 #include <nlohmann/json.hpp>
+#include <sstream>
+#include <unordered_map>
 
 using namespace WallpaperEngine::WebBrowser::CEF;
 using namespace WallpaperEngine::Data::Model;
 
+namespace {
+// Wallpaper Engine hands web pages colors and vectors as "x y z" and combo values as numbers
+// ("value == 1" and switch/case both show up in wallpapers, and the latter is strict)
+nlohmann::json toPageValue (const DynamicValue& property) {
+    std::ostringstream ss;
+
+    switch (property.getType ()) {
+	case DynamicValue::Boolean: return property.getBool ();
+	case DynamicValue::Int: return property.getInt ();
+	case DynamicValue::Float: return property.getFloat ();
+	case DynamicValue::Vec2: ss << property.getVec2 ().x << " " << property.getVec2 ().y; return ss.str ();
+	case DynamicValue::Vec3:
+	case DynamicValue::Vec4:
+	    ss << property.getVec3 ().x << " " << property.getVec3 ().y << " " << property.getVec3 ().z;
+	    return ss.str ();
+	case DynamicValue::String: {
+	    const auto& text = property.getString ();
+
+	    if (dynamic_cast<const PropertyCombo*> (&property) == nullptr) {
+		return text;
+	    }
+
+	    int asInt = 0;
+	    const auto end = text.data () + text.size ();
+
+	    if (!text.empty () && std::from_chars (text.data (), end, asInt).ptr == end) {
+		return asInt;
+	    }
+
+	    char* parsedEnd = nullptr;
+	    const double asNumber = std::strtod (text.c_str (), &parsedEnd);
+
+	    if (!text.empty () && parsedEnd == text.c_str () + text.size ()) {
+		return asNumber;
+	    }
+
+	    return text;
+	}
+	default: return property.toString ();
+    }
+}
+} // namespace
+
 BrowserClient::BrowserClient (CefRefPtr<CefRenderHandler> ptr, const Properties& properties) :
     m_renderHandler (std::move (ptr)), m_properties (properties) { }
 
@@ -22,14 +70,46 @@ bool BrowserClient::OnConsoleMessage (
     CefRefPtr<CefBrowser> browser, cef_log_severity_t level, const CefString& message, const CefString& source,
     int line
 ) {
-    // Some wallpapers log continuously during normal operation (Live2D transitions, asset fetch
-    // tracing, etc) - forwarding every console.log/INFO means a synchronous flushed write for the
-    // wallpaper's whole lifetime. Only warnings and above are worth the cost.
-    if (level < LOGSEVERITY_WARNING) {
+    // some wallpapers log continuously, below warnings only the first few messages are forwarded
+    static std::atomic<int> chatterBudget = 60;
+
+    if (level < LOGSEVERITY_WARNING && chatterBudget.fetch_sub (1) <= 0) {
 	return false;
     }
 
-    std::cout << "[web console] " << source.ToString () << ":" << line << ": " << message.ToString () << std::endl;
+    // A page that trips over the same error every animation frame (WebGL: INVALID_ENUM from a legacy particle
+    // script) would otherwise write hundreds of identical lines a second: show the first few, then every tenfold
+    static std::mutex repeatLock;
+    static std::unordered_map<std::string, unsigned> repeats;
+
+    const std::string text = message.ToString ();
+    unsigned seen = 1;
+
+    {
+	std::lock_guard guard (repeatLock);
+
+	if (repeats.size () < 4096 || repeats.contains (text)) {
+	    seen = ++repeats[text];
+	}
+    }
+
+    bool show = seen <= 3;
+
+    for (unsigned step = 10; !show && step <= seen; step *= 10) {
+	show = seen == step;
+    }
+
+    if (!show) {
+	return false;
+    }
+
+    std::cout << "[web console] " << source.ToString () << ":" << line << ": " << text;
+
+    if (seen > 3) {
+	std::cout << " (seen " << seen << " times)";
+    }
+
+    std::cout << std::endl;
     return false;
 }
 
@@ -49,23 +129,17 @@ void BrowserClient::OnLoadEnd (CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame
     nlohmann::json props = nlohmann::json::object ();
 
     for (const auto& [name, property] : this->m_properties) {
-	nlohmann::json value;
-
-	switch (property->getType ()) {
-	case DynamicValue::Boolean: value = property->getBool (); break;
-	case DynamicValue::Int: value = property->getInt (); break;
-	case DynamicValue::Float: value = property->getFloat (); break;
-	case DynamicValue::String: value = property->getString (); break;
-	default: value = property->toString (); break;
-	}
+	const auto value = toPageValue (*property);
 
 	props[name] = { { "value", value } };
     }
 
-    const std::string script = "if (window.wallpaperPropertyListener && "
-				"window.wallpaperPropertyListener.applyUserProperties) { "
-				"window.wallpaperPropertyListener.applyUserProperties(" + props.dump ()
-	+ "); }";
+    // through the page shim (see SubprocessApp) when there is one, it holds on to them for pages whose listener isn't
+    // defined yet
+    const std::string script = "if (window.__lweProperties) { window.__lweProperties(" + props.dump ()
+	+ "); } else if (window.wallpaperPropertyListener && window.wallpaperPropertyListener.applyUserProperties) { "
+	  "window.wallpaperPropertyListener.applyUserProperties(" + props.dump () + "); }";
 
     frame->ExecuteJavaScript (script, frame->GetURL (), 0);
+    this->m_loaded = true;
 }

+ 4 - 0
src/WallpaperEngine/WebBrowser/CEF/BrowserClient.h

@@ -43,6 +43,9 @@ public:
     // this leaves them stuck rendering nothing, silently, forever.
     void OnLoadEnd (CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, int httpStatusCode) override;
 
+    /** Whether the main frame finished loading and got its initial properties */
+    [[nodiscard]] bool isLoaded () const { return this->m_loaded; }
+
     CefRefPtr<CefRenderHandler> m_renderHandler = nullptr;
 
     IMPLEMENT_REFCOUNTING (BrowserClient);
@@ -50,5 +53,6 @@ public:
 private:
     const WallpaperEngine::Data::Model::Properties& m_properties;
     std::atomic<bool> m_closed = false;
+    std::atomic<bool> m_loaded = false;
 };
 } // namespace WallpaperEngine::WebBrowser::CEF

+ 323 - 0
src/WallpaperEngine/WebBrowser/CEF/PageBridge.cpp

@@ -0,0 +1,323 @@
+#include "PageBridge.h"
+
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <cstdio>
+#include <cstring>
+#include <fstream>
+#include <iterator>
+#include <nlohmann/json.hpp>
+#include <ranges>
+
+#include "BrowserClient.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "include/cef_parser.h"
+
+using namespace WallpaperEngine::WebBrowser::CEF;
+using namespace WallpaperEngine::WebBrowser::IPC;
+using namespace WallpaperEngine::Data::Model;
+
+namespace {
+constexpr std::size_t MAX_COVER_SIZE = 8 * 1024 * 1024;
+constexpr auto DIRECTORY_SCAN_INTERVAL = std::chrono::seconds (2);
+
+void runScript (const CefRefPtr<CefFrame>& frame, const std::string& script) {
+    frame->ExecuteJavaScript (script, frame->GetURL (), 0);
+}
+
+// pages get every string as it is on disk / on the bus, which is not guaranteed to be valid UTF-8
+std::string toJson (const nlohmann::json& value) {
+    return value.dump (-1, ' ', false, nlohmann::json::error_handler_t::replace);
+}
+
+std::string lowerExtension (const std::filesystem::path& path) {
+    std::string extension = path.extension ().string ();
+
+    std::ranges::transform (extension, extension.begin (), [] (unsigned char c) { return std::tolower (c); });
+
+    return extension;
+}
+
+// Same split Wallpaper Engine makes for directory properties: images unless the project asks for videos
+bool matchesFileType (const std::filesystem::path& path, const std::string& fileType) {
+    static const std::array images = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tga", ".tif", ".tiff",
+				       ".avif", ".jfif", ".svg" };
+    static const std::array videos = { ".mp4", ".webm", ".mkv", ".avi", ".mov", ".m4v", ".ogv", ".wmv", ".flv" };
+
+    const std::string extension = lowerExtension (path);
+
+    if (fileType == "video") {
+	return std::ranges::find (videos, extension) != videos.end ();
+    }
+
+    return std::ranges::find (images, extension) != images.end ();
+}
+
+const char* imageMimeType (const std::string& data) {
+    if (data.starts_with ("\x89PNG")) {
+	return "image/png";
+    }
+    if (data.starts_with ("\xFF\xD8")) {
+	return "image/jpeg";
+    }
+    if (data.starts_with ("GIF8")) {
+	return "image/gif";
+    }
+    if (data.size () > 12 && data.compare (0, 4, "RIFF") == 0 && data.compare (8, 4, "WEBP") == 0) {
+	return "image/webp";
+    }
+    if (data.starts_with ("BM")) {
+	return "image/bmp";
+    }
+
+    return nullptr;
+}
+
+// Media players hand out local files, pages get them inline like they do on Windows. Anything that isn't a known
+// image format is not sent at all - the path comes from whatever program registered on the bus.
+std::string coverDataUrl (const std::string& path) {
+    if (path.empty ()) {
+	return "";
+    }
+
+    std::error_code error;
+
+    if (!std::filesystem::is_regular_file (path, error) || std::filesystem::file_size (path, error) > MAX_COVER_SIZE) {
+	return "";
+    }
+
+    std::ifstream file (path, std::ios::binary);
+    const std::string data ((std::istreambuf_iterator<char> (file)), std::istreambuf_iterator<char> ());
+    const char* mime = imageMimeType (data);
+
+    if (mime == nullptr) {
+	return "";
+    }
+
+    return std::string ("data:") + mime + ";base64," + CefBase64Encode (data.data (), data.size ()).ToString ();
+}
+
+std::string cssColor (const uint8_t (&color)[3]) {
+    return "rgb(" + std::to_string (color[0]) + ", " + std::to_string (color[1]) + ", " + std::to_string (color[2])
+	+ ")";
+}
+} // namespace
+
+PageBridge::PageBridge (
+    CefRefPtr<CefBrowser> browser, const WebHostSharedMemory& shm, const Properties& properties,
+    const BrowserClient& client
+) : m_browser (std::move (browser)), m_shm (shm), m_properties (properties), m_client (client) {
+    this->m_lastAudioSeq = shm.audioSeq.load (std::memory_order_relaxed);
+}
+
+void PageBridge::update () {
+    if (!this->m_client.isLoaded ()) {
+	return;
+    }
+
+    const auto frame = this->m_browser->GetMainFrame ();
+
+    if (!frame) {
+	return;
+    }
+
+    const auto now = std::chrono::steady_clock::now ();
+
+    if (!this->m_started) {
+	this->m_started = true;
+	this->m_lastDirectoryScan = now;
+
+	runScript (frame, "window.__lweMedia&&window.__lweMedia({status:{enabled:true}});");
+	this->scanDirectories (frame);
+
+	// the page shim holds the user properties back until this, so pages get their files first
+	runScript (frame, "window.__lweDirectoriesReady&&window.__lweDirectoriesReady();");
+    }
+
+    this->forwardAudio (frame);
+    this->forwardMedia (frame);
+
+    if (now - this->m_lastDirectoryScan >= DIRECTORY_SCAN_INTERVAL) {
+	this->m_lastDirectoryScan = now;
+	this->scanDirectories (frame);
+    }
+}
+
+// the recorder only produces a mono spectrum, so both channels get the same data
+void PageBridge::forwardAudio (CefRefPtr<CefFrame> frame) {
+    constexpr std::size_t bands = WebHostSharedMemory::AUDIO_BANDS;
+
+    const uint32_t seq = this->m_shm.audioSeq.load (std::memory_order_acquire);
+
+    if (seq == this->m_lastAudioSeq) {
+	return;
+    }
+
+    this->m_lastAudioSeq = seq;
+
+    float values[bands];
+    bool silent = true;
+
+    for (std::size_t i = 0; i < bands; i++) {
+	values[i] = this->m_shm.audioBands[i].load (std::memory_order_relaxed);
+	silent = silent && values[i] <= 0.0f;
+    }
+
+    // a recorder that isn't running (or a quiet system) would otherwise cost a script call per frame for nothing, but
+    // one all-zero frame still has to go out so the visualizer settles back down
+    if (silent && this->m_lastAudioSilent) {
+	return;
+    }
+
+    this->m_lastAudioSilent = silent;
+
+    std::string script = "window.__lweAudio&&window.__lweAudio([";
+    char number[24];
+
+    for (int channel = 0; channel < 2; channel++) {
+	for (std::size_t i = 0; i < bands; i++) {
+	    snprintf (number, sizeof (number), "%.4f,", values[i]);
+	    script += number;
+	}
+    }
+
+    script.back () = ']';
+    script += ");";
+
+    runScript (frame, script);
+}
+
+void PageBridge::forwardMedia (CefRefPtr<CefFrame> frame) {
+    const auto& shm = this->m_shm;
+    const uint32_t seq = shm.mediaSeq.load (std::memory_order_acquire);
+
+    // odd means the main process is in the middle of writing it
+    if (seq == this->m_lastMediaSeq || seq % 2 != 0) {
+	return;
+    }
+
+    const int32_t state = shm.mediaState;
+    const double position = shm.mediaPosition;
+    const double duration = shm.mediaDuration;
+    const uint32_t coverVersion = shm.mediaCoverVersion;
+    const std::string title (shm.mediaTitle, strnlen (shm.mediaTitle, WebHostSharedMemory::MEDIA_TEXT));
+    const std::string artist (shm.mediaArtist, strnlen (shm.mediaArtist, WebHostSharedMemory::MEDIA_TEXT));
+    const std::string album (shm.mediaAlbum, strnlen (shm.mediaAlbum, WebHostSharedMemory::MEDIA_TEXT));
+    const std::string coverPath (shm.mediaCoverPath, strnlen (shm.mediaCoverPath, WebHostSharedMemory::MEDIA_PATH));
+    uint8_t palette[5][3];
+
+    std::copy (&shm.mediaPalette[0][0], &shm.mediaPalette[0][0] + 15, &palette[0][0]);
+    std::atomic_thread_fence (std::memory_order_seq_cst);
+
+    // written to while we were reading, try again next time round
+    if (shm.mediaSeq.load (std::memory_order_acquire) != seq) {
+	return;
+    }
+
+    this->m_lastMediaSeq = seq;
+
+    nlohmann::json event = {
+	{ "properties",
+	  { { "title", title },
+	    { "artist", artist },
+	    { "subTitle", "" },
+	    { "albumTitle", album },
+	    { "albumArtist", artist },
+	    { "genres", "" },
+	    { "contentType", "" } } },
+	{ "playback", { { "state", state } } },
+	{ "timeline", { { "position", position }, { "duration", duration } } },
+    };
+
+    if (coverVersion != this->m_lastCoverVersion) {
+	this->m_lastCoverVersion = coverVersion;
+
+	event["thumbnail"] = {
+	    { "thumbnail", coverDataUrl (coverPath) },
+	    { "primaryColor", cssColor (palette[0]) },
+	    { "secondaryColor", cssColor (palette[1]) },
+	    { "tertiaryColor", cssColor (palette[2]) },
+	    { "textColor", cssColor (palette[3]) },
+	    { "highContrastColor", cssColor (palette[4]) },
+	};
+    }
+
+    sLog.out (
+	"--web-host: media state ", state, " (0 stopped, 1 playing, 2 paused), title '", title, "', artist '", artist,
+	"', cover ", coverPath.empty () ? "none" : coverPath
+    );
+
+    runScript (frame, "window.__lweMedia&&window.__lweMedia(" + toJson (event) + ");");
+}
+
+// polled, this process has nothing to receive change notifications on
+void PageBridge::scanDirectories (CefRefPtr<CefFrame> frame) {
+    for (const auto& [name, property] : this->m_properties) {
+	const auto* file = dynamic_cast<const PropertyFile*> (property.get ());
+
+	if (file == nullptr || !file->isDirectory ()) {
+	    continue;
+	}
+
+	FileTimes current;
+	const std::string directory = file->getString ();
+	std::error_code error;
+
+	if (!directory.empty () && !std::filesystem::is_directory (directory, error)) {
+	    if (this->m_reportedMissing.insert (name).second) {
+		sLog.error ("--web-host: directory property ", name, " points to a folder that does not exist: ", directory);
+	    }
+	} else if (!directory.empty ()) {
+	    for (std::filesystem::directory_iterator it (directory, error), end; !error && it != end; it.increment (error)) {
+		const auto& entry = *it;
+		std::error_code entryError;
+
+		if (entry.path ().filename ().string ().starts_with (".") || !entry.is_regular_file (entryError)
+		    || !matchesFileType (entry.path (), file->getFileType ())) {
+		    continue;
+		}
+
+		current[entry.path ().string ()] = entry.last_write_time (entryError);
+	    }
+	}
+
+	auto& known = this->m_directories[name];
+	std::vector<std::string> changed;
+	std::vector<std::string> removed;
+
+	for (const auto& [path, time] : current) {
+	    if (const auto previous = known.find (path); previous == known.end () || previous->second != time) {
+		changed.push_back (path);
+	    }
+	}
+
+	for (const auto& path : known | std::views::keys) {
+	    if (!current.contains (path)) {
+		removed.push_back (path);
+	    }
+	}
+
+	known = std::move (current);
+
+	if (!changed.empty () || !removed.empty ()) {
+	    sLog.out (
+		"--web-host: directory property ", name, ": ", changed.size (), " new or changed and ", removed.size (),
+		" removed file(s) in ", directory
+	    );
+	}
+
+	if (!changed.empty ()) {
+	    runScript (
+		frame, "window.__lweDirectory&&window.__lweDirectory('added'," + toJson (name) + "," + toJson (changed) + ");"
+	    );
+	}
+
+	if (!removed.empty ()) {
+	    runScript (
+		frame,
+		"window.__lweDirectory&&window.__lweDirectory('removed'," + toJson (name) + "," + toJson (removed) + ");"
+	    );
+	}
+    }
+}

+ 53 - 0
src/WallpaperEngine/WebBrowser/CEF/PageBridge.h

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <chrono>
+#include <cstdint>
+#include <filesystem>
+#include <map>
+#include <set>
+#include <string>
+#include <vector>
+
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h"
+#include "include/cef_browser.h"
+
+namespace WallpaperEngine::WebBrowser::CEF {
+class BrowserClient;
+
+/**
+ * Runs in the web host process and feeds the page the audio spectrum, media player state and directory property
+ * contents it gets from the main process through shared memory. The page side is the script SubprocessApp injects.
+ */
+class PageBridge {
+public:
+    PageBridge (
+	CefRefPtr<CefBrowser> browser, const IPC::WebHostSharedMemory& shm,
+	const WallpaperEngine::Data::Model::Properties& properties, const BrowserClient& client
+    );
+
+    /** Call regularly from the host loop, does nothing until the page finished loading */
+    void update ();
+
+private:
+    using FileTimes = std::map<std::string, std::filesystem::file_time_type>;
+
+    void forwardAudio (CefRefPtr<CefFrame> frame);
+    void forwardMedia (CefRefPtr<CefFrame> frame);
+    void scanDirectories (CefRefPtr<CefFrame> frame);
+
+    CefRefPtr<CefBrowser> m_browser;
+    const IPC::WebHostSharedMemory& m_shm;
+    const WallpaperEngine::Data::Model::Properties& m_properties;
+    const BrowserClient& m_client;
+
+    bool m_started = false;
+    uint32_t m_lastAudioSeq = 0;
+    bool m_lastAudioSilent = true;
+    uint32_t m_lastMediaSeq = 0;
+    uint32_t m_lastCoverVersion = 0;
+    std::chrono::steady_clock::time_point m_lastDirectoryScan;
+    std::map<std::string, FileTimes> m_directories;
+    std::set<std::string> m_reportedMissing;
+};
+} // namespace WallpaperEngine::WebBrowser::CEF

+ 9 - 8
src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.cpp

@@ -30,12 +30,13 @@ void SharedMemoryRenderHandler::OnPaint (
 	return;
     }
 
-    // Seqlock: only this thread (CEF's UI thread, one browser per host process) ever writes, so the
-    // sequence is always even when we get here.
-    const uint32_t seq = this->m_shm->frameSeq.load (std::memory_order_relaxed);
-    this->m_shm->frameSeq.store (seq + 1, std::memory_order_release);
-    this->m_shm->frameWidth.store (static_cast<uint32_t> (width), std::memory_order_relaxed);
-    this->m_shm->frameHeight.store (static_cast<uint32_t> (height), std::memory_order_relaxed);
-    std::memcpy (this->m_shm->frameBuffer (), buffer, static_cast<size_t> (width) * height * 4);
-    this->m_shm->frameSeq.store (seq + 2, std::memory_order_release);
+    // paint into the slot only this side owns, then hand it over with one exchange
+    std::memcpy (this->m_shm->frameBuffer (this->m_backSlot), buffer, static_cast<size_t> (width) * height * 4);
+    this->m_shm->slotWidth[this->m_backSlot].store (static_cast<uint32_t> (width), std::memory_order_relaxed);
+    this->m_shm->slotHeight[this->m_backSlot].store (static_cast<uint32_t> (height), std::memory_order_relaxed);
+
+    const uint32_t previous
+	= this->m_shm->frameSlot.exchange (this->m_backSlot | WebHostSharedMemory::FRAME_DIRTY, std::memory_order_acq_rel);
+
+    this->m_backSlot = previous & WebHostSharedMemory::FRAME_SLOT_MASK;
 }

+ 2 - 0
src/WallpaperEngine/WebBrowser/CEF/SharedMemoryRenderHandler.h

@@ -27,5 +27,7 @@ public:
 
 private:
     WallpaperEngine::WebBrowser::IPC::WebHostSharedMemory* m_shm;
+    // slot currently owned by this side, painted into and then swapped with the shared one
+    uint32_t m_backSlot = 0;
 };
 } // namespace WallpaperEngine::WebBrowser::CEF

+ 312 - 0
src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.cpp

@@ -14,6 +14,318 @@ void SubprocessApp::OnRegisterCustomSchemes (CefRawPtr<CefSchemeRegistrar> regis
     );
 }
 
+CefRefPtr<CefRenderProcessHandler> SubprocessApp::GetRenderProcessHandler () { return this; }
+
+void SubprocessApp::OnContextCreated (
+    CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, CefRefPtr<CefV8Context> context
+) {
+    // The parts of Wallpaper Engine's web runtime that aren't plain user properties. The host process (PageBridge)
+    // calls the __lwe* functions, everything else is what pages call or register on. Has to exist before the first
+    // page script: a missing wallpaperRegister*Listener throws and takes the rest of the page's setup down with it.
+    static const char* script = R"JS(
+(function () {
+    var reported = new WeakSet();
+
+    // off-screen alert() is a silent no-op, and pages routinely report their setup errors through it
+    window.alert = function (message) {
+	console.error('[lwe] alert(): ' + message);
+    };
+
+    var safeCall = function (callback, argument) {
+	try { callback(argument); } catch (e) {
+	    if (!reported.has(callback)) { reported.add(callback); console.error(e); }
+	}
+    };
+
+    var audioListeners = [];
+    if (typeof window.wallpaperRegisterAudioListener !== 'function') {
+	window.wallpaperRegisterAudioListener = function (callback) { audioListeners.push(callback); };
+    }
+    // one array per audio frame: 64 left bands then 64 right bands
+    window.__lweAudio = function (frame) {
+	for (var i = 0; i < audioListeners.length; i++) safeCall(audioListeners[i], frame);
+    };
+
+    // a listener that registers after the first event still gets the current state, like it does on Windows
+    var mediaListeners = {};
+    var mediaLast = {};
+    var mediaKinds = { properties: 'Properties', thumbnail: 'Thumbnail', timeline: 'Timeline', playback: 'Playback', status: 'Status' };
+    Object.keys(mediaKinds).forEach(function (key) {
+	var kind = mediaKinds[key];
+	var name = 'wallpaperRegisterMedia' + kind + 'Listener';
+	if (typeof window[name] === 'function') return;
+	window[name] = function (callback) {
+	    (mediaListeners[key] = mediaListeners[key] || []).push(callback);
+	    if (mediaLast[key]) safeCall(callback, mediaLast[key]);
+	};
+    });
+    window.__lweMedia = function (events) {
+	Object.keys(events).forEach(function (key) {
+	    if (!mediaKinds[key]) return;
+	    mediaLast[key] = events[key];
+	    (mediaListeners[key] || []).forEach(function (callback) { safeCall(callback, events[key]); });
+	});
+    };
+
+    // A page may only define window.wallpaperPropertyListener some time after it finished loading, so keep the
+    // current state and give it to whichever listener object shows up. Properties wait until the host has reported
+    // every directory (or a few seconds have passed), slideshow pages need the file list before the properties.
+    var lastProperties = null;
+    var directoryFiles = {};
+    var deliveredTo = null;
+    var directoriesReady = false;
+    var startedAt = Date.now();
+    // what the page did with what it was given, for when a wallpaper shows nothing
+    var describe = function (listener) {
+	var interesting = {};
+	Object.keys(lastProperties).forEach(function (key) {
+	    if (/wallpapermode|directory|imageswitch|imagedisplay|^image$|random$|^weather/i.test(key)) interesting[key] = lastProperties[key].value;
+	});
+	console.warn('[lwe] page listener has: ' + Object.keys(listener).join(', '));
+	console.warn('[lwe] properties of interest: ' + JSON.stringify(interesting));
+	setTimeout(function () {
+	    var visible = function (list) {
+		return Array.prototype.slice.call(list).map(function (element) { return (element.currentSrc || element.src || '').slice(-60); }).filter(Boolean);
+	    };
+	    var style = getComputedStyle(document.body);
+	    console.warn('[lwe] page state after 6s: ' + JSON.stringify({
+		imgs: visible(document.images).slice(0, 5),
+		videos: visible(document.querySelectorAll('video')).slice(0, 3),
+		canvases: document.querySelectorAll('canvas').length,
+		bodyBackground: style.backgroundImage.slice(0, 120),
+		bgImgVariable: style.getPropertyValue('--bg-img').slice(0, 120),
+		files: Object.keys(directoryFiles).map(function (name) { return name + ':' + directoryFiles[name].length; })
+	    }));
+	}, 6000);
+    };
+    var deliver = function () {
+	var listener = window.wallpaperPropertyListener;
+	if (!listener || listener === deliveredTo || lastProperties === null) return;
+	if (typeof listener.applyUserProperties !== 'function') return;
+	if (!directoriesReady && Date.now() - startedAt < 3000) return;
+
+	deliveredTo = listener;
+	describe(listener);
+
+	// Pages on the older jquery.backstretch slideshow only show a photo when their directory property is applied
+	// (their file callbacks just store the list), but they set their default background while the list is still
+	// empty. Those get the properties first, then the files, then the directory properties once more, on their own,
+	// the way a changed property arrives. Everything else gets the files first and the properties after.
+	var directories = Object.keys(directoryFiles).filter(function (name) { return directoryFiles[name].length && lastProperties[name]; });
+	var slideshowFirst = directories.length > 0 && !!(window.jQuery && window.jQuery.fn && window.jQuery.fn.backstretch);
+
+	if (slideshowFirst) safeCall(function (properties) { listener.applyUserProperties(properties); }, lastProperties);
+
+	Object.keys(directoryFiles).forEach(function (name) {
+	    if (!directoryFiles[name].length) return;
+	    if (typeof listener.userDirectoryFilesAddedOrChanged !== 'function') {
+		console.warn('[lwe] the page has no userDirectoryFilesAddedOrChanged, ' + directoryFiles[name].length + ' files of ' + name + ' were not delivered');
+		return;
+	    }
+	    console.warn('[lwe] gave ' + directoryFiles[name].length + ' files of ' + name + ' to the page');
+	    safeCall(function (files) { listener.userDirectoryFilesAddedOrChanged(name, files); }, directoryFiles[name].slice());
+	});
+
+	if (!slideshowFirst) {
+	    safeCall(function (properties) { listener.applyUserProperties(properties); }, lastProperties);
+	    return;
+	}
+
+	var changed = {};
+	directories.forEach(function (name) { changed[name] = lastProperties[name]; });
+	// a page written against whole property objects may trip over a partial one after it did what it needed to
+	try { listener.applyUserProperties(changed); } catch (e) { console.warn('[lwe] the page threw on the directory update: ' + e); }
+    };
+    window.__lweDirectoriesReady = function () {
+	directoriesReady = true;
+	deliver();
+    };
+    // Slideshow pages built on jquery.backstretch always crop to fill: their own fill/stretch/fit/center option only
+    // touches the single image mode, so it does nothing while the slideshow runs. The option is honored for the
+    // slideshow too, through the image's own sizing (1 fill, 2 stretch, 3 fit, 5 center, tiling has no equivalent).
+    var fitStyle = null;
+    var applyImageFit = function (properties) {
+	var option = properties.imagedisplaystlye;
+	var fits = { 2: 'fill', 3: 'contain', 5: 'none' };
+	var fit = option && fits[Number(option.value)];
+	if (option) {
+	    console.warn('[lwe] imagedisplaystlye ' + JSON.stringify(option.value) + ' -> ' + (fit ? 'object-fit ' + fit : 'page default'));
+	    setTimeout(function () {
+		var images = document.querySelectorAll('.backstretch img');
+		var last = images[images.length - 1];
+		var box = last && last.getBoundingClientRect();
+		console.warn('[lwe] slideshow images: ' + images.length + (last ? ', last ' + Math.round(box.width) + 'x' + Math.round(box.height)
+		    + ' at ' + Math.round(box.left) + ',' + Math.round(box.top) + ', object-fit ' + getComputedStyle(last).objectFit
+		    + ', natural ' + last.naturalWidth + 'x' + last.naturalHeight + ', viewport ' + innerWidth + 'x' + innerHeight : ''));
+	    }, 8000);
+	}
+	if (!fit && !fitStyle) return;
+	if (!fitStyle) {
+	    fitStyle = document.createElement('style');
+	    (document.head || document.documentElement).appendChild(fitStyle);
+	}
+	fitStyle.textContent = fit ? '.backstretch img { width: 100% !important; height: 100% !important; left: 0 !important; '
+	    + 'top: 0 !important; max-width: none !important; max-height: none !important; object-fit: ' + fit + ' !important; }' : '';
+    };
+    window.__lweProperties = function (properties) {
+	applyImageFit(properties);
+	lastProperties = properties;
+	deliveredTo = null;
+	deliver();
+    };
+    setInterval(deliver, 250);
+    setTimeout(function () {
+	if (lastProperties !== null && !deliveredTo) console.warn('[lwe] the page did not define wallpaperPropertyListener.applyUserProperties within 10 seconds');
+    }, 10000);
+
+    // directory properties, see PageBridge::scanDirectories
+    window.__lweDirectory = function (kind, name, files) {
+	var known = directoryFiles[name] || [];
+	if (kind === 'added') {
+	    files.forEach(function (file) { if (known.indexOf(file) < 0) known.push(file); });
+	} else {
+	    known = known.filter(function (file) { return files.indexOf(file) < 0; });
+	}
+	directoryFiles[name] = known;
+
+	// a listener that has not been given the initial state yet gets these as part of it, see deliver()
+	var listener = window.wallpaperPropertyListener;
+	var method = kind === 'added' ? 'userDirectoryFilesAddedOrChanged' : 'userDirectoryFilesRemoved';
+	if (listener && listener === deliveredTo) {
+	    if (typeof listener[method] !== 'function') {
+		console.warn('[lwe] the page has no ' + method + ', ' + files.length + ' files of ' + name + ' were not delivered');
+		return;
+	    }
+	    console.warn('[lwe] gave ' + files.length + ' files (' + kind + ') of ' + name + ' to the page');
+	    safeCall(function (files) { listener[method](name, files); }, files);
+	}
+    };
+    if (typeof window.wallpaperRequestRandomFileForProperty !== 'function') {
+	window.wallpaperRequestRandomFileForProperty = function (name, callback) {
+	    var known = directoryFiles[name] || [];
+	    callback(name, known.length ? known[Math.floor(Math.random() * known.length)] : null);
+	};
+    }
+
+    // Pages turn file and directory properties into "file:///" + path URLs, which this origin can't load, so point
+    // them at the wallpaper's own scheme (WPSchemeHandler serves them from disk)
+    var origin = location.origin;
+    if (!origin || origin === 'null') return;
+
+    var fileUrl = /file:\/\/\/([^'")]*)/g;
+    var encodePath = function (path) {
+	// pages hand these over both raw and already escaped, normalize to one form
+	return path.replace(/^\/+/, '').split('/').map(function (segment) {
+	    try { segment = decodeURIComponent(segment); } catch (e) {}
+	    return encodeURIComponent(segment);
+	}).join('/');
+    };
+    // what the page sets as sources, to see how a page that shows nothing goes about showing things
+    var sourcesTraced = 0;
+    var traceSource = function (kind, value) {
+	if (sourcesTraced >= 40 || typeof value !== 'string' || !value || value.indexOf('data:') === 0) return;
+	sourcesTraced++;
+	console.warn('[lwe] page sets ' + kind + ': ' + value.slice(0, 140));
+    };
+    var traced = 0;
+    var rewrite = function (value) {
+	if (typeof value !== 'string' || value.indexOf('file:///') === -1) return value;
+	return value.replace(fileUrl, function (match, path) {
+	    // pages build these from not-yet-set properties too ("file:///" + "" or + {})
+	    if (!path || path.indexOf('[object') === 0) return match;
+	    if (traced++ < 10) console.warn('[lwe] file URL for the page: /' + path);
+	    return origin + '/__lwe_file__/' + encodePath(path);
+	});
+    };
+    // Chromium reports failed subresource loads nowhere the host can see, so say which ones failed
+    var failures = 0;
+    window.addEventListener('error', function (event) {
+	var target = event.target;
+	if (!target || target === window) return;
+	var source = target.currentSrc || target.src || target.href;
+	// an empty src attribute resolves to the page itself, pages reset their media elements that way all the time
+	if (!source || source === location.href || failures++ >= 20) return;
+	console.warn('[lwe] failed to load ' + target.tagName.toLowerCase() + ' ' + source);
+    }, true);
+    var wrapSetter = function (target, property, wrap, label) {
+	var descriptor = target && Object.getOwnPropertyDescriptor(target, property);
+	if (!descriptor || !descriptor.set) return;
+	Object.defineProperty(target, property, {
+	    configurable: true,
+	    enumerable: descriptor.enumerable,
+	    get: descriptor.get,
+	    set: function (value) {
+		if (label) traceSource(label + '.' + property, value);
+		descriptor.set.call(this, wrap(value));
+	    },
+	});
+    };
+
+    var setProperty = CSSStyleDeclaration.prototype.setProperty;
+    CSSStyleDeclaration.prototype.setProperty = function (name, value, priority) {
+	if (typeof value === 'string' && value.indexOf('url(') !== -1) traceSource('css ' + name, value);
+	return setProperty.call(this, name, rewrite(value), priority);
+    };
+    ['backgroundImage', 'background', 'maskImage', 'webkitMaskImage', 'borderImageSource', 'listStyleImage', 'content', 'cssText']
+	.forEach(function (property) { wrapSetter(CSSStyleDeclaration.prototype, property, rewrite); });
+
+    [['img', HTMLImageElement], ['media', HTMLMediaElement], ['source', HTMLSourceElement], ['iframe', HTMLIFrameElement]].forEach(function (entry) {
+	wrapSetter(entry[1].prototype, 'src', rewrite, entry[0]);
+    });
+    wrapSetter(HTMLVideoElement.prototype, 'poster', rewrite);
+
+    var setAttribute = Element.prototype.setAttribute;
+    Element.prototype.setAttribute = function (name, value) {
+	var lower = String(name).toLowerCase();
+	if (lower === 'src' || lower === 'poster' || lower === 'srcset' || lower === 'style') {
+	    traceSource('attribute ' + lower, value);
+	    value = rewrite(value);
+	}
+	return setAttribute.call(this, name, value);
+    };
+
+    // camelCase style properties (style.backgroundImage) bypass the setters above, so fix the style attribute they
+    // end up in. Uses the native setAttribute so the fix isn't traced or rewritten again.
+    var fixStyleAttribute = function (element) {
+	var css = element.getAttribute && element.getAttribute('style');
+	if (!css || css.indexOf('file:///') === -1) return;
+
+	// even an identical value queues another record for this observer, and a URL rewrite() leaves alone (a page's
+	// "file:///" + "" before its properties arrived) would then loop forever
+	var fixed = rewrite(css);
+	if (fixed !== css) setAttribute.call(element, 'style', fixed);
+    };
+    new MutationObserver(function (records) {
+	records.forEach(function (record) { fixStyleAttribute(record.target); });
+    }).observe(document, { attributes: true, attributeFilter: ['style'], subtree: true });
+
+    var nativeFetch = window.fetch;
+    if (nativeFetch) {
+	window.fetch = function (input, init) {
+	    traceSource('fetch', typeof input === 'string' ? input : input && input.url);
+	    return nativeFetch.call(this, typeof input === 'string' ? rewrite(input) : input, init);
+	};
+    }
+    var xhrOpen = XMLHttpRequest.prototype.open;
+    XMLHttpRequest.prototype.open = function (method, url) {
+	var args = Array.prototype.slice.call(arguments);
+	traceSource('xhr', String(url));
+	args[1] = rewrite(String(url));
+	return xhrOpen.apply(this, args);
+    };
+    var NativeFontFace = window.FontFace;
+    if (NativeFontFace) {
+	window.FontFace = function (family, source, descriptors) {
+	    return new NativeFontFace(family, typeof source === 'string' ? rewrite(source) : source, descriptors);
+	};
+	window.FontFace.prototype = NativeFontFace.prototype;
+    }
+})();
+)JS";
+
+    frame->ExecuteJavaScript (script, frame->GetURL (), 0);
+}
+
 const WallpaperEngine::Application::WallpaperApplication& SubprocessApp::getApplication () const {
     return this->m_application;
 }

+ 7 - 1
src/WallpaperEngine/WebBrowser/CEF/SubprocessApp.h

@@ -9,12 +9,18 @@ class WallpaperApplication;
 }
 
 namespace WallpaperEngine::WebBrowser::CEF {
-class SubprocessApp : public CefApp {
+class SubprocessApp : public CefApp, public CefRenderProcessHandler {
 public:
     explicit SubprocessApp (WallpaperEngine::Application::WallpaperApplication& application);
 
     void OnRegisterCustomSchemes (CefRawPtr<CefSchemeRegistrar> registrar) override;
 
+    [[nodiscard]] CefRefPtr<CefRenderProcessHandler> GetRenderProcessHandler () override;
+
+    void OnContextCreated (
+	CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, CefRefPtr<CefV8Context> context
+    ) override;
+
 protected:
     const WallpaperEngine::Application::WallpaperApplication& getApplication () const;
 

+ 116 - 4
src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.cpp

@@ -1,14 +1,66 @@
 #include "WPSchemeHandler.h"
 #include "WallpaperEngine/Assets/AssetLoadException.h"
+#include <filesystem>
+#include <algorithm>
+#include <cstdlib>
+#include <fstream>
 #include <iostream>
 
 #include "MimeTypes.h"
 #include "include/cef_parser.h"
 
 #include "WallpaperEngine/Data/Model/Project.h"
+#include "WallpaperEngine/Data/Model/Property.h"
+#include <ranges>
 
 using namespace WallpaperEngine::WebBrowser::CEF;
 
+namespace {
+// Web wallpapers build "file:///" + <file property> URLs, which a page served from a custom
+// scheme can't load. The scripts injected by SubprocessApp rewrite them to this prefix instead.
+constexpr auto LOCAL_FILE_PREFIX = "__lwe_file__/";
+
+// A file the user picked through one of the wallpaper's own file properties, or anything inside a directory property
+bool isPropertyFile (const Project& project, const std::filesystem::path& path) {
+    std::error_code error;
+    const auto resolved = std::filesystem::weakly_canonical (path, error);
+
+    if (error) {
+	return false;
+    }
+
+    for (const auto& property : project.properties | std::views::values) {
+	const auto* file = dynamic_cast<const PropertyFile*> (property.get ());
+
+	if (file == nullptr || file->getString ().empty ()) {
+	    continue;
+	}
+
+	if (!file->isDirectory ()) {
+	    if (std::filesystem::path (file->getString ()) == path) {
+		return true;
+	    }
+
+	    continue;
+	}
+
+	const auto directory = std::filesystem::weakly_canonical (file->getString (), error);
+
+	if (error) {
+	    continue;
+	}
+
+	const auto relative = resolved.lexically_relative (directory);
+
+	if (!relative.empty () && *relative.begin () != "..") {
+	    return true;
+	}
+    }
+
+    return false;
+}
+} // namespace
+
 WPSchemeHandler::WPSchemeHandler (const Project& project) :
     m_project (project), m_assetLoader (*this->m_project.assetLocator) { }
 
@@ -21,8 +73,6 @@ bool WPSchemeHandler::Open (CefRefPtr<CefRequest> request, bool& handle_request,
 	return false;
     }
 
-    std::cout << "Processing request for path " << request->GetURL ().c_str () << std::endl;
-
     // CefParseURL hands back the path exactly as it appeared in the URL, still percent-encoded -
     // filenames with spaces or other escaped characters (e.g. "Corin%20[M3].png") would otherwise
     // never match the real file ("Corin [M3].png") on disk.
@@ -42,7 +92,41 @@ bool WPSchemeHandler::Open (CefRefPtr<CefRequest> request, bool& handle_request,
 	    this->m_mimeType = mime;
 	}
 
-	this->m_contents = this->m_assetLoader.read (file);
+	if (file.starts_with (LOCAL_FILE_PREFIX)) {
+	    // only files the user picked through (or that live in a folder picked through) this wallpaper's own properties
+	    const std::filesystem::path local = "/" + file.substr (std::char_traits<char>::length (LOCAL_FILE_PREFIX));
+
+	    if (!isPropertyFile (this->m_project, local) || !std::filesystem::is_regular_file (local)) {
+		throw AssetLoadException (
+		    "Not a file or directory property of this wallpaper", local, std::make_error_code (std::errc::permission_denied)
+		);
+	    }
+
+	    std::cout << "--web-host: serving " << local.string () << " to the page" << std::endl;
+	    this->m_contents = std::make_shared<std::ifstream> (local, std::ios::binary);
+	    this->m_isLocalFile = true;
+	    this->m_totalSize = static_cast<int64_t> (std::filesystem::file_size (local));
+	    this->m_remaining = this->m_totalSize;
+
+	    // "bytes=<start>-[<end>]", the only form media elements send
+	    if (const std::string range = request->GetHeaderByName ("Range").ToString (); range.starts_with ("bytes=")) {
+		const auto dash = range.find ('-');
+		const int64_t start = std::atoll (range.c_str () + 6);
+		const int64_t last = dash != std::string::npos && dash + 1 < range.size ()
+		    ? std::atoll (range.c_str () + dash + 1)
+		    : this->m_totalSize - 1;
+
+		if (start >= 0 && start < this->m_totalSize) {
+		    this->m_partial = true;
+		    this->m_rangeStart = start;
+		    this->m_remaining = std::min (last, this->m_totalSize - 1) - start + 1;
+		    this->m_contents->seekg (start);
+		}
+	    }
+	} else {
+	    this->m_contents = this->m_assetLoader.read (file);
+	}
+
 	callback->Continue ();
     } catch (AssetLoadException& e) {
 	std::cout << "Cannot read file " << file << ": " << e.what () << std::endl;
@@ -66,6 +150,26 @@ void WPSchemeHandler::GetResponseHeaders (
     }
 
     response->SetMimeType (this->m_mimeType);
+
+    if (this->m_isLocalFile) {
+	response->SetHeaderByName ("Accept-Ranges", "bytes", true);
+
+	if (this->m_partial) {
+	    response->SetStatus (206);
+	    response->SetHeaderByName (
+		"Content-Range",
+		"bytes " + std::to_string (this->m_rangeStart) + "-"
+		    + std::to_string (this->m_rangeStart + this->m_remaining - 1) + "/" + std::to_string (this->m_totalSize),
+		true
+	    );
+	} else {
+	    response->SetStatus (200);
+	}
+
+	response_length = this->m_remaining;
+	return;
+    }
+
     response->SetStatus (200);
 
     // signals an unknown-length file
@@ -81,10 +185,14 @@ bool WPSchemeHandler::Read (
 
     bytes_read = 0;
 
-    if (this->m_contents->eof ()) {
+    if (this->m_contents->eof () || this->m_remaining == 0) {
 	return false;
     }
 
+    if (this->m_remaining > 0) {
+	bytes_to_read = static_cast<int> (std::min<int64_t> (bytes_to_read, this->m_remaining));
+    }
+
     try {
 	this->m_contents->read (static_cast<std::istream::char_type*> (data_out), bytes_to_read);
     } catch (std::ios::failure&) {
@@ -94,5 +202,9 @@ bool WPSchemeHandler::Read (
 
     bytes_read = this->m_contents->gcount ();
 
+    if (this->m_remaining > 0) {
+	this->m_remaining -= bytes_read;
+    }
+
     return true;
 }

+ 8 - 0
src/WallpaperEngine/WebBrowser/CEF/WPSchemeHandler.h

@@ -41,6 +41,14 @@ private:
     ReadStreamSharedPtr m_contents = nullptr;
     std::string m_mimeType;
 
+    // Only set for local files (see LOCAL_FILE_PREFIX), which media elements need Range support for to seek at all
+    bool m_isLocalFile = false;
+    bool m_partial = false;
+    int64_t m_totalSize = 0;
+    int64_t m_rangeStart = 0;
+    // bytes still to deliver, -1 for "until the stream ends"
+    int64_t m_remaining = -1;
+
     IMPLEMENT_REFCOUNTING (WPSchemeHandler);
     DISALLOW_COPY_AND_ASSIGN (WPSchemeHandler);
 };

+ 78 - 0
src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.cpp

@@ -1,9 +1,17 @@
 #include "WebHostSharedMemory.h"
 
 #include <atomic>
+#include <cerrno>
+#include <climits>
+#include <csignal>
+#include <cstring>
+#include <filesystem>
+#include <ctime>
 #include <fcntl.h>
+#include <linux/futex.h>
 #include <new>
 #include <sys/mman.h>
+#include <sys/syscall.h>
 #include <unistd.h>
 
 #include "WallpaperEngine/Logging/Log.h"
@@ -18,9 +26,79 @@ std::string generateName () {
 }
 } // namespace
 
+void WebHostSharedMemory::requestFrame () {
+    this->frameRequestSeq.fetch_add (1, std::memory_order_release);
+    // not the _PRIVATE variant, the waiter is in another process
+    syscall (SYS_futex, reinterpret_cast<uint32_t*> (&this->frameRequestSeq), FUTEX_WAKE, 1, nullptr, nullptr, 0);
+}
+
+uint32_t WebHostSharedMemory::waitForFrameRequest (const uint32_t lastSeen, const uint32_t timeoutMs) {
+    timespec timeout { .tv_sec = static_cast<time_t> (timeoutMs / 1000),
+		       .tv_nsec = static_cast<long> (timeoutMs % 1000) * 1000000L };
+
+    // returns right away if the value already changed, so a request that lands between the caller's last look and
+    // this call is never missed
+    syscall (
+	SYS_futex, reinterpret_cast<uint32_t*> (&this->frameRequestSeq), FUTEX_WAIT, lastSeen, &timeout, nullptr, 0
+    );
+
+    return this->frameRequestSeq.load (std::memory_order_acquire);
+}
+
+namespace {
+// "<prefix><pid>-<rest>" -> pid, or 0 if the name isn't ours
+pid_t pidFromName (const std::string& name, const std::string& prefix) {
+    if (!name.starts_with (prefix)) {
+	return 0;
+    }
+
+    const std::size_t end = name.find ('-', prefix.size ());
+
+    if (end == std::string::npos || end == prefix.size ()) {
+	return 0;
+    }
+
+    try {
+	return static_cast<pid_t> (std::stol (name.substr (prefix.size (), end - prefix.size ())));
+    } catch (...) {
+	return 0;
+    }
+}
+
+bool processExists (pid_t pid) { return kill (pid, 0) == 0 || errno == EPERM; }
+} // namespace
+
+void WallpaperEngine::WebBrowser::IPC::removeStaleWebHostFiles () {
+    std::error_code error;
+
+    for (const auto& entry : std::filesystem::directory_iterator ("/dev/shm", error)) {
+	const std::string name = entry.path ().filename ().string ();
+	const pid_t pid = pidFromName (name, "lwe-web-");
+
+	if (pid > 0 && !processExists (pid)) {
+	    shm_unlink (("/" + name).c_str ());
+	}
+    }
+
+    error.clear ();
+
+    for (const auto& entry : std::filesystem::directory_iterator (std::filesystem::temp_directory_path (), error)) {
+	const pid_t pid = pidFromName (entry.path ().filename ().string (), "lwe-cef-");
+
+	if (pid > 0 && !processExists (pid)) {
+	    std::error_code removeError;
+	    std::filesystem::remove_all (entry.path (), removeError);
+	}
+    }
+}
+
 WebHostSharedMemory* WallpaperEngine::WebBrowser::IPC::createSharedMemory (
     std::string& outName, uint32_t width, uint32_t height
 ) {
+    // once per engine process, before the first segment of this run exists
+    static const bool swept = (removeStaleWebHostFiles (), true);
+    (void)swept;
+
     const std::size_t size = WebHostSharedMemory::totalSize (width, height);
     outName = generateName ();
 

+ 68 - 12
src/WallpaperEngine/WebBrowser/IPC/WebHostSharedMemory.h

@@ -8,17 +8,30 @@
 namespace WallpaperEngine::WebBrowser::IPC {
 // Shared-memory transport between the main engine process (reads frames, writes input/size) and a
 // disposable "web host" child process that owns the actual CEF browser (writes frames, reads
-// input/size). One segment per CWeb instance. The pixel buffer is allocated once at maxWidth x
+// input/size). One segment per CWeb instance. The pixel buffers are allocated once at maxWidth x
 // maxHeight (the wallpaper's maxRenderSize - see CWeb), but the browser can be resized within that
 // cap for the lifetime of the host process by changing desiredWidth/desiredHeight; actual painted
-// content may be smaller than the cap and is described by frameWidth/frameHeight.
+// content may be smaller than the cap and is described by slotWidth/slotHeight.
 struct WebHostSharedMemory {
-    // Seqlock guarding the pixel buffer + frameWidth/frameHeight: the writer (host process) bumps
-    // this to an odd value before writing and to the next even value after. A reader must retry if
-    // it observes an odd value, or if the value changed between the start and end of its read.
-    std::atomic<uint32_t> frameSeq { 0 };
-    std::atomic<uint32_t> frameWidth { 0 };
-    std::atomic<uint32_t> frameHeight { 0 };
+    // Triple buffer for the pixel data. Three slots exist: the host owns one (the "back" slot, tracked
+    // privately by the host, it paints into it), the main process owns one (the "front" slot, tracked
+    // privately by the main process, it uploads from it) and the third sits here, in frameSlot. Publishing a
+    // frame swaps the back slot with this one and marks it dirty, taking a frame swaps the front slot with
+    // it. Both are a single atomic exchange, so neither side ever waits on the other or reads a half
+    // written frame.
+    // Bits 0-1 are the slot index, FRAME_DIRTY means the host published it and the main process hasn't
+    // taken it yet. Starts as slot 1, clean (host begins on slot 0, main process on slot 2).
+    static constexpr uint32_t FRAME_SLOTS = 3;
+    static constexpr uint32_t FRAME_SLOT_MASK = 3;
+    static constexpr uint32_t FRAME_DIRTY = 4;
+    std::atomic<uint32_t> frameSlot { 1 };
+    // Size of the content painted into each slot, written before the slot is published
+    std::atomic<uint32_t> slotWidth[FRAME_SLOTS] {};
+    std::atomic<uint32_t> slotHeight[FRAME_SLOTS] {};
+
+    // Main process -> host process: bumped whenever the page should produce its next frame (CEF runs with
+    // external begin frames). Doubles as a futex word, see waitForFrameRequest().
+    std::atomic<uint32_t> frameRequestSeq { 0 };
 
     // Main process -> host process: desired browser viewport size. The host polls this and calls
     // CefBrowserHost::WasResized() when it changes.
@@ -39,17 +52,55 @@ struct WebHostSharedMemory {
     std::atomic<int32_t> leftClick { 0 };
     std::atomic<int32_t> rightClick { 0 };
 
+    // Main process -> host process: latest audio spectrum (mono, 0..1) as produced by the recorder.
+    // audioSeq is bumped after every update so the host only forwards it to the page when it changed;
+    // a torn read across bands is harmless for a visualizer, so there's no seqlock here.
+    static constexpr std::size_t AUDIO_BANDS = 64;
+    std::atomic<uint32_t> audioSeq { 0 };
+    std::atomic<float> audioBands[AUDIO_BANDS] {};
+
+    // Main process -> host process: what the desktop's media player (MPRIS) is currently doing. The host process has
+    // no D-Bus connection of its own, so the main process mirrors its MediaSource here. Guarded by a seqlock:
+    // odd mediaSeq means a write is in progress, and a reader retries if it changed while reading.
+    static constexpr std::size_t MEDIA_TEXT = 256;
+    static constexpr std::size_t MEDIA_PATH = 1024;
+    std::atomic<uint32_t> mediaSeq { 0 };
+    // Bumped only when the cover changed, so the host doesn't reload and re-encode it for every progress update
+    uint32_t mediaCoverVersion = 0;
+    // Matches WallpaperEngine::Media::MediaSource::PlaybackState
+    int32_t mediaState = 0;
+    bool mediaAvailable = false;
+    double mediaPosition = 0.0;
+    double mediaDuration = 0.0;
+    char mediaTitle[MEDIA_TEXT] = {};
+    char mediaArtist[MEDIA_TEXT] = {};
+    char mediaAlbum[MEDIA_TEXT] = {};
+    // Local file of the cover, empty if there is none (or it isn't a file:// URL)
+    char mediaCoverPath[MEDIA_PATH] = {};
+    // primary, secondary, tertiary, text and high contrast as RGB8, see Media::ThumbnailPalette
+    uint8_t mediaPalette[5][3] = {};
+
     // Fixed capacity of the buffer below, set once at creation - never changes for the lifetime of
     // the segment.
     uint32_t maxWidth = 0;
     uint32_t maxHeight = 0;
 
-    // BGRA8 pixel data, maxWidth * maxHeight * 4 bytes capacity, immediately follows this header in
-    // the same mapping. Only the top-left frameWidth x frameHeight of it holds live content.
-    [[nodiscard]] uint8_t* frameBuffer () { return reinterpret_cast<uint8_t*> (this) + sizeof (WebHostSharedMemory); }
+    // BGRA8 pixel data, FRAME_SLOTS slots of maxWidth * maxHeight * 4 bytes capacity each, immediately follow
+    // this header in the same mapping. Only the top-left slotWidth x slotHeight of a slot holds live content.
+    [[nodiscard]] uint8_t* frameBuffer (uint32_t slot) {
+	return reinterpret_cast<uint8_t*> (this) + sizeof (WebHostSharedMemory)
+	    + static_cast<std::size_t> (slot) * maxWidth * maxHeight * 4;
+    }
+
+    // Main process: asks the host to produce a frame and wakes it if it's sleeping in waitForFrameRequest()
+    void requestFrame ();
+
+    // Host process: sleeps until frameRequestSeq differs from lastSeen or timeoutMs passes. Returns the current
+    // value, which the caller compares against lastSeen itself.
+    uint32_t waitForFrameRequest (uint32_t lastSeen, uint32_t timeoutMs);
 
     [[nodiscard]] static std::size_t totalSize (uint32_t maxWidth, uint32_t maxHeight) {
-	return sizeof (WebHostSharedMemory) + static_cast<std::size_t> (maxWidth) * maxHeight * 4;
+	return sizeof (WebHostSharedMemory) + FRAME_SLOTS * static_cast<std::size_t> (maxWidth) * maxHeight * 4;
     }
 };
 
@@ -58,6 +109,11 @@ struct WebHostSharedMemory {
 // on failure.
 WebHostSharedMemory* createSharedMemory (std::string& outName, uint32_t maxWidth, uint32_t maxHeight);
 
+// Removes what web host processes of engine processes that no longer exist left behind: shm segments named
+// "lwe-web-<pid>-<n>" and CEF profile directories named "lwe-cef-<pid>-<uuid>" in the temp directory (the pid is
+// the one that created it). Segments are 3 frames each, so a few killed engines add up to hundreds of MB of RAM.
+void removeStaleWebHostFiles ();
+
 // Attaches to a segment created by createSharedMemory() elsewhere (a different process). maxWidth
 // and maxHeight must match what the creator used. Returns nullptr on failure.
 WebHostSharedMemory* attachSharedMemory (const std::string& name, uint32_t maxWidth, uint32_t maxHeight);

+ 14 - 2
src/WallpaperEngine/WebBrowser/WebBrowserContext.cpp

@@ -7,6 +7,7 @@
 #include "include/cef_render_handler.h"
 #include <filesystem>
 #include <random>
+#include <unistd.h>
 
 using namespace WallpaperEngine::WebBrowser;
 
@@ -72,9 +73,15 @@ WebBrowserContext::WebBrowserContext (WallpaperEngine::Application::WallpaperApp
     }
 
     CefSettings settings;
-    std::string cache_path = (std::filesystem::temp_directory_path () / uuid::generate_uuid_v4 ()).string ();
-    cef_string_utf8_to_utf16 (cache_path.c_str (), cache_path.length (), &settings.root_cache_path);
+    // the pid in the name lets a later engine run tell this profile is stale if we get killed before cleaning up
+    this->m_cachePath
+	= (std::filesystem::temp_directory_path () / ("lwe-cef-" + std::to_string (getpid ()) + "-" + uuid::generate_uuid_v4 ()))
+	      .string ();
+    cef_string_utf8_to_utf16 (this->m_cachePath.c_str (), this->m_cachePath.length (), &settings.root_cache_path);
     settings.windowless_rendering_enabled = true;
+    // Chromium's own ERROR-level chatter (cancelled requests and the like) is noise here, the page's console is
+    // still forwarded by BrowserClient
+    settings.log_severity = LOGSEVERITY_FATAL;
 #if defined(CEF_NO_SANDBOX)
     settings.no_sandbox = true;
 #endif
@@ -87,4 +94,9 @@ WebBrowserContext::WebBrowserContext (WallpaperEngine::Application::WallpaperApp
 WebBrowserContext::~WebBrowserContext () {
     sLog.out ("Shutting down CEF");
     CefShutdown ();
+
+    if (!this->m_cachePath.empty ()) {
+	std::error_code error;
+	std::filesystem::remove_all (this->m_cachePath, error);
+    }
 }

+ 4 - 0
src/WallpaperEngine/WebBrowser/WebBrowserContext.h

@@ -5,6 +5,8 @@
 #include "include/cef_browser_process_handler.h"
 #include "include/wrapper/cef_helpers.h"
 
+#include <string>
+
 #define WPENGINE_SCHEME "wp"
 
 namespace WallpaperEngine::Application {
@@ -24,6 +26,8 @@ public:
 private:
     CefRefPtr<CefApp> m_browserApplication = nullptr;
     CefRefPtr<CefCommandLine> m_commandLine = nullptr;
+    // Chromium profile of this process, created under the temp directory and removed once CEF is shut down
+    std::string m_cachePath;
     WallpaperEngine::Application::WallpaperApplication& m_wallpaperApplication;
 };
 } // namespace WallpaperEngine::WebBrowser