dsh-plugin-vrmpet3d 0.1.0 → 0.1.2
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 +2 -0
- package/desktop/main.js +47 -4
- package/desktop/preload.js +7 -2
- package/lib/client.core.js +487 -53
- package/lib/client.js +391 -57
- package/lib/client.menu.js +14 -4
- package/lib/client.scene.js +134 -7
- package/lib/config-page.html +82 -45
- package/lib/config-page.js +1271 -252
- package/lib/core.js +487 -53
- package/lib/desktop-page.js +298 -44
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +172 -101
- package/lib/index.js +196 -58
- package/lib/menu.js +14 -4
- package/lib/model-picker.js +139 -49
- package/lib/picker-page.html +34 -8
- package/lib/picker-page.js +192 -11
- package/lib/scene.js +134 -7
- package/lib/setup-page.html +68 -49
- package/lib/setup-page.js +395 -42
- package/lib/vrm-info.js +16 -1
- package/lib/whale.js +191 -0
- package/package.json +1 -1
package/lib/vrm-info.js
CHANGED
|
@@ -437,7 +437,7 @@ export function describeGlb(glb, label) {
|
|
|
437
437
|
(gltf.materials || []).forEach((material, index) => {
|
|
438
438
|
const pbr = material.pbrMetallicRoughness || {};
|
|
439
439
|
const extensions = material.extensions || {};
|
|
440
|
-
const mtoon =
|
|
440
|
+
const mtoon = vrm0 && (vrm0.materialProperties || [])[index];
|
|
441
441
|
const mtoon1 = extensions.VRMC_materials_mtoon || null;
|
|
442
442
|
report.materials.push({
|
|
443
443
|
index,
|
|
@@ -826,6 +826,21 @@ export function cacheStats() {
|
|
|
826
826
|
*/
|
|
827
827
|
export function compactModelInfo(info) {
|
|
828
828
|
return {
|
|
829
|
+
toon: {
|
|
830
|
+
total: (info.materials || []).length,
|
|
831
|
+
materials: (info.materials || []).filter((m) => m.mtoon1 || (m.mtoon && m.mtoon.shader === 'VRM/MToon')).map((m) => {
|
|
832
|
+
// Same VRM 0 -> MToon 1 conversion as the pinned three-vrm loader.
|
|
833
|
+
const legacy = m.mtoon && m.mtoon.floatProperties || {};
|
|
834
|
+
const shift = legacy._ShadeShift ?? 0;
|
|
835
|
+
const toony = (legacy._ShadeToony ?? 0.9) + (1 - (legacy._ShadeToony ?? 0.9)) * (0.5 + 0.5 * shift);
|
|
836
|
+
return {
|
|
837
|
+
name: m.name,
|
|
838
|
+
shadingToonyFactor: m.mtoon1 ? (m.mtoon1.shadingToonyFactor ?? 0.9) : toony,
|
|
839
|
+
shadingShiftFactor: m.mtoon1 ? (m.mtoon1.shadingShiftFactor ?? 0) : -shift - (1 - toony),
|
|
840
|
+
outline: m.mtoon1 ? Boolean(m.mtoon1.outlineWidthMode && m.mtoon1.outlineWidthMode !== 'none' && m.mtoon1.outlineWidthFactor > 0)
|
|
841
|
+
: Boolean(m.mtoon.floatProperties._OutlineWidthMode && m.mtoon.floatProperties._OutlineWidth > 0),
|
|
842
|
+
}; }),
|
|
843
|
+
},
|
|
829
844
|
supported: info.supported === true,
|
|
830
845
|
vrmSpec: info.vrmSpec,
|
|
831
846
|
generator: info.generator,
|
package/lib/whale.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH's own animated whale mark: where it lives, how to read it, and what to fall back on.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT A FILE IN THE PACKAGE
|
|
5
|
+
* ------------------------------------
|
|
6
|
+
* The mark is the 60-frame APNG the app animates behind its running label, and it belongs to DeepSeek, not
|
|
7
|
+
* to this project. `docs/ASSET-CONTRACT.md` §1 is the rule for an asset the project did not make: load it
|
|
8
|
+
* from where it already lives, never copy it into the tree. It lives as a `mask:url(data:image/png;base64,…)`
|
|
9
|
+
* declaration inside DSH's own chat client bundle —
|
|
10
|
+
*
|
|
11
|
+
* <install>/resources/app.asar/dsh/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js
|
|
12
|
+
*
|
|
13
|
+
* — so the running DSH IS the source, and the honest way to serve it is to read it out of the installation
|
|
14
|
+
* that is running this plugin. That also makes the mark track the app: an updated DSH hands over its updated
|
|
15
|
+
* mark, with nothing to re-extract by hand and nothing committed.
|
|
16
|
+
*
|
|
17
|
+
* The host can read it: Electron's `fs` resolves the member inside `app.asar` transparently, and this half
|
|
18
|
+
* runs in the same process that already reads that archive to serve `/favicon.svg` and the client bundles.
|
|
19
|
+
* Under a plain-node deployment (`dsh web` on a terminal) there is no asar to read, but the same member is a
|
|
20
|
+
* real file under the installation's `node_modules`, which is the second half of `whaleBundleCandidates`.
|
|
21
|
+
*
|
|
22
|
+
* THE CHAIN, IN ORDER (the operator, 2026-10-16: 「宿主最早加载插件的时候就抄下来,抄不到就逐级回落」)
|
|
23
|
+
* -------------------------------------------------------------------------------------------------------
|
|
24
|
+
* 1. the running DSH's own bundle, parsed out of its CSS declaration;
|
|
25
|
+
* 2. `assets/whale-tail.apng` — a checkout that pre-seeded one (`scripts/whale-asset.mjs`), or a user who
|
|
26
|
+
* dropped their own mark there;
|
|
27
|
+
* 3. the copy a PREVIOUS activation cached in the plugin's own state directory, which is what keeps the
|
|
28
|
+
* mark alive across a DSH layout change this code has not learned yet;
|
|
29
|
+
* 4. nothing: `/whale` answers 404 and both prompts fall back to the static SVG, which is the app's own
|
|
30
|
+
* fallback and a complete mark on its own.
|
|
31
|
+
*
|
|
32
|
+
* Rung 1 runs at ACTIVATION, once, and its result is what the route serves: 552 KB read and 21 KB decoded,
|
|
33
|
+
* measured in single-digit milliseconds, against a request that would otherwise repeat it per prompt. The
|
|
34
|
+
* cached copy is refreshed on every successful rung 1, so it can never be the stale answer while the source
|
|
35
|
+
* is readable — it is a fallback, not a cache in front of the source.
|
|
36
|
+
*
|
|
37
|
+
* Every path is an ARGUMENT rather than an environment read, which is what lets `verify-model.mjs` drive the
|
|
38
|
+
* whole chain — order, decoding, rejection of a still PNG, the cached copy — without a DSH installation.
|
|
39
|
+
*
|
|
40
|
+
* Host-side only: nothing here is served to a browser (`lib/` ships it, `WINDOW_SOURCES` does not).
|
|
41
|
+
*/
|
|
42
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
43
|
+
import { dirname, join } from 'node:path';
|
|
44
|
+
|
|
45
|
+
/** The declaration DSH's chat stylesheet carries the mark in. */
|
|
46
|
+
export const WHALE_MARKER = 'mask:url(data:image/png;base64,';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The member of a DSH installation that carries it, relative to whichever root it is found under:
|
|
50
|
+
* `<app.asar>/dsh/node_modules/…` for the packaged app, `<install>/node_modules/…` for a plain install.
|
|
51
|
+
*/
|
|
52
|
+
export const WHALE_BUNDLE_MEMBER = 'node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js';
|
|
53
|
+
|
|
54
|
+
/** The name the mark is kept under, both in `assets/` and in the state directory. */
|
|
55
|
+
export const WHALE_FILE_NAME = 'whale-tail.apng';
|
|
56
|
+
|
|
57
|
+
/** A PNG signature, and the two chunks that make a PNG an APNG. */
|
|
58
|
+
const PNG_SIGNATURE = '89504e470d0a1a0a';
|
|
59
|
+
|
|
60
|
+
/** Characters that end the base64 payload in a CSS declaration. */
|
|
61
|
+
const MARKER_END = [')', '"', "'", ';', ' ', '\n'];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Is this buffer the ANIMATED whale mark?
|
|
65
|
+
*
|
|
66
|
+
* Deliberately not a PNG decoder: it checks the signature, that an `acTL` chunk is present and that at least
|
|
67
|
+
* one `fcTL` frame control follows. That is enough to tell the animated mark from the still PNG that would
|
|
68
|
+
* silently never animate — which is the failure this route exists to make visible — without carrying an image
|
|
69
|
+
* library into the host half.
|
|
70
|
+
*
|
|
71
|
+
* @param {Buffer} bytes candidate image bytes
|
|
72
|
+
* @returns {{bytes: Buffer, frames: number, width: number, height: number}|{error: string}}
|
|
73
|
+
*/
|
|
74
|
+
export function inspectWhaleImage(bytes) {
|
|
75
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 24) {
|
|
76
|
+
return { error: `the mark is ${bytes ? bytes.length : 0} bytes, too short to be an APNG` };
|
|
77
|
+
}
|
|
78
|
+
if (bytes.subarray(0, 8).toString('hex') !== PNG_SIGNATURE) return { error: 'the mark is not a PNG' };
|
|
79
|
+
const text = bytes.toString('latin1');
|
|
80
|
+
const frames = (text.match(/fcTL/g) || []).length;
|
|
81
|
+
if (!text.includes('acTL') || frames === 0) return { error: 'the mark is a still PNG, not the animated one' };
|
|
82
|
+
return { bytes, frames, width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read the mark out of a chat client bundle's text.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} bundle the whole `client.js` source
|
|
89
|
+
* @returns {{bytes: Buffer, frames: number, width: number, height: number}|{error: string}}
|
|
90
|
+
*/
|
|
91
|
+
export function parseWhaleBundle(bundle) {
|
|
92
|
+
const at = bundle.indexOf(WHALE_MARKER);
|
|
93
|
+
if (at < 0) return { error: `the bundle carries no ${WHALE_MARKER} declaration` };
|
|
94
|
+
const start = at + WHALE_MARKER.length;
|
|
95
|
+
let end = start;
|
|
96
|
+
while (end < bundle.length && !MARKER_END.includes(bundle[end])) end += 1;
|
|
97
|
+
const base64 = bundle.slice(start, end);
|
|
98
|
+
if (base64.length === 0) return { error: 'the embedded mask declaration is empty' };
|
|
99
|
+
let bytes;
|
|
100
|
+
try {
|
|
101
|
+
bytes = Buffer.from(base64, 'base64');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return { error: `the embedded mask is not valid base64: ${error.message}` };
|
|
104
|
+
}
|
|
105
|
+
return inspectWhaleImage(bytes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every place the chat bundle may be, in the order worth trying.
|
|
110
|
+
*
|
|
111
|
+
* `asar` is the archive itself (`…/resources/app.asar`), `resourcesPath` is Electron's own answer for where
|
|
112
|
+
* `resources/` is, and `installRoot` is what the launcher's search finds — the directory holding the
|
|
113
|
+
* executable and `resources/`. All three are optional, and each contributes the layouts that make sense for
|
|
114
|
+
* it, so a packaged app, a plain install and a test fixture are all just "some roots".
|
|
115
|
+
*
|
|
116
|
+
* @param {{asar?: string, resourcesPath?: string, installRoot?: string}} roots
|
|
117
|
+
* @returns {string[]} candidate file paths, deduplicated, in preference order
|
|
118
|
+
*/
|
|
119
|
+
export function whaleBundleCandidates({ asar, resourcesPath, installRoot } = {}) {
|
|
120
|
+
const candidates = [];
|
|
121
|
+
const push = (base) => {
|
|
122
|
+
if (typeof base === 'string' && base) candidates.push(join(base, WHALE_BUNDLE_MEMBER));
|
|
123
|
+
};
|
|
124
|
+
// The packaged application: the whole DSH tree sits under `dsh/` inside the archive.
|
|
125
|
+
push(asar);
|
|
126
|
+
if (asar) push(join(asar, 'dsh'));
|
|
127
|
+
if (resourcesPath) {
|
|
128
|
+
push(join(resourcesPath, 'app.asar'));
|
|
129
|
+
push(join(resourcesPath, 'app.asar', 'dsh'));
|
|
130
|
+
}
|
|
131
|
+
// A plain install (npm, or the CLI's own tree): the packages are real directories.
|
|
132
|
+
push(installRoot);
|
|
133
|
+
if (installRoot) {
|
|
134
|
+
push(join(installRoot, 'resources', 'app.asar'));
|
|
135
|
+
push(join(installRoot, 'resources', 'app.asar', 'dsh'));
|
|
136
|
+
}
|
|
137
|
+
return [...new Set(candidates)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Cache a copy beside the settings, best effort: a read-only or missing directory must not cost the mark. */
|
|
141
|
+
function writeStateCopy(file, bytes, log) {
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
144
|
+
writeFileSync(file, bytes);
|
|
145
|
+
return true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
log?.(`vrmpet3d: could not cache the whale mark at ${file}: ${error.message}`);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Read one whole file, or a reason why not — the two file rungs share this so their reports match. */
|
|
153
|
+
function readWholeFile(file) {
|
|
154
|
+
try {
|
|
155
|
+
return { bytes: readFileSync(file) };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { error: error.code === 'ENOENT' ? 'absent' : error.message };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Walk the chain and return the mark.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} options
|
|
165
|
+
* asar, resourcesPath, installRoot where to look for DSH's own bundle (see {@link whaleBundleCandidates})
|
|
166
|
+
* localFile the plugin's own `assets/whale-tail.apng`
|
|
167
|
+
* stateFile the fallback copy this keeps in the plugin's state directory
|
|
168
|
+
* log optional `(message) => void` for a failed state copy
|
|
169
|
+
* @returns {{bytes: Buffer, frames: number, width: number, height: number, source: string}|{error: string, tried: string[]}}
|
|
170
|
+
*/
|
|
171
|
+
export function resolveWhaleMark({ asar, resourcesPath, installRoot, localFile, stateFile, log } = {}) {
|
|
172
|
+
const tried = [];
|
|
173
|
+
for (const bundle of whaleBundleCandidates({ asar, resourcesPath, installRoot })) {
|
|
174
|
+
if (!existsSync(bundle)) { tried.push(`${bundle} (absent)`); continue; }
|
|
175
|
+
const file = readWholeFile(bundle);
|
|
176
|
+
if (file.error) { tried.push(`${bundle} (${file.error})`); continue; }
|
|
177
|
+
const parsed = parseWhaleBundle(file.bytes.toString('utf8'));
|
|
178
|
+
if (parsed.error) { tried.push(`${bundle} (${parsed.error})`); continue; }
|
|
179
|
+
if (stateFile) writeStateCopy(stateFile, parsed.bytes, log);
|
|
180
|
+
return { ...parsed, source: bundle };
|
|
181
|
+
}
|
|
182
|
+
for (const [file, label] of [[localFile, "the plugin's own mark"], [stateFile, 'the copy a previous activation left']]) {
|
|
183
|
+
if (!file) continue;
|
|
184
|
+
const read = readWholeFile(file);
|
|
185
|
+
if (read.error) { tried.push(`${file} (${read.error})`); continue; }
|
|
186
|
+
const parsed = inspectWhaleImage(read.bytes);
|
|
187
|
+
if (parsed.error) { tried.push(`${file} (${parsed.error})`); continue; }
|
|
188
|
+
return { ...parsed, source: `${label}: ${file}` };
|
|
189
|
+
}
|
|
190
|
+
return { error: 'no animated mark could be read', tried };
|
|
191
|
+
}
|