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 +3 -3
- package/README.zh.md +3 -3
- package/lib/client.js +274 -51
- package/lib/core.js +64 -0
- package/lib/index.js +9 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
839
|
-
//
|
|
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-
|
|
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-
|
|
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("
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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:
|
|
1535
|
+
object-position: center;
|
|
1365
1536
|
display: block;
|
|
1366
1537
|
background: transparent; border: 0;
|
|
1367
|
-
filter:
|
|
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).
|
|
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
|
-
|
|
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.
|
|
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",
|