webwallgl 1.3.5 → 1.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -36,12 +36,12 @@ import { mount, httpSource } from "webwallgl";
36
36
 
37
37
  ```
38
38
  // 2) ESM CDN via jsDelivr (without a bundler)
39
- import { mount, httpSource } from "https://cdn.jsdelivr.net/npm/webwallgl@1.3.5/webwallgl.min.mjs";
39
+ import { mount, httpSource } from "https://cdn.jsdelivr.net/npm/webwallgl@1.3.16/webwallgl.min.mjs";
40
40
  ```
41
41
 
42
42
  ```
43
43
  <!-- 3) UMD <script>: exposes the global WebWallGL -->
44
- <script src="https://cdn.jsdelivr.net/npm/webwallgl@1.3.5/webwallgl.global.min.js"></script>
44
+ <script src="https://cdn.jsdelivr.net/npm/webwallgl@1.3.16/webwallgl.global.min.js"></script>
45
45
  <script>
46
46
  const { mount, httpSource } = WebWallGL;
47
47
  </script>
@@ -101,6 +101,7 @@ The library makes only two network requests (scene.pkg and optional project.json
101
101
 
102
102
  | Factory | Use case |
103
103
  | --- | --- |
104
+ | `1.3.16` | 2026-09-11 | Media-component fixes and branding: (1) album covers on 33 wallpapers rendered as black placeholders because the $mediaThumbnail / $mediaPreviousThumbnail binding in an effect pass usertextures slot was dropped by parsing; the binding is now merged so covers show. (2) media title/artist layers were blank because width-limited wrapping was squeezed by the 2x2 placeholder box; the text canvas now grows only for placeholder-sized boxes, and typewriter scripts no longer stick at the placeholder text. (3) simulated audio is now true stereo. (4) the simulated media source is rebranded: title WebWallGL, artist oneincase, cover is the library logo. (5) fix lost font after the shared text canvas is resized: assigning textCanvas.width/height resets the 2D context and wipes the font back to the default 10px, so large clocks/dates were nearly invisible (2468489223, 3379996991); drawing now uses an explicit font string. (6) fix clock/date scripts misclassified as writeback and having their text cleared (3379996991). |
104
105
  | `httpSource(baseUrl, init?)` | HTTP base URL; falls back through the three real layouts: scene.pkg → scenes/scene.pkg → gifscene.pkg |
105
106
  | `fileSource(file, project?)` | A local .pkg from &lt;input type=file> or drag & drop |
106
107
  | `bytesSource(pkg, project?, key?)` | Bytes already in hand (bundled, IndexedDB cache, custom transport) |
@@ -144,8 +145,9 @@ input.addEventListener("change", () => {
144
145
  | `setAudio(src)` | Swap the audio spectrum source (pull model, once per frame); null falls back to the built-in sim. Survives scene changes. Works for scene and web |
145
146
  | `setMedia(src)` | Swap the system media source (Now Playing); shared by scene and web, survives scene changes |
146
147
  | `media` | Media control surface: read snapshot, plus skipNext / skipPrevious / play / pause / playPause transport control |
147
- | `pushPointer(u, v, buttons?)` | Inject pointer state (u/v normalized 0..1). For hosts whose window cannot receive the mouse; works for scene and web |
148
+ | `pushPointer(u, v, buttons?, mods?)` | Inject pointer state (u/v normalized 0..1; mods is a ctrl/shift/alt/meta mask). For hosts whose window cannot receive the mouse; works for scene and web |
148
149
  | `pointerLeave()` | Pointer left: clears buttons but keeps the last position (dropping it makes parallax and xray visibly jump) |
150
+ | `pushWheel(dx, dy, mode?, mods?)` | Inject wheel / trackpad gestures (web wallpapers only). Positive dy scrolls content down; a macOS pinch maps to the ctrl bit in mods |
149
151
  | `load(source)` | Switch scenes reusing the same canvas and WebGL context; resolves after the first frame |
150
152
  | `release() / restore()` | Free GL resources keeping the config (display sleep) / rebuild from the kept config |
151
153
  | `destroy()` | Terminal: frees resources, unbinds listeners; the instance is dead afterwards |
@@ -250,7 +252,7 @@ wp.media.playPause();
250
252
 
251
253
  ## Injecting pointer & audio
252
254
 
253
- A wallpaper host often cannot rely on the browser's native input: desktop wallpapers sit in the desktop underlay layer where the system's desktop window swallows mouse events, and the audio spectrum has to be captured by the host itself. Both channels are fed through instance methods.
255
+ A wallpaper host often cannot rely on the browser's native input: desktop wallpapers sit in the desktop underlay layer where the system's desktop window swallows mouse and wheel events, and the audio spectrum has to be captured by the host itself. These channels are fed through instance methods.
254
256
 
255
257
  ```
256
258
  // Pointer: u/v are normalized 0..1; buttons matches MouseEvent.buttons
@@ -258,6 +260,10 @@ wp.pushPointer(0.5, 0.5, 0); // hover at the center
258
260
  wp.pushPointer(0.5, 0.5, 1); // press the left button
259
261
  wp.pointerLeave(); // pointer left (clears buttons, keeps last position)
260
262
 
263
+ // Wheel / trackpad (web wallpapers only): dy matches DOM deltaY; mode 0=px 1=line 2=page
264
+ wp.pushWheel(0, 100, 0, 0); // two-finger scroll down one notch
265
+ wp.pushWheel(0, -50, 0, 1); // macOS pinch = ctrl bit (mods bit0)
266
+
261
267
  // Audio: pull model — the render loop calls snapshot() once per frame
262
268
  let latest = { left: new Float32Array(64), right: new Float32Array(64) };
263
269
  wp.setAudio({ snapshot: () => latest });
@@ -269,6 +275,8 @@ wp.setAudio(null); // remove the source, fall back to the built-in s
269
275
  ```
270
276
 
271
277
  - Injected pointer state coexists with the canvas's own DOM listeners — last writer wins. It works for scene and web wallpapers; media wallpapers have no pointer concept, so the call is silently inert
278
+ - pushWheel only affects web wallpapers: scenes have no wheel API (zero consumers across 194 scene wallpapers tested). The web side synthesizes both the modern wheel and the legacy mousewheel — the only wallpaper that genuinely uses the wheel (a 360° panorama, 3406740580) listens solely to the legacy event, while three.js OrbitControls listens solely to the modern one. DOMMouseScroll is deliberately not dispatched, or the same scroll would be processed twice
279
+ - macOS trackpad: feed two-finger scrolls as pixel deltas (mode=0); map a two-finger pinch to ctrl+wheel (mods bit0), exactly as browsers do — OrbitControls / pano2vr rely on that bit to tell zoom from scroll
272
280
  - Audio contract: 64 bands per channel, values 0..1. Short arrays are zero-padded and long ones truncated; the 32/16-band downsamples plus level and silence detection are derived by the library
273
281
  - Returning null (or throwing) from snapshot() means "no data this frame" and the engine falls back to the built-in simulation — no special handling needed while host capture is still warming up
274
282
  - setAudio survives scene changes: install it once and it applies to every scene loaded afterwards
@@ -321,10 +329,20 @@ b.pause(); // does not affect a
321
329
 
322
330
  ## Changelog
323
331
 
324
- Current version: 1.3.5. This section records only user-visible changes (API, behavior, compatibility, fidelity), each backed by a commit in the repository; pure internal refactors and verifier scripts are omitted.
332
+ Current version: 1.3.16. This section records only user-visible changes (API, behavior, compatibility, fidelity), each backed by a commit in the repository; pure internal refactors and verifier scripts are omitted.
325
333
 
326
334
  | Version | Date | Notes |
327
335
  | --- | --- | --- |
336
+ | `1.3.15` | 2026-09-08 | The video pair gains a self-healing watchdog: elements can enter "phantom playback" (paused=false but the decoder stalls and time stops advancing, with no event to listen for), freezing the wallpaper permanently — the render loop now detects currentTime not advancing for ~500ms on a visible page and hard-restarts decode via pause→play; legitimate throttling while the page is occluded is not misjudged |
337
+ | `1.3.14` | 2026-09-08 | Fourth pass at loop handover: the hard cut became a fast fade. Crawling keeps the layer beneath already moving while the old main (holding its last frame) fades out linearly over 64ms — the blend window masks the 1–3 frames of imprecision inherent to element-level handover (ended dispatch, layer-swap compositing) instead of requiring every stage to be zero-latency |
338
+ | `1.3.13` | 2026-09-08 | Third pass at seamless-loop handover, "crawling": during the main video's final 0.12s the standby actually plays at 1/8 rate (its pipeline stays in the playing state while advancing only ~1 frame); on ended the rate flips back to 1x and layers swap in the same tick — a rate change is a pure clock operation, removing both the wakeup/freeze and the content jump of earlier versions |
339
+ | `1.3.12` | 2026-09-08 | Loop handover refined again: pre-play removed (it caused a transient dual-4K-decode contention plus a content jump at the swap); the main video now holds its last frame on ended while the standby is started and confirmed to actually advance before layers swap — the hold lands on the content cut, reading as a normal edit |
340
+ | `1.3.11` | 2026-09-08 | Seamless-loop handover reworked to "pre-play + exact ended swap": the standby actually starts playing underneath the main video a few frames before the end, and layers swap the instant ended fires (frame-exact, not polled) — eliminating the resume-wakeup latency and rAF detection lag behind the last 1–2 frame hitch |
341
+ | `1.3.10` | 2026-09-08 | destroy() gains a releasePkgCache option: destroying an instance also evicts that wallpaper's parsed scene.pkg cache (previously the old package stayed cached after switching, so memory never dropped) |
342
+ | `1.3.9` | 2026-09-08 | New pushWheel channel for wheel / macOS trackpad injection (web wallpapers only): synthesizes wheel plus the legacy mousewheel, and maps a two-finger pinch to ctrl+wheel; pushPointer gains an optional modifier mask |
343
+ | `1.3.8` | 2026-09-08 | Video wallpapers now render as DOM video: 4K is no longer downsampled to 2048 (sharpness) and an A/B element pair gives seamless looping (loop-point hitch 84ms→33ms); the in-scene video texture cap now follows the render target too |
344
+ | `1.3.7` | 2026-09-08 | Fix: the library entry's setRenderDpr / restore remount went fully black (it reused a canvas whose context had been lost); scene wallpapers now support the real cover texture via $mediaThumbnail |
345
+ | `1.3.6` | 2026-09-08 | New MediaSnapshot.thumbnail: the host can now pass the real album cover to web wallpapers (previously only colors were available, and the cover was always a gradient placeholder) |
328
346
  | `1.3.5` | 2026-09-08 | xray effect: when the author doesn't configure size in the scene, the fallback is now 1 (identity) instead of the shader comment's 0.2 |
329
347
  | `1.3.4` | 2026-09-08 | Closes the four items deferred from 1.3.3: setFit now affects web wallpapers, audio:null truly mutes, the bare-iframe fallback no longer reports 0 fps, and debug globals are cleared on unmount |
330
348
  | `1.3.3` | 2026-09-08 | Full audit: fixed autoplay:false hanging mount(), setMedia being inert on the scene path, AudioContext leaking on wallpaper swap, and more |
@@ -336,6 +354,16 @@ Current version: 1.3.5. This section records only user-visible changes (API, beh
336
354
  | `1.0.0` | 2026-09-06 | First stable release: the public API is settled (mount / SceneInstance / Source) |
337
355
  | `1.0.0-beta1` | 2026-09-04 | First public preview |
338
356
 
357
+ 1.3.9 adds a wheel-injection channel. Pointer injection (pushPointer) always carried only position and buttons, never the wheel — the desktop wallpaper window receives no mouse events, and so no wheel either. But scanning all 256 local wallpapers showed a narrower scope than expected: the 194 scene wallpapers have zero wheel consumers (the WE scene script sandbox has no wheel API at all; every scroll token in a scene package is the texture-scroll layer effect g_ScrollSpeed), so only web wallpapers matter. Of the 52 web wallpapers, exactly one genuinely consumes the wheel — a 360° panorama (3406740580, where the wheel changes the field of view) — plus one wallpaper where three.js OrbitControls has zoom enabled by default. The snag is that the panorama listens only to the legacy mousewheel / DOMMouseScroll, never the modern wheel, while OrbitControls listens only to the modern wheel — synthesizing either one alone leaves the other class completely inert, with no error. So the web side now dispatches both a modern wheel and a legacy mousewheel per push (wheelDelta has the opposite sign to deltaY and detail stays 0); DOMMouseScroll is deliberately not dispatched, because each of the three legacy consumers binds it and mousewheel to the very same handler, so firing all three would process one scroll twice — the FOV jumps two steps at a time and merely looks like an over-sensitive wheel. macOS trackpad support is the point: two-finger scroll is a plain pixel wheel (deltaMode=0), while a two-finger pinch is translated, exactly as browsers do, to a wheel whose ctrl bit is set (OrbitControls / pano2vr rely on event.ctrlKey to tell zoom from scroll); when mapping from NSEvent.magnify the host must negate scrollingDeltaY. Wheel position reuses the last pointer coordinates. Calling pushWheel for a scene wallpaper is silently inert, the same treatment as video and other media wallpapers.
358
+
359
+ 1.3.8 is a set of video-wallpaper changes, prompted by 4K video looking soft. The cause was not any sharpness setting: video frames used to be uploaded as WebGL textures, and that path had a hard-coded 2048 long-edge cap — a 3840×2160 source became 2048×1152 (28% of the area) and was then scaled back up to fill the screen, while a Retina render target is commonly 3024 or even 3840. In effect you were shown an upscaled 2K image. Video wallpapers now display through a plain `&lt;video>` element instead: the browser decodes and composites in hardware at display size, so you get native resolution, and a WebGL context plus a full-canvas upload per frame are saved. The trade-off is that pure video wallpapers lose effect-chain/particle overlay — which they never used anyway. Wallpapers with an in-scene video texture layer don't take this path, but that path's cap was also changed to min(hardware MAX_TEXTURE_SIZE, render-target long edge, 3840): uploading a texture larger than the render target is pure waste, as the extra pixels are discarded at sampling time.
360
+
361
+ The same version also wires up seamless looping. WebKit's `&lt;video loop>` resets the decode pipeline at the loop point and no amount of buffering avoids it — measured on a 12-second 3840×2160@60fps clip, the worst frame gap at the loop point was 84ms (roughly five dropped frames). The library already had an A/B element scheme (as the main element nears the end, the standby starts, plays one or two frames and pauses to stay warm, then hands over within 2–5 frames of the true end); it just wasn't wired to video wallpapers, and the DOM path's comment claimed "dropped the pair, halves memory". Measurement shows that claim was wrong: the standby element has **no src** most of the time and only preheats in the final 0.5s window, so on a 12-second clip the overlap is under 5% — process RSS peak went from 128MB to 130MB while the worst loop-point gap dropped to 33ms. So it is on by default with no switch. If the standby isn't ready in time it falls back to native loop, which merely restores the old behaviour rather than interrupting or blanking. Also fixed along the way: `pause`/`resume`/`setVolume`/`setFit` previously only knew about scene instances and silently did nothing for DOM video; not reporting a first frame on the DOM path left `mount()`'s promise hanging forever; and without continuous frame marking `instance.stats` always reported "stopped". Liveness deliberately uses rAF rather than requestVideoFrameCallback — the latter exists but never fires in WKWebView (0 callbacks in 1.5s while the video plays normally).
362
+
363
+ 1.3.7 has two items, both on the unmount-then-remount path. First, the library entry's setRenderDpr and restore went fully black after remounting: teardown calls renderer.dispose(), which uses WEBGL_lose_context.loseContext(), and per spec a subsequent getContext("webgl2") on that same canvas returns the very same lost context object (verified: identical reference, isContextLost() true) — only a fresh canvas yields a usable context. The full-page renderer never hit this because its teardown clears the container's innerHTML and the canvas goes with it; the library form has no such container, so the canvas was kept and reused, and a host merely changing the resolution got a black screen with no error at all, since neither method arms a first-frame guard. Now the context's liveness is checked before reuse and a dead canvas is replaced; when the caller passed its own canvas (the library must not swap someone else's DOM) an explicit error is reported instead. Second, scene wallpaper covers: unlike web wallpapers these don't go through a script callback — authors put the reserved WE texture names $mediaThumbnail / $mediaPreviousThumbnail straight into a layer's image / textures slot, so the MediaSnapshot.thumbnail added in 1.3.6 meant nothing to the scene path. A cover change in the snapshot is now decoded asynchronously and uploaded as a GL texture (the previous one shifts to $mediaPreviousThumbnail), sharing the same pixel-upload code as the existing "live system" path.
364
+
365
+ 1.3.6 contains a single change: the missing cover channel in the media snapshot. MediaSnapshot previously carried only hasThumbnail plus the five color fields, with no image data — the event.thumbnail delivered to a web wallpaper's mediaThumbnailChanged was a 64×64 gradient the library painted from primary/secondary, so corpus code like `img.src = e.thumbnail` ran but never showed a real cover. MediaSnapshot and createMediaSource now both take an optional thumbnail (data URL or same-origin URL): when the host supplies it, it is passed through verbatim; when it doesn't, the gradient placeholder is still used. Supplying thumbnail without hasThumbnail implies the latter. The event diff also accounts for thumbnail now — system media interfaces typically deliver the track name first and the artwork a moment later, so watching only hasThumbnail/trackIndex would miss the "same track, cover just arrived" transition. Scene (WebGL) wallpapers can't use it in this version: their scripts read only hasThumbnail and the colors, and the image itself travels through a reserved texture (see 1.3.7).
366
+
339
367
  1.3.5 contains a single change: the xray effect's fallback value. xray's size drives the effect radius (it is inverted internally, so size=1 is identity). When the author doesn't write size into the scene's constantshadervalues, the shader declaration comment's "default":0.2 used to be applied — but that is the slider's initial position when the WE editor creates the effect, not a runtime fallback: as soon as the editor attaches the effect to a layer it writes the current slider value into the scene file, so the official runtime always reads an explicit value. Applying 0.2 shrank the effect radius to a fifth, leaving only a small patch around the cursor. The fallback is now 1. The change is scoped to this one parameter; the comment defaults for multiply and the texture slots are unchanged.
340
368
 
341
369
  1.3.4 closes the four items 1.3.3 listed as deferred. All four are observable behaviour bugs, not cleanup refactors:
package/README.md CHANGED
@@ -36,12 +36,12 @@ import { mount, httpSource } from "webwallgl";
36
36
 
37
37
  ```
38
38
  // 2) ESM CDN(jsDelivr,vite/webpack 之外的直引方式)
39
- import { mount, httpSource } from "https://cdn.jsdelivr.net/npm/webwallgl@1.3.5/webwallgl.min.mjs";
39
+ import { mount, httpSource } from "https://cdn.jsdelivr.net/npm/webwallgl@1.3.16/webwallgl.min.mjs";
40
40
  ```
41
41
 
42
42
  ```
43
43
  <!-- 3) UMD <script>:暴露全局 WebWallGL -->
44
- <script src="https://cdn.jsdelivr.net/npm/webwallgl@1.3.5/webwallgl.global.min.js"></script>
44
+ <script src="https://cdn.jsdelivr.net/npm/webwallgl@1.3.16/webwallgl.global.min.js"></script>
45
45
  <script>
46
46
  const { mount, httpSource } = WebWallGL;
47
47
  </script>
@@ -100,6 +100,7 @@ console.log(wp.canvas);
100
100
 
101
101
  | 工厂 | 用途 |
102
102
  | --- | --- |
103
+ | `1.3.16` | 2026-09-11 | 媒体组件修复与品牌化:①33 张壁纸的专辑封面此前显示黑色占位——作者把 $mediaThumbnail/$mediaPreviousThumbnail 绑在效果 pass 的 usertextures 槽,解析只取了默认占位纹理;现按绑定合并,封面正常显示(3785267658/3786330502 等)。②媒体歌名/歌手整层空白:限宽换行曾被 2×2 占位盒的内宽压成每字一行再收成省略号;文字画布改为只对占位小盒按内容对称扩边,打字机式脚本的标题不再卡在占位文本。③模拟音频改成真立体声(底鼓居中、军鼓/踩镲分左右、和弦左右独立)。④模拟媒体源换成库品牌:曲名 WebWallGL、歌手 oneincase、封面用库 logo。⑤修复共享文字画布尺寸重设后字体丢失:改 textCanvas.width/height 会重置 2D 上下文,measure 阶段设的字号被清成默认 10px,导致大字号时钟/日期按 10px 绘制、整层几乎不可见(2468489223、3379996991);现绘制用显式字体串。⑥修复时钟/日期脚本被误判为写回式而清空文本(3379996991)。 |
103
104
  | `httpSource(baseUrl, init?)` | HTTP 基址;自动按 scene.pkg → scenes/scene.pkg → gifscene.pkg 三种真实布局回退 |
104
105
  | `fileSource(file, project?)` | &lt;input type=file> 或拖拽进来的 .pkg 本地文件 |
105
106
  | `bytesSource(pkg, project?, key?)` | 已经拿到字节(bundle 内嵌、IndexedDB 缓存、自定义通道) |
@@ -143,8 +144,9 @@ input.addEventListener("change", () => {
143
144
  | `setAudio(src)` | 换音频频谱源(拉模式,每帧一次);null 回落内置模拟。换场景不清空。scene 与 web 都生效 |
144
145
  | `setMedia(src)` | 换系统媒体源(Now Playing);scene 与 web 共用同一实例,换场景不清空 |
145
146
  | `media` | 媒体控制面:读 snapshot,以及 skipNext / skipPrevious / play / pause / playPause 反向控制 |
146
- | `pushPointer(u, v, buttons?)` | 外部指针注入(u/v 为 0..1 归一化)。用于窗口收不到鼠标的宿主;scene 与 web 均生效 |
147
+ | `pushPointer(u, v, buttons?, mods?)` | 外部指针注入(u/v 为 0..1 归一化;mods 为 ctrl/shift/alt/meta 掩码)。用于窗口收不到鼠标的宿主;scene 与 web 均生效 |
147
148
  | `pointerLeave()` | 指针离开:只清按键、保留最后位置(清位置会让视差与 xray 明显抽一下) |
149
+ | `pushWheel(dx, dy, mode?, mods?)` | 滚轮 / 触摸板注入(仅网页壁纸)。dy 正=内容向下;Mac 触摸板双指捏合映射成 mods 的 ctrl 位 |
148
150
  | `load(source)` | 换场景,复用同一 canvas 与 WebGL 上下文;首帧后 resolve |
149
151
  | `release() / restore()` | 释放显存但保留配置(显示器睡眠)/ 用保留的配置重建 |
150
152
  | `destroy()` | 终态:释放资源、解绑监听,之后实例不可再用 |
@@ -249,7 +251,7 @@ wp.media.playPause();
249
251
 
250
252
  ## 注入指针与音频
251
253
 
252
- 壁纸宿主常常拿不到浏览器天然的输入:桌面壁纸叠在桌面 underlay 层,鼠标事件被系统的桌面窗口吃掉;音频频谱也得由宿主自己采集。两条通道都由实例方法喂进来。
254
+ 壁纸宿主常常拿不到浏览器天然的输入:桌面壁纸叠在桌面 underlay 层,鼠标与滚轮事件被系统的桌面窗口吃掉;音频频谱也得由宿主自己采集。这几条通道都由实例方法喂进来。
253
255
 
254
256
  ```
255
257
  // 指针:u/v 是 0..1 归一化坐标,buttons 同 MouseEvent.buttons
@@ -257,6 +259,10 @@ wp.pushPointer(0.5, 0.5, 0); // 悬停在正中
257
259
  wp.pushPointer(0.5, 0.5, 1); // 按下左键
258
260
  wp.pointerLeave(); // 鼠标移出(只清按键,保留最后位置)
259
261
 
262
+ // 滚轮 / 触摸板(仅网页壁纸):dy 与 DOM deltaY 同向;mode 0=像素 1=行 2=页
263
+ wp.pushWheel(0, 100, 0, 0); // 双指向下滚一格
264
+ wp.pushWheel(0, -50, 0, 1); // Mac 双指捏合 = ctrl 位(mods bit0)
265
+
260
266
  // 音频:拉模式,渲染循环每帧调一次 snapshot()
261
267
  let latest = { left: new Float32Array(64), right: new Float32Array(64) };
262
268
  wp.setAudio({ snapshot: () => latest });
@@ -268,6 +274,8 @@ wp.setAudio(null); // 撤源,回落内置模拟
268
274
  ```
269
275
 
270
276
  - 指针注入与 canvas 自身的 DOM 监听并存,谁后写谁赢;scene 与 web 壁纸都生效,媒体壁纸没有指针概念,调用静默无效
277
+ - pushWheel 只对网页壁纸生效:场景壁纸没有滚轮 API(实测 194 张场景壁纸零消费)。网页侧会同时合成现代 wheel 与旧式 mousewheel——语料里唯一真正用滚轮的 360° 全景(3406740580)只听旧式,而 three.js OrbitControls 只听现代;不发 DOMMouseScroll,否则同一滚动会被处理两遍
278
+ - Mac 触摸板:双指滚动直接喂像素级 delta(mode=0);双指捏合按浏览器约定映射成 ctrl+滚轮(mods bit0),OrbitControls / pano2vr 都靠它区分缩放与滚动
271
279
  - 音频契约:left/right 各 64 段、值域 0..1。段数不足补零、超出截断;32/16 段降采样与响度、静音判定由库派生
272
280
  - snapshot() 返回 null(或抛错)表示本帧无数据,引擎自动回落内置模拟源——宿主采集还没就绪时不必特殊处理
273
281
  - setAudio 换场景不清空:装一次对之后 load() 的所有场景都生效
@@ -320,10 +328,20 @@ b.pause(); // 不影响 a
320
328
 
321
329
  ## 版本更新说明
322
330
 
323
- 当前版本 1.3.5。本节只记对使用者可见的变化(API、行为、兼容性、还原度),逐条对应仓库里的提交;纯内部重构与判据脚本不列。
331
+ 当前版本 1.3.16。本节只记对使用者可见的变化(API、行为、兼容性、还原度),逐条对应仓库里的提交;纯内部重构与判据脚本不列。
324
332
 
325
333
  | 版本 | 日期 | 说明 |
326
334
  | --- | --- | --- |
335
+ | `1.3.15` | 2026-09-08 | 视频对新增自愈看门狗:元素可能进入"假播放"(paused=false 但解码停摆、时间不走,无任何事件可听)导致壁纸永久冻结——渲染循环里检测可见页面上 currentTime 连续 ~500ms 不前进即 pause→play 硬重启解码;页面被遮挡时的合法节流不会误判 |
336
+ | `1.3.14` | 2026-09-08 | 循环交接第四版:硬切改快速淡出。爬行保证下层交接时已在运动,上层旧主元素(定格在末帧)以 64ms 线性淡出露出下层——元素级交接固有的 1~3 帧不精度(ended 分发、层交换合成)被融合窗口整体掩掉,不再依赖把每一环都压到零延迟 |
337
+ | `1.3.13` | 2026-09-08 | 无缝循环交接第三版「爬行」:备用在主元素最后 0.12s 以 1/8 速率实际播放(媒体管线全程 playing 态,仅前进约 1 帧),ended 触发时拨回 1x 并同步换层——速率切换是纯时钟操作,消除前两版的唤醒延迟/定格与内容跳跃 |
338
+ | `1.3.12` | 2026-09-08 | 无缝循环交接再调:去掉预播(双路 4K 解码瞬时争抢 + 交接处内容跳跃两个残留卡顿源),改为末帧定格——主元素 ended 后停在末帧,唤起备用并确认其真正前进再换层;定格发生在内容切点上,观感为正常剪辑切换 |
339
+ | `1.3.11` | 2026-09-08 | 无缝循环交接改为「预播 + ended 精确交接」:备用在主元素最后几帧就开始在其下方实际播放,主元素 ended(精确到帧,非轮询)一触发即换层 —— 消除了旧方案里恢复播放的唤醒延迟与 rAF 检测滞后带来的最后 1~2 帧卡顿 |
340
+ | `1.3.10` | 2026-09-08 | destroy() 新增 releasePkgCache 选项:销毁实例时连带淘汰该壁纸的 scene.pkg 解析缓存(此前切换壁纸后旧包仍留在缓存里,内存不降) |
341
+ | `1.3.9` | 2026-09-08 | 新增 pushWheel 滚轮 / Mac 触摸板注入通道(仅网页壁纸):合成 wheel + 旧式 mousewheel,双指捏合映射为 ctrl+滚轮;pushPointer 增加可选修饰键掩码 |
342
+ | `1.3.8` | 2026-09-08 | 视频壁纸改走 DOM 直显:4K 不再被降采样到 2048(清晰度)+ A/B 双元素无缝循环(循环点卡顿 84ms→33ms);场景内视频纹理层的上限也跟随渲染目标 |
343
+ | `1.3.7` | 2026-09-08 | 修复库入口 setRenderDpr / restore 重挂后画面全黑(复用了已 loseContext 的画布);场景壁纸支持 $mediaThumbnail 真实封面纹理 |
344
+ | `1.3.6` | 2026-09-08 | 新增 MediaSnapshot.thumbnail:宿主可把真实专辑封面透传给网页壁纸(此前只能给取色,封面固定是渐变占位图) |
327
345
  | `1.3.5` | 2026-09-08 | xray 效果:作者未在场景里配置 size 时,缺省从 shader 注释的 0.2 改为 1(恒等) |
328
346
  | `1.3.4` | 2026-09-08 | 补齐 1.3.3 遗留四项:setFit 对网页壁纸生效、audio:null 真静音、裸 iframe 回退不再帧数恒 0、调试全局随卸载清理 |
329
347
  | `1.3.3` | 2026-09-08 | 全量审计:修 autoplay:false 挂死 mount()、scene 侧 setMedia 无效、换壁纸泄漏 AudioContext 等 |
@@ -335,6 +353,16 @@ b.pause(); // 不影响 a
335
353
  | `1.0.0` | 2026-09-06 | 首个正式版:公共 API 定稿(mount / SceneInstance / Source 三件套) |
336
354
  | `1.0.0-beta1` | 2026-09-04 | 首个公开测试版 |
337
355
 
356
+ 1.3.9 加的是滚轮注入通道。指针注入(pushPointer)一直只有位置与按键,没有滚轮 —— 桌面壁纸窗口收不到鼠标事件,自然也收不到滚轮。但这次扫了本机全部 256 张壁纸后发现范围比预想窄:194 张场景壁纸零滚轮消费(WE 的场景脚本沙箱根本没有滚轮 API,场景包里所有 scroll 字样都是纹理滚动图层效果 g_ScrollSpeed),只有网页壁纸用得上;而 52 张网页壁纸里真正消费滚轮的只有一张 360° 全景(3406740580,滚轮改视角 FOV),外加 three.js OrbitControls 默认开启的一张。麻烦的是这张全景只听旧式 mousewheel / DOMMouseScroll,不听现代 wheel,而 OrbitControls 又只听现代 wheel —— 只合成任意一种都会让另一类壁纸完全无反应,且没有任何报错。所以网页侧每次推送同时派发现代 wheel 与旧式 mousewheel(wheelDelta 与 deltaY 反号、detail 置 0);刻意不发 DOMMouseScroll,因为三个旧式消费方每一个都把它和 mousewheel 绑到同一个处理函数上,三个都发等于同一次滚动处理两遍,FOV 一次跳两格,看起来只是「滚轮太灵敏」。Mac 触摸板的适配是重点:双指滚动直接是像素级 wheel(deltaMode=0),双指捏合则按浏览器约定翻译成 ctrl 位为真的 wheel(OrbitControls / pano2vr 都靠 event.ctrlKey 区分缩放与滚动),宿主从 NSEvent.magnify 映射时注意 scrollingDeltaY 要取反。滚轮位置沿用最后一次指针坐标。场景壁纸调用 pushWheel 静默无效,与视频等媒体壁纸同样处理。
357
+
358
+ 1.3.8 是一组视频壁纸的改动,起因是 4K 视频看起来发糊。原因不在任何清晰度设置,而是视频帧过去要先上传成 WebGL 纹理,那条路径有个写死的 2048 长边上限 —— 3840×2160 的源被降到 2048×1152(面积只剩 28%)再放大铺满屏幕,而 Retina 上渲染目标常见 3024 甚至 3840,等于把一张 2K 图放大给你看。现在视频类型直接用 `&lt;video>` 显示,不再经纹理:浏览器按显示尺寸硬件解码合成,拿到原生分辨率,还省掉一个 WebGL 上下文和每帧一次全画布上传。代价是纯视频壁纸没有效果链/粒子叠加能力,它本来也用不到;「场景内含视频纹理层」的壁纸不走这条路,但那条路径的上限也一并改成了 min(硬件 MAX_TEXTURE_SIZE, 渲染目标长边, 3840) —— 上传比渲染目标更大的纹理是纯浪费,多出的像素在采样阶段就被丢掉。
359
+
360
+ 同一版还接上了无缝循环。WebKit 的 `&lt;video loop>` 在循环点会重置解码管线,缓冲再充分也躲不掉 —— 实测 3840×2160@60fps 的 12 秒素材,循环点最坏帧间隔 84ms(约卡 5 帧)。库里本来就有 A/B 双元素方案(主元素临近结尾时备用起播一两帧后暂停保温,真到结尾的 2~5 帧内交接),只是没接到视频壁纸上,而当初 DOM 路径注释里写着「放弃双元素,内存减半」。这个说法经实测是错的:备用元素平时**不赋 src**,只在结尾前 0.5s 窗口才预热,12 秒视频里重叠占比不到 5%,进程 RSS 峰值从 128MB 到 130MB,而循环点最坏间隔降到 33ms。所以它默认开启,没有开关。备用未及时就绪时自动退回原生 loop,只是回到旧表现,不会中断或黑屏。附带修掉的:`pause`/`resume`/`setVolume`/`setFit` 过去只认场景实例,对 DOM 视频静默失效;DOM 路径不上报首帧会让 `mount()` 的 Promise 永久挂起;不持续打点会让 `instance.stats` 恒报「已停」。判活刻意用 rAF 而非 requestVideoFrameCallback —— 后者在 WKWebView 里存在却从不回调(视频正常播放时 1.5 秒 0 次)。
361
+
362
+ 1.3.7 两项,都在「卸载后重挂」这条路上。一是库入口的 setRenderDpr 与 restore 重挂后画面全黑:清理阶段 renderer.dispose() 走的是 WEBGL_lose_context.loseContext(),而按规范同一个 canvas 之后再 getContext("webgl2") 拿回的仍是那个已丢失的上下文对象(实测新旧引用相同、isContextLost() 为真),只有换一块新画布才能拿到可用上下文。整页渲染器不踩是因为它清理时把容器 innerHTML 清空、画布跟着删掉;库形态没有那层容器,画布被留下复用,于是宿主一改清晰度就黑屏,而且两个方法都不挂首帧守卫,连报错都没有。现在复用前先查上下文存活,死了就换新画布,调用方直接传 canvas(库不能替它换 DOM)时如实报错。二是场景壁纸的封面:它不像网页壁纸走脚本回调,作者是把 $mediaThumbnail / $mediaPreviousThumbnail 这两个 WE 保留纹理名直接填进层的 image / textures 槽,所以 1.3.6 加的 MediaSnapshot.thumbnail 对场景侧原本没有意义。现在快照里的封面变化会异步解码并上传成 GL 纹理(旧的顺位挪到 $mediaPreviousThumbnail),与「系统实况」原有的上传逻辑共用同一段像素代码。
363
+
364
+ 1.3.6 只有一项,补的是媒体快照里缺的封面通道。此前 MediaSnapshot 只有 hasThumbnail 和五个取色字段,没有图片本体:网页壁纸的 mediaThumbnailChanged 收到的 event.thumbnail 是库拿 primary/secondary 现画的 64×64 渐变块,语料里 `img.src = e.thumbnail` 那类写法能跑但显示的不是真封面。现在 MediaSnapshot 与 createMediaSource 都多一个可选的 thumbnail(data URL 或同源 URL),宿主填了就原样透传给壁纸,没填仍走渐变占位图;只给 thumbnail 不给 hasThumbnail 时后者自动为真。另外事件 diff 也把 thumbnail 纳入判定 —— 系统媒体接口普遍先给歌名再补封面,只看 hasThumbnail/trackIndex 会漏掉「同一首歌补上封面」这一次变化。场景(WebGL)壁纸在本版本里还用不上它:它们的脚本只读 hasThumbnail 与取色,图片本体要走保留纹理(见 1.3.7)。
365
+
338
366
  1.3.5 只有一项,改的是 xray 效果的缺省值。xray 的 size 决定效果范围(内部取倒数,size=1 是恒等)。作者若没在场景的 constantshadervalues 里写 size,此前会套用 shader 声明注释里的 "default":0.2 —— 但那是 WE 编辑器新建效果时滑条的初始位置,不是运行时缺省:编辑器一旦把效果加到层上就会把当时的滑条值写进场景文件,所以官方运行时永远读得到显式值。套 0.2 会让效果范围缩成五分之一,只剩光标旁一小块。现在缺省是 1。作用面收窄在这一个参数上,multiply 与贴图槽的注释缺省不变。
339
367
 
340
368
  1.3.4 收掉 1.3.3 结尾列为「留待后续」的四项,都是能观测到的行为偏差,不是清理式重构:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webwallgl",
3
- "version": "1.3.5",
3
+ "version": "1.3.16",
4
4
  "description": "Wallpaper Engine scene wallpaper renderer for the browser (npm / CDN)",
5
5
  "license": "MIT",
6
6
  "author": "oneincase <462534624@qq.com>",
package/types.d.ts CHANGED
@@ -124,6 +124,17 @@ export type MediaSnapshot = {
124
124
  position: number;
125
125
  duration: number;
126
126
  hasThumbnail: boolean;
127
+ /**
128
+ * 真实封面图(data URL 或同源 URL)。宿主能拿到系统封面时给这里,
129
+ * 网页壁纸的 `mediaThumbnailChanged(e)` 会直接收到它作为 `e.thumbnail`。
130
+ *
131
+ * 留空时库用 primary/secondary 生成一张渐变占位图 —— 保证语料里
132
+ * `img.src = e.thumbnail` 那类写法不会拿到空串,但显示的不是真专辑封面。
133
+ *
134
+ * 注意场景(WebGL)壁纸不消费图片本体,其脚本只读 `e.hasThumbnail` 与取色,
135
+ * 所以这个字段只对网页壁纸有效。
136
+ */
137
+ thumbnail?: string;
127
138
  /** 封面取色。见 MediaColor 的类型约束 */
128
139
  primaryColor: MediaColor;
129
140
  secondaryColor: MediaColor;
@@ -161,6 +172,56 @@ export type MediaSource = {
161
172
  pause?(): void;
162
173
  playPause?(): void;
163
174
  };
175
+ /**
176
+ * `createMediaSource(init, controls)` 的 init:宿主通常拿得到的那部分字段,
177
+ * 其余(配色、歌词行、trackIndex)由库补默认值。
178
+ *
179
+ * 类型本体放在这里而不是 api/media-source.ts,是因为只有本文件会被
180
+ * tsconfig.lib-types.json 编成发布的 types.d.ts —— 定义在别处的话
181
+ * 消费方 `import { createMediaSource } from "webwallgl"` 摸不到入参类型。
182
+ */
183
+ export type MediaSourceInit = {
184
+ hasMedia?: boolean;
185
+ /** 0=停止 1=播放 2=暂停;也接受布尔 playing(true→1、false→2) */
186
+ state?: MediaPlaybackState;
187
+ playing?: boolean;
188
+ title?: string;
189
+ artist?: string;
190
+ album?: string;
191
+ albumArtist?: string;
192
+ /** 秒 */
193
+ position?: number;
194
+ duration?: number;
195
+ hasThumbnail?: boolean;
196
+ /**
197
+ * 真实封面图(data URL 或同源 URL)。给了就直接透给网页壁纸的
198
+ * `mediaThumbnailChanged(e).thumbnail`;不给则库生成渐变占位图。
199
+ * 传了非空值时 hasThumbnail 自动视为 true
200
+ */
201
+ thumbnail?: string;
202
+ primaryColor?: MediaColorInit;
203
+ secondaryColor?: MediaColorInit;
204
+ tertiaryColor?: MediaColorInit;
205
+ textColor?: MediaColorInit;
206
+ highContrastColor?: MediaColorInit;
207
+ trackIndex?: number;
208
+ /** [秒, 文本] 按时间升序;库按 position 定位当前行 */
209
+ lyrics?: Array<[number, string]>;
210
+ };
211
+ /** `mediaColor()` 与配色字段接受的形态 */
212
+ export type MediaColorInit = number | number[] | {
213
+ x: number;
214
+ y: number;
215
+ z: number;
216
+ } | MediaColor;
217
+ /** `createMediaSource(init, controls)` 的 controls:宿主转发给真实播放器 */
218
+ export type MediaSourceControls = {
219
+ skipNext?(): void;
220
+ skipPrevious?(): void;
221
+ play?(): void;
222
+ pause?(): void;
223
+ playPause?(): void;
224
+ };
164
225
  /** 壁纸侧可用的媒体控制面(SceneInstance.media) */
165
226
  export type MediaControl = {
166
227
  readonly snapshot: MediaSnapshot;
@@ -300,11 +361,13 @@ export type SceneInstance = {
300
361
  * @param u 归一化横坐标 0..1(相对画布左缘)
301
362
  * @param v 归一化纵坐标 0..1(相对画布上缘,y 向下)
302
363
  * @param buttons 按键位掩码,同 MouseEvent.buttons;只有 bit0(左键)被消费
364
+ * @param mods 修饰键位掩码:bit0 ctrl / bit1 shift / bit2 alt / bit3 meta。
365
+ * 省略等于 0。只有 web 壁纸消费(合成事件的 ctrlKey 等字段)
303
366
  *
304
367
  * 与 canvas 自身的 DOM 指针监听并存,谁后写谁赢。scene 与 web 壁纸都生效,
305
368
  * 媒体壁纸(video/gif/image)没有指针概念,调用静默无效。
306
369
  */
307
- pushPointer(u: number, v: number, buttons?: number): void;
370
+ pushPointer(u: number, v: number, buttons?: number, mods?: number): void;
308
371
  /**
309
372
  * 外部指针离开本窗口(鼠标移到了别的显示器)。
310
373
  *
@@ -312,14 +375,39 @@ export type SceneInstance = {
312
375
  * 视差弹回中心,画面明显抽一下。语义与 DOM 的 mouseleave 一致。
313
376
  */
314
377
  pointerLeave(): void;
378
+ /**
379
+ * 外部滚轮注入:把宿主捕获的滚轮 / 触摸板手势推进壁纸。
380
+ *
381
+ * **只有 web 壁纸生效。** scene 壁纸静默无效不是遗漏:WE 的场景脚本沙箱
382
+ * 不暴露任何滚轮 API,实测 194 张场景壁纸零消费(场景包里的 `scroll`
383
+ * 全都是纹理滚动 shader 的 `g_ScrollSpeed`,与鼠标无关)。
384
+ *
385
+ * @param dx 横向滚动量,正 = 内容向右(与 WheelEvent.deltaX 同向同量级)
386
+ * @param dy 纵向滚动量,正 = 内容向下(与 WheelEvent.deltaY 同向;
387
+ * macOS 原生 NSEvent.scrollingDeltaY 是**反向**的,宿主需取反)
388
+ * @param mode 同 WheelEvent.deltaMode:0 像素 / 1 行 / 2 页。触摸板恒为 0
389
+ * @param mods 修饰键位掩码,bit0 ctrl。**触摸板双指捏合应映射成 ctrl + 滚轮**
390
+ * —— 浏览器就是这样把 macOS 的 magnify 手势喂给网页的,
391
+ * OrbitControls / pano2vr 一族都靠 `event.ctrlKey` 区分缩放与滚动
392
+ *
393
+ * 位置沿用最后一次 `pushPointer()` 的坐标(滚轮事件本身不带位置)。
394
+ */
395
+ pushWheel(dx: number, dy: number, mode?: number, mods?: number): void;
315
396
  /** 换场景,复用同一 canvas 与 WebGL 上下文 */
316
397
  load(source: Source): Promise<void>;
317
398
  /** 释放 GL/视频/音频资源,保留配置(显示器睡眠等场景) */
318
399
  release(): void;
319
400
  /** 用保留的配置重建 */
320
401
  restore(): void;
321
- /** 彻底销毁:解绑事件监听、释放全部资源,之后不可再用 */
322
- destroy(): void;
402
+ /**
403
+ * 彻底销毁:解绑事件监听、释放全部资源,之后不可再用。
404
+ * `releasePkgCache: true` 连带淘汰本实例 source 的 scene.pkg 解析缓存 ——
405
+ * 缓存默认跨实例保留(同壁纸重挂不重新下载),宿主"销毁即放弃"的语义
406
+ * (如桌面壁纸逐张切换)需要显式声明,否则旧包会压在缓存里不落内存。
407
+ */
408
+ destroy(opts?: {
409
+ releasePkgCache?: boolean;
410
+ }): void;
323
411
  readonly stats: FrameStats;
324
412
  readonly info: SceneInfo | null;
325
413
  /** 事件订阅,返回取消函数 */
package/webwallgl.d.ts CHANGED
@@ -2,8 +2,15 @@
2
2
  //
3
3
  // ⚠️ 维护约束:函数签名是**手写的契约面**,全部类型来自 api/types.ts
4
4
  // (tsc 自动生成到 dist/lib/types,构建时由 build:lib 拷贝拼接)。
5
- // 改 api/mount.ts / api/source.ts 的**函数签名**时必须同步本文件 ——
6
- // 类型本体(MountOptions/SceneInstance/Source 等)改 api/types.ts 即可自动带出。
5
+ // 改 api/index.ts 的**导出清单**或 mount/source/media-source 的**函数签名**时
6
+ // 必须同步本文件 —— 类型本体(MountOptions/SceneInstance/Source/MediaSourceInit
7
+ // 等)改 api/types.ts 即可自动带出。
8
+ //
9
+ // 只在 types.ts 里定义类型:别处(如 media-source.ts)定义的类型不进
10
+ // tsconfig.lib-types.json 的编译范围,本文件 import 它会解析失败。
11
+ //
12
+ // 漏声明一个运行时导出的后果不是"类型弱一点",而是消费方 import 直接
13
+ // TS2305 编译不过(verify-arch 会比对 api/index.ts 与本文件的导出清单)。
7
14
  //
8
15
  // ⚠️ 必须 import 后再 export,不能写成 `export { type X } from "./types"`:
9
16
  // 后者只做转发导出,**不把名字引入本文件作用域**,下面的函数签名会引用到
@@ -22,6 +29,13 @@ import type {
22
29
  PointerSource,
23
30
  AudioSource,
24
31
  MediaSource,
32
+ MediaSnapshot,
33
+ MediaColor,
34
+ MediaColorInit,
35
+ MediaControl,
36
+ MediaPlaybackState,
37
+ MediaSourceInit,
38
+ MediaSourceControls,
25
39
  DiagnosticLevel,
26
40
  } from "./types";
27
41
 
@@ -38,6 +52,13 @@ export type {
38
52
  PointerSource,
39
53
  AudioSource,
40
54
  MediaSource,
55
+ MediaSnapshot,
56
+ MediaColor,
57
+ MediaColorInit,
58
+ MediaControl,
59
+ MediaPlaybackState,
60
+ MediaSourceInit,
61
+ MediaSourceControls,
41
62
  DiagnosticLevel,
42
63
  };
43
64
 
@@ -60,3 +81,23 @@ export declare function bytesSource(
60
81
  project?: unknown,
61
82
  key?: string,
62
83
  ): Source;
84
+
85
+ export declare function mediaSource(
86
+ urlOrFile: string | File | Blob,
87
+ options?: { type?: "video" | "gif" | "image"; key?: string },
88
+ ): Source;
89
+
90
+ export declare function sniffMediaType(
91
+ url: string,
92
+ ): "video" | "gif" | "image" | null;
93
+
94
+ export declare function createMediaSource(
95
+ init?: MediaSourceInit,
96
+ controls?: MediaSourceControls,
97
+ ): MediaSource & { set(patch: MediaSourceInit): void };
98
+
99
+ export declare function mediaColor(
100
+ r: MediaColorInit,
101
+ g?: number,
102
+ b?: number,
103
+ ): MediaColor;