dsh-pet-live2d 3.1.3 → 3.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/CHANGELOG.md +14 -0
- package/README.md +26 -9
- package/lib/client.js +547 -58
- package/lib/index.js +86 -1
- package/lib/sound-files.js +231 -0
- package/package.json +3 -3
- package/pets/ds-whale-girl/README.md +2 -0
- package/pets/ds-whale-girl/pet.json +7 -1
package/lib/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
30
30
|
import { createDisplayLayer } from './display.js'
|
|
31
31
|
import { desktopHint, desktopSupported, resolveDesktopBinary } from './desktop.js'
|
|
32
32
|
import { readSettings, writeSettings } from './settings.js'
|
|
33
|
+
import { SOUND_PHASES, MAX_SOUND_BODY, decodeSound, readSound, resetSound, writeSound } from './sound-files.js'
|
|
33
34
|
|
|
34
35
|
export const name = 'live2d-pet'
|
|
35
36
|
|
|
@@ -547,6 +548,8 @@ export function scanPet(dir, id) {
|
|
|
547
548
|
// 台词:宠物自己的声音(问候 / 点击 / 摸头 / 摸尾巴 / 转晕 / 归位 / 每个相位)。
|
|
548
549
|
// 用户能在设置里逐条改,改过的存浏览器;这里是**默认值**。
|
|
549
550
|
lines: typeof block.lines === 'object' && block.lines !== null ? block.lines : {},
|
|
551
|
+
// null 表示旧宠物没有声明提示音,浏览器半区才使用内置兜底。
|
|
552
|
+
sounds: typeof block.sounds === 'object' && block.sounds !== null && !Array.isArray(block.sounds) ? block.sounds : null,
|
|
550
553
|
// 互动反应候选(标签:动作的中文名或表情名)。摸头 / 摸尾巴 / 转晕各一组。
|
|
551
554
|
patReactions: readReactionList(block.patReactions),
|
|
552
555
|
tailReactions: readReactionList(block.tailReactions),
|
|
@@ -604,6 +607,8 @@ const BUNDLED_PET_HASHES = {
|
|
|
604
607
|
'18840cd90fe70aa68632c45f77b4af1254595b5f963d261d2cbf34f6fbcaf579',
|
|
605
608
|
// 1.0.1(2.3.0 ~ 2.3.2 随包的那份):内容与上面那条相同,只是换了插件版本号
|
|
606
609
|
'7c6cdb9c9f3d92636c388bffb3229c439cf01a65fe6a8a88499a8e4071f7884a',
|
|
610
|
+
// 1.1.0(会话提示音进入随包宠物之前)
|
|
611
|
+
'31eb2a210d3d99d2f2d53462b8a074ae22209579f7b8fcaa41dd5516e0a861b3',
|
|
607
612
|
],
|
|
608
613
|
}
|
|
609
614
|
|
|
@@ -796,6 +801,7 @@ function sendJson(response, status, payload) {
|
|
|
796
801
|
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
|
797
802
|
response.writeHead(status, {
|
|
798
803
|
'cache-control': 'no-store',
|
|
804
|
+
'x-content-type-options': 'nosniff',
|
|
799
805
|
'content-type': 'application/json; charset=utf-8',
|
|
800
806
|
'content-length': String(body.byteLength),
|
|
801
807
|
})
|
|
@@ -901,6 +907,7 @@ function catalogRoute() {
|
|
|
901
907
|
tailParts: pet.tailParts,
|
|
902
908
|
partNames: pet.partNames,
|
|
903
909
|
lines: pet.lines,
|
|
910
|
+
sounds: pet.sounds,
|
|
904
911
|
patReactions: pet.patReactions,
|
|
905
912
|
tailReactions: pet.tailReactions,
|
|
906
913
|
spinReactions: pet.spinReactions,
|
|
@@ -1464,12 +1471,90 @@ function settingsRoute(home) {
|
|
|
1464
1471
|
}
|
|
1465
1472
|
}
|
|
1466
1473
|
|
|
1474
|
+
// 回环地址仍可能被 DNS 重绑定命中:写入接口只认服务器实际监听端口的本机 Host。
|
|
1475
|
+
function trustedSoundHost(request) {
|
|
1476
|
+
const port = request.socket?.localPort
|
|
1477
|
+
const host = request.headers.host?.toLowerCase()
|
|
1478
|
+
return Number.isInteger(port) && [
|
|
1479
|
+
`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`,
|
|
1480
|
+
].includes(host)
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// 音频接口的 pet id 必须来自当前目录;音频文件本身永不进入宠物目录。
|
|
1484
|
+
function soundRoute(home) {
|
|
1485
|
+
return {
|
|
1486
|
+
kind: 'prefix',
|
|
1487
|
+
path: API + '/sound',
|
|
1488
|
+
handler: async (request, response) => {
|
|
1489
|
+
const fail = (status, error) => sendJson(response, status, { ok: false, error })
|
|
1490
|
+
if (!loopbackOnly(request)) return fail(403, 'forbidden')
|
|
1491
|
+
let parts
|
|
1492
|
+
try {
|
|
1493
|
+
parts = segmentsAfter(new URL(request.url ?? '/', 'http://pet.local').pathname, API + '/sound')?.map(decodeURIComponent)
|
|
1494
|
+
} catch { return fail(400, 'invalid-path') }
|
|
1495
|
+
if (!parts || (parts.length !== 1 && parts.length !== 2)
|
|
1496
|
+
|| !/^[A-Za-z0-9_-]+$/.test(parts[0])
|
|
1497
|
+
|| (parts.length === 2 && !SOUND_PHASES.includes(parts[1]))) return fail(404, 'not-found')
|
|
1498
|
+
const [id, phase] = parts
|
|
1499
|
+
if (!buildCatalog().some((pet) => pet.id === id)) return fail(404, 'not-found')
|
|
1500
|
+
if (request.method === 'POST') {
|
|
1501
|
+
if (phase === undefined) return fail(405, 'method-not-allowed')
|
|
1502
|
+
if (!trustedSoundHost(request)) return fail(403, 'untrusted-host')
|
|
1503
|
+
// 浏览器的跨站请求不能更改本地文件;代理端用自己的 Host 转发。
|
|
1504
|
+
const origin = request.headers.origin
|
|
1505
|
+
if (typeof origin === 'string' && origin !== `http://${request.headers.host}` && origin !== `https://${request.headers.host}`) return fail(403, 'cross-origin')
|
|
1506
|
+
if (!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) return fail(415, 'json-required')
|
|
1507
|
+
let size = 0
|
|
1508
|
+
const chunks = []
|
|
1509
|
+
try {
|
|
1510
|
+
for await (const chunk of request) {
|
|
1511
|
+
size += chunk.length
|
|
1512
|
+
if (size > MAX_SOUND_BODY) return fail(413, 'body-too-large')
|
|
1513
|
+
chunks.push(chunk)
|
|
1514
|
+
}
|
|
1515
|
+
} catch { return fail(400, 'bad-body') }
|
|
1516
|
+
let parsed
|
|
1517
|
+
try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return fail(400, 'bad-body') }
|
|
1518
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return fail(400, 'bad-body')
|
|
1519
|
+
try {
|
|
1520
|
+
if (parsed.action === 'reset' && Object.keys(parsed).length === 1) resetSound(home, id, phase)
|
|
1521
|
+
else if (Object.keys(parsed).length === 1 && Object.hasOwn(parsed, 'base64')) {
|
|
1522
|
+
const bytes = decodeSound(parsed.base64)
|
|
1523
|
+
if (!bytes) return fail(400, 'invalid-audio')
|
|
1524
|
+
writeSound(home, id, phase, bytes)
|
|
1525
|
+
} else return fail(400, 'bad-body')
|
|
1526
|
+
} catch { return fail(500, 'write-failed') }
|
|
1527
|
+
return sendJson(response, 200, { ok: true })
|
|
1528
|
+
}
|
|
1529
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return fail(405, 'method-not-allowed')
|
|
1530
|
+
if (phase === undefined) {
|
|
1531
|
+
const sounds = {}
|
|
1532
|
+
for (const key of SOUND_PHASES) {
|
|
1533
|
+
const sound = readSound(home, id, key)
|
|
1534
|
+
if (sound) sounds[key] = {
|
|
1535
|
+
url: `${API}/sound/${encodeURIComponent(id)}/${key}?token=${sound.token}`,
|
|
1536
|
+
mime: sound.mime, bytes: sound.bytes.length, token: sound.token,
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return sendJson(response, 200, { ok: true, sounds })
|
|
1540
|
+
}
|
|
1541
|
+
const sound = readSound(home, id, phase)
|
|
1542
|
+
if (!sound) return fail(404, 'not-found')
|
|
1543
|
+
response.writeHead(200, {
|
|
1544
|
+
'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
|
|
1545
|
+
'content-type': sound.mime, 'content-length': String(sound.bytes.length),
|
|
1546
|
+
})
|
|
1547
|
+
response.end(request.method === 'HEAD' ? undefined : sound.bytes)
|
|
1548
|
+
},
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1467
1552
|
/** The complete route table this plugin owns. */
|
|
1468
1553
|
export function buildRoutes(hub, display, home) {
|
|
1469
1554
|
const routes = [catalogRoute(), assetRoute(), runtimeRoute(), eventsRoute(hub)]
|
|
1470
1555
|
if (display !== undefined) routes.push(layerRoute(display))
|
|
1471
1556
|
// 共享设置:不依赖显示层(测试里也可能只给 home)。
|
|
1472
|
-
if (typeof home === 'string' && home !== '') routes.push(settingsRoute(home))
|
|
1557
|
+
if (typeof home === 'string' && home !== '') routes.push(settingsRoute(home), soundRoute(home))
|
|
1473
1558
|
return routes
|
|
1474
1559
|
}
|
|
1475
1560
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// 用户上传的相位音频只落在独立目录;请求路径和文件名均由白名单生成。
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
3
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const SOUND_PHASES = Object.freeze(['thinking', 'tool', 'waiting', 'asking', 'helper', 'queued', 'done', 'failed'])
|
|
7
|
+
export const MAX_AUDIO = 1024 * 1024
|
|
8
|
+
export const MAX_SOUND_BODY = 2 * 1024 * 1024
|
|
9
|
+
|
|
10
|
+
// ---- 容器判据:**与桌面端 Rust 宿主逐条一致** -------------------------------
|
|
11
|
+
//
|
|
12
|
+
// `dsh-live2d-pet-desktop/src-tauri/src/host/http.rs` 的 `valid_wav` / `valid_ogg` /
|
|
13
|
+
// `valid_mp3` / `ogg_checksum` 是同一套规则的另一个实现。**改一边必须同时改另一边**,
|
|
14
|
+
// 而且两边都要跑 `tools/sound-vectors.json` 的合同向量(`tools/browser-test/test-sound-upload.mjs`
|
|
15
|
+
// 与 Rust 单测 `sound_vectors_contract` 都读它)—— 判据分叉的症状是"网页端传得进、
|
|
16
|
+
// 桌面端读不出来/相位的已上传消失",而且两边都不报错。
|
|
17
|
+
//
|
|
18
|
+
// 只认魔数是不够的:那样一个 4 字节的 "OggS"、一个乱改过长度的 WAV 都会写进
|
|
19
|
+
// `$DSH_HOME/pet-sounds/`,桌面端却读不出来。
|
|
20
|
+
const u16 = (bytes, at) => bytes[at] | (bytes[at + 1] << 8)
|
|
21
|
+
const u32 = (bytes, at) => (bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16) | (bytes[at + 3] << 24)) >>> 0
|
|
22
|
+
const at4 = (bytes, from, to) => bytes.toString('latin1', from, to)
|
|
23
|
+
|
|
24
|
+
function validWav(bytes) {
|
|
25
|
+
if (bytes.length < 12 || at4(bytes, 0, 4) !== 'RIFF' || at4(bytes, 8, 12) !== 'WAVE') return false
|
|
26
|
+
// RIFF 头里的长度字段:流式录音这类文件常写 0 或 0xFFFFFFFF("长度未知/一直写到尾"),
|
|
27
|
+
// 浏览器照样能播,所以只对这三个值放行,其余仍要求精确匹配(畸形头照旧拒绝)。
|
|
28
|
+
const declared = u32(bytes, 4)
|
|
29
|
+
if (declared !== 0 && declared !== 0xffffffff && declared !== bytes.length - 8) return false
|
|
30
|
+
let offset = 12
|
|
31
|
+
let blockAlign
|
|
32
|
+
let dataSize
|
|
33
|
+
while (offset < bytes.length) {
|
|
34
|
+
if (offset + 8 > bytes.length) return false
|
|
35
|
+
const id = at4(bytes, offset, offset + 4)
|
|
36
|
+
const size = u32(bytes, offset + 4)
|
|
37
|
+
const end = offset + 8 + size
|
|
38
|
+
if (end > bytes.length) return false // 块越界(对应 Rust 的 get() 失败)
|
|
39
|
+
if (id === 'fmt ') {
|
|
40
|
+
if (blockAlign !== undefined || size < 16) return false
|
|
41
|
+
const format = u16(bytes, offset + 8)
|
|
42
|
+
const channels = u16(bytes, offset + 10)
|
|
43
|
+
const rate = u32(bytes, offset + 12)
|
|
44
|
+
const byteRate = u32(bytes, offset + 16)
|
|
45
|
+
const align = u16(bytes, offset + 20)
|
|
46
|
+
const bits = u16(bytes, offset + 22)
|
|
47
|
+
if ((format !== 1 && format !== 3) || channels === 0 || rate === 0 || bits === 0 || bits % 8 !== 0) return false
|
|
48
|
+
if (format === 3 && bits !== 32 && bits !== 64) return false
|
|
49
|
+
if (align !== channels * (bits / 8)) return false
|
|
50
|
+
if (byteRate !== rate * align) return false // rate ≤ 2^32、align ≤ 2^16:JS 双精度够用
|
|
51
|
+
blockAlign = align
|
|
52
|
+
} else if (id === 'data') {
|
|
53
|
+
if (dataSize !== undefined) return false
|
|
54
|
+
dataSize = size
|
|
55
|
+
}
|
|
56
|
+
offset = end + (size % 2) // 块按偶数字节对齐
|
|
57
|
+
if (offset > bytes.length) return false
|
|
58
|
+
}
|
|
59
|
+
if (blockAlign === undefined || dataSize === undefined) return false
|
|
60
|
+
return dataSize > 0 && dataSize % blockAlign === 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Ogg 的 CRC32(多项式 0x04c11db7,高位先行,校验时把 22..26 这 4 字节当 0)。 */
|
|
64
|
+
function oggChecksum(page) {
|
|
65
|
+
let crc = 0
|
|
66
|
+
for (let index = 0; index < page.length; index += 1) {
|
|
67
|
+
const byte = index >= 22 && index < 26 ? 0 : page[index]
|
|
68
|
+
crc = (crc ^ (byte << 24)) >>> 0
|
|
69
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
70
|
+
crc = ((crc << 1) ^ ((crc & 0x80000000) !== 0 ? 0x04c11db7 : 0)) >>> 0
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return crc
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validOgg(bytes) {
|
|
77
|
+
if (bytes.length < 27) return false
|
|
78
|
+
if (at4(bytes, 0, 4) !== 'OggS' || bytes[4] !== 0 || bytes[5] !== 2) return false
|
|
79
|
+
if (u32(bytes, 18) !== 0) return false // 页序号必须是 0:只认第一页
|
|
80
|
+
const segmentCount = bytes[26]
|
|
81
|
+
const segmentsAt = 27
|
|
82
|
+
if (segmentsAt + segmentCount > bytes.length) return false
|
|
83
|
+
const segments = bytes.subarray(segmentsAt, segmentsAt + segmentCount)
|
|
84
|
+
let payloadSize = 0
|
|
85
|
+
for (const size of segments) payloadSize += size
|
|
86
|
+
const start = segmentsAt + segmentCount
|
|
87
|
+
if (start + payloadSize > bytes.length) return false
|
|
88
|
+
const page = bytes.subarray(0, start + payloadSize)
|
|
89
|
+
if (oggChecksum(page) !== u32(bytes, 22)) return false
|
|
90
|
+
let packetEnd = -1
|
|
91
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
92
|
+
if (segments[index] < 255) { packetEnd = index; break }
|
|
93
|
+
}
|
|
94
|
+
if (packetEnd < 0) return false
|
|
95
|
+
let packetSize = 0
|
|
96
|
+
for (let index = 0; index <= packetEnd; index += 1) packetSize += segments[index]
|
|
97
|
+
const packet = page.subarray(start, start + packetSize)
|
|
98
|
+
if (packet.length >= 8 && at4(packet, 0, 8) === 'OpusHead') {
|
|
99
|
+
if (packet.length < 19 || packet[8] !== 1 || packet[9] === 0) return false
|
|
100
|
+
if (packet[18] === 0) return packet[9] <= 2 && packet.length === 19
|
|
101
|
+
return packet[18] !== 255 && packet.length >= 21 + packet[9]
|
|
102
|
+
}
|
|
103
|
+
if (packet.length >= 7 && packet[0] === 0x01 && at4(packet, 1, 7) === 'vorbis') {
|
|
104
|
+
return packet.length === 30
|
|
105
|
+
&& packet[7] === 0 && packet[8] === 0 && packet[9] === 0 && packet[10] === 0
|
|
106
|
+
&& packet[11] !== 0
|
|
107
|
+
&& !(packet[12] === 0 && packet[13] === 0 && packet[14] === 0 && packet[15] === 0)
|
|
108
|
+
&& (packet[28] & 15) >= 6 && (packet[28] >> 4) >= (packet[28] & 15) && (packet[28] >> 4) <= 13
|
|
109
|
+
&& packet[29] === 1
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MP3_BITRATES = {
|
|
115
|
+
mpeg1L1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0],
|
|
116
|
+
mpeg1L2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0],
|
|
117
|
+
mpeg1L3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
|
|
118
|
+
mpeg2L1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0],
|
|
119
|
+
mpeg2L23: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
|
|
120
|
+
}
|
|
121
|
+
const mp3Bitrates = (version, layer) => {
|
|
122
|
+
if (version === 3) return layer === 3 ? MP3_BITRATES.mpeg1L1 : layer === 2 ? MP3_BITRATES.mpeg1L2 : MP3_BITRATES.mpeg1L3
|
|
123
|
+
return layer === 3 ? MP3_BITRATES.mpeg2L1 : MP3_BITRATES.mpeg2L23
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validMp3(bytes) {
|
|
127
|
+
let offset = 0
|
|
128
|
+
if (at4(bytes, 0, 3) === 'ID3') {
|
|
129
|
+
if (bytes.length < 10) return false
|
|
130
|
+
const major = bytes[3]
|
|
131
|
+
const flags = bytes[5]
|
|
132
|
+
if (major < 2 || major > 4 || bytes[4] === 0xff) return false
|
|
133
|
+
for (let index = 6; index < 10; index += 1) {
|
|
134
|
+
if ((bytes[index] & 0x80) !== 0) return false // 尺寸字段是 synchsafe 的
|
|
135
|
+
}
|
|
136
|
+
const reserved = major === 4 ? 0x0f : major === 3 ? 0x1f : 0x3f
|
|
137
|
+
if ((flags & reserved) !== 0) return false
|
|
138
|
+
let tagSize = 0
|
|
139
|
+
for (let index = 6; index < 10; index += 1) tagSize = tagSize * 128 + bytes[index]
|
|
140
|
+
offset = 10 + tagSize + (major === 4 && (flags & 0x10) !== 0 ? 10 : 0)
|
|
141
|
+
}
|
|
142
|
+
if (offset + 4 > bytes.length) return false
|
|
143
|
+
const b1 = bytes[offset + 1]
|
|
144
|
+
const b2 = bytes[offset + 2]
|
|
145
|
+
const b3 = bytes[offset + 3]
|
|
146
|
+
if (bytes[offset] !== 0xff || (b1 & 0xe0) !== 0xe0) return false
|
|
147
|
+
const version = (b1 >> 3) & 3
|
|
148
|
+
const layer = (b1 >> 1) & 3
|
|
149
|
+
const bitrateIndex = (b2 >> 4) & 0x0f
|
|
150
|
+
const rateIndex = (b2 >> 2) & 3
|
|
151
|
+
if (version === 1 || layer === 0 || bitrateIndex === 0 || bitrateIndex === 15 || rateIndex === 3) return false
|
|
152
|
+
const bitrate = mp3Bitrates(version, layer)[bitrateIndex] * 1000
|
|
153
|
+
const rate = Math.floor([44100, 48000, 32000][rateIndex] / (version === 3 ? 1 : version === 2 ? 2 : 4))
|
|
154
|
+
const padding = (b2 >> 1) & 1
|
|
155
|
+
const frameSize = layer === 3
|
|
156
|
+
? Math.floor(12 * bitrate / rate + padding) * 4
|
|
157
|
+
: Math.floor((layer === 1 && version !== 3 ? 72 : 144) * bitrate / rate) + padding
|
|
158
|
+
return (b3 & 3) !== 2 && bytes.length - offset >= frameSize
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function soundMime(bytes) {
|
|
162
|
+
if (validWav(bytes)) return 'audio/wav'
|
|
163
|
+
if (validOgg(bytes)) return 'audio/ogg'
|
|
164
|
+
if (validMp3(bytes)) return 'audio/mpeg'
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function soundDir(home, create) {
|
|
169
|
+
if (create) mkdirSync(home, { recursive: true })
|
|
170
|
+
const dir = join(home, 'pet-sounds')
|
|
171
|
+
if (create && !existsSync(dir)) mkdirSync(dir, { mode: 0o700 })
|
|
172
|
+
const stat = lstatSync(dir)
|
|
173
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe-path')
|
|
174
|
+
return dir
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function soundPath(home, id, phase, create = false) {
|
|
178
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id) || !SOUND_PHASES.includes(phase)) throw new Error('invalid-path')
|
|
179
|
+
return join(soundDir(home, create), `${id}--${phase}.audio`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function readSound(home, id, phase) {
|
|
183
|
+
try {
|
|
184
|
+
const path = soundPath(home, id, phase)
|
|
185
|
+
const stat = lstatSync(path)
|
|
186
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_AUDIO) return undefined
|
|
187
|
+
const bytes = readFileSync(path)
|
|
188
|
+
const mime = soundMime(bytes)
|
|
189
|
+
if (!mime || bytes.length > MAX_AUDIO) return undefined
|
|
190
|
+
return { bytes, mime, token: createHash('sha256').update(bytes).digest('hex') }
|
|
191
|
+
} catch { return undefined }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function writeSound(home, id, phase, bytes) {
|
|
195
|
+
const path = soundPath(home, id, phase, true)
|
|
196
|
+
if (existsSync(path) && (lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile())) throw new Error('unsafe-path')
|
|
197
|
+
const temp = join(soundDir(home, false), `.${randomBytes(16).toString('hex')}.tmp`)
|
|
198
|
+
const fd = openSync(temp, 'wx', 0o600)
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(fd, bytes)
|
|
201
|
+
closeSync(fd)
|
|
202
|
+
renameSync(temp, path)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
try { closeSync(fd) } catch { /* 已关闭 */ }
|
|
205
|
+
try { unlinkSync(temp) } catch { /* 已移走 */ }
|
|
206
|
+
throw error
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function resetSound(home, id, phase) {
|
|
211
|
+
let path
|
|
212
|
+
try { path = soundPath(home, id, phase) }
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error.code === 'ENOENT') return
|
|
215
|
+
throw error
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const stat = lstatSync(path)
|
|
219
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('unsafe-path')
|
|
220
|
+
unlinkSync(path)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error.code !== 'ENOENT') throw error
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function decodeSound(value) {
|
|
227
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > Math.ceil(MAX_AUDIO / 3) * 4 + 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return undefined
|
|
228
|
+
const bytes = Buffer.from(value, 'base64')
|
|
229
|
+
if (bytes.length === 0 || bytes.length > MAX_AUDIO || bytes.toString('base64') !== value || !soundMime(bytes)) return undefined
|
|
230
|
+
return bytes
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pet-live2d",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Live2D 桌宠插件:为 DSH Web GUI 挂一只可拖动、会跟随鼠标、能换动作与表情的 Live2D 宠物。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"dsh-pet-live2d-desktop-darwin-arm64": "3.
|
|
53
|
-
"dsh-pet-live2d-desktop-win32-x64": "3.
|
|
52
|
+
"dsh-pet-live2d-desktop-darwin-arm64": "3.2.0",
|
|
53
|
+
"dsh-pet-live2d-desktop-win32-x64": "3.2.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"esbuild": "^0.25.10",
|
|
@@ -58,6 +58,8 @@
|
|
|
58
58
|
| pet.json 字段 | 变成设置页里的什么 |
|
|
59
59
|
|---|---|
|
|
60
60
|
| `live2d.looksByPhase` | 每个相位的默认池子:一个槽位一个选择 = 那个槽位池子里"权重 1 的一条" |
|
|
61
|
+
| `live2d.lines.phase` | 每个相位的默认气泡台词 |
|
|
62
|
+
| `live2d.sounds` | 每个相位的提示音符 `[频率 Hz, 起始秒数]`;这只宠物的完成、失败、待批准、待回答各有两音 |
|
|
61
63
|
| `live2d.motions` | 每个相位的默认动作(`done` 吹泡泡糖、`failed` 鲸鱼喷水就是这么来的)|
|
|
62
64
|
| `expressionSlots[].options[].pairs` | 该选项的「同时」关系(选了它就一起点亮哪个槽位) |
|
|
63
65
|
| `expressionSlots[].options[].requires` | 该选项的「前提」关系(必须先处于哪个状态才播得出来) |
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"id": "ds-whale-girl",
|
|
4
4
|
"displayName": "DS鲸鱼娘",
|
|
5
5
|
"description": "氵六青的 DS 鲸鱼娘 Live2D 桌宠:8 组动作 + 44 个表情/道具,点她会有反应。",
|
|
6
|
-
"version": "1.1.
|
|
6
|
+
"version": "1.1.1",
|
|
7
7
|
"author": "模型:上善无形 / ZipZipPipe / 氵六青 · 插件:A8Chann",
|
|
8
8
|
"license": "CC BY-NC-SA 4.0 (attribution, NON-COMMERCIAL, share-alike). Character: original OC by 上善无形 (https://www.pixiv.net/users/62155430); DeepSeek maid redesign by ZipZipPipe (https://www.pixiv.net/users/18604994); this Live2D model by 氵六青 (https://space.bilibili.com/11272072), who authorised redistribution here — which does NOT lift the base NC/SA terms. Commercial use requires permission from each rights holder. See NOTICE.md.",
|
|
9
9
|
"homepage": "https://space.bilibili.com/11272072",
|
|
@@ -661,6 +661,12 @@
|
|
|
661
661
|
"failed": "呜…出错了"
|
|
662
662
|
}
|
|
663
663
|
},
|
|
664
|
+
"sounds": {
|
|
665
|
+
"done": [[660, 0], [880, 0.13]],
|
|
666
|
+
"failed": [[370, 0], [277, 0.16]],
|
|
667
|
+
"waiting": [[520, 0], [660, 0.16]],
|
|
668
|
+
"asking": [[520, 0], [780, 0.16]]
|
|
669
|
+
},
|
|
664
670
|
"patReactions": [
|
|
665
671
|
"重锤出击",
|
|
666
672
|
"问号",
|