wallpaper-engine-dsh 0.1.7 → 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
@@ -11,7 +11,7 @@ A DSH bundle that turns your local **Wallpaper Engine** library into the live ba
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
12
  - **Four live sliders** — Wallpaper blur, Scrim, Borders, Glass — all instant and persisted.
13
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
- - **Canvas fit** — Cover/Contain/Stretch/Original sizing plus five-way alignment for video and still wallpapers.
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.
15
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).
16
16
  - **Bilingual** — the settings UI follows the shell's Language preference (Chinese/English), with a manual override in the picker.
17
17
  - **Crossfade** — wallpaper switches and clearing fade smoothly instead of snapping.
@@ -35,7 +35,7 @@ Restart `dsh web`, then open **Settings → General → Wallpaper Engine**.
35
35
 
36
36
  1. Pick a wallpaper from the thumbnail grid — it fades in behind the app. Use the search box to narrow large libraries.
37
37
  2. **Pause/Play** controls video playback; **Close** fades the wallpaper out; **Refresh** rescans the library (new workshop subscriptions appear without reloading the page).
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/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.
39
39
  4. Create rotation lists with **New**, fill them from the grid or import a WE playlist, then enable **Auto-rotate**.
40
40
  5. The resource row shows the current frame rate and offers two auto-pause switches. `prefers-reduced-motion` starts videos paused.
41
41
  6. The Language control switches the picker between Chinese and English; on Auto it follows the shell setting.
@@ -50,7 +50,7 @@ The bundle has two halves:
50
50
  - `GET /we-background/inventory[?refresh=1]`
51
51
  - `GET /we-background/media/<token>[/asset…]` — with HTTP Range support for video seeking; web wallpapers can fetch their bundled sub-assets
52
52
  - `GET /we-background/preview/<token>`
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.
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.
54
54
 
55
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.
56
56
 
package/README.zh.md CHANGED
@@ -11,7 +11,7 @@
11
11
  - **轮播列表** —— 可建任意多个列表,各自拥有壁纸集合、切换间隔(1–120 分钟)和顺序(顺序/随机)。首次运行自动导入第一个可播放的 Wallpaper Engine 播放列表,其余列表可随时导入。
12
12
  - **四个实时滑杆** —— 壁纸模糊、暗化、边框、玻璃,即时生效并持久保存。
13
13
  - **任意壁纸都可读** —— 遮罩颜色与文字颜色相对(浅色主题提亮、深色主题压暗);"智能可视"开关每隔几秒采样一次壁纸亮度,画面过亮或过暗时自动加强遮罩。下拉菜单、弹窗等浮层保持 DSH 原生不透明表面,不受影响。
14
- - **画布排布** —— 视频与静态壁纸支持填充/适应/拉伸/原始四种排布和五向对齐。
14
+ - **手动裁剪** —— 先选基础排布(填充/适应/拉伸/原始),再自己取景:点「调整画面」把壁纸全屏展开,拖动平移、滚轮缩放,「重置裁剪」一键还原。
15
15
  - **资源监控** —— 帧率读数与低帧率提示,页面隐藏或电量不足时自动暂停(均可开关)。
16
16
  - **中英双语** —— 设置界面跟随 DSH 的"语言"偏好自动切换,也可在面板内手动指定。
17
17
  - **交叉淡入** —— 切换与关闭都平滑过渡,不生硬跳变。
@@ -35,7 +35,7 @@ dsh plugin --profile web add link:<克隆文件夹的绝对路径>
35
35
 
36
36
  1. 从缩略图网格选择壁纸,它会在界面背后淡入。壁纸库较大时用搜索框快速定位。
37
37
  2. **暂停/播放** 控制视频播放;**关闭** 淡出壁纸;**刷新** 重新扫描壁纸库,新订阅的创意工坊壁纸无需刷新页面即可出现。
38
- 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。**排布/对齐** 一行设置视频与静态壁纸在画布上的摆放(填充/适应/拉伸/原始,五个锚点)。
38
+ 3. 用滑杆调节融合效果。壁纸太花时调高 **暗化** 和 **边框** 直到文字清晰;界面跟随 DSH 明暗主题自动适配。**排布** 一行决定视频与静态壁纸的基础摆放(填充/适应/拉伸/原始),点「调整画面」可在画布上直接拖动平移、滚轮缩放取景。
39
39
  4. 用 **新建** 创建轮播列表,从网格挑选或导入 WE 播放列表,然后开启 **自动轮转**。
40
40
  5. 资源行显示当前帧率,并提供两个自动暂停开关。系统开启 `prefers-reduced-motion` 时视频默认暂停。
41
41
  6. "语言"控件在中英文之间切换面板;选"自动"则跟随 DSH 的语言设置。
@@ -50,7 +50,7 @@ bundle 分为两半:
50
50
  - `GET /we-background/inventory[?refresh=1]`
51
51
  - `GET /we-background/media/<token>[/资源…]` —— 支持 HTTP Range,视频可拖动进度;网页壁纸可加载其打包的子资源
52
52
  - `GET /we-background/preview/<token>`
53
- - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。壁纸模糊采用裁边式超采样(内容不变形),叠加微量抖动噪声消除渐变色带;浏览器解不了的视频会自动改用该壁纸的预览图。
53
+ - **浏览器插件** 把选中的壁纸渲染在应用框架之下的固定层,提供设置界面,所有视觉效果都读 DSH 设计令牌,主题切换自然生效。壁纸模糊采用裁边式超采样(内容不变形),叠加微量抖动噪声消除渐变色带;浏览器解不了的视频会自动改用该壁纸的预览图。场景与应用壁纸在浏览器里只能显示工坊预览图——插件会读取每张预览图的像素尺寸,低分辨率的以「清晰原图居中 + 自身高斯模糊铺底」的氛围方式呈现,而不是拉伸成一片糊。
54
54
 
55
55
  清单由宿主缓存 30 秒,媒体令牌按壁纸稳定复用(后台重建不会掐断播放中的流);安装发现异步执行并缓存 10 分钟。本插件不注册模型工具,不占用 prompt。
56
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" &&
@@ -90,12 +96,12 @@ window.__ModuleLoader__.load({
90
96
  fitContain: "适应",
91
97
  fitFill: "拉伸",
92
98
  fitNone: "原始",
93
- position: "对齐",
94
- posCenter: "居中",
95
- posTop: "顶部",
96
- posBottom: "底部",
97
- posLeft: "居左",
98
- posRight: "居右",
99
+ adjust: "调整画面",
100
+ adjustDone: "完成",
101
+ adjustHint: "拖动平移 · 滚轮缩放 · Esc 或点「完成」退出",
102
+ resetCrop: "重置裁剪",
103
+ ambientNote: "预览分辨率低,已氛围化显示",
104
+ lowResBadge: "低清",
99
105
  statusGroup: (name, total, usable, interval, order) =>
100
106
  "列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
101
107
  statusUsable: (n) => n + " 张可用壁纸",
@@ -170,12 +176,12 @@ window.__ModuleLoader__.load({
170
176
  fitContain: "Contain",
171
177
  fitFill: "Stretch",
172
178
  fitNone: "Original",
173
- position: "Align",
174
- posCenter: "Center",
175
- posTop: "Top",
176
- posBottom: "Bottom",
177
- posLeft: "Left",
178
- 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",
179
185
  statusGroup: (name, total, usable, interval, order) =>
180
186
  "List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
181
187
  statusUsable: (n) => n + " usable wallpapers",
@@ -225,11 +231,12 @@ window.__ModuleLoader__.load({
225
231
  autoScrim: true,
226
232
  lang: "auto",
227
233
  fit: "cover",
228
- position: "center",
234
+ zoom: 1,
235
+ offsetX: 0,
236
+ offsetY: 0,
229
237
  };
230
238
 
231
239
  const FIT_MODES = ["cover", "contain", "fill", "none"];
232
- const POSITIONS = ["center", "top", "bottom", "left", "right"];
233
240
 
234
241
  // ── Persisted selection ─────────────────────────────────────────────────────
235
242
  function clampNum(v, lo, hi, fallback) {
@@ -276,7 +283,9 @@ window.__ModuleLoader__.load({
276
283
  autoScrim: o.autoScrim !== false,
277
284
  lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
278
285
  fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
279
- 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),
280
289
  };
281
290
  } catch {
282
291
  return { id: "", ...DEFAULTS };
@@ -288,6 +297,8 @@ window.__ModuleLoader__.load({
288
297
  ...readPersisted(),
289
298
  url: null,
290
299
  type: null,
300
+ previewW: null, // pixel width of the still being shown (ambient decisions)
301
+ adjusting: false, // crop-adjust overlay open (not persisted)
291
302
  playing: !REDUCED_MOTION,
292
303
  loading: false,
293
304
  rotationTimer: null,
@@ -328,7 +339,9 @@ window.__ModuleLoader__.load({
328
339
  autoScrim: selection.autoScrim,
329
340
  lang: selection.lang,
330
341
  fit: selection.fit,
331
- position: selection.position,
342
+ zoom: selection.zoom,
343
+ offsetX: selection.offsetX,
344
+ offsetY: selection.offsetY,
332
345
  }));
333
346
  } catch { /* storage full / blocked → session-only state, not fatal */ }
334
347
  }
@@ -582,6 +595,7 @@ window.__ModuleLoader__.load({
582
595
  }
583
596
 
584
597
  function applySelection(id) {
598
+ if (selection.adjusting) exitAdjust();
585
599
  selection.id = id || "";
586
600
  persistSelection();
587
601
  const w = selection.id
@@ -590,14 +604,17 @@ window.__ModuleLoader__.load({
590
604
  if (isPlayable(w) && !demotedToPreview.has(selection.id)) {
591
605
  selection.url = w.media;
592
606
  selection.type = w.type; // "video" | "web"
607
+ selection.previewW = null;
593
608
  } else if (w && w.preview) {
594
609
  // Static path: scene/application wallpapers, and playable wallpapers
595
610
  // demoted after repeated media failures (undecodable container…).
596
611
  selection.url = w.preview;
597
612
  selection.type = "image";
613
+ selection.previewW = typeof w.previewW === "number" ? w.previewW : null;
598
614
  } else {
599
615
  selection.url = null;
600
616
  selection.type = null;
617
+ selection.previewW = null;
601
618
  }
602
619
  syncRotationTimer();
603
620
  emit();
@@ -666,15 +683,42 @@ window.__ModuleLoader__.load({
666
683
  media.draggable = false;
667
684
  media.setAttribute("decoding", "async");
668
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
+ }
669
706
  }
670
- // First luminance sample as soon as the frame is decodable (video/still
671
- // only — iframe contents are opaque to us). Later samples ride the timer.
672
- if (sel.type === "video" || sel.type === "image") {
673
- media.addEventListener(sel.type === "video" ? "loadeddata" : "load", () => {
674
- sampleWallpaperLuminance();
675
- applyEffects();
676
- });
677
- }
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) {
678
722
  media.addEventListener("error", () => {
679
723
  const id = sel.id;
680
724
  const count = (mediaFailures.get(id) || 0) + 1;
@@ -693,7 +737,120 @@ window.__ModuleLoader__.load({
693
737
  applySelection(id);
694
738
  }
695
739
  });
696
- 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;
697
854
  }
698
855
 
699
856
  // Crossfade/clear leave timers: tracked so dispose can cancel them instead of
@@ -832,13 +989,25 @@ window.__ModuleLoader__.load({
832
989
  s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
833
990
  s.setProperty("--webg-glass-brightness", "1.04");
834
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");
835
1001
  // Dither-noise strength scales with blur: invisible at 0, ~4% at 60px.
836
1002
  s.setProperty("--webg-noise-opacity",
837
1003
  selection.wallpaperBlur > 0 ? (0.02 + selection.wallpaperBlur * 0.0004).toFixed(4) : "0");
838
- // Canvas fit: object-fit/position for video and still wallpapers (iframes
839
- // 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.
840
1007
  s.setProperty("--webg-fit", selection.fit);
841
- 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 + "%");
842
1011
 
843
1012
  // Write the scrim colour directly too, then force a synchronous layout, so
844
1013
  // slider feedback lands on this frame even on stalled compositors.
@@ -857,9 +1026,13 @@ window.__ModuleLoader__.load({
857
1026
  s.removeProperty("--webg-saturate");
858
1027
  s.removeProperty("--webg-glass-brightness");
859
1028
  s.removeProperty("--webg-wallpaper-blur");
1029
+ s.removeProperty("--webg-media-filter");
1030
+ s.removeProperty("--webg-noise-display");
860
1031
  s.removeProperty("--webg-noise-opacity");
861
1032
  s.removeProperty("--webg-fit");
862
- s.removeProperty("--webg-position");
1033
+ s.removeProperty("--webg-zoom");
1034
+ s.removeProperty("--webg-offset-x");
1035
+ s.removeProperty("--webg-offset-y");
863
1036
  const scrim = document.getElementById(SCRIM_ID);
864
1037
  if (scrim) scrim.style.background = "";
865
1038
  }
@@ -920,6 +1093,8 @@ window.__ModuleLoader__.load({
920
1093
  })
921
1094
  : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
922
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),
923
1098
  React.createElement("span", { className: "webg-card-title" }, w.title),
924
1099
  );
925
1100
  }
@@ -1022,12 +1197,6 @@ window.__ModuleLoader__.load({
1022
1197
  applyEffects();
1023
1198
  emit();
1024
1199
  };
1025
- const onPosition = (e) => {
1026
- selection.position = POSITIONS.indexOf(e.target.value) >= 0 ? e.target.value : "center";
1027
- persistSelection();
1028
- applyEffects();
1029
- emit();
1030
- };
1031
1200
 
1032
1201
  if (!sel.loaded) {
1033
1202
  return React.createElement("div", { className: "webg-picker" },
@@ -1264,7 +1433,8 @@ window.__ModuleLoader__.load({
1264
1433
  // ── Effects ──
1265
1434
  sel.id && React.createElement(React.Fragment, null,
1266
1435
  // Canvas fit only matters for video/still wallpapers — an iframe
1267
- // 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.
1268
1438
  sel.type !== "web" && React.createElement("div", { className: "webg-row webg-fit-row" },
1269
1439
  React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1270
1440
  React.createElement("select", {
@@ -1275,15 +1445,16 @@ window.__ModuleLoader__.load({
1275
1445
  ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1276
1446
  React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1277
1447
  )),
1278
- React.createElement("span", { className: "webg-hint webg-label" }, t.position),
1279
- React.createElement("select", {
1280
- className: "webg-select webg-position-select",
1281
- value: sel.position,
1282
- onChange: onPosition,
1283
- },
1284
- ...[t.posCenter, t.posTop, t.posBottom, t.posLeft, t.posRight].map((label, i) =>
1285
- React.createElement("option", { key: POSITIONS[i], value: POSITIONS[i] }, label),
1286
- )),
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),
1287
1458
  ),
1288
1459
  React.createElement("div", { className: "webg-effects" },
1289
1460
  SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
@@ -1361,18 +1532,63 @@ window.__ModuleLoader__.load({
1361
1532
  height: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1362
1533
  max-width: none; max-height: none;
1363
1534
  object-fit: var(--webg-fit, cover);
1364
- object-position: var(--webg-position, center);
1535
+ object-position: center;
1365
1536
  display: block;
1366
1537
  background: transparent; border: 0;
1367
- 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;
1368
1584
  }
1369
1585
 
1370
1586
  /* Gaussian blur alone shows banding on smooth gradients; a whisper of
1371
1587
  fractal noise over the layer dithers it away (iOS does the same inside
1372
- its frosted materials). Opacity rides the blur radius — zero blur, zero
1373
- noise. */
1588
+ its frosted materials). display:none at zero blur — no layer, no cost. */
1374
1589
  .webg-layer::after {
1375
1590
  content: ""; position: absolute; inset: 0; pointer-events: none;
1591
+ display: var(--webg-noise-display, none);
1376
1592
  opacity: var(--webg-noise-opacity, 0);
1377
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>');
1378
1594
  background-size: 120px 120px;
@@ -1584,8 +1800,12 @@ window.__ModuleLoader__.load({
1584
1800
  font-size: 12px; color: var(--dsw-alias-label-secondary, #888);
1585
1801
  }
1586
1802
  .webg-card-type {
1587
- position: absolute; top: 4px; right: 4px;
1588
- 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;
1589
1809
  font-size: 10px; line-height: 1.5;
1590
1810
  color: #fff; background: rgba(0, 0, 0, 0.5);
1591
1811
  }
@@ -1701,6 +1921,9 @@ window.__ModuleLoader__.load({
1701
1921
  return () => {
1702
1922
  unsubLayers();
1703
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();
1704
1927
  if (samplerId !== null) window.clearInterval(samplerId);
1705
1928
  if (observer) observer.disconnect();
1706
1929
  sampledLuminance = null;
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.7",
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",