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 +5 -4
- package/README.zh.md +5 -4
- package/lib/client.js +357 -45
- package/lib/core.js +64 -0
- package/lib/index.js +9 -0
- package/package.json +1 -1
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** —
|
|
13
|
-
- **
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
781
|
-
//
|
|
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-
|
|
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 =
|
|
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-
|
|
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("
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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:
|
|
1535
|
+
object-position: center;
|
|
1295
1536
|
display: block;
|
|
1296
1537
|
background: transparent; border: 0;
|
|
1297
|
-
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;
|
|
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).
|
|
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
|
-
|
|
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.
|
|
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",
|