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 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 and zoomed from 0.1× to 4×, with just enough kept on screen that it can never be dragged out of sight. 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 framing — the transform applies to the iframe directly.
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
- **任何壁纸上文字都可读。** 遮罩颜色始终与文字颜色相反:深色主题用黑色遮罩,浅色主题用白色遮罩,因此过亮或过暗的壁纸都不会盖住文字。在此基础上,「智能可视」每隔几秒采样一次壁纸亮度,亮度过高或过低时自动加强遮罩。下拉菜单、弹窗等浮层显示在 DSH 原生的不透明表面上,不受影响。
11
+ **任何壁纸上文字都可读。** 遮罩颜色始终与文字颜色相反:深色主题用黑色遮罩,浅色主题用白色遮罩,因此过亮或过暗的壁纸都不会盖住文字。在此基础上,「智能可视」每隔几秒采样一次壁纸亮度,亮度过高或过低时自动加强遮罩(网页壁纸的像素在沙箱中读不到,改用其预览图作为采样依据)。下拉菜单、弹窗等浮层显示在 DSH 原生的不透明表面上,不受影响。
12
12
 
13
- **手动取景。** 先选基础排布(填充、适应、拉伸、原始、自由),再手动调整画面:点「调整画面」将壁纸全屏展开,滚轮缩放,拖动平移。前四种排布下裁剪范围经过限制,画面始终铺满屏幕,不露出边缘;「自由」把壁纸按原始大小放在黑色画布上,可拖动到任意位置,缩放范围 0.1–4×,并始终保留一小块在屏幕内,不会拖丢。按 Esc 或点「完成」退出,取景设置会保存,「重置裁剪」可一键恢复默认。网页壁纸使用相同的排布下拉与缩放/平移取景(变换直接作用于 iframe)。
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 源的身份执行;媒体响应的 CORS 通配只允许持有令牌的一方读取其本来就能请求的文件,访问权限仍由令牌决定,且整个服务只监听本机回环地址;长度不足的响应会直接中断,而不是悄悄截断。清单由宿主缓存 30 秒;安装位置的发现过程在请求路径之外异步执行,并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
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, and pan is clamped to ±(zoom−1)/2·100 % — the image
738
- // always fills the screen. The "free" mode drops that invariant on purpose:
739
- // the media sits at natural size on a black canvas, zoom runs 0.1–4×, and
740
- // pan is only clamped to keep a sliver (≥10 % of the box) on screen so the
741
- // wallpaper can never be dragged entirely out of sight.
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. |offset| ≤ (zoom+1)·50 − 10 keeps
747
- // at least 10 % of the media edge-to-edge visible in both axes.
748
- const reach = (selection.zoom + 1) * 50 - 10;
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
- drag = { x: e.clientX, y: e.clientY };
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 onPointerUp = () => {
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
- // Scrim: present while a wallpaper is active. On clear it outlives the
974
- // layer until the fade-out finishes, so the veil fades WITH the wallpaper.
975
- if (selection.url) {
976
- const scrim = document.getElementById(SCRIM_ID);
977
- if (!scrim) {
978
- const s = document.createElement("div");
979
- s.id = SCRIM_ID;
980
- s.className = "webg-scrim";
981
- document.body.appendChild(s);
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; iframes of web wallpapers can never be sampled — opaque origin).
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 the current video/img, or null. */
999
- function sampleWallpaperLuminance() {
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
- sampledLuminance = sum / (d.length / 4) / 255;
1020
- } catch { /* frame not ready / unreadable → keep the previous sample */ }
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 and reins the pan in; the other
1296
- // direction only ever relaxes bounds, so the framing carries over.
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: pan offsets are % of the media box (independent of zoom
1684
- because translate is listed before scale), zoom is centered. */
1685
- transform: translate(var(--webg-offset-x, 0%), var(--webg-offset-y, 0%)) scale(var(--webg-zoom, 1));
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), so
1807
- the label must use its inverse token — never a hardcoded white. */
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(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
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(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
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(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
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.5",
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",