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/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 = extensions.VRM && extensions.VRM.materialProperties;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-vrmpet3d",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness 的 VRM 3D 桌宠:任务状态联动、独立悬浮窗、模型动作与 AI 碎碎念。",
5
5
  "type": "module",
6
6
  "license": "MIT",