wallpaper-engine-dsh 0.1.6 → 0.1.8

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
@@ -9,8 +9,9 @@ A DSH bundle that turns your local **Wallpaper Engine** library into the live ba
9
9
  - **Whole-library browsing** — video and web wallpapers render live; scene and application wallpapers show their preview as a static background. Everything with a preview or playable file is selectable, badged by type (Video / Web / Still).
10
10
  - **Search** — filter the grid instantly by title or workshop id.
11
11
  - **Rotation lists** — any number of carousel lists, each with its own wallpapers, interval (1–120 min) and order (sequence/random). Your first playable Wallpaper Engine playlist is imported automatically; others can be imported into any list.
12
- - **Four live sliders** — wallpaper blur, scrim, border contrast, and glass frosting, all instant and persisted.
13
- - **Canvas fit** — cover/contain/stretch/original sizing plus five-way alignment for video and still wallpapers.
12
+ - **Four live sliders** — Wallpaper blur, Scrim, Borders, Glass — all instant and persisted.
13
+ - **Legibility on any wallpaper** — the veil opposes the text colour (white veil in light theme, black in dark) and a Smart veil toggle samples the wallpaper's brightness every few seconds, automatically strengthening the veil when a scene gets too bright or too dark for the current theme. Dropdowns, menus, and dialogs keep DSH's opaque surfaces untouched.
14
+ - **Manual crop** — pick a base layout (Cover/Contain/Stretch/Original), then frame it yourself: **Adjust** opens the live wallpaper fullscreen where you drag to pan and scroll to zoom; **Reset crop** restores it.
14
15
  - **Resource monitor** — FPS readout with a low-framerate hint, plus auto-pause when the tab is hidden or the battery runs low (both optional).
15
16
  - **Bilingual** — the settings UI follows the shell's Language preference (Chinese/English), with a manual override in the picker.
16
17
  - **Crossfade** — wallpaper switches and clearing fade smoothly instead of snapping.
@@ -34,7 +35,7 @@ Restart `dsh web`, then open **Settings → General → Wallpaper Engine**.
34
35
 
35
36
  1. Pick a wallpaper from the thumbnail grid — it fades in behind the app. Use the search box to narrow large libraries.
36
37
  2. **Pause/Play** controls video playback; **Close** fades the wallpaper out; **Refresh** rescans the library (new workshop subscriptions appear without reloading the page).
37
- 3. Tune the blend with the sliders. On busy wallpapers, raise **Scrim** and **Borders** until text is comfortable; the UI follows DSH's light/dark theme automatically. The **Fit/Align** row sets how video and still wallpapers sit on the canvas (cover/contain/stretch/original, five anchor points).
38
+ 3. Tune the blend with the sliders. On busy wallpapers, raise **Scrim** and **Borders** until text is comfortable; the UI follows DSH's light/dark theme automatically. The **Fit** row sets the base layout for video and still wallpapers — **Adjust** then lets you pan and zoom the crop directly on the canvas.
38
39
  4. Create rotation lists with **New**, fill them from the grid or import a WE playlist, then enable **Auto-rotate**.
39
40
  5. The resource row shows the current frame rate and offers two auto-pause switches. `prefers-reduced-motion` starts videos paused.
40
41
  6. The Language control switches the picker between Chinese and English; on Auto it follows the shell setting.
@@ -49,7 +50,7 @@ The bundle has two halves:
49
50
  - `GET /we-background/inventory[?refresh=1]`
50
51
  - `GET /we-background/media/<token>[/asset…]` — with HTTP Range support for video seeking; web wallpapers can fetch their bundled sub-assets
51
52
  - `GET /we-background/preview/<token>`
52
- - 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. Wallpaper blur is clipped overscan (no content warping) with a whisper of dither noise to keep gradients band-free; a video the browser cannot decode falls back to the wallpaper's preview image.
53
+ - 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. Wallpaper blur is clipped overscan (no content warping) with a whisper of dither noise to keep gradients band-free; a video the browser cannot decode falls back to the wallpaper's preview image. Scene and application wallpapers can only show their workshop preview — the bundle reads each preview's pixel size and presents low-resolution ones as sharp centered art over a blurred copy of themselves, instead of a mushy fullscreen stretch.
53
54
 
54
55
  The host answers from a 30-second inventory cache with stable per-wallpaper tokens (an in-flight stream is never killed by a background rebuild); discovery is resolved asynchronously and cached for ten minutes. The bundle registers no model tools and adds no prompt text.
55
56
 
package/README.zh.md CHANGED
@@ -9,8 +9,9 @@
9
9
  - **全库浏览** —— 视频与网页壁纸动态渲染;场景与应用壁纸显示预览图作为静态背景。凡是有预览图或可播放文件的壁纸都可选用,按类型标注角标(视频 / 网页 / 静态)。
10
10
  - **搜索** —— 按标题或创意工坊 ID 即时筛选网格。
11
11
  - **轮播列表** —— 可建任意多个列表,各自拥有壁纸集合、切换间隔(1–120 分钟)和顺序(顺序/随机)。首次运行自动导入第一个可播放的 Wallpaper Engine 播放列表,其余列表可随时导入。
12
- - **四个实时滑杆** —— 壁纸模糊、暗化、边框对比、玻璃磨砂,即时生效并持久保存。
13
- - **画布排布** —— 视频与静态壁纸支持填充/适应/拉伸/原始四种排布和五向对齐。
12
+ - **四个实时滑杆** —— 壁纸模糊、暗化、边框、玻璃,即时生效并持久保存。
13
+ - **任意壁纸都可读** —— 遮罩颜色与文字颜色相对(浅色主题提亮、深色主题压暗);"智能可视"开关每隔几秒采样一次壁纸亮度,画面过亮或过暗时自动加强遮罩。下拉菜单、弹窗等浮层保持 DSH 原生不透明表面,不受影响。
14
+ - **手动裁剪** —— 先选基础排布(填充/适应/拉伸/原始),再自己取景:点「调整画面」把壁纸全屏展开,拖动平移、滚轮缩放,「重置裁剪」一键还原。
14
15
  - **资源监控** —— 帧率读数与低帧率提示,页面隐藏或电量不足时自动暂停(均可开关)。
15
16
  - **中英双语** —— 设置界面跟随 DSH 的"语言"偏好自动切换,也可在面板内手动指定。
16
17
  - **交叉淡入** —— 切换与关闭都平滑过渡,不生硬跳变。
@@ -34,7 +35,7 @@ dsh plugin --profile web add link:<克隆文件夹的绝对路径>
34
35
 
35
36
  1. 从缩略图网格选择壁纸,它会在界面背后淡入。壁纸库较大时用搜索框快速定位。
36
37
  2. **暂停/播放** 控制视频播放;**关闭** 淡出壁纸;**刷新** 重新扫描壁纸库,新订阅的创意工坊壁纸无需刷新页面即可出现。
37
- 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。**排布/对齐** 一行设置视频与静态壁纸在画布上的摆放(填充/适应/拉伸/原始,五个锚点)。
38
+ 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。**排布** 一行决定视频与静态壁纸的基础摆放(填充/适应/拉伸/原始),点「调整画面」可在画布上直接拖动平移、滚轮缩放取景。
38
39
  4. 用 **新建** 创建轮播列表,从网格挑选或导入 WE 播放列表,然后开启 **自动轮转**。
39
40
  5. 资源行显示当前帧率,并提供两个自动暂停开关。系统开启 `prefers-reduced-motion` 时视频默认暂停。
40
41
  6. "语言"控件在中英文之间切换面板;选"自动"则跟随 DSH 的语言设置。
@@ -49,7 +50,7 @@ bundle 分为两半:
49
50
  - `GET /we-background/inventory[?refresh=1]`
50
51
  - `GET /we-background/media/<token>[/资源…]` —— 支持 HTTP Range,视频可拖动进度;网页壁纸可加载其打包的子资源
51
52
  - `GET /we-background/preview/<token>`
52
- - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。壁纸模糊采用裁边式超采样(内容不变形),叠加微量抖动噪声消除渐变色带;浏览器解不了的视频会自动改用该壁纸的预览图。
53
+ - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。壁纸模糊采用裁边式超采样(内容不变形),叠加微量抖动噪声消除渐变色带;浏览器解不了的视频会自动改用该壁纸的预览图。场景与应用壁纸在浏览器里只能显示工坊预览图——插件会读取每张预览图的像素尺寸,低分辨率的以「清晰原图居中 + 自身高斯模糊铺底」的氛围方式呈现,而不是拉伸成一片糊。
53
54
 
54
55
  清单由宿主缓存 30 秒,媒体令牌按壁纸稳定复用(后台重建不会掐断播放中的流);安装发现异步执行并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
55
56
 
package/lib/client.js CHANGED
@@ -19,6 +19,12 @@ window.__ModuleLoader__.load({
19
19
  /** Below this FPS the picker shows a performance hint. */
20
20
  const LOW_FPS = 24;
21
21
 
22
+ /**
23
+ * Below this preview width a fullscreen cover render is pure upscale mush —
24
+ * present as ambient (sharp contain over a blurred copy of itself) instead.
25
+ */
26
+ const LOW_RES_PREVIEW_PX = 1200;
27
+
22
28
  // Respect the OS "reduce motion" preference: start paused rather than
23
29
  // autoplaying a video loop at someone who asked for less motion.
24
30
  const REDUCED_MOTION = typeof window !== "undefined" &&
@@ -79,6 +85,8 @@ window.__ModuleLoader__.load({
79
85
  glass: "玻璃",
80
86
  pauseOnHidden: "页面隐藏时暂停",
81
87
  pauseOnBattery: "低电量时暂停",
88
+ autoScrim: "智能可视",
89
+ autoScrimTitle: "采样壁纸亮度,过亮或过暗时自动加强遮罩,保证文字可读",
82
90
  fpsTitle: "页面当前渲染帧率",
83
91
  lowFpsHint: "帧率偏低,可尝试暂停视频、增大壁纸模糊或换用静态壁纸",
84
92
  language: "语言",
@@ -88,12 +96,12 @@ window.__ModuleLoader__.load({
88
96
  fitContain: "适应",
89
97
  fitFill: "拉伸",
90
98
  fitNone: "原始",
91
- position: "对齐",
92
- posCenter: "居中",
93
- posTop: "顶部",
94
- posBottom: "底部",
95
- posLeft: "居左",
96
- posRight: "居右",
99
+ adjust: "调整画面",
100
+ adjustDone: "完成",
101
+ adjustHint: "拖动平移 · 滚轮缩放 · Esc 或点「完成」退出",
102
+ resetCrop: "重置裁剪",
103
+ ambientNote: "预览分辨率低,已氛围化显示",
104
+ lowResBadge: "低清",
97
105
  statusGroup: (name, total, usable, interval, order) =>
98
106
  "列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
99
107
  statusUsable: (n) => n + " 张可用壁纸",
@@ -157,6 +165,8 @@ window.__ModuleLoader__.load({
157
165
  glass: "Glass",
158
166
  pauseOnHidden: "Pause when tab hidden",
159
167
  pauseOnBattery: "Pause on low battery",
168
+ autoScrim: "Smart veil",
169
+ autoScrimTitle: "Samples wallpaper brightness and strengthens the veil when needed so text stays readable",
160
170
  fpsTitle: "Current page frame rate",
161
171
  lowFpsHint: "Low frame rate — try pausing the video, raising wallpaper blur, or using a still wallpaper",
162
172
  language: "Language",
@@ -166,12 +176,12 @@ window.__ModuleLoader__.load({
166
176
  fitContain: "Contain",
167
177
  fitFill: "Stretch",
168
178
  fitNone: "Original",
169
- position: "Align",
170
- posCenter: "Center",
171
- posTop: "Top",
172
- posBottom: "Bottom",
173
- posLeft: "Left",
174
- posRight: "Right",
179
+ adjust: "Adjust",
180
+ adjustDone: "Done",
181
+ adjustHint: "Drag to pan · wheel to zoom · Esc or Done to exit",
182
+ resetCrop: "Reset crop",
183
+ ambientNote: "Low-res preview — ambient fill",
184
+ lowResBadge: "Low-res",
175
185
  statusGroup: (name, total, usable, interval, order) =>
176
186
  "List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
177
187
  statusUsable: (n) => n + " usable wallpapers",
@@ -218,13 +228,15 @@ window.__ModuleLoader__.load({
218
228
  rotationSeeded: false,
219
229
  pauseOnHidden: true,
220
230
  pauseOnBattery: true,
231
+ autoScrim: true,
221
232
  lang: "auto",
222
233
  fit: "cover",
223
- position: "center",
234
+ zoom: 1,
235
+ offsetX: 0,
236
+ offsetY: 0,
224
237
  };
225
238
 
226
239
  const FIT_MODES = ["cover", "contain", "fill", "none"];
227
- const POSITIONS = ["center", "top", "bottom", "left", "right"];
228
240
 
229
241
  // ── Persisted selection ─────────────────────────────────────────────────────
230
242
  function clampNum(v, lo, hi, fallback) {
@@ -268,9 +280,12 @@ window.__ModuleLoader__.load({
268
280
  rotationSeeded: o.rotationSeeded === true,
269
281
  pauseOnHidden: o.pauseOnHidden !== false,
270
282
  pauseOnBattery: o.pauseOnBattery !== false,
283
+ autoScrim: o.autoScrim !== false,
271
284
  lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
272
285
  fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
273
- position: POSITIONS.indexOf(o.position) >= 0 ? o.position : DEFAULTS.position,
286
+ zoom: clampNum(o.zoom, 0.25, 4, DEFAULTS.zoom),
287
+ offsetX: clampNum(o.offsetX, -200, 200, DEFAULTS.offsetX),
288
+ offsetY: clampNum(o.offsetY, -200, 200, DEFAULTS.offsetY),
274
289
  };
275
290
  } catch {
276
291
  return { id: "", ...DEFAULTS };
@@ -282,6 +297,8 @@ window.__ModuleLoader__.load({
282
297
  ...readPersisted(),
283
298
  url: null,
284
299
  type: null,
300
+ previewW: null, // pixel width of the still being shown (ambient decisions)
301
+ adjusting: false, // crop-adjust overlay open (not persisted)
285
302
  playing: !REDUCED_MOTION,
286
303
  loading: false,
287
304
  rotationTimer: null,
@@ -319,9 +336,12 @@ window.__ModuleLoader__.load({
319
336
  rotationSeeded: selection.rotationSeeded,
320
337
  pauseOnHidden: selection.pauseOnHidden,
321
338
  pauseOnBattery: selection.pauseOnBattery,
339
+ autoScrim: selection.autoScrim,
322
340
  lang: selection.lang,
323
341
  fit: selection.fit,
324
- position: selection.position,
342
+ zoom: selection.zoom,
343
+ offsetX: selection.offsetX,
344
+ offsetY: selection.offsetY,
325
345
  }));
326
346
  } catch { /* storage full / blocked → session-only state, not fatal */ }
327
347
  }
@@ -575,6 +595,7 @@ window.__ModuleLoader__.load({
575
595
  }
576
596
 
577
597
  function applySelection(id) {
598
+ if (selection.adjusting) exitAdjust();
578
599
  selection.id = id || "";
579
600
  persistSelection();
580
601
  const w = selection.id
@@ -583,14 +604,17 @@ window.__ModuleLoader__.load({
583
604
  if (isPlayable(w) && !demotedToPreview.has(selection.id)) {
584
605
  selection.url = w.media;
585
606
  selection.type = w.type; // "video" | "web"
607
+ selection.previewW = null;
586
608
  } else if (w && w.preview) {
587
609
  // Static path: scene/application wallpapers, and playable wallpapers
588
610
  // demoted after repeated media failures (undecodable container…).
589
611
  selection.url = w.preview;
590
612
  selection.type = "image";
613
+ selection.previewW = typeof w.previewW === "number" ? w.previewW : null;
591
614
  } else {
592
615
  selection.url = null;
593
616
  selection.type = null;
617
+ selection.previewW = null;
594
618
  }
595
619
  syncRotationTimer();
596
620
  emit();
@@ -659,7 +683,42 @@ window.__ModuleLoader__.load({
659
683
  media.draggable = false;
660
684
  media.setAttribute("decoding", "async");
661
685
  media.className = "webg-media";
686
+ // Low-resolution previews (many workshop scenes ship 256–500px thumbs)
687
+ // look terrible stretched fullscreen under cover. Present them like
688
+ // music-app artwork instead: the image at its natural sharpness
689
+ // (contain), over a heavily blurred full-bleed copy of itself.
690
+ if (sel.fit === "cover" && typeof sel.previewW === "number" &&
691
+ sel.previewW > 0 && sel.previewW < LOW_RES_PREVIEW_PX) {
692
+ const wrap = document.createElement("div");
693
+ wrap.className = "webg-ambient";
694
+ const bg = document.createElement("img");
695
+ bg.src = sel.url;
696
+ bg.alt = "";
697
+ bg.draggable = false;
698
+ bg.className = "webg-ambient-bg";
699
+ wrap.appendChild(bg);
700
+ media.classList.add("webg-ambient-fg");
701
+ wrap.appendChild(media);
702
+ attachStillSampling(media);
703
+ attachMediaError(media, sel);
704
+ return wrap;
705
+ }
662
706
  }
707
+ attachStillSampling(media, sel);
708
+ attachMediaError(media, sel);
709
+ return media;
710
+ }
711
+
712
+ function attachStillSampling(media, sel) {
713
+ const type = (sel && sel.type) || (media.tagName === "IMG" ? "image" : null);
714
+ if (type !== "video" && type !== "image") return;
715
+ media.addEventListener(type === "video" ? "loadeddata" : "load", () => {
716
+ sampleWallpaperLuminance();
717
+ applyEffects();
718
+ });
719
+ }
720
+
721
+ function attachMediaError(media, sel) {
663
722
  media.addEventListener("error", () => {
664
723
  const id = sel.id;
665
724
  const count = (mediaFailures.get(id) || 0) + 1;
@@ -678,7 +737,120 @@ window.__ModuleLoader__.load({
678
737
  applySelection(id);
679
738
  }
680
739
  });
681
- return media;
740
+ }
741
+
742
+ // ── Manual crop: drag-to-pan / wheel-to-zoom in an adjust overlay ───────────
743
+ // The layer normally sits at z-index -2 behind the entire app, so it can
744
+ // never receive pointer events. Adjust mode moves the LIVE layer node into a
745
+ // topmost transparent overlay — the user edits the real thing, WYSIWYG.
746
+ let adjustOverlay = null;
747
+ let adjustCleanup = null;
748
+
749
+ function exitAdjust() {
750
+ if (!adjustOverlay) return;
751
+ const layer = document.getElementById(LAYER_ID);
752
+ if (adjustCleanup) adjustCleanup();
753
+ if (layer) document.body.appendChild(layer); // move back behind the app
754
+ adjustOverlay.remove();
755
+ adjustOverlay = null;
756
+ adjustCleanup = null;
757
+ selection.adjusting = false;
758
+ persistSelection();
759
+ emit();
760
+ }
761
+
762
+ function enterAdjust() {
763
+ const layer = document.getElementById(LAYER_ID);
764
+ if (!layer || adjustOverlay) return;
765
+ const overlay = document.createElement("div");
766
+ overlay.className = "webg-adjust";
767
+ overlay.appendChild(layer); // moves the live node into the overlay
768
+
769
+ const bar = document.createElement("div");
770
+ bar.className = "webg-adjust-bar";
771
+ const hint = document.createElement("span");
772
+ hint.className = "webg-adjust-hint";
773
+ hint.textContent = S().adjustHint;
774
+ const done = document.createElement("button");
775
+ done.type = "button";
776
+ done.className = "webg-btn webg-btn--primary";
777
+ done.textContent = S().adjustDone;
778
+ bar.appendChild(hint);
779
+ bar.appendChild(done);
780
+ overlay.appendChild(bar);
781
+ document.body.appendChild(overlay);
782
+ adjustOverlay = overlay;
783
+ selection.adjusting = true;
784
+
785
+ // translate() in the transform list is applied AFTER scale() (right-to-
786
+ // left), so pan deltas are independent of zoom: dx px = dx/width %.
787
+ let drag = null;
788
+ const onPointerDown = (e) => {
789
+ if (e.target === done || bar.contains(e.target)) return;
790
+ drag = { x: e.clientX, y: e.clientY };
791
+ if (overlay.setPointerCapture && e.pointerId !== undefined) {
792
+ try { overlay.setPointerCapture(e.pointerId); } catch {}
793
+ }
794
+ };
795
+ const onPointerMove = (e) => {
796
+ if (!drag) return;
797
+ const w = overlay.clientWidth || 1;
798
+ const h = overlay.clientHeight || 1;
799
+ // True clamping (clampNum is a validate-or-fallback for persisted data).
800
+ selection.offsetX = Math.min(200, Math.max(-200,
801
+ selection.offsetX + ((e.clientX - drag.x) / w) * 100));
802
+ selection.offsetY = Math.min(200, Math.max(-200,
803
+ selection.offsetY + ((e.clientY - drag.y) / h) * 100));
804
+ drag = { x: e.clientX, y: e.clientY };
805
+ applyEffects();
806
+ };
807
+ const onPointerUp = () => {
808
+ if (!drag) return;
809
+ drag = null;
810
+ persistSelection();
811
+ };
812
+ const onWheel = (e) => {
813
+ if (typeof e.preventDefault === "function") e.preventDefault();
814
+ const next = clampNum(selection.zoom * Math.exp(-(e.deltaY || 0) * 0.0012),
815
+ 0.25, 4, selection.zoom);
816
+ selection.zoom = Math.round(next * 100) / 100;
817
+ applyEffects();
818
+ persistSelection();
819
+ };
820
+ const onKey = (e) => { if (e.key === "Escape") exitAdjust(); };
821
+ const onDone = () => exitAdjust();
822
+
823
+ overlay.addEventListener("pointerdown", onPointerDown);
824
+ overlay.addEventListener("pointermove", onPointerMove);
825
+ overlay.addEventListener("pointerup", onPointerUp);
826
+ overlay.addEventListener("wheel", onWheel, { passive: false });
827
+ done.addEventListener("click", onDone);
828
+ document.addEventListener("keydown", onKey);
829
+
830
+ adjustCleanup = () => {
831
+ overlay.removeEventListener("pointerdown", onPointerDown);
832
+ overlay.removeEventListener("pointermove", onPointerMove);
833
+ overlay.removeEventListener("pointerup", onPointerUp);
834
+ overlay.removeEventListener("wheel", onWheel);
835
+ done.removeEventListener("click", onDone);
836
+ document.removeEventListener("keydown", onKey);
837
+ };
838
+ emit();
839
+ }
840
+
841
+ function resetCrop() {
842
+ selection.zoom = 1;
843
+ selection.offsetX = 0;
844
+ selection.offsetY = 0;
845
+ persistSelection();
846
+ applyEffects();
847
+ emit();
848
+ }
849
+
850
+ function isAmbientActive(sel) {
851
+ return sel.type === "image" && sel.fit === "cover" &&
852
+ typeof sel.previewW === "number" && sel.previewW > 0 &&
853
+ sel.previewW < LOW_RES_PREVIEW_PX;
682
854
  }
683
855
 
684
856
  // Crossfade/clear leave timers: tracked so dispose can cancel them instead of
@@ -765,27 +937,82 @@ window.__ModuleLoader__.load({
765
937
  }
766
938
 
767
939
  // ── Effect application: push the knobs into CSS variables ───────────────────
940
+ // Wallpaper luminance sampled from the live media (null until the first
941
+ // sample; iframes of web wallpapers can never be sampled — opaque origin).
942
+ let sampledLuminance = null;
943
+ let sampleCanvas = null;
944
+
945
+ /** Average Rec.601 luminance (0..1) of the current video/img, or null. */
946
+ function sampleWallpaperLuminance() {
947
+ if (typeof document === "undefined") return;
948
+ const layer = document.getElementById(LAYER_ID);
949
+ const media = layer && (layer.querySelector("video") || layer.querySelector("img"));
950
+ if (!media) { sampledLuminance = null; return; }
951
+ try {
952
+ if (!sampleCanvas) {
953
+ const c = document.createElement("canvas");
954
+ if (typeof c.getContext !== "function") return; // no canvas → no sampling
955
+ c.width = 16; c.height = 16;
956
+ sampleCanvas = c;
957
+ }
958
+ const g = sampleCanvas.getContext("2d", { willReadFrequently: true });
959
+ g.drawImage(media, 0, 0, 16, 16);
960
+ const d = g.getImageData(0, 0, 16, 16).data;
961
+ let sum = 0;
962
+ for (let i = 0; i < d.length; i += 4) {
963
+ sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
964
+ }
965
+ sampledLuminance = sum / (d.length / 4) / 255;
966
+ } catch { /* frame not ready / unreadable → keep the previous sample */ }
967
+ }
968
+
768
969
  function applyEffects() {
769
970
  const s = document.body.style;
770
- s.setProperty("--webg-scrim-color", "rgba(0,0,0," + selection.scrim + ")");
971
+ // The veil OPPOSES the text colour: dark theme (light text) darkens bright
972
+ // wallpapers, light theme (dark text) brightens dark ones — either way the
973
+ // text side wins. This is what makes ANY wallpaper survivable.
974
+ const dark = Boolean(document.body.hasAttribute && document.body.hasAttribute("data-ds-dark-theme"));
975
+ let alpha = selection.scrim;
976
+ if (selection.autoScrim && sampledLuminance !== null) {
977
+ // Distance of the wallpaper's brightness from the "comfortable middle"
978
+ // towards the text colour's weakness, mapped to a veil-alpha floor.
979
+ const need = dark
980
+ ? Math.min(0.55, Math.max(0, sampledLuminance - 0.5))
981
+ : Math.min(0.55, Math.max(0, 0.5 - sampledLuminance));
982
+ alpha = Math.max(alpha, Math.round(need * 1000) / 1000);
983
+ }
984
+ const veilColor = "rgba(" + (dark ? "0,0,0" : "255,255,255") + "," + alpha + ")";
985
+ s.setProperty("--webg-scrim-color", veilColor);
771
986
  s.setProperty("--webg-border-alpha", String(selection.border));
772
987
  s.setProperty("--webg-blur", selection.blur + "px");
773
988
  // The glass "colour melt" scales with blur radius: 0 blur → no melt.
774
989
  s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
775
990
  s.setProperty("--webg-glass-brightness", "1.04");
776
991
  s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
992
+ // At 0 blur, keep the media OUT of the filter pipeline entirely: even
993
+ // blur(0px) forces an extra raster/compositing pass, and the noise overlay
994
+ // would still occupy a layer at opacity 0. filter:none + display:none keep
995
+ // the pixels bit-exact and the layer list minimal.
996
+ s.setProperty("--webg-media-filter",
997
+ selection.wallpaperBlur > 0
998
+ ? "blur(" + selection.wallpaperBlur + "px) saturate(1.08)"
999
+ : "none");
1000
+ s.setProperty("--webg-noise-display", selection.wallpaperBlur > 0 ? "block" : "none");
777
1001
  // Dither-noise strength scales with blur: invisible at 0, ~4% at 60px.
778
1002
  s.setProperty("--webg-noise-opacity",
779
1003
  selection.wallpaperBlur > 0 ? (0.02 + selection.wallpaperBlur * 0.0004).toFixed(4) : "0");
780
- // Canvas fit: object-fit/position for video and still wallpapers (iframes
781
- // ignore object-fit — web wallpapers always fill their layer).
1004
+ // Canvas fit: object-fit base mode plus the user's manual crop — pan
1005
+ // offsets (percent of the media box) and zoom, applied as a transform so
1006
+ // no remount is ever needed while dragging.
782
1007
  s.setProperty("--webg-fit", selection.fit);
783
- s.setProperty("--webg-position", selection.position);
1008
+ s.setProperty("--webg-zoom", String(selection.zoom));
1009
+ s.setProperty("--webg-offset-x", selection.offsetX + "%");
1010
+ s.setProperty("--webg-offset-y", selection.offsetY + "%");
784
1011
 
785
1012
  // Write the scrim colour directly too, then force a synchronous layout, so
786
1013
  // slider feedback lands on this frame even on stalled compositors.
787
1014
  const scrim = document.getElementById(SCRIM_ID);
788
- if (scrim) scrim.style.background = "rgba(0,0,0," + selection.scrim + ")";
1015
+ if (scrim) scrim.style.background = veilColor;
789
1016
  if (document.body && document.body.offsetHeight !== undefined) {
790
1017
  void document.body.offsetHeight;
791
1018
  }
@@ -799,9 +1026,13 @@ window.__ModuleLoader__.load({
799
1026
  s.removeProperty("--webg-saturate");
800
1027
  s.removeProperty("--webg-glass-brightness");
801
1028
  s.removeProperty("--webg-wallpaper-blur");
1029
+ s.removeProperty("--webg-media-filter");
1030
+ s.removeProperty("--webg-noise-display");
802
1031
  s.removeProperty("--webg-noise-opacity");
803
1032
  s.removeProperty("--webg-fit");
804
- s.removeProperty("--webg-position");
1033
+ s.removeProperty("--webg-zoom");
1034
+ s.removeProperty("--webg-offset-x");
1035
+ s.removeProperty("--webg-offset-y");
805
1036
  const scrim = document.getElementById(SCRIM_ID);
806
1037
  if (scrim) scrim.style.background = "";
807
1038
  }
@@ -862,6 +1093,8 @@ window.__ModuleLoader__.load({
862
1093
  })
863
1094
  : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
864
1095
  React.createElement("span", { className: "webg-card-type" }, typeBadge(w, t)),
1096
+ (typeof w.previewW === "number" && w.previewW > 0 && w.previewW < LOW_RES_PREVIEW_PX) &&
1097
+ React.createElement("span", { className: "webg-card-lowres" }, t.lowResBadge),
865
1098
  React.createElement("span", { className: "webg-card-title" }, w.title),
866
1099
  );
867
1100
  }
@@ -947,6 +1180,12 @@ window.__ModuleLoader__.load({
947
1180
  persistSelection();
948
1181
  emit();
949
1182
  };
1183
+ const onToggleAutoScrim = () => {
1184
+ selection.autoScrim = !selection.autoScrim;
1185
+ persistSelection();
1186
+ applyEffects();
1187
+ emit();
1188
+ };
950
1189
 
951
1190
  const onScrim = (pct) => { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); };
952
1191
  const onBorder = (pct) => { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); };
@@ -958,12 +1197,6 @@ window.__ModuleLoader__.load({
958
1197
  applyEffects();
959
1198
  emit();
960
1199
  };
961
- const onPosition = (e) => {
962
- selection.position = POSITIONS.indexOf(e.target.value) >= 0 ? e.target.value : "center";
963
- persistSelection();
964
- applyEffects();
965
- emit();
966
- };
967
1200
 
968
1201
  if (!sel.loaded) {
969
1202
  return React.createElement("div", { className: "webg-picker" },
@@ -1200,7 +1433,8 @@ window.__ModuleLoader__.load({
1200
1433
  // ── Effects ──
1201
1434
  sel.id && React.createElement(React.Fragment, null,
1202
1435
  // Canvas fit only matters for video/still wallpapers — an iframe
1203
- // ignores object-fit and always fills its layer.
1436
+ // ignores object-fit and always fills its layer. Manual crop (drag /
1437
+ // wheel in the adjust overlay) replaced the old fixed anchor select.
1204
1438
  sel.type !== "web" && React.createElement("div", { className: "webg-row webg-fit-row" },
1205
1439
  React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1206
1440
  React.createElement("select", {
@@ -1211,15 +1445,16 @@ window.__ModuleLoader__.load({
1211
1445
  ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1212
1446
  React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1213
1447
  )),
1214
- React.createElement("span", { className: "webg-hint webg-label" }, t.position),
1215
- React.createElement("select", {
1216
- className: "webg-select webg-position-select",
1217
- value: sel.position,
1218
- onChange: onPosition,
1219
- },
1220
- ...[t.posCenter, t.posTop, t.posBottom, t.posLeft, t.posRight].map((label, i) =>
1221
- React.createElement("option", { key: POSITIONS[i], value: POSITIONS[i] }, label),
1222
- )),
1448
+ React.createElement("button", {
1449
+ className: "webg-btn", type: "button", onClick: () => enterAdjust(),
1450
+ disabled: sel.adjusting,
1451
+ }, t.adjust),
1452
+ (sel.zoom !== 1 || sel.offsetX !== 0 || sel.offsetY !== 0) &&
1453
+ React.createElement("button", {
1454
+ className: "webg-btn", type: "button", onClick: resetCrop,
1455
+ }, t.resetCrop),
1456
+ isAmbientActive(sel) &&
1457
+ React.createElement("span", { className: "webg-hint" }, t.ambientNote),
1223
1458
  ),
1224
1459
  React.createElement("div", { className: "webg-effects" },
1225
1460
  SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
@@ -1242,6 +1477,12 @@ window.__ModuleLoader__.load({
1242
1477
  }),
1243
1478
  t.pauseOnBattery,
1244
1479
  ),
1480
+ React.createElement("label", { className: "webg-toggle", title: t.autoScrimTitle },
1481
+ React.createElement("input", {
1482
+ type: "checkbox", checked: sel.autoScrim, onChange: onToggleAutoScrim,
1483
+ }),
1484
+ t.autoScrim,
1485
+ ),
1245
1486
  sel.fps !== null && React.createElement("span", {
1246
1487
  className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1247
1488
  title: t.fpsTitle,
@@ -1291,18 +1532,63 @@ window.__ModuleLoader__.load({
1291
1532
  height: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1292
1533
  max-width: none; max-height: none;
1293
1534
  object-fit: var(--webg-fit, cover);
1294
- object-position: var(--webg-position, center);
1535
+ object-position: center;
1295
1536
  display: block;
1296
1537
  background: transparent; border: 0;
1297
- filter: blur(var(--webg-wallpaper-blur, 0px)) saturate(1.08);
1538
+ filter: var(--webg-media-filter, none);
1539
+ /* Manual crop: pan offsets are % of the media box (independent of zoom
1540
+ because translate is listed before scale), zoom is centered. */
1541
+ transform: translate(var(--webg-offset-x, 0%), var(--webg-offset-y, 0%)) scale(var(--webg-zoom, 1));
1542
+ transform-origin: center center;
1543
+ }
1544
+
1545
+ /* Ambient presentation for low-resolution stills: the sharp image (fg,
1546
+ contain) floats over a heavily blurred full-bleed copy of itself (bg).
1547
+ The fg keeps the user's wallpaper-blur/crop; the bg has its own fixed
1548
+ blur with its own overscan, so its fringe never shows. */
1549
+ .webg-ambient {
1550
+ position: absolute;
1551
+ inset: calc(-1 * var(--webg-wallpaper-blur, 0px));
1552
+ overflow: hidden;
1553
+ }
1554
+ .webg-ambient-bg {
1555
+ position: absolute; inset: -64px;
1556
+ width: calc(100% + 128px); height: calc(100% + 128px);
1557
+ object-fit: cover; display: block;
1558
+ filter: blur(48px) saturate(1.25) brightness(0.92);
1559
+ }
1560
+ .webg-ambient .webg-media {
1561
+ inset: 0; width: 100%; height: 100%;
1562
+ object-fit: contain;
1563
+ }
1564
+
1565
+ /* Crop-adjust overlay: topmost, transparent — only its hint bar is chrome.
1566
+ The live layer is moved INSIDE, so editing is what-you-see. */
1567
+ .webg-adjust {
1568
+ position: fixed; inset: 0; z-index: 2147483000;
1569
+ cursor: grab; touch-action: none;
1570
+ }
1571
+ .webg-adjust:active { cursor: grabbing; }
1572
+ .webg-adjust .webg-layer { position: absolute; z-index: 0; }
1573
+ .webg-adjust-bar {
1574
+ position: absolute; top: 18px; left: 50%; transform: translateX(-50%);
1575
+ z-index: 1; cursor: default;
1576
+ display: flex; align-items: center; gap: 12px;
1577
+ padding: 8px 8px 8px 14px; border-radius: 12px;
1578
+ background: var(--dsw-alias-bg-overlay, rgba(30, 32, 38, 0.85));
1579
+ color: var(--dsw-alias-label-primary, inherit);
1580
+ backdrop-filter: blur(20px);
1581
+ -webkit-backdrop-filter: blur(20px);
1582
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
1583
+ font-size: 12px; white-space: nowrap;
1298
1584
  }
1299
1585
 
1300
1586
  /* Gaussian blur alone shows banding on smooth gradients; a whisper of
1301
1587
  fractal noise over the layer dithers it away (iOS does the same inside
1302
- its frosted materials). Opacity rides the blur radius — zero blur, zero
1303
- noise. */
1588
+ its frosted materials). display:none at zero blur — no layer, no cost. */
1304
1589
  .webg-layer::after {
1305
1590
  content: ""; position: absolute; inset: 0; pointer-events: none;
1591
+ display: var(--webg-noise-display, none);
1306
1592
  opacity: var(--webg-noise-opacity, 0);
1307
1593
  background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/></filter><rect width="120" height="120" filter="url(%23n)"/></svg>');
1308
1594
  background-size: 120px 120px;
@@ -1514,8 +1800,12 @@ window.__ModuleLoader__.load({
1514
1800
  font-size: 12px; color: var(--dsw-alias-label-secondary, #888);
1515
1801
  }
1516
1802
  .webg-card-type {
1517
- position: absolute; top: 4px; right: 4px;
1518
- padding: 1px 6px; border-radius: 4px;
1803
+ position: absolute; top: 4px; right: 4px; padding: 1px 6px; border-radius: 4px;
1804
+ font-size: 10px; line-height: 1.5;
1805
+ color: #fff; background: rgba(0, 0, 0, 0.5);
1806
+ }
1807
+ .webg-card-lowres {
1808
+ position: absolute; top: 4px; left: 4px; padding: 1px 6px; border-radius: 4px;
1519
1809
  font-size: 10px; line-height: 1.5;
1520
1810
  color: #fff; background: rgba(0, 0, 0, 0.5);
1521
1811
  }
@@ -1612,9 +1902,31 @@ window.__ModuleLoader__.load({
1612
1902
  const unsubEffects = subscribe(applyEffects);
1613
1903
  syncLayers();
1614
1904
  applyEffects();
1905
+ // Luminance sampler: re-reads the wallpaper periodically so a bright
1906
+ // scene arriving mid-video re-raises the veil. (rAF-free: 2.5 s is
1907
+ // plenty for legibility, and an idle timer costs ~nothing.)
1908
+ let samplerId = null;
1909
+ if (typeof window !== "undefined" && typeof window.setInterval === "function") {
1910
+ samplerId = window.setInterval(() => {
1911
+ sampleWallpaperLuminance();
1912
+ applyEffects();
1913
+ }, 2500);
1914
+ }
1915
+ // A theme switch flips the veil colour immediately (black ↔ white).
1916
+ let observer = null;
1917
+ if (typeof MutationObserver === "function") {
1918
+ observer = new MutationObserver(() => applyEffects());
1919
+ observer.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
1920
+ }
1615
1921
  return () => {
1616
1922
  unsubLayers();
1617
1923
  unsubEffects();
1924
+ // Close the crop-adjust overlay if it is open (moves the layer back,
1925
+ // removes the bar and its listeners) before tearing layers down.
1926
+ exitAdjust();
1927
+ if (samplerId !== null) window.clearInterval(samplerId);
1928
+ if (observer) observer.disconnect();
1929
+ sampledLuminance = null;
1618
1930
  clearRotationTimer();
1619
1931
  // Cancel pending crossfade/clear timers so they never fire on
1620
1932
  // torn-down nodes after dispose.
package/lib/core.js CHANGED
@@ -25,6 +25,59 @@ export const WE_APPID = '431960';
25
25
  /** Wallpaper kinds WE can declare. */
26
26
  export const KINDS = Object.freeze(['scene', 'video', 'web', 'application']);
27
27
 
28
+ // ── 6. Image dimension sniffing ─────────────────────────────────────────────
29
+
30
+ /**
31
+ * Read pixel dimensions from an image's header bytes (JPEG/PNG/GIF/WebP).
32
+ * Used to tell the client how sharp a scene/application preview really is —
33
+ * workshop previews range from 256px thumbnails to full renders, and the UI
34
+ * presents low-resolution ones differently.
35
+ *
36
+ * Header-only: reads at most 64 KiB, never decodes pixels. Returns
37
+ * `{ w, h }` or null for unrecognized/truncated data.
38
+ *
39
+ * @param {Buffer|Uint8Array} b
40
+ * @returns {{ w: number, h: number } | null}
41
+ */
42
+ export function sniffImageSize(b) {
43
+ if (!b || b.length < 10) return null; // GIF needs 10; other formats check their own reach
44
+ // PNG: 8-byte signature, then IHDR with width/height big-endian at 16/20.
45
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) {
46
+ if (b.length < 24) return null;
47
+ return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
48
+ }
49
+ // GIF: logical screen dimensions little-endian at 6/8.
50
+ const sig6 = b.toString('latin1', 0, 6);
51
+ if (sig6 === 'GIF87a' || sig6 === 'GIF89a') {
52
+ return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
53
+ }
54
+ // JPEG: walk markers to the first start-of-frame segment.
55
+ if (b[0] === 0xFF && b[1] === 0xD8) {
56
+ let i = 2;
57
+ while (i + 9 < b.length) {
58
+ if (b[i] !== 0xFF) { i++; continue; }
59
+ const marker = b[i + 1];
60
+ if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
61
+ return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
62
+ }
63
+ i += 2 + b.readUInt16BE(i + 2);
64
+ }
65
+ return null;
66
+ }
67
+ // WebP: RIFF container; VP8X carries canvas size minus one, uint24 LE.
68
+ if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP' &&
69
+ b.toString('latin1', 12, 16) === 'VP8X' && b.length >= 30) {
70
+ return {
71
+ w: 1 + b[24] + (b[25] << 8) + (b[26] << 16),
72
+ h: 1 + b[27] + (b[28] << 8) + (b[29] << 16),
73
+ };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Kinds whose only browser-renderable asset is the preview image. */
79
+ const PREVIEW_ONLY_KINDS = new Set(['scene', 'application']);
80
+
28
81
  /** Kinds this bundle can render in a browser. */
29
82
  export const PORTABLE_KINDS = Object.freeze(['video', 'web']);
30
83
 
@@ -560,6 +613,15 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
560
613
  const previewToken = hasPreview
561
614
  ? mint({ abs: p.previewAbs, rootDir: null, key: `${p.id}:preview` })
562
615
  : null;
616
+ // Preview pixel size matters for scene/application wallpapers: the
617
+ // preview is ALL the browser can show, so the client needs to know how
618
+ // sharp it is. Sniff only for preview-only kinds (videos don't care).
619
+ let previewSize = null;
620
+ if (hasPreview && PREVIEW_ONLY_KINDS.has(p.type) &&
621
+ typeof io.readBuffer === 'function') {
622
+ try { previewSize = sniffImageSize(await io.readBuffer(p.previewAbs, 65536)); }
623
+ catch { previewSize = null; }
624
+ }
563
625
  wallpapers.push({
564
626
  id: p.id,
565
627
  title: p.title,
@@ -567,6 +629,8 @@ export async function buildInventoryFrom(discovery = {}, io = {}) {
567
629
  playable: hasMedia && Boolean(mediaToken),
568
630
  media: hasMedia && mediaToken ? `${base}/media/${mediaToken}` : null,
569
631
  preview: hasPreview && previewToken ? `${base}/preview/${previewToken}` : null,
632
+ previewW: previewSize ? previewSize.w : null,
633
+ previewH: previewSize ? previewSize.h : null,
570
634
  });
571
635
  }
572
636
  const playableIds = new Set(wallpapers.filter((w) => w.playable).map((w) => w.id));
package/lib/index.js CHANGED
@@ -61,6 +61,15 @@ const asyncIo = {
61
61
  readFileSync: (p, encoding) => fsp.readFile(p, encoding),
62
62
  readdirSync: (p) => fsp.readdir(p),
63
63
  statSync: (p) => fsp.stat(p),
64
+ // Header-only binary read for the image dimension sniffer (64 KiB cap).
65
+ readBuffer: async (p, max) => {
66
+ const fh = await fsp.open(p, 'r');
67
+ try {
68
+ const buf = Buffer.alloc(Math.min(max, 65536));
69
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
70
+ return buf.subarray(0, bytesRead);
71
+ } finally { await fh.close(); }
72
+ },
64
73
  };
65
74
 
66
75
  /** Steam root recorded by the Windows installer; null elsewhere/on failure. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallpaper-engine-dsh",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",