dsh-intertidal 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.en.md +103 -0
- package/README.md +102 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshots/media-modes.png +0 -0
- package/assets/screenshots/overlay-countdown.png +0 -0
- package/assets/screenshots/procedural-overview.png +0 -0
- package/assets/screenshots/settings-narrow.png +0 -0
- package/cordis.patch.yml +4 -0
- package/dist/client.js +3828 -0
- package/package.json +70 -4
- package/screenshots.json +6 -0
- package/src/cities.js +165 -0
- package/src/client/api.js +140 -0
- package/src/client/birds.js +112 -0
- package/src/client/format.js +28 -0
- package/src/client/i18n.js +250 -0
- package/src/client/index.jsx +163 -0
- package/src/client/overlay.jsx +260 -0
- package/src/client/palette.js +391 -0
- package/src/client/rhythm.js +538 -0
- package/src/client/sea-canvas.jsx +1290 -0
- package/src/client/settings-panel.jsx +430 -0
- package/src/client/sound.js +163 -0
- package/src/client/styles.js +217 -0
- package/src/client/water.js +330 -0
- package/src/host.js +458 -0
- package/src/media.js +423 -0
- package/src/settings.js +322 -0
- package/src/sky.js +428 -0
- package/src/store.js +169 -0
package/src/media.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// 潮间 · 媒体素材(v2:用真实图片/视频当海的基底,程序化海降级为兜底)
|
|
2
|
+
//
|
|
3
|
+
// 两条来源:
|
|
4
|
+
// dir —— 用户指定的本地目录,随机挑一张(每次浮现换一张)
|
|
5
|
+
// url —— 用户给的链接;可能是直链图片,也可能是一个网页(从 og:image / 首图里挖)
|
|
6
|
+
//
|
|
7
|
+
// 三条约束:
|
|
8
|
+
// 1. **路径围栏**:只服务目录内的 basename,扩展名白名单之外一律不认,杜绝 `../` 穿越。
|
|
9
|
+
// 2. **不联网就不用网络**:`dir` 模式完全不发请求;`url` 模式只在缓存缺失时下载一次,
|
|
10
|
+
// 之后一直用本地缓存(离线也能继续看)。
|
|
11
|
+
// 3. **装载期零 IO**:本文件 import 时不读目录、不建目录、不解析 $DSH_HOME。
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { createReadStream } from 'node:fs'
|
|
15
|
+
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { basename, extname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
18
|
+
|
|
19
|
+
export const IMAGE_EXT = Object.freeze(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.bmp'])
|
|
20
|
+
export const VIDEO_EXT = Object.freeze(['.mp4', '.webm', '.mov', '.m4v'])
|
|
21
|
+
|
|
22
|
+
export const MIME = Object.freeze({
|
|
23
|
+
'.jpg': 'image/jpeg',
|
|
24
|
+
'.jpeg': 'image/jpeg',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.webp': 'image/webp',
|
|
27
|
+
'.avif': 'image/avif',
|
|
28
|
+
'.gif': 'image/gif',
|
|
29
|
+
'.bmp': 'image/bmp',
|
|
30
|
+
'.mp4': 'video/mp4',
|
|
31
|
+
'.webm': 'video/webm',
|
|
32
|
+
'.mov': 'video/quicktime',
|
|
33
|
+
'.m4v': 'video/x-m4v',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const MAX_DOWNLOAD = 40 * 1024 * 1024
|
|
37
|
+
const MAX_HTML = 2 * 1024 * 1024
|
|
38
|
+
const MAX_CACHE_BYTES = 200 * 1024 * 1024
|
|
39
|
+
const FETCH_TIMEOUT = 12_000
|
|
40
|
+
const CACHE_DIR_NAME = 'cache'
|
|
41
|
+
|
|
42
|
+
/** 扩展名 → 'image' | 'video' | null */
|
|
43
|
+
export function kindOf(name) {
|
|
44
|
+
const ext = extname(String(name ?? '')).toLowerCase()
|
|
45
|
+
if (IMAGE_EXT.includes(ext)) return 'image'
|
|
46
|
+
if (VIDEO_EXT.includes(ext)) return 'video'
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 合法的素材文件名:只有 basename,没有分隔符,扩展名在白名单内。 */
|
|
51
|
+
export function isMediaName(name) {
|
|
52
|
+
if (typeof name !== 'string' || !name) return false
|
|
53
|
+
if (name !== basename(name)) return false
|
|
54
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) return false
|
|
55
|
+
if (name.startsWith('.')) return false
|
|
56
|
+
return kindOf(name) !== null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function cacheDir(home) {
|
|
60
|
+
return join(home, 'intertidal', CACHE_DIR_NAME)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 目录里有哪些素材(只认白名单扩展名,跳过隐藏文件)。 */
|
|
64
|
+
export async function scanDir(dir) {
|
|
65
|
+
if (typeof dir !== 'string' || !isAbsolute(dir)) {
|
|
66
|
+
return { dir, ok: false, count: 0, images: 0, videos: 0, names: [], error: 'not-absolute' }
|
|
67
|
+
}
|
|
68
|
+
let entries
|
|
69
|
+
try {
|
|
70
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { dir, ok: false, count: 0, images: 0, videos: 0, names: [], error: String(error?.code ?? error) }
|
|
73
|
+
}
|
|
74
|
+
const names = []
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.isFile()) continue
|
|
77
|
+
if (!isMediaName(entry.name)) continue
|
|
78
|
+
names.push(entry.name)
|
|
79
|
+
}
|
|
80
|
+
names.sort()
|
|
81
|
+
const images = names.filter((name) => kindOf(name) === 'image').length
|
|
82
|
+
return {
|
|
83
|
+
dir,
|
|
84
|
+
ok: true,
|
|
85
|
+
count: names.length,
|
|
86
|
+
images,
|
|
87
|
+
videos: names.length - images,
|
|
88
|
+
names: names.slice(0, 50),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 上一个挑到的文件(避免"随机"连着两次同一张)。 */
|
|
93
|
+
const lastPicked = new Map()
|
|
94
|
+
|
|
95
|
+
/** 从目录里挑一个素材;空目录返回 null。 */
|
|
96
|
+
export async function pickFromDir(dir, { shuffle = true } = {}) {
|
|
97
|
+
const scan = await scanDir(dir)
|
|
98
|
+
if (!scan.ok || scan.count === 0) return null
|
|
99
|
+
const previous = lastPicked.get(dir)
|
|
100
|
+
const pool = !shuffle && Array.isArray(scan.names) && scan.names.length ? scan.names : scan.names
|
|
101
|
+
let candidates = pool
|
|
102
|
+
if (pool.length > 1 && previous) candidates = pool.filter((name) => name !== previous)
|
|
103
|
+
const name = candidates[Math.floor(Math.random() * candidates.length)] ?? pool[0]
|
|
104
|
+
lastPicked.set(dir, name)
|
|
105
|
+
return { kind: kindOf(name), name, url: `/intertidal/media/file?name=${encodeURIComponent(name)}`, source: 'dir' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 目录内一个文件的绝对路径;不合法或不存在返回 null(围栏的第二道:路径必须真的在目录下)。 */
|
|
109
|
+
export async function resolveDirFile(dir, name) {
|
|
110
|
+
if (typeof dir !== 'string' || !isAbsolute(dir)) return null
|
|
111
|
+
if (!isMediaName(name)) return null
|
|
112
|
+
const target = resolve(dir, name)
|
|
113
|
+
if (!target.startsWith(resolve(dir) + sep)) return null
|
|
114
|
+
try {
|
|
115
|
+
const info = await stat(target)
|
|
116
|
+
if (!info.isFile()) return null
|
|
117
|
+
return { path: target, size: info.size, mime: MIME[extname(name).toLowerCase()] ?? 'application/octet-stream' }
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// url 模式:下载一次、本地缓存、之后离线可用
|
|
125
|
+
|
|
126
|
+
function cacheKey(url) {
|
|
127
|
+
return createHash('sha1').update(url).digest('hex')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 只允许公网 http/https:拒绝 loopback 与私有网段(防 SSRF)。 */
|
|
131
|
+
export function isFetchableUrl(text) {
|
|
132
|
+
let parsed
|
|
133
|
+
try {
|
|
134
|
+
parsed = new URL(String(text))
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
139
|
+
const host = parsed.hostname.toLowerCase()
|
|
140
|
+
if (!host) return false
|
|
141
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return false
|
|
142
|
+
if (host === '::1' || host === '[::1]') return false
|
|
143
|
+
if (/^127\./.test(host)) return false
|
|
144
|
+
if (/^10\./.test(host)) return false
|
|
145
|
+
if (/^192\.168\./.test(host)) return false
|
|
146
|
+
if (/^169\.254\./.test(host)) return false
|
|
147
|
+
const private172 = /^172\.(\d{1,3})\./.exec(host)
|
|
148
|
+
if (private172) {
|
|
149
|
+
const second = Number(private172[1])
|
|
150
|
+
if (second >= 16 && second <= 31) return false
|
|
151
|
+
}
|
|
152
|
+
if (host.startsWith('[')) return false // 其余 IPv6 一律不接
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function fetchWithLimit(url, { limit, accept }) {
|
|
157
|
+
const controller = new AbortController()
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT)
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(url, {
|
|
161
|
+
signal: controller.signal,
|
|
162
|
+
redirect: 'follow',
|
|
163
|
+
headers: {
|
|
164
|
+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) dsh-intertidal/0.2',
|
|
165
|
+
'accept': accept,
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status })
|
|
169
|
+
const length = Number(res.headers.get('content-length') ?? 0)
|
|
170
|
+
if (Number.isFinite(length) && length > limit) throw new Error('内容太大')
|
|
171
|
+
const buffer = Buffer.from(await res.arrayBuffer())
|
|
172
|
+
if (buffer.length > limit) throw new Error('内容太大')
|
|
173
|
+
return { buffer, contentType: String(res.headers.get('content-type') ?? '') }
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timer)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 从一个网页 HTML 里挖出最像"首图"的地址。 */
|
|
180
|
+
export function extractImageUrl(html, baseUrl) {
|
|
181
|
+
const text = String(html ?? '')
|
|
182
|
+
const patterns = [
|
|
183
|
+
/<meta[^>]+property=["']og:image(?::secure_url|:url)?["'][^>]*content=["']([^"']+)["']/i,
|
|
184
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]*property=["']og:image(?::secure_url|:url)?["']/i,
|
|
185
|
+
/<meta[^>]+name=["']twitter:image(?::src)?["'][^>]*content=["']([^"']+)["']/i,
|
|
186
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]*name=["']twitter:image(?::src)?["']/i,
|
|
187
|
+
/<link[^>]+rel=["']image_src["'][^>]*href=["']([^"']+)["']/i,
|
|
188
|
+
/<img[^>]+src=["']([^"']+)["']/i,
|
|
189
|
+
]
|
|
190
|
+
for (const pattern of patterns) {
|
|
191
|
+
const match = pattern.exec(text)
|
|
192
|
+
if (!match) continue
|
|
193
|
+
try {
|
|
194
|
+
const absolute = new URL(decodeURIComponent(match[1].replace(/&/g, '&')), baseUrl)
|
|
195
|
+
if (absolute.protocol === 'http:' || absolute.protocol === 'https:') return absolute.href
|
|
196
|
+
} catch {
|
|
197
|
+
/* 试下一条 */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extFromContentType(contentType, fallbackUrl) {
|
|
204
|
+
const type = contentType.split(';')[0].trim().toLowerCase()
|
|
205
|
+
const byType = {
|
|
206
|
+
'image/jpeg': '.jpg',
|
|
207
|
+
'image/png': '.png',
|
|
208
|
+
'image/webp': '.webp',
|
|
209
|
+
'image/avif': '.avif',
|
|
210
|
+
'image/gif': '.gif',
|
|
211
|
+
'image/bmp': '.bmp',
|
|
212
|
+
'video/mp4': '.mp4',
|
|
213
|
+
'video/webm': '.webm',
|
|
214
|
+
'video/quicktime': '.mov',
|
|
215
|
+
}[type]
|
|
216
|
+
if (byType) return byType
|
|
217
|
+
try {
|
|
218
|
+
const ext = extname(new URL(fallbackUrl).pathname).toLowerCase()
|
|
219
|
+
if (MIME[ext]) return ext
|
|
220
|
+
} catch {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
223
|
+
return null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 把总量压回上限(按修改时间从旧到新删)。 */
|
|
227
|
+
export async function pruneCache(dir, maxBytes = MAX_CACHE_BYTES) {
|
|
228
|
+
let entries
|
|
229
|
+
try {
|
|
230
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
231
|
+
} catch {
|
|
232
|
+
return { removed: 0 }
|
|
233
|
+
}
|
|
234
|
+
const files = []
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (!entry.isFile()) continue
|
|
237
|
+
try {
|
|
238
|
+
const info = await stat(join(dir, entry.name))
|
|
239
|
+
files.push({ name: entry.name, size: info.size, mtime: info.mtimeMs })
|
|
240
|
+
} catch {
|
|
241
|
+
/* 读不到就跳过 */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
files.sort((a, b) => a.mtime - b.mtime)
|
|
245
|
+
let total = files.reduce((sum, file) => sum + file.size, 0)
|
|
246
|
+
let removed = 0
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
if (total <= maxBytes) break
|
|
249
|
+
try {
|
|
250
|
+
await unlink(join(dir, file.name))
|
|
251
|
+
total -= file.size
|
|
252
|
+
removed += 1
|
|
253
|
+
} catch {
|
|
254
|
+
/* ignore */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { removed }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 解析一个链接 → 缓存中的素材。
|
|
262
|
+
* 直链图片/视频直接下载;网页则先挖 og:image 再下载。
|
|
263
|
+
* @returns {{kind,name,url,source}|null}
|
|
264
|
+
*/
|
|
265
|
+
export async function resolveUrlToCache(url, cacheDirectory) {
|
|
266
|
+
if (!isFetchableUrl(url)) return null
|
|
267
|
+
const key = cacheKey(url)
|
|
268
|
+
const directory = cacheDirectory
|
|
269
|
+
|
|
270
|
+
// 已经缓存过就直接用(同一个链接不会重复下载)
|
|
271
|
+
try {
|
|
272
|
+
const names = await readdir(directory)
|
|
273
|
+
const hit = names.find((name) => name.startsWith(`${key}.`) && kindOf(name))
|
|
274
|
+
if (hit) {
|
|
275
|
+
const info = await stat(join(directory, hit))
|
|
276
|
+
if (info.size > 0) {
|
|
277
|
+
return { kind: kindOf(hit), name: hit, url: `/intertidal/media/file?name=${encodeURIComponent(hit)}`, source: 'url' }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
/* 目录还不存在,往下走 */
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let fetched
|
|
285
|
+
try {
|
|
286
|
+
fetched = await fetchWithLimit(url, { limit: MAX_HTML, accept: 'image/*,video/*,text/html;q=0.8,*/*;q=0.5' })
|
|
287
|
+
} catch {
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const isHtml = fetched.contentType.startsWith('text/html')
|
|
292
|
+
let payload = fetched.buffer
|
|
293
|
+
let contentType = fetched.contentType
|
|
294
|
+
let sourceUrl = url
|
|
295
|
+
|
|
296
|
+
if (isHtml) {
|
|
297
|
+
const imageUrl = extractImageUrl(fetched.buffer.toString('utf8'), url)
|
|
298
|
+
if (!imageUrl || !isFetchableUrl(imageUrl)) return null
|
|
299
|
+
try {
|
|
300
|
+
const image = await fetchWithLimit(imageUrl, { limit: MAX_DOWNLOAD, accept: 'image/*,video/*,*/*;q=0.5' })
|
|
301
|
+
payload = image.buffer
|
|
302
|
+
contentType = image.contentType
|
|
303
|
+
sourceUrl = imageUrl
|
|
304
|
+
} catch {
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const ext = extFromContentType(contentType, sourceUrl)
|
|
310
|
+
if (!ext) return null
|
|
311
|
+
|
|
312
|
+
const name = `${key}${ext}`
|
|
313
|
+
try {
|
|
314
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
315
|
+
await writeFile(join(directory, name), payload, { mode: 0o600 })
|
|
316
|
+
} catch {
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
void pruneCache(directory)
|
|
320
|
+
return { kind: kindOf(name), name, url: `/intertidal/media/file?name=${encodeURIComponent(name)}`, source: 'url' }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 缓存目录里挑一个(url 模式下的随机换图)。 */
|
|
324
|
+
export async function pickFromCache(directory) {
|
|
325
|
+
let names
|
|
326
|
+
try {
|
|
327
|
+
names = await readdir(directory)
|
|
328
|
+
} catch {
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
const usable = names.filter(isMediaName)
|
|
332
|
+
if (!usable.length) return null
|
|
333
|
+
const name = usable[Math.floor(Math.random() * usable.length)]
|
|
334
|
+
return { kind: kindOf(name), name, url: `/intertidal/media/file?name=${encodeURIComponent(name)}`, source: 'url' }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 打开一个媒体文件流(支持 Range,视频才能拖/循环)。 */
|
|
338
|
+
export async function openMediaFile(directory, name) {
|
|
339
|
+
if (!isMediaName(name)) return null
|
|
340
|
+
const target = resolve(directory, name)
|
|
341
|
+
if (!target.startsWith(resolve(directory) + sep)) return null
|
|
342
|
+
try {
|
|
343
|
+
const info = await stat(target)
|
|
344
|
+
if (!info.isFile()) return null
|
|
345
|
+
return {
|
|
346
|
+
stream: (range) => createReadStream(target, range),
|
|
347
|
+
size: info.size,
|
|
348
|
+
mime: MIME[extname(name).toLowerCase()] ?? 'application/octet-stream',
|
|
349
|
+
path: target,
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 读一点点字节做图片尺寸嗅探(只认 PNG/JPEG/GIF/WebP 四类头,认不出就返回 null)。 */
|
|
357
|
+
export async function sniffSize(path) {
|
|
358
|
+
let buffer
|
|
359
|
+
try {
|
|
360
|
+
const handle = await readFile(path)
|
|
361
|
+
buffer = handle.subarray(0, 64 * 1024)
|
|
362
|
+
} catch {
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
// PNG
|
|
367
|
+
if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47) {
|
|
368
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
|
369
|
+
}
|
|
370
|
+
// GIF
|
|
371
|
+
if (buffer.length >= 10 && buffer.toString('ascii', 0, 3) === 'GIF') {
|
|
372
|
+
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }
|
|
373
|
+
}
|
|
374
|
+
// WebP
|
|
375
|
+
if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
|
|
376
|
+
const chunk = buffer.toString('ascii', 12, 16)
|
|
377
|
+
if (chunk === 'VP8X') {
|
|
378
|
+
const width = 1 + (buffer.readUIntLE(24, 3) & 0xffffff)
|
|
379
|
+
const height = 1 + (buffer.readUIntLE(27, 3) & 0xffffff)
|
|
380
|
+
return { width, height }
|
|
381
|
+
}
|
|
382
|
+
if (chunk === 'VP8 ') {
|
|
383
|
+
return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
|
|
384
|
+
}
|
|
385
|
+
if (chunk === 'VP8L') {
|
|
386
|
+
const bits = buffer.readUInt32LE(21)
|
|
387
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// JPEG:扫 SOFn
|
|
391
|
+
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
392
|
+
let offset = 2
|
|
393
|
+
while (offset + 9 < buffer.length) {
|
|
394
|
+
if (buffer[offset] !== 0xff) {
|
|
395
|
+
offset += 1
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
const marker = buffer[offset + 1]
|
|
399
|
+
const length = buffer.readUInt16BE(offset + 2)
|
|
400
|
+
const isSof = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)
|
|
401
|
+
if (isSof) {
|
|
402
|
+
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
|
|
403
|
+
}
|
|
404
|
+
offset += 2 + length
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
/* 认不出就当没有 */
|
|
409
|
+
}
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export const MEDIA_LIMITS = Object.freeze({ MAX_DOWNLOAD, MAX_HTML, MAX_CACHE_BYTES })
|
|
414
|
+
|
|
415
|
+
/** 给测试用:清掉"上次挑到哪个"的记忆。 */
|
|
416
|
+
export function forgetPicks() {
|
|
417
|
+
lastPicked.clear()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 默认主页(测试可改 $DSH_HOME 后再取)。 */
|
|
421
|
+
export function defaultDshHome() {
|
|
422
|
+
return process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
423
|
+
}
|