wallpaper-engine-dsh 1.0.5 → 1.0.7
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.md +3 -3
- package/README.zh.md +3 -3
- package/lib/client.js +219 -49
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@ A DSH bundle that turns your local **Wallpaper Engine** library into the live ba
|
|
|
8
8
|
|
|
9
9
|
**The library, rendered live.** The grid lists every wallpaper the browser can genuinely render — video and web wallpapers. Scene and application wallpapers are Wallpaper Engine's private `.pkg` scene packages; since no browser can execute them, they are filtered out rather than offered as degraded stills. Everything listed plays for real, badged Video or Web, and a search box narrows large libraries by title or workshop id instantly.
|
|
10
10
|
|
|
11
|
-
**Legibility on any wallpaper.** Text stays readable by construction, not by luck. The veil always opposes the text colour — black in the dark theme, white in the light theme — so neither a pitch-black nor a pure-white wallpaper can swallow the UI. On top of that, a Smart veil samples the wallpaper's brightness every few seconds and automatically strengthens the veil when a scene drifts too bright (or too dark) for the current theme; it also strengthens the border contrast and the glass frosting on the same evidence. Dropdowns, menus, and dialogs sit on DSH's opaque surfaces and are never affected.
|
|
11
|
+
**Legibility on any wallpaper.** Text stays readable by construction, not by luck. The veil always opposes the text colour — black in the dark theme, white in the light theme — so neither a pitch-black nor a pure-white wallpaper can swallow the UI. On top of that, a Smart veil samples the wallpaper's brightness every few seconds and automatically strengthens the veil when a scene drifts too bright (or too dark) for the current theme (web wallpapers' pixels are unreadable inside the sandbox, so their preview still stands in as the sample); it also strengthens the border contrast and the glass frosting on the same evidence. Dropdowns, menus, and dialogs sit on DSH's opaque surfaces and are never affected.
|
|
12
12
|
|
|
13
|
-
**Manual framing.** Pick a base layout — Cover, Contain, Stretch, Original, or Free — then frame the shot yourself. **Adjust** opens the live wallpaper fullscreen: scroll to zoom, drag to pan. In the first four layouts the crop is clamped so the image always fills the screen with no exposed edges; **Free** places the wallpaper at natural size on a black canvas where it can be dragged anywhere
|
|
13
|
+
**Manual framing.** Pick a base layout — Cover, Contain, Stretch, Original, or Free — then frame the shot yourself. **Adjust** opens the live wallpaper fullscreen: scroll to zoom, drag to pan. In the first four layouts the crop is clamped so the image always fills the screen with no exposed edges; **Free** places the wallpaper at natural size on a black canvas where it can be dragged anywhere, zoomed from 0.1× to 4×, and rotated to any angle (±90° buttons or Alt+drag), with just enough kept on screen that it can never be dragged out of sight. **Mirror** flips the wallpaper horizontally and works in every layout — a flip never exposes an edge. Esc or Done finishes, the framing persists, and **Reset crop** restores the default in one click. Web wallpapers take the same select and the same zoom/pan/rotate framing — the transform applies to the iframe directly.
|
|
14
14
|
|
|
15
15
|
**Four live sliders.** Wallpaper blur, Scrim, Borders, and Glass — every change lands instantly and persists. Blur renders in place: composition, layout, and crop stay identical at every blur level, and within one blur radius of the screen edge the picture fades softly into the veil beneath the UI chrome. A whisper of dither noise keeps smooth gradients band-free; at zero blur the media bypasses the filter pipeline entirely for bit-exact pixels.
|
|
16
16
|
|
|
@@ -58,7 +58,7 @@ The bundle has two halves:
|
|
|
58
58
|
- `GET /we-background/preview/<token>`
|
|
59
59
|
- A **browser plugin** renders the chosen wallpaper on a fixed layer behind the app frame, contributes the settings UI, and keeps every effect on DSH design tokens so themes apply cleanly.
|
|
60
60
|
|
|
61
|
-
Media serving is defensive by design: tokens are unguessable 72-bit values, stable across inventory rebuilds (an in-flight stream is never killed by a background refresh) and re-minted only on an explicit refresh; sub-asset paths are contained lexically and re-verified against the real filesystem so symlinks cannot escape their wallpaper directory; HTML and SVG responses carry a `sandbox` CSP so they can never execute against the DSH origin; the wildcard CORS on media only lets a caller who already knows a token read bytes it could already request — the token stays the capability, and the server is loopback-only; and responses that would come up short destroy themselves rather than truncate silently. The host answers from a 30-second inventory cache, resolves discovery asynchronously off the request path, and caches it for ten minutes. The bundle registers no model tools and adds no prompt text.
|
|
61
|
+
Media serving is defensive by design: tokens are unguessable 72-bit values, stable across inventory rebuilds (an in-flight stream is never killed by a background refresh) and re-minted only on an explicit refresh; sub-asset paths are contained lexically and re-verified against the real filesystem so symlinks cannot escape their wallpaper directory; HTML and SVG responses carry a `sandbox` CSP so they can never execute against the DSH origin; the wallpaper iframe additionally pins a neutral `color-scheme`, so the shell's dark-theme scheme never leaks into — and breaks — third-party wallpaper apps; the wildcard CORS on media only lets a caller who already knows a token read bytes it could already request — the token stays the capability, and the server is loopback-only; and responses that would come up short destroy themselves rather than truncate silently. The host answers from a 30-second inventory cache, resolves discovery asynchronously off the request path, and caches it for ten minutes. The bundle registers no model tools and adds no prompt text.
|
|
62
62
|
|
|
63
63
|
## Limitations
|
|
64
64
|
|
package/README.zh.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
**只列出能真实播放的壁纸。** 网格中列出浏览器可以真实渲染的全部类型——视频与网页壁纸。场景/应用壁纸是 Wallpaper Engine 私有的 `.pkg` 场景包,浏览器无法执行,因此直接过滤,不以降级的静态图代替。列出的每张壁纸都真实播放,并按 视频/网页 标注类型;搜索框可按标题或创意工坊 ID 筛选较大的壁纸库。
|
|
10
10
|
|
|
11
|
-
**任何壁纸上文字都可读。**
|
|
11
|
+
**任何壁纸上文字都可读。** 遮罩颜色始终与文字颜色相反:深色主题用黑色遮罩,浅色主题用白色遮罩,因此过亮或过暗的壁纸都不会盖住文字。在此基础上,「智能可视」每隔几秒采样一次壁纸亮度,亮度过高或过低时自动加强遮罩(网页壁纸的像素在沙箱中读不到,改用其预览图作为采样依据)。下拉菜单、弹窗等浮层显示在 DSH 原生的不透明表面上,不受影响。
|
|
12
12
|
|
|
13
|
-
**手动取景。** 先选基础排布(填充、适应、拉伸、原始、自由),再手动调整画面:点「调整画面」将壁纸全屏展开,滚轮缩放,拖动平移。前四种排布下裁剪范围经过限制,画面始终铺满屏幕,不露出边缘;「自由」把壁纸按原始大小放在黑色画布上,可拖动到任意位置,缩放范围 0.1–4
|
|
13
|
+
**手动取景。** 先选基础排布(填充、适应、拉伸、原始、自由),再手动调整画面:点「调整画面」将壁纸全屏展开,滚轮缩放,拖动平移。前四种排布下裁剪范围经过限制,画面始终铺满屏幕,不露出边缘;「自由」把壁纸按原始大小放在黑色画布上,可拖动到任意位置,缩放范围 0.1–4×,并可旋转任意角度(±90° 按钮或 Alt+拖动),同时始终保留一小块在屏幕内,不会拖丢。「镜像」水平翻转壁纸,所有排布下都可用——翻转不会破坏铺满。按 Esc 或点「完成」退出,取景设置会保存,「重置裁剪」可一键恢复默认。网页壁纸使用相同的排布下拉与缩放/平移/旋转取景(变换直接作用于 iframe)。
|
|
14
14
|
|
|
15
15
|
**四个实时滑杆。** 壁纸模糊、暗化、边框、玻璃,每一步调节立即生效并保存。模糊在原位渲染:任意模糊度下,画面的比例、排布与取景完全一致;屏幕边缘一个模糊半径内的画面向遮罩过渡,被界面边饰覆盖,通常不可见。模糊时叠加轻微噪声,消除渐变色带;模糊为零时媒体完全绕过滤镜管线,像素保持不变。
|
|
16
16
|
|
|
@@ -58,7 +58,7 @@ bundle 分为两半:
|
|
|
58
58
|
- `GET /we-background/preview/<token>`
|
|
59
59
|
- **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读取 DSH 设计令牌,主题切换时自动适配。
|
|
60
60
|
|
|
61
|
-
媒体服务在多个层面做了防护:令牌是无法猜测的 72 位随机值,清单重建时保持不变(后台刷新不会中断正在播放的流),只在显式刷新时重新生成;子资源路径先做词法包含检查,再对照真实文件系统复核,符号链接无法指向壁纸目录之外;HTML 与 SVG 响应带 `sandbox` CSP,不能以 DSH
|
|
61
|
+
媒体服务在多个层面做了防护:令牌是无法猜测的 72 位随机值,清单重建时保持不变(后台刷新不会中断正在播放的流),只在显式刷新时重新生成;子资源路径先做词法包含检查,再对照真实文件系统复核,符号链接无法指向壁纸目录之外;HTML 与 SVG 响应带 `sandbox` CSP,不能以 DSH 源的身份执行;壁纸 iframe 固定使用中性 `color-scheme`,外壳的深色主题不会泄漏进(并破坏)第三方壁纸应用;媒体响应的 CORS 通配只允许持有令牌的一方读取其本来就能请求的文件,访问权限仍由令牌决定,且整个服务只监听本机回环地址;长度不足的响应会直接中断,而不是悄悄截断。清单由宿主缓存 30 秒;安装位置的发现过程在请求路径之外异步执行,并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
|
|
62
62
|
|
|
63
63
|
## 限制
|
|
64
64
|
|
package/lib/client.js
CHANGED
|
@@ -95,6 +95,12 @@ window.__ModuleLoader__.load({
|
|
|
95
95
|
adjust: "调整画面",
|
|
96
96
|
adjustDone: "完成",
|
|
97
97
|
adjustHint: "拖动平移 · 滚轮缩放 · Esc 或点「完成」退出",
|
|
98
|
+
adjustHintFree: "拖动平移 · 滚轮缩放 · Alt+拖动旋转 · Esc 或点「完成」退出",
|
|
99
|
+
mirror: "镜像",
|
|
100
|
+
rotateCcw: "⟲",
|
|
101
|
+
rotateCw: "⟳",
|
|
102
|
+
rotateCcwTitle: "逆时针旋转 90°",
|
|
103
|
+
rotateCwTitle: "顺时针旋转 90°",
|
|
98
104
|
resetCrop: "重置裁剪",
|
|
99
105
|
statusGroup: (name, total, usable, interval, order) =>
|
|
100
106
|
"列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
|
|
@@ -173,6 +179,12 @@ window.__ModuleLoader__.load({
|
|
|
173
179
|
adjust: "Adjust",
|
|
174
180
|
adjustDone: "Done",
|
|
175
181
|
adjustHint: "Drag to pan · wheel to zoom · Esc or Done to exit",
|
|
182
|
+
adjustHintFree: "Drag to pan · wheel to zoom · Alt+drag to rotate · Esc or Done to exit",
|
|
183
|
+
mirror: "Mirror",
|
|
184
|
+
rotateCcw: "⟲",
|
|
185
|
+
rotateCw: "⟳",
|
|
186
|
+
rotateCcwTitle: "Rotate 90° counterclockwise",
|
|
187
|
+
rotateCwTitle: "Rotate 90° clockwise",
|
|
176
188
|
resetCrop: "Reset crop",
|
|
177
189
|
statusGroup: (name, total, usable, interval, order) =>
|
|
178
190
|
"List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
|
|
@@ -226,6 +238,8 @@ window.__ModuleLoader__.load({
|
|
|
226
238
|
zoom: 1,
|
|
227
239
|
offsetX: 0,
|
|
228
240
|
offsetY: 0,
|
|
241
|
+
rotation: 0, // free-fit only; degrees, positive = clockwise
|
|
242
|
+
mirrored: false, // horizontal flip, available in every fit mode
|
|
229
243
|
};
|
|
230
244
|
|
|
231
245
|
const FIT_MODES = ["cover", "contain", "fill", "none", "free"];
|
|
@@ -281,6 +295,8 @@ window.__ModuleLoader__.load({
|
|
|
281
295
|
zoom: clampNum(o.zoom, 0.1, 4, DEFAULTS.zoom),
|
|
282
296
|
offsetX: clampNum(o.offsetX, -300, 300, DEFAULTS.offsetX),
|
|
283
297
|
offsetY: clampNum(o.offsetY, -300, 300, DEFAULTS.offsetY),
|
|
298
|
+
rotation: clampNum(o.rotation, -1080, 1080, DEFAULTS.rotation),
|
|
299
|
+
mirrored: o.mirrored === true,
|
|
284
300
|
};
|
|
285
301
|
} catch {
|
|
286
302
|
return { id: "", ...DEFAULTS };
|
|
@@ -339,6 +355,8 @@ window.__ModuleLoader__.load({
|
|
|
339
355
|
zoom: selection.zoom,
|
|
340
356
|
offsetX: selection.offsetX,
|
|
341
357
|
offsetY: selection.offsetY,
|
|
358
|
+
rotation: selection.rotation,
|
|
359
|
+
mirrored: selection.mirrored,
|
|
342
360
|
}));
|
|
343
361
|
} catch { /* storage full / blocked → session-only state, not fatal */ }
|
|
344
362
|
}
|
|
@@ -604,6 +622,10 @@ window.__ModuleLoader__.load({
|
|
|
604
622
|
if (isPlayable(w) && !demotedToPreview.has(selection.id)) {
|
|
605
623
|
selection.url = w.media;
|
|
606
624
|
selection.type = w.type; // "video" | "web"
|
|
625
|
+
// Brightness evidence belongs to the showing wallpaper: leaving a web
|
|
626
|
+
// wallpaper must drop its preview-based estimate instead of letting it
|
|
627
|
+
// mis-veil the next media until its own first frame arrives.
|
|
628
|
+
if (selection.type !== "web") sampledLuminance = null;
|
|
607
629
|
} else if (isPlayable(w) && w.preview) {
|
|
608
630
|
// Playable wallpaper demoted after repeated media failures (an
|
|
609
631
|
// undecodable container) — its preview still is the graceful fallback.
|
|
@@ -734,18 +756,32 @@ window.__ModuleLoader__.load({
|
|
|
734
756
|
//
|
|
735
757
|
// Crop geometry is chosen by the fit mode. The four object-fit modes keep a
|
|
736
758
|
// GAP-FREE invariant: the media box covers the viewport exactly at zoom 1,
|
|
737
|
-
// zoom floors at 1,
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
759
|
+
// zoom floors at 1, pan is clamped to ±(zoom−1)/2·100 %, and rotation is
|
|
760
|
+
// forced to 0 — a rotated rectangle cannot cover the screen, so turning
|
|
761
|
+
// belongs to free mode only. Mirroring is allowed everywhere: a flip is
|
|
762
|
+
// symmetric and never breaks coverage. The "free" mode drops the gap-free
|
|
763
|
+
// invariant on purpose: the media sits at natural size on a black canvas,
|
|
764
|
+
// zoom runs 0.1–4×, rotation is arbitrary, and pan is only clamped to keep
|
|
765
|
+
// a sliver (≥10 % of the box) on screen so the wallpaper can never be
|
|
766
|
+
// dragged entirely out of sight.
|
|
742
767
|
function clampCrop() {
|
|
768
|
+
if (!isFreeFit()) selection.rotation = 0;
|
|
769
|
+
else {
|
|
770
|
+
// Keep the stored angle canonical (−180, 180]: Alt+drag can accumulate
|
|
771
|
+
// several full turns, and persisting raw degrees would come back from
|
|
772
|
+
// storage truncated at the ±1080 read clamp — a DIFFERENT visual angle.
|
|
773
|
+
// Modulo keeps every equivalent representation identical on disk.
|
|
774
|
+
selection.rotation = ((selection.rotation + 180) % 360 + 360) % 360 - 180;
|
|
775
|
+
}
|
|
743
776
|
selection.zoom = Math.round(Math.min(4, Math.max(isFreeFit() ? 0.1 : 1, selection.zoom)) * 100) / 100;
|
|
744
777
|
if (isFreeFit()) {
|
|
745
778
|
// Content spans ±zoom·50 % of the box around its centre; the pan offset
|
|
746
|
-
// shifts it by its own % of the box.
|
|
747
|
-
//
|
|
748
|
-
|
|
779
|
+
// shifts it by its own % of the box. Rotation expands the projected
|
|
780
|
+
// extent by k = |cos θ| + |sin θ| (up to √2 at 45°). |offset| ≤
|
|
781
|
+
// (zoom·k+1)·50 − 10 keeps ≥10 % of the media visible in both axes.
|
|
782
|
+
const rad = selection.rotation * Math.PI / 180;
|
|
783
|
+
const k = Math.abs(Math.cos(rad)) + Math.abs(Math.sin(rad));
|
|
784
|
+
const reach = (selection.zoom * k + 1) * 50 - 10;
|
|
749
785
|
selection.offsetX = Math.min(reach, Math.max(-reach, selection.offsetX));
|
|
750
786
|
selection.offsetY = Math.min(reach, Math.max(-reach, selection.offsetY));
|
|
751
787
|
return;
|
|
@@ -786,12 +822,31 @@ window.__ModuleLoader__.load({
|
|
|
786
822
|
bar.className = "webg-adjust-bar";
|
|
787
823
|
const hint = document.createElement("span");
|
|
788
824
|
hint.className = "webg-adjust-hint";
|
|
789
|
-
hint.textContent = S().adjustHint;
|
|
825
|
+
hint.textContent = isFreeFit() ? S().adjustHintFree : S().adjustHint;
|
|
790
826
|
const done = document.createElement("button");
|
|
791
827
|
done.type = "button";
|
|
792
828
|
done.className = "webg-btn webg-btn--primary";
|
|
793
829
|
done.textContent = S().adjustDone;
|
|
794
830
|
bar.appendChild(hint);
|
|
831
|
+
// Free fit only: a rotated rectangle cannot cover the screen, so turning
|
|
832
|
+
// exists solely where exposed edges are allowed. ±90° buttons cover the
|
|
833
|
+
// common cases; arbitrary angles come from Alt+drag below.
|
|
834
|
+
if (isFreeFit()) {
|
|
835
|
+
for (const dir of [-90, 90]) {
|
|
836
|
+
const rot = document.createElement("button");
|
|
837
|
+
rot.type = "button";
|
|
838
|
+
rot.className = "webg-btn";
|
|
839
|
+
rot.textContent = dir < 0 ? S().rotateCcw : S().rotateCw;
|
|
840
|
+
rot.title = dir < 0 ? S().rotateCcwTitle : S().rotateCwTitle;
|
|
841
|
+
rot.addEventListener("click", () => {
|
|
842
|
+
selection.rotation += dir;
|
|
843
|
+
clampCrop();
|
|
844
|
+
applyEffects();
|
|
845
|
+
persistSelection();
|
|
846
|
+
});
|
|
847
|
+
bar.appendChild(rot);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
795
850
|
bar.appendChild(done);
|
|
796
851
|
overlay.appendChild(bar);
|
|
797
852
|
document.body.appendChild(overlay);
|
|
@@ -801,14 +856,34 @@ window.__ModuleLoader__.load({
|
|
|
801
856
|
// translate() in the transform list is applied AFTER scale() (right-to-
|
|
802
857
|
// left), so pan deltas are independent of zoom: dx px = dx/width %.
|
|
803
858
|
let drag = null;
|
|
859
|
+
// Alt+drag (free fit): swing the media around the overlay's centre —
|
|
860
|
+
// the angle delta between pointer positions becomes the rotation delta.
|
|
861
|
+
let rot = null; // { a0: starting pointer angle, base: starting rotation }
|
|
862
|
+
const centerAngle = (e) => {
|
|
863
|
+
const cx = (overlay.clientWidth || 1) / 2;
|
|
864
|
+
const cy = (overlay.clientHeight || 1) / 2;
|
|
865
|
+
return Math.atan2(e.clientY - cy, e.clientX - cx);
|
|
866
|
+
};
|
|
804
867
|
const onPointerDown = (e) => {
|
|
805
868
|
if (e.target === done || bar.contains(e.target)) return;
|
|
806
|
-
|
|
869
|
+
if (e.altKey && isFreeFit()) {
|
|
870
|
+
rot = { a0: centerAngle(e), base: selection.rotation };
|
|
871
|
+
} else {
|
|
872
|
+
drag = { x: e.clientX, y: e.clientY };
|
|
873
|
+
}
|
|
874
|
+
// Capture for EITHER gesture: pointer moves must keep arriving even
|
|
875
|
+
// when the cursor leaves the window mid-swing.
|
|
807
876
|
if (overlay.setPointerCapture && e.pointerId !== undefined) {
|
|
808
877
|
try { overlay.setPointerCapture(e.pointerId); } catch {}
|
|
809
878
|
}
|
|
810
879
|
};
|
|
811
880
|
const onPointerMove = (e) => {
|
|
881
|
+
if (rot) {
|
|
882
|
+
selection.rotation = rot.base + (centerAngle(e) - rot.a0) * 180 / Math.PI;
|
|
883
|
+
clampCrop();
|
|
884
|
+
applyEffects();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
812
887
|
if (!drag) return;
|
|
813
888
|
// translate % refers to the media box, which is the viewport exactly —
|
|
814
889
|
// divide the pixel delta by it so the content tracks the cursor 1:1.
|
|
@@ -820,18 +895,16 @@ window.__ModuleLoader__.load({
|
|
|
820
895
|
drag = { x: e.clientX, y: e.clientY };
|
|
821
896
|
applyEffects();
|
|
822
897
|
};
|
|
823
|
-
const
|
|
898
|
+
const endPointer = () => {
|
|
899
|
+
if (rot) { rot = null; persistSelection(); return; }
|
|
824
900
|
if (!drag) return;
|
|
825
901
|
drag = null;
|
|
826
902
|
persistSelection();
|
|
827
903
|
};
|
|
904
|
+
const onPointerUp = endPointer;
|
|
828
905
|
// pointercancel (touch gesture takeover): end the drag without losing the
|
|
829
906
|
// current offsets — otherwise drag stays "stuck" until the next pointerup.
|
|
830
|
-
const onPointerCancel =
|
|
831
|
-
if (!drag) return;
|
|
832
|
-
drag = null;
|
|
833
|
-
persistSelection();
|
|
834
|
-
};
|
|
907
|
+
const onPointerCancel = endPointer;
|
|
835
908
|
const onWheel = (e) => {
|
|
836
909
|
if (typeof e.preventDefault === "function") e.preventDefault();
|
|
837
910
|
// clampCrop enforces the mode's zoom floor: the gap-free modes never
|
|
@@ -870,6 +943,7 @@ window.__ModuleLoader__.load({
|
|
|
870
943
|
selection.zoom = 1;
|
|
871
944
|
selection.offsetX = 0;
|
|
872
945
|
selection.offsetY = 0;
|
|
946
|
+
selection.rotation = 0; // framing geometry home; mirroring keeps its own toggle
|
|
873
947
|
persistSelection();
|
|
874
948
|
applyEffects();
|
|
875
949
|
emit();
|
|
@@ -970,23 +1044,31 @@ window.__ModuleLoader__.load({
|
|
|
970
1044
|
}, LEAVE_MS);
|
|
971
1045
|
}
|
|
972
1046
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1047
|
+
// Scrim: present while a wallpaper is active. On clear it outlives the
|
|
1048
|
+
// layer until the fade-out finishes, so the veil fades WITH the wallpaper.
|
|
1049
|
+
if (selection.url) {
|
|
1050
|
+
const scrim = document.getElementById(SCRIM_ID);
|
|
1051
|
+
if (!scrim) {
|
|
1052
|
+
const s = document.createElement("div");
|
|
1053
|
+
s.id = SCRIM_ID;
|
|
1054
|
+
s.className = "webg-scrim";
|
|
1055
|
+
document.body.appendChild(s);
|
|
1056
|
+
}
|
|
1057
|
+
document.body.setAttribute(ACTIVE_ATTR, "on");
|
|
1058
|
+
// Keep the smart veil's preview probe pointed at the showing web
|
|
1059
|
+
// wallpaper (mount and token re-issue alike).
|
|
1060
|
+
if (selection.type === "web") {
|
|
1061
|
+
const w = selection.inventory.wallpapers.find((x) => x.id === selection.id);
|
|
1062
|
+
if (w && w.preview) probePreviewLuminance(w.preview);
|
|
1063
|
+
}
|
|
982
1064
|
}
|
|
983
|
-
document.body.setAttribute(ACTIVE_ATTR, "on");
|
|
984
|
-
}
|
|
985
1065
|
}
|
|
986
1066
|
|
|
987
1067
|
// ── Effect application: push the knobs into CSS variables ───────────────────
|
|
988
1068
|
// Wallpaper luminance sampled from the live media (null until the first
|
|
989
|
-
// sample
|
|
1069
|
+
// sample). Web wallpapers render in an opaque-origin iframe whose pixels are
|
|
1070
|
+
// unreadable — for those, the inventory's PREVIEW still stands in as a
|
|
1071
|
+
// brightness proxy (see probePreviewLuminance).
|
|
990
1072
|
let sampledLuminance = null;
|
|
991
1073
|
let sampleCanvas = null;
|
|
992
1074
|
// Snapshot of every input applyEffects reads, from its last full write. The
|
|
@@ -995,29 +1077,69 @@ window.__ModuleLoader__.load({
|
|
|
995
1077
|
// nothing. clearEffects() resets it so the next apply always writes.
|
|
996
1078
|
let lastEffectKey = null;
|
|
997
1079
|
|
|
998
|
-
/** Average Rec.601 luminance (0..1) of
|
|
999
|
-
function
|
|
1000
|
-
if (typeof document === "undefined") return;
|
|
1001
|
-
const layer = document.getElementById(LAYER_ID);
|
|
1002
|
-
const media = layer && (layer.querySelector("video") || layer.querySelector("img"));
|
|
1003
|
-
if (!media) { sampledLuminance = null; return; }
|
|
1080
|
+
/** Average Rec.601 luminance (0..1) of a drawable media frame, or null. */
|
|
1081
|
+
function luminanceOf(media) {
|
|
1004
1082
|
try {
|
|
1005
1083
|
if (!sampleCanvas) {
|
|
1006
1084
|
const c = document.createElement("canvas");
|
|
1007
|
-
if (typeof c.getContext !== "function") return; // no canvas → no sampling
|
|
1085
|
+
if (typeof c.getContext !== "function") return null; // no canvas → no sampling
|
|
1008
1086
|
c.width = 16; c.height = 16;
|
|
1009
1087
|
sampleCanvas = c;
|
|
1010
1088
|
}
|
|
1011
1089
|
const g = sampleCanvas.getContext("2d", { willReadFrequently: true });
|
|
1012
1090
|
g.drawImage(media, 0, 0, 16, 16);
|
|
1013
1091
|
const d = g.getImageData(0, 0, 16, 16).data;
|
|
1014
|
-
if (!d || d.length < 4) return; // unreadable frame → keep previous sample
|
|
1092
|
+
if (!d || d.length < 4) return null; // unreadable frame → keep previous sample
|
|
1015
1093
|
let sum = 0;
|
|
1016
1094
|
for (let i = 0; i < d.length; i += 4) {
|
|
1017
1095
|
sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
|
|
1018
1096
|
}
|
|
1019
|
-
|
|
1020
|
-
} catch { /* frame not ready / unreadable → keep
|
|
1097
|
+
return sum / (d.length / 4) / 255;
|
|
1098
|
+
} catch { return null; /* frame not ready / unreadable → keep previous */ }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Hidden preview probe: whenever a web wallpaper is showing, its preview
|
|
1102
|
+
// still (same-origin through our route) is decoded here so the smart veil
|
|
1103
|
+
// can read the wallpaper's rough brightness even though the sandboxed
|
|
1104
|
+
// iframe's own pixels are forever unreadable.
|
|
1105
|
+
const PREVIEW_PROBE_ID = "wallpaper-engine-dsh-preview-probe";
|
|
1106
|
+
let previewProbe = null;
|
|
1107
|
+
function probePreviewLuminance(url) {
|
|
1108
|
+
if (!url) return;
|
|
1109
|
+
if (!previewProbe) {
|
|
1110
|
+
previewProbe = document.createElement("img");
|
|
1111
|
+
previewProbe.id = PREVIEW_PROBE_ID;
|
|
1112
|
+
previewProbe.alt = "";
|
|
1113
|
+
previewProbe.setAttribute("decoding", "async");
|
|
1114
|
+
// Offscreen, invisible, 1px: it exists to be decoded and drawn, nothing
|
|
1115
|
+
// else. Attached to <body> so tooling (and our verify harness) can see it.
|
|
1116
|
+
previewProbe.setAttribute("style",
|
|
1117
|
+
"position:fixed;left:-10px;top:-10px;width:1px;height:1px;opacity:0;pointer-events:none");
|
|
1118
|
+
previewProbe.addEventListener("load", () => {
|
|
1119
|
+
// A GIF preview draws its first frame — plenty for a brightness proxy.
|
|
1120
|
+
const lum = luminanceOf(previewProbe);
|
|
1121
|
+
if (lum !== null && selection.type === "web") {
|
|
1122
|
+
sampledLuminance = lum;
|
|
1123
|
+
applyEffects();
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
document.body.appendChild(previewProbe);
|
|
1127
|
+
}
|
|
1128
|
+
if (previewProbe.getAttribute("src") !== url) previewProbe.src = url;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function sampleWallpaperLuminance() {
|
|
1132
|
+
if (typeof document === "undefined") return;
|
|
1133
|
+
const layer = document.getElementById(LAYER_ID);
|
|
1134
|
+
const media = layer && (layer.querySelector("video") || layer.querySelector("img"));
|
|
1135
|
+
if (!media) {
|
|
1136
|
+
// Nothing live to sample. A web wallpaper keeps its preview-based
|
|
1137
|
+
// estimate; any other kind has no brightness evidence at all.
|
|
1138
|
+
if (selection.type !== "web") sampledLuminance = null;
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const lum = luminanceOf(media);
|
|
1142
|
+
if (lum !== null) sampledLuminance = lum;
|
|
1021
1143
|
}
|
|
1022
1144
|
|
|
1023
1145
|
function applyEffects() {
|
|
@@ -1028,7 +1150,7 @@ window.__ModuleLoader__.load({
|
|
|
1028
1150
|
const key = [
|
|
1029
1151
|
dark, selection.scrim, selection.border, selection.blur, selection.wallpaperBlur,
|
|
1030
1152
|
selection.autoScrim, sampledLuminance, selection.fit, selection.zoom,
|
|
1031
|
-
selection.offsetX, selection.offsetY,
|
|
1153
|
+
selection.offsetX, selection.offsetY, selection.rotation, selection.mirrored,
|
|
1032
1154
|
].join("\u0000");
|
|
1033
1155
|
if (key === lastEffectKey) return; // nothing it reads changed → no writes, no reflow
|
|
1034
1156
|
lastEffectKey = key;
|
|
@@ -1071,6 +1193,8 @@ window.__ModuleLoader__.load({
|
|
|
1071
1193
|
s.setProperty("--webg-zoom", String(selection.zoom));
|
|
1072
1194
|
s.setProperty("--webg-offset-x", selection.offsetX + "%");
|
|
1073
1195
|
s.setProperty("--webg-offset-y", selection.offsetY + "%");
|
|
1196
|
+
s.setProperty("--webg-rotate", selection.rotation + "deg");
|
|
1197
|
+
s.setProperty("--webg-mirror", selection.mirrored ? "-1" : "1");
|
|
1074
1198
|
|
|
1075
1199
|
// Write the scrim colour directly too, then force a synchronous layout, so
|
|
1076
1200
|
// slider feedback lands on this frame even on stalled compositors.
|
|
@@ -1097,6 +1221,8 @@ window.__ModuleLoader__.load({
|
|
|
1097
1221
|
s.removeProperty("--webg-zoom");
|
|
1098
1222
|
s.removeProperty("--webg-offset-x");
|
|
1099
1223
|
s.removeProperty("--webg-offset-y");
|
|
1224
|
+
s.removeProperty("--webg-rotate");
|
|
1225
|
+
s.removeProperty("--webg-mirror");
|
|
1100
1226
|
const scrim = document.getElementById(SCRIM_ID);
|
|
1101
1227
|
if (scrim) scrim.style.background = "";
|
|
1102
1228
|
}
|
|
@@ -1292,14 +1418,23 @@ window.__ModuleLoader__.load({
|
|
|
1292
1418
|
function onFit(e) {
|
|
1293
1419
|
selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
|
|
1294
1420
|
// Re-seat the crop into the new mode's envelope: leaving free for a
|
|
1295
|
-
// gap-free mode pulls zoom back up to 1
|
|
1296
|
-
// direction only ever relaxes bounds, so the framing
|
|
1421
|
+
// gap-free mode pulls zoom back up to 1, reins the pan in and zeroes the
|
|
1422
|
+
// rotation; the other direction only ever relaxes bounds, so the framing
|
|
1423
|
+
// carries over. Mirroring survives every switch — a flip never breaks
|
|
1424
|
+
// coverage.
|
|
1297
1425
|
clampCrop();
|
|
1298
1426
|
persistSelection();
|
|
1299
1427
|
applyEffects();
|
|
1300
1428
|
emit();
|
|
1301
1429
|
}
|
|
1302
1430
|
|
|
1431
|
+
function onToggleMirror() {
|
|
1432
|
+
selection.mirrored = !selection.mirrored;
|
|
1433
|
+
persistSelection();
|
|
1434
|
+
applyEffects();
|
|
1435
|
+
emit();
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1303
1438
|
// ── Picker sections (presentational; every input arrives as an argument) ────
|
|
1304
1439
|
// WallpaperPicker composes these in a fixed order. Each one owns exactly one
|
|
1305
1440
|
// visual row/block of the settings panel and produces the same DOM the old
|
|
@@ -1539,6 +1674,13 @@ window.__ModuleLoader__.load({
|
|
|
1539
1674
|
...[t.fitCover, t.fitContain, t.fitFill, t.fitNone, t.fitFree].map((label, i) =>
|
|
1540
1675
|
React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
|
|
1541
1676
|
)),
|
|
1677
|
+
// Mirror is a coverage-preserving flip: valid in EVERY fit mode and
|
|
1678
|
+
// for every wallpaper kind (the transform applies to iframes too).
|
|
1679
|
+
React.createElement("button", {
|
|
1680
|
+
className: "webg-btn" + (sel.mirrored ? " webg-btn--active" : ""),
|
|
1681
|
+
type: "button",
|
|
1682
|
+
onClick: onToggleMirror,
|
|
1683
|
+
}, t.mirror),
|
|
1542
1684
|
React.createElement("button", {
|
|
1543
1685
|
className: "webg-btn", type: "button", onClick: enterAdjust,
|
|
1544
1686
|
disabled: sel.adjusting,
|
|
@@ -1669,20 +1811,32 @@ window.__ModuleLoader__.load({
|
|
|
1669
1811
|
Gaussian's natural edge fade (≤ one blur radius, under the veil and the
|
|
1670
1812
|
UI chrome) is accepted. A slight saturate keeps blurred colours from
|
|
1671
1813
|
washing out. */
|
|
1814
|
+
/* color-scheme: normal stops the DSH theme from leaking INTO the sandboxed
|
|
1815
|
+
web-wallpaper iframe. The shell sets color-scheme: dark on <html> in the
|
|
1816
|
+
dark theme; that value propagates to embedded documents, and at least one
|
|
1817
|
+
official wallpaper (CORSAIR Collection, an Angular app) never paints
|
|
1818
|
+
under an inherited dark scheme — a solid white page instead. The value
|
|
1819
|
+
"normal" restores the neutral default; a wallpaper that opts into a
|
|
1820
|
+
scheme via its own styles still wins. */
|
|
1672
1821
|
.webg-layer .webg-media {
|
|
1673
1822
|
position: absolute;
|
|
1674
1823
|
inset: 0;
|
|
1675
1824
|
width: 100%;
|
|
1676
1825
|
height: 100%;
|
|
1677
1826
|
max-width: none; max-height: none;
|
|
1827
|
+
color-scheme: normal;
|
|
1678
1828
|
object-fit: var(--webg-fit, cover);
|
|
1679
1829
|
object-position: center;
|
|
1680
1830
|
display: block;
|
|
1681
1831
|
background: transparent; border: 0;
|
|
1682
1832
|
filter: var(--webg-media-filter, none);
|
|
1683
|
-
/* Manual crop:
|
|
1684
|
-
|
|
1685
|
-
|
|
1833
|
+
/* Manual crop: the transform list is ordered pan → rotate → zoom →
|
|
1834
|
+
mirror. translate comes first so pan offsets stay screen-aligned no
|
|
1835
|
+
matter the rotation; mirror is innermost so the flip happens in the
|
|
1836
|
+
media's own axes. */
|
|
1837
|
+
transform: translate(var(--webg-offset-x, 0%), var(--webg-offset-y, 0%))
|
|
1838
|
+
rotate(var(--webg-rotate, 0deg)) scale(var(--webg-zoom, 1))
|
|
1839
|
+
scaleX(var(--webg-mirror, 1));
|
|
1686
1840
|
transform-origin: center center;
|
|
1687
1841
|
}
|
|
1688
1842
|
|
|
@@ -1803,17 +1957,32 @@ window.__ModuleLoader__.load({
|
|
|
1803
1957
|
border-color: var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.5));
|
|
1804
1958
|
}
|
|
1805
1959
|
.webg-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
1806
|
-
/* brand-primary is monochrome (near-black light / near-white dark)
|
|
1807
|
-
|
|
1960
|
+
/* brand-primary is monochrome (near-black light / near-white dark). The
|
|
1961
|
+
shell's brand-primary-INVERT token aliases the same shade as brand-
|
|
1962
|
+
primary itself in both themes (no contrast), so anything sitting ON a
|
|
1963
|
+
brand-primary fill takes its contrast colour from the inverted label
|
|
1964
|
+
ramp instead — near-white on the dark light-theme fill, dark bluish-800
|
|
1965
|
+
on the near-white dark-theme fill. */
|
|
1966
|
+
body {
|
|
1967
|
+
--webg-on-brand: var(--dsw-alias-label-primary-inverted,
|
|
1968
|
+
var(--dsw-alias-label-primary-foreground, rgb(249, 250, 251)));
|
|
1969
|
+
}
|
|
1808
1970
|
.webg-btn--primary {
|
|
1809
1971
|
background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
|
|
1810
1972
|
border-color: transparent;
|
|
1811
|
-
color: var(--
|
|
1973
|
+
color: var(--webg-on-brand);
|
|
1812
1974
|
}
|
|
1813
1975
|
.webg-btn--primary:hover:not(:disabled) {
|
|
1814
1976
|
background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
|
|
1815
1977
|
opacity: 0.85;
|
|
1816
1978
|
}
|
|
1979
|
+
/* Toggle-style button showing an engaged state (e.g. 镜像): brand fill
|
|
1980
|
+
with the contrasting on-brand label. */
|
|
1981
|
+
.webg-btn--active {
|
|
1982
|
+
background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
|
|
1983
|
+
border-color: transparent;
|
|
1984
|
+
color: var(--webg-on-brand);
|
|
1985
|
+
}
|
|
1817
1986
|
|
|
1818
1987
|
.webg-select, .webg-text {
|
|
1819
1988
|
padding: 5px 10px;
|
|
@@ -1873,7 +2042,7 @@ window.__ModuleLoader__.load({
|
|
|
1873
2042
|
-webkit-appearance: none;
|
|
1874
2043
|
width: 14px; height: 14px; border-radius: 50%;
|
|
1875
2044
|
margin-top: -5px; /* centers the 14px thumb on the 4px track */
|
|
1876
|
-
background: var(--
|
|
2045
|
+
background: var(--webg-on-brand);
|
|
1877
2046
|
border: 2.5px solid var(--dsw-alias-brand-primary, rgb(15, 17, 21));
|
|
1878
2047
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
|
|
1879
2048
|
transition: transform 120ms ease;
|
|
@@ -1971,7 +2140,7 @@ window.__ModuleLoader__.load({
|
|
|
1971
2140
|
position: absolute; top: 4px; left: 4px; width: 18px; height: 18px;
|
|
1972
2141
|
border-radius: 4px;
|
|
1973
2142
|
background: var(--dsw-alias-brand-primary, rgb(15, 17, 21));
|
|
1974
|
-
color: var(--
|
|
2143
|
+
color: var(--webg-on-brand);
|
|
1975
2144
|
font-size: 12px; line-height: 18px; text-align: center;
|
|
1976
2145
|
}
|
|
1977
2146
|
`;
|
|
@@ -2055,6 +2224,7 @@ window.__ModuleLoader__.load({
|
|
|
2055
2224
|
if (samplerId !== null) window.clearInterval(samplerId);
|
|
2056
2225
|
if (observer) observer.disconnect();
|
|
2057
2226
|
sampledLuminance = null;
|
|
2227
|
+
if (previewProbe) { previewProbe.remove(); previewProbe = null; }
|
|
2058
2228
|
clearRotationTimer();
|
|
2059
2229
|
// Cancel pending crossfade/clear timers so they never fire on
|
|
2060
2230
|
// torn-down nodes after dispose.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wallpaper-engine-dsh",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Wallpaper Engine library as the DSH web GUI background — video/web/still wallpapers, rotation lists, search, resource monitor, bilingual UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|