dsh-wisp 0.6.0 → 1.25.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/README.md +869 -10
- package/assets/canon/attn.webp +0 -0
- package/assets/canon/happy.webp +0 -0
- package/assets/canon/idle.webp +0 -0
- package/assets/canon/poked.webp +0 -0
- package/assets/canon/proud.webp +0 -0
- package/assets/canon/sleepy.webp +0 -0
- package/assets/canon/work.webp +0 -0
- package/assets/classic/attn.webp +0 -0
- package/assets/classic/happy.webp +0 -0
- package/assets/classic/idle.webp +0 -0
- package/assets/classic/poked.webp +0 -0
- package/assets/classic/proud.webp +0 -0
- package/assets/classic/sleepy.webp +0 -0
- package/assets/classic/work.webp +0 -0
- package/assets/deepsea/attn.webp +0 -0
- package/assets/deepsea/happy.webp +0 -0
- package/assets/deepsea/idle.webp +0 -0
- package/assets/deepsea/poked.webp +0 -0
- package/assets/deepsea/proud.webp +0 -0
- package/assets/deepsea/sleepy.webp +0 -0
- package/assets/deepsea/work.webp +0 -0
- package/assets/night/attn.webp +0 -0
- package/assets/night/happy.webp +0 -0
- package/assets/night/idle.webp +0 -0
- package/assets/night/poked.webp +0 -0
- package/assets/night/proud.webp +0 -0
- package/assets/night/sleepy.webp +0 -0
- package/assets/night/work.webp +0 -0
- package/assets/pajama/attn.webp +0 -0
- package/assets/pajama/happy.webp +0 -0
- package/assets/pajama/idle.webp +0 -0
- package/assets/pajama/poked.webp +0 -0
- package/assets/pajama/proud.webp +0 -0
- package/assets/pajama/sleepy.webp +0 -0
- package/assets/pajama/work.webp +0 -0
- package/audit/generation-audit.jsonl +35 -0
- package/build.mjs +93 -30
- package/cordis.patch.yml +2 -0
- package/lib/client.js +1292 -41
- package/lib/client.template.js +1247 -37
- package/lib/index.js +115 -12
- package/package.json +8 -1
- package/tools/CHARACTER-PROMPT.md +221 -3
- package/tools/assets.mjs +39 -16
- package/tools/audit-log.mjs +245 -0
- package/tools/bundle-cost.mjs +105 -0
- package/tools/pack-check.mjs +78 -0
- package/tools/perf.mjs +186 -0
- package/tools/smoke.mjs +577 -0
- package/tools/tiers.mjs +104 -0
- package/verify-wisp.mjs +2681 -872
- package/assets/happy.webp +0 -0
- package/assets/happy_hi.webp +0 -0
- package/assets/happy_md.webp +0 -0
- package/assets/happy_sm.webp +0 -0
- package/assets/idle.webp +0 -0
- package/assets/idle_hi.webp +0 -0
- package/assets/idle_md.webp +0 -0
- package/assets/idle_sm.webp +0 -0
- package/assets/sleepy.webp +0 -0
- package/assets/sleepy_hi.webp +0 -0
- package/assets/sleepy_md.webp +0 -0
- package/assets/sleepy_sm.webp +0 -0
- package/assets/work.webp +0 -0
- package/assets/work_hi.webp +0 -0
- package/assets/work_md.webp +0 -0
- package/assets/work_sm.webp +0 -0
package/lib/index.js
CHANGED
|
@@ -1,19 +1,122 @@
|
|
|
1
|
-
// Host half of
|
|
1
|
+
// Host half of dsh-wisp.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// needs is already available in the browser: the client builtins provide React,
|
|
5
|
-
// a package-owned stylesheet inserter, and the live Slot tree. Keeping the host
|
|
6
|
-
// half empty means the plugin needs no peer dependencies at all, so it loads on
|
|
7
|
-
// a stock DSH desktop install whose profile has an empty `dependencies` map.
|
|
3
|
+
// Two jobs, both small:
|
|
8
4
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
5
|
+
// 1. Answer the browser half's `host.call('checkUpdate')` — the ONE thing the
|
|
6
|
+
// companion cannot do on its own: the browser half's `fetch` is a teaching
|
|
7
|
+
// trap, and this half runs in a vm sandbox whose Node APIs are trapped too.
|
|
8
|
+
// The platform's answer to that is a *service*: `ctx.web.fetch(...)`, the
|
|
9
|
+
// same seam DSH's own `web_fetch` tool uses. Services are the sanctioned way
|
|
10
|
+
// for a sandboxed half to reach a real capability, so the update check goes
|
|
11
|
+
// through it — nothing is bypassed.
|
|
12
|
+
//
|
|
13
|
+
// 2. Nothing else. `web` is declared as an OPTIONAL inject on purpose: a
|
|
14
|
+
// required inject that nothing provides would leave this fiber `waiting`
|
|
15
|
+
// forever, and the companion would simply disappear from the UI.
|
|
12
16
|
|
|
17
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
13
18
|
export const name = 'wisp'
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
/**
|
|
21
|
+
* `web` is optional on purpose. A required inject that nothing provides keeps
|
|
22
|
+
* the fiber in `waiting` forever, and the user's companion would vanish.
|
|
23
|
+
*/
|
|
24
|
+
export const inject = { required: [], optional: ['web'] }
|
|
25
|
+
|
|
26
|
+
/** Where the check reads the published version from. Overridable per install. */
|
|
27
|
+
export const DEFAULT_UPDATE_URL = 'https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json'
|
|
28
|
+
|
|
29
|
+
/** How long a check may take before it is reported as a timeout. */
|
|
30
|
+
const CHECK_TIMEOUT_MS = 8000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fetch the published package.json and pull `version` out of it.
|
|
34
|
+
* @param web - the `web` service, or undefined when it is not loaded.
|
|
35
|
+
* @param url - the manifest URL.
|
|
36
|
+
* @param signal - optional cancellation signal.
|
|
37
|
+
* @returns a plain, JSON-serializable verdict (this crosses the sandbox boundary).
|
|
38
|
+
*/
|
|
39
|
+
export async function readPublishedVersion(web, url, signal) {
|
|
40
|
+
if (!web || typeof web.fetch !== 'function') {
|
|
41
|
+
return { ok: false, reason: 'no-web-service', url }
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const res = await web.fetch({ url }, signal)
|
|
45
|
+
if (!res || typeof res.statusCode !== 'number') return { ok: false, reason: 'bad-result', url }
|
|
46
|
+
/* Per the service contract a non-2xx response is a RESULT, not a throw —
|
|
47
|
+
so the status must be checked here instead of caught below. */
|
|
48
|
+
if (res.statusCode !== 200) return { ok: false, reason: 'http-' + res.statusCode, url }
|
|
49
|
+
const text = res.body && typeof res.body.content === 'string' ? res.body.content : ''
|
|
50
|
+
let latest = null
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(text)
|
|
53
|
+
if (parsed && typeof parsed.version === 'string') latest = parsed.version
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { ok: false, reason: 'not-json', url }
|
|
56
|
+
}
|
|
57
|
+
if (latest === null) return { ok: false, reason: 'no-version-field', url }
|
|
58
|
+
return { ok: true, url, latest }
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const detail = error && error.message ? String(error.message) : String(error)
|
|
61
|
+
return { ok: false, reason: 'fetch-failed', detail, url }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compare two dotted versions. Returns -1 / 0 / 1; non-numeric parts count as 0. */
|
|
66
|
+
export function compareVersions(a, b) {
|
|
67
|
+
const parse = (v) => String(v).split('-')[0].split('.').map((n) => {
|
|
68
|
+
const x = Number.parseInt(n, 10)
|
|
69
|
+
return Number.isFinite(x) ? x : 0
|
|
70
|
+
})
|
|
71
|
+
const left = parse(a)
|
|
72
|
+
const right = parse(b)
|
|
73
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
74
|
+
const l = left[i] ?? 0
|
|
75
|
+
const r = right[i] ?? 0
|
|
76
|
+
if (l !== r) return l < r ? -1 : 1
|
|
77
|
+
}
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Register the handler the browser half reaches through `host.call`. */
|
|
82
|
+
export function registerHandlers(ctx, options = {}) {
|
|
83
|
+
const url = typeof options.updateUrl === 'string' && options.updateUrl !== ''
|
|
84
|
+
? options.updateUrl
|
|
85
|
+
: DEFAULT_UPDATE_URL
|
|
86
|
+
/* `harness` is a sandbox global: the host half is evaluated inside a vm whose
|
|
87
|
+
context carries it. Guard the reference so this module also loads — and is
|
|
88
|
+
testable — outside that sandbox. */
|
|
89
|
+
const seat = typeof harness === 'undefined' ? undefined : harness
|
|
90
|
+
if (!seat || typeof seat.handle !== 'function') {
|
|
91
|
+
return { registered: false, reason: 'no-harness-seat' }
|
|
92
|
+
}
|
|
93
|
+
const dispose = seat.handle('checkUpdate', async (args) => {
|
|
94
|
+
const current = args && typeof args.current === 'string' ? args.current : null
|
|
95
|
+
const canAbort = typeof AbortController === 'function'
|
|
96
|
+
const controller = canAbort ? new AbortController() : null
|
|
97
|
+
let timer = null
|
|
98
|
+
if (controller && typeof setTimeout === 'function') {
|
|
99
|
+
timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS)
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const verdict = await readPublishedVersion(ctx && ctx.web, url, controller ? controller.signal : undefined)
|
|
103
|
+
return { ...verdict, current, checkedAt: new Date().toISOString() }
|
|
104
|
+
} finally {
|
|
105
|
+
if (timer !== null && typeof clearTimeout === 'function') clearTimeout(timer)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
return { registered: true, dispose, url }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function apply(ctx, config) {
|
|
112
|
+
/* The body stays tiny: register the handler, and keep its disposer on the
|
|
113
|
+
fiber so unloading the package takes the handler with it. */
|
|
114
|
+
const result = registerHandlers(ctx, config || {})
|
|
115
|
+
if (result.registered && typeof ctx.effect === 'function') {
|
|
116
|
+
ctx.effect(() => () => {
|
|
117
|
+
if (typeof result.dispose === 'function') result.dispose()
|
|
118
|
+
}, 'dsh-wisp: checkUpdate handler')
|
|
119
|
+
}
|
|
17
120
|
}
|
|
18
121
|
|
|
19
|
-
export default { name, apply }
|
|
122
|
+
export default { name, inject, apply }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wisp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.25.0",
|
|
4
4
|
"description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -30,8 +30,15 @@
|
|
|
30
30
|
"cordis.patch.yml",
|
|
31
31
|
"build.mjs",
|
|
32
32
|
"verify-wisp.mjs",
|
|
33
|
+
"tools/smoke.mjs",
|
|
33
34
|
"tools/keyout.mjs",
|
|
34
35
|
"tools/assets.mjs",
|
|
36
|
+
"tools/tiers.mjs",
|
|
37
|
+
"tools/perf.mjs",
|
|
38
|
+
"tools/pack-check.mjs",
|
|
39
|
+
"tools/bundle-cost.mjs",
|
|
40
|
+
"tools/audit-log.mjs",
|
|
41
|
+
"audit/",
|
|
35
42
|
"tools/CHARACTER-PROMPT.md",
|
|
36
43
|
"assets/",
|
|
37
44
|
"README.md",
|
|
@@ -25,11 +25,29 @@
|
|
|
25
25
|
## 提示词正文(逐字复制)
|
|
26
26
|
|
|
27
27
|
```
|
|
28
|
-
|
|
28
|
+
一位留着**深蓝色中长直发**的女性:头发**自然披散**下来、长及腰际,**没有扎成马尾、没有双马尾、没有盘发**;
|
|
29
|
+
发梢由深蓝**渐变到浅蓝**;额前是**齐刘海**,两侧鬓发垂到肩下。
|
|
30
|
+
**头顶正中有一缕呆毛**:向上立起、向前上方弯成一个大圆环,高出头顶轮廓(社区标识之一)。
|
|
29
31
|
|
|
30
32
|
身后从腰部长出一条蓝色鲸尾,尾鳍自然向下,必须在画面中清楚可见。
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
宝蓝色大眼睛;头顶戴白色荷叶边女仆发箍,发箍上别一枚蓝色鲸鱼造型发夹。
|
|
35
|
+
|
|
36
|
+
**她的耳朵不是人类耳朵,而是一对鲸鱼胸鳍(社区规范的「鲸鳍耳」)**:
|
|
37
|
+
① **朝向最重要**:长在头两侧、**向后下方斜掠、几乎贴着头发向后伸展**,不是朝上竖立、不是向两侧平伸。
|
|
38
|
+
(第一版做成了向上外张的大翅膀 —— 用户说"太大了",实测**问题在朝向不在长度**。)
|
|
39
|
+
② **狭长**:根部略宽,向后逐渐收窄成一个**尖梢**,外缘一道平滑弧线,有一点厚度感;
|
|
40
|
+
③ **配色是关键**:**上表面深蓝色、下缘与内侧白色或很浅的蓝白**,深浅分界清楚 ——
|
|
41
|
+
这是座头鲸胸鳍正反两面的配色,也是最显眼的识别点;
|
|
42
|
+
④ **尺寸小巧**:每片长度约为**头宽的四分之一**,**不要超过三分之一**,绝不要画成翅膀那样大 ——
|
|
43
|
+
耳朵是配角,不该比头还显眼;
|
|
44
|
+
⑤ **没有人类耳朵,没有耳环耳坠耳钉,也没有任何挂在耳朵上的饰物。**
|
|
45
|
+
|
|
46
|
+
**头顶要有一根呆毛**(社区标识之一):一小缕头发从发箍上方的头顶竖起、顶端向前弯成一个小圆环
|
|
47
|
+
(像钩子或小问号),**明显高出头发轮廓、是独立的一缕**。
|
|
48
|
+
**这一条必须写进提示词末尾的「画面完整性,逐条核对」清单** —— 只写在开头会被模型省略(实测过)。
|
|
49
|
+
|
|
50
|
+
颈部一条细蓝色颈饰。
|
|
33
51
|
|
|
34
52
|
【体型】成年女性的成熟身型:高挑、长腿、细腰,肩颈与锁骨线条优美,曲线明显但不夸张。
|
|
35
53
|
|
|
@@ -46,7 +64,7 @@
|
|
|
46
64
|
【构图】全身直立、脚底贴住画面底边、角色居中。
|
|
47
65
|
```
|
|
48
66
|
|
|
49
|
-
##
|
|
67
|
+
## 【姿态】七选一(对应七个 sprite)
|
|
50
68
|
|
|
51
69
|
| 文件 | 姿态 |
|
|
52
70
|
|---|---|
|
|
@@ -54,9 +72,160 @@
|
|
|
54
72
|
| `happy.png` | 双手举在身体两侧欢呼,闭着眼睛开心大笑,脸颊微红,身体微微后仰。 |
|
|
55
73
|
| `sleepy.png` | 怀里抱着一只蓝色鲸鱼抱枕,抱在胸腹前;半闭着眼睛打盹,困倦地微微低头。 |
|
|
56
74
|
| `work.png` | 一手托着一台打开的笔记本电脑,另一手在键盘上打字,认真专注地看着屏幕。 |
|
|
75
|
+
| `attn.png` | 身体微微前倾,一只手抬到胸前朝前方招了招,像是在叫对方过来;眉头微蹙、认真地直视前方,带着一点催促。 |
|
|
76
|
+
| `proud.png` | 双手抱在胸前,下巴微微抬起,眼睛半闭着、嘴角上扬,神情带着一点不掩饰的得意;身体微微后仰,站得很稳。 |
|
|
77
|
+
| `poked.png` | 身体明显往后一缩、双肩微微耸起,一只手抬到胸前摆出「等一下」的姿势(掌心朝前);眼睛睁得比平时大、眉毛微挑,嘴巴小小地抿着,带着一点惊讶与抗议。 |
|
|
57
78
|
|
|
58
79
|
---
|
|
59
80
|
|
|
81
|
+
## 第二套皮肤:初版女仆(`classic`)
|
|
82
|
+
|
|
83
|
+
包里有两套皮肤,但上面写的是**旗舰那套(`deepsea`)**的规格。第二套的规格不同,而且它的
|
|
84
|
+
绿幕原图已经不在 Maker 项目目录里(只有抠好的透明 PNG 与本仓库已发布的 WebP),所以规格
|
|
85
|
+
必须记在这里,否则不可复现。
|
|
86
|
+
|
|
87
|
+
差异只在两处:**没有【体型】段**(用画风里的"少女体型"约束),以及**服装更保守**。
|
|
88
|
+
|
|
89
|
+
一位留着深蓝色超长双马尾的少女:两条深蓝色马尾又长又粗、从头顶两侧一直垂到大腿两侧,
|
|
90
|
+
必须完整画出来;发梢渐变为浅蓝;齐刘海,两侧鬓发垂到肩下。
|
|
91
|
+
|
|
92
|
+
身后从腰部长出一条蓝色鲸尾,尾鳍自然向下,必须在画面中清楚可见。
|
|
93
|
+
|
|
94
|
+
宝蓝色大眼睛;头顶戴白色荷叶边女仆发箍,发箍上别一枚蓝色鲸鱼造型发夹。
|
|
95
|
+
|
|
96
|
+
**她的耳朵不是人类耳朵,而是一对鲸鱼胸鳍(社区规范的「鲸鳍耳」)**:
|
|
97
|
+
① 长在头两侧、**向后上方斜掠张开**(像一对后掠的小翅膀),不是朝上竖立、不是朝前伸;
|
|
98
|
+
② **狭长**:根部略宽,向后逐渐收窄成一个**尖梢**,外缘一道平滑弧线,有一点厚度感;
|
|
99
|
+
③ **配色是关键**:**上表面深蓝色、下缘与内侧白色或很浅的蓝白**,深浅分界清楚 ——
|
|
100
|
+
这是座头鲸胸鳍正反两面的配色,也是最显眼的识别点;
|
|
101
|
+
④ **尺寸小巧**:每片长度约为**头宽的四分之一**,**不要超过三分之一**,绝不要画成翅膀那样大 ——
|
|
102
|
+
耳朵是配角,不该比头还显眼;
|
|
103
|
+
⑤ **没有人类耳朵,没有耳环耳坠耳钉,也没有任何挂在耳朵上的饰物。**
|
|
104
|
+
|
|
105
|
+
颈部一条细蓝色颈饰。
|
|
106
|
+
|
|
107
|
+
【服装】单薄女仆装:深蓝色细吊带连衣短裙,单层,方领,领口不高不低、不要露胸沟;身前罩
|
|
108
|
+
一件单层白色半身围裙,围裙的荷叶边是深蓝色,围裙带在腰后系成一个深蓝色蝴蝶结;裙摆很短、
|
|
109
|
+
单层、自然垂落;不穿长袜,直接露出腿;脚穿白色细带平底鞋。
|
|
110
|
+
|
|
111
|
+
【画风】干净的动漫赛璐璐上色,平涂色块加简单明暗,日系动画角色设定图风格,人物为少女体型,
|
|
112
|
+
不要写实、不要韩系半写实、不要成熟化。
|
|
113
|
+
|
|
114
|
+
【姿态】见上表。【画面完整性】【背景】【构图】与旗舰那套完全一致。
|
|
115
|
+
|
|
116
|
+
## 第三套皮肤:宵蓝礼服(`night`)
|
|
117
|
+
|
|
118
|
+
形象要素与旗舰那套**逐字相同**(深蓝超长双马尾、鲸尾、白色荷叶边女仆发箍、鲸鱼发夹、鳍耳饰、
|
|
119
|
+
细蓝颈饰、成熟成年体型、画风段、画面完整性段、构图段),**只替换【服装】段**:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
【服装】单层深蓝色露肩礼服:一字露肩剪裁,低领口露出锁骨与肩线;裙身单层薄料、修身、裙摆及膝略短、
|
|
123
|
+
侧边开一道长衩露出一条腿;腰间一条同色细腰带;不穿长袜,直接露出双腿;脚穿白色细带高跟鞋。
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
五张姿态照第 49 行那张表,一张不改。实测键控质量 0.45%–0.61% 软边(与前两套同级),
|
|
127
|
+
入包那档合计 821 KB。
|
|
128
|
+
|
|
129
|
+
> 三套皮肤的差别是"**同一件人之外的东西换了**":换掉服装而不是换掉身份,所以三张站稳的图
|
|
130
|
+
> 放在一起仍然认得出是同一个人。这是刻意的 —— 皮肤不是不同角色。
|
|
131
|
+
|
|
132
|
+
## 第四套皮肤:宵眠睡衣(`pajama`)
|
|
133
|
+
|
|
134
|
+
形象要素与旗舰那套**逐字相同**,只替换【服装】段:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
【服装】宽松的深蓝色长袖睡衣:上衣宽大柔软、领口有一圈白色鲸鱼刺绣、下摆盖住臀部;
|
|
138
|
+
下身同色宽松睡裤、裤脚有白色鲸鱼图案;脚穿一双毛绒鲸鱼拖鞋;不穿袜子。
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
五张姿态照第 49 行那张表,一张不改。实测键控 0.48%–0.56% 软边。
|
|
142
|
+
|
|
143
|
+
> 这一套的用意是**剪影差异**:前三套都是"贴身 + 露腿",睡衣是"宽松 + 包住腿 + 毛绒拖鞋",
|
|
144
|
+
> 一眼就能分辨她在穿什么。它也让 `sleepy`(抱着鲸鱼抱枕打盹)变得贴切 —— 那是这套里最好的一格。
|
|
145
|
+
|
|
146
|
+
## 社区规范:她长什么样是有共识的
|
|
147
|
+
|
|
148
|
+
社区为此专门做了一个开源角色规范项目(**非官方**):[Neko3000/deepseek-whalechan](https://github.com/Neko3000/deepseek-whalechan)
|
|
149
|
+
([中文 README](https://github.com/Neko3000/deepseek-whalechan/blob/main/README.zh.md))。它把"鲸鱼娘"的识别点锁定为:
|
|
150
|
+
|
|
151
|
+
> 精准锁定**发色渐变、鲸鳍耳、呆毛、鲸尾**与女仆装等核心标识
|
|
152
|
+
|
|
153
|
+
| 锁定标识 | 本插件 | 说明 |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| 发色渐变 | ✓ | 深蓝 → 发梢浅蓝 |
|
|
156
|
+
| **鲸鳍耳** | ✓(1.16.0 起) | 耳朵本身就是一对鲸鱼胸鳍;此前误做成"耳侧挂鳍形耳饰" |
|
|
157
|
+
| **呆毛** | ✗ **尚未实现** | 头顶那缕绕成小圈的头发,社区标识之一 |
|
|
158
|
+
| 鲸尾 | ✓ | 腰后一条鲸尾 |
|
|
159
|
+
| 女仆装 | 部分 | 社区是长袖全套女仆装 + 白袜皮鞋;本插件是单层吊带短裙(刻意的风格选择) |
|
|
160
|
+
|
|
161
|
+
**教训**:先去看社区共识,再动手画。我一开始把"鲸"理解成"加一点鲸鱼元素",于是给她挂了鳍形耳坠、
|
|
162
|
+
还把"鳍"写成"鲸鱼鳍造型"——结果出来的是鱼鳍、直板、水滴宝石,绕了三轮。**社区早就把答案写在规范里了。**
|
|
163
|
+
|
|
164
|
+
**出处与许可**(据该仓库 README):规范文档与提示词模板采用 **CC-BY-NC-SA 4.0**,代码工具链采用 MIT;
|
|
165
|
+
角色形象属于社区共创,已知原始素材著作权归 B 站 **ZipZipPipe** 与 **上善无形** 等作者;
|
|
166
|
+
**欢迎非商业二创**(建议注明出处 `DeepSeek Whale-chan Project`),**商业使用需获得原始著作权人书面授权**。
|
|
167
|
+
本插件是非商业的个人项目;若将来要分发或商用,需要遵守上述条款。
|
|
168
|
+
|
|
169
|
+
## 第五套皮肤:原版女仆(`canon`)—— 对齐社区规范
|
|
170
|
+
|
|
171
|
+
这一套的目标**不是原创,而是复刻社区共识**。生成参数与前面几套完全一致,提示词如下(逐字复制)。
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
【背景要求,最重要】整张图必须是纯绿色背景(严格是鲜艳的纯绿 rgb(0,255,0)),从头到脚均匀铺满、没有任何渐变、纹理、暗角、阴影或其他物体,角色完全被这块纯绿色包围,角色四周留出一圈纯绿色空隙。绝对不要黑色背景、不要深色背景、不要白色背景、不要渐变背景。
|
|
175
|
+
|
|
176
|
+
【发型,注意不是双马尾】一位留着**深蓝色中长直发**的少女:头发**自然披散**下来、长及腰际,**没有扎成马尾、没有双马尾、没有盘发**;发梢由深蓝**渐变到浅蓝**;额前是**齐刘海**,两侧鬓发自然垂到肩下。
|
|
177
|
+
|
|
178
|
+
【头部特征,逐条画出来】
|
|
179
|
+
① **呆毛**:头顶正中有一缕头发**向上立起、向前上方弯成一个明显的大圆环**(像一个圈),它高出头顶轮廓很多,是独立的一缕,非常醒目;
|
|
180
|
+
② **发箍**:戴一条**白色荷叶边女仆发箍**,发箍**右侧**别着一个**蓝色小蝴蝶结**;
|
|
181
|
+
【耳部,按顺序照画】耳朵不是人类耳朵,是两片鲸鱼胸鳍,长在头部两侧、眼睛稍下的位置。
|
|
182
|
+
每一片的形状是一片**细长、光滑、末端圆钝的鳍**,像一片长叶子:
|
|
183
|
+
① 方向与位置(最关键):从头部侧面**贴着头发、向外并向下约 35 度**伸出去,**紧贴头侧的头发**、
|
|
184
|
+
往后掠;**不是向上竖立、不是向两侧平伸、不是像翅膀张开**;
|
|
185
|
+
② 长度:**较长**,伸出的长度**约等于头的宽度**,明显超出头发轮廓;
|
|
186
|
+
③ 宽度:根部略宽、向末端收窄,最宽处约为长度的三分之一,整片是**扁平的**;
|
|
187
|
+
④ 轮廓:上缘是一条光滑的弧线,下缘也是一条**光滑连续的弧线**,只有极轻微的自然起伏;
|
|
188
|
+
整片**柔软、圆润**,末梢是**圆钝的小尖**并微微向下弯;
|
|
189
|
+
⑤ 颜色:**外表面深蓝色**(与头发同色、边缘一圈深色描边);**下缘与内侧有一条白色到银灰色的宽带**
|
|
190
|
+
(约占鳍宽的一半),像翻开的内衬 —— **这是最显眼的识别点**;
|
|
191
|
+
⑥ 两片左右基本对称。没有人类耳朵,没有耳环、耳坠、耳钉,没有任何挂在耳侧的饰物。
|
|
192
|
+
|
|
193
|
+
【眼睛】宝蓝色的大眼睛,多层高光与渐变瞳孔,睫毛分明。
|
|
194
|
+
|
|
195
|
+
【服装】深蓝色长袖女仆连衣裙:立领,胸前有一排白色荷叶边饰边,领口系一个深蓝色蝴蝶结并别一枚蓝宝石胸针;袖口有金色纹样镶边;腰间系一条白色半身围裙、带荷叶边;裙摆及膝、多层荷叶边,裙摆边缘有金色细纹样;腿上穿**白色长袜**;脚穿**深蓝色圆头皮鞋、带金色小搭扣**。
|
|
196
|
+
|
|
197
|
+
【体型】少女体型:比例舒展、四肢自然修长、身形纤细,头身比约 4 头身。
|
|
198
|
+
|
|
199
|
+
【画风】高质量的现代二次元插画,商业级插画精度:精细的线稿,高密度分明的发丝刻画,眼睛有多层高光与渐变瞳孔,皮肤与布料用柔和细腻的多层过渡阴影与通透的配色,光影层次丰富。保持二次元的动漫面部与身体比例。不要照片写实、不要三维渲染感、不要粗糙平涂、不要塑料感。
|
|
200
|
+
|
|
201
|
+
【姿态】站立,双手在身前轻轻交叠、微微侧身看向镜头,安静而温柔地微笑。
|
|
202
|
+
|
|
203
|
+
【画面完整性,逐条核对】画面中只有一个角色。① 头发是**深蓝色中长直发、自然披散**,**不是双马尾、不是马尾、不是盘发**;② 头顶的**大圆环呆毛清楚可见**;③ 头两侧的**鲸鱼胸鳍状耳朵向后下方斜掠**、可见蓝色上表面与白色下表面、下缘有锯齿缺口;④ 发尾干净完整、渐变到浅蓝;⑤ 手指完整;⑥ 五官清晰。
|
|
204
|
+
|
|
205
|
+
【构图】全身直立、脚底贴住画面底边、角色居中。
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
与前面几套的差别(都是社区规范要求的,不是随便改的):
|
|
209
|
+
|
|
210
|
+
| 项 | 前面几套(deepsea / classic / night / pajama) | 原版(`canon`) |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| 发型 | **超长双马尾**(本插件的原创设计) | **中长直发披散**(社区规范) |
|
|
213
|
+
| 呆毛 | 无 | **有**(大圆环) |
|
|
214
|
+
| 发箍 | 白色荷叶边 + 鲸鱼发夹 | 白色荷叶边 + **右侧蓝色蝴蝶结** |
|
|
215
|
+
| 服装 | 单层吊带短裙(本插件的风格) | **长袖全套女仆装 + 白袜 + 深蓝圆头皮鞋**(社区规范) |
|
|
216
|
+
| 鲸鳍耳 | 有 | 有(同样向后下斜掠、双色、下缘锯齿) |
|
|
217
|
+
|
|
218
|
+
## 两个工具的分工
|
|
219
|
+
|
|
220
|
+
| 工具 | 输入 | 什么时候用 |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| `tools/assets.mjs` | **绿幕母版**(纯绿底 PNG) | 刚出完图,抠图 + 分档一次做完 |
|
|
223
|
+
| `tools/tiers.mjs` | **已经抠好的透明 PNG** | 手上只有成品,或绿幕原图已不在 |
|
|
224
|
+
|
|
225
|
+
**不要把已经抠过的图再喂给 `assets.mjs`** —— 那是二次抠图,结果依赖"透明像素里是否还残留
|
|
226
|
+
绿色"。抠图在这里是幂等的**假象**,不是保证。两个工具都会拒绝明显不对的输入:
|
|
227
|
+
`assets.mjs` 在"键控几乎无效"时退出,`tiers.mjs` 在"没有 alpha 通道"时退出。
|
|
228
|
+
|
|
60
229
|
## 为什么不用透明背景
|
|
61
230
|
|
|
62
231
|
服务端的 `transparent: true` **会把不该抠的抠掉**:角色是深蓝/浅蓝/白配色,浅蓝渐变的**发梢**与
|
|
@@ -66,6 +235,55 @@
|
|
|
66
235
|
所以:**出图用绿幕,抠图自己做**(`node tools/assets.mjs`),只有 0.4%–0.7% 的边缘像素需要处理,
|
|
67
236
|
发丝一根不丢。
|
|
68
237
|
|
|
238
|
+
## 耳部写法:两个实测出来的坑(都能复现)
|
|
239
|
+
|
|
240
|
+
**坑一:不要提"缺口"或"锯齿"** —— 哪怕是用来说"不要锯齿"。
|
|
241
|
+
|
|
242
|
+
社区的鳍耳下缘确实有极轻微的波浪,我一开始把它当成最重要的识别点写("下缘有 3 个半圆缺口,像被剪了三刀"),
|
|
243
|
+
结果**三份不同写法全都画成了锋利的尖牙/锯子边**(A / B / C 三个版本一次全中)。
|
|
244
|
+
更糟的是往里写"不要尖锐的锯齿、不要像锯子"也没用 —— **负面词同样会招来那个形状**。
|
|
245
|
+
|
|
246
|
+
正确写法:只说**正面**的形状语言 —— "上缘是一条光滑的弧线,下缘也是一条光滑连续的弧线,
|
|
247
|
+
只有极轻微的自然起伏",把"缺口"整个不提。实测这样出来的边缘是干净的(E / F)。
|
|
248
|
+
|
|
249
|
+
**坑二:尺寸不是主要问题,朝向才是。**
|
|
250
|
+
|
|
251
|
+
用户反馈"耳朵太大了"时,我把长度从"头宽的三分之一"一路缩到"三分之一",结果**又缩过头**(E 太小)。
|
|
252
|
+
放大社区立绘逐像素核对后发现:社区的鳍其实**很长(约等于头宽)**,问题是它**贴着头发向后下方掠**,
|
|
253
|
+
而我做成了**朝上外张的翅膀**。正确写法是把"朝向"写成第①条、并明确否定三种错误朝向
|
|
254
|
+
(向上竖、向两侧平伸、像翅膀张开),长度反而要写"约等于头宽"。
|
|
255
|
+
|
|
256
|
+
**结论**:这几轮下来,纯文生图能做到的是"**同一个设计、一眼认出是同一个角色**";
|
|
257
|
+
做不到**像素级复刻** —— 社区自己的角色一致性工具也是一套训练过的 LoRA(见其 Civitai 模型页),
|
|
258
|
+
而不是靠纯提示词。要求像素级一致时,纯文生图这条路是走不通的。
|
|
259
|
+
|
|
260
|
+
## 写作技巧:要求要放在末尾的清单里
|
|
261
|
+
|
|
262
|
+
同一个要求写在提示词**开头**会被忽略,写在末尾的**【画面完整性,逐条核对】**里才会被认真执行 ——
|
|
263
|
+
**呆毛**就是这么拿到的:第一版写在开头,模型直接省略;写进清单(还编了号)之后一次就有了。
|
|
264
|
+
双马尾、鲸尾、发尾完整这些一直生效的项,本来就都在那个清单里。
|
|
265
|
+
|
|
266
|
+
清单里的每一条都要**可核对**:与其说"画得好看",不如说"明显高出头发轮廓、是独立的一缕、不能被省略"。
|
|
267
|
+
|
|
268
|
+
## 三个踩过的坑
|
|
269
|
+
|
|
270
|
+
### 鲸鱼鳍 ≠ 鱼鳍(写"鳍"就会得到鱼鳍)
|
|
271
|
+
|
|
272
|
+
原稿写的是「耳侧有鲸鱼鳍造型耳饰」。出来的是一对**带棘刺、有放射状鳍条、边缘锯形**的鱼鳍 ——
|
|
273
|
+
那是热带鱼的鳍,不是鲸的。
|
|
274
|
+
|
|
275
|
+
改了两轮才拿准措辞:
|
|
276
|
+
|
|
277
|
+
| 措辞 | 结果 |
|
|
278
|
+
|---|---|
|
|
279
|
+
| 鲸鱼鳍造型耳饰 | ✗ 鱼鳍(带鳍条、棘刺) |
|
|
280
|
+
| 细长圆润、像叶片 | ✗ 平滑了,但末端分叉,像羽毛 |
|
|
281
|
+
| 柳叶、末端圆钝 | ✗✗ **跑偏成水滴形宝石耳坠**("水滴/圆钝"把模型带去了宝石) |
|
|
282
|
+
| **扁平的长条板、两端磨圆、宽度一致、边缘连续光滑;并显式排除"水滴、宝石、吊坠、羽毛"** | ✓ 扁平的圆头桨板 |
|
|
283
|
+
|
|
284
|
+
教训:**形状类的要求要写成几何描述(扁平、等宽、圆头、边缘连续),而不是比喻**;比喻会各自联想,
|
|
285
|
+
而且负面清单必须列全 —— 光说"不要鱼鳍"不够,还得说清"不是什么"。
|
|
286
|
+
|
|
69
287
|
## 三个踩过的坑
|
|
70
288
|
|
|
71
289
|
1. **绿幕要求必须写在第一段**,否则模型可能改画黑底 —— 四角实测 `rgb(0,0,0)`,抠图直接失效。
|
package/tools/assets.mjs
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
node tools/assets.mjs --from ./绿幕母版 --out ./assets
|
|
17
17
|
========================================================================== */
|
|
18
18
|
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
20
20
|
import { createRequire } from 'node:module'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
21
22
|
import { dirname, join, resolve } from 'node:path'
|
|
22
23
|
import { fileURLToPath } from 'node:url'
|
|
23
24
|
|
|
@@ -38,33 +39,41 @@ function loadSharp() {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const argv = process.argv.slice(2)
|
|
42
|
+
/* 默认只写第一档(会进包的那一档);--all-tiers 才写全部四档。 */
|
|
43
|
+
const allTiers = argv.includes('--all-tiers')
|
|
41
44
|
const flag = (name, fallback) => {
|
|
42
45
|
const at = argv.indexOf(name)
|
|
43
46
|
return at >= 0 && argv[at + 1] !== undefined ? argv[at + 1] : fallback
|
|
44
47
|
}
|
|
45
|
-
const MOODS = ['idle', 'happy', 'sleepy', 'work']
|
|
48
|
+
const MOODS = ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud']
|
|
46
49
|
const srcDir = resolve(here, flag('--from', 'masters'))
|
|
47
|
-
|
|
50
|
+
/* 皮肤 = assets/ 下的一个子目录。--skin 决定输出到哪一套。
|
|
51
|
+
注意 build.mjs 会把每个子目录当成一套皮肤打进包里,所以皮肤越多包越大。 */
|
|
52
|
+
const skin = flag('--skin', '')
|
|
53
|
+
const outDir = resolve(here, flag('--out', skin === '' ? 'assets' : join('assets', skin)))
|
|
48
54
|
|
|
49
55
|
const T0 = 14
|
|
50
56
|
const T1 = 96
|
|
51
|
-
|
|
57
|
+
/* 只有第一档会被 build.mjs 打进 JS 包并真正显示;其余三档运行时**从不读取**
|
|
58
|
+
(_hi 也测不出画质差别:DPR 2 差 0.3%,DPR 1/3 反而低 4~5%)。
|
|
59
|
+
所以默认只写第一档 —— 不让 9.9 MB 死重悄悄长回包里。
|
|
60
|
+
确实要用别的档重打包时:加 --all-tiers。 */
|
|
61
|
+
const ALL_TIERS = [
|
|
52
62
|
{ suffix: '', w: 1024, q: 84 },
|
|
53
63
|
{ suffix: '_md', w: 512, q: 82 },
|
|
54
64
|
{ suffix: '_sm', w: 256, q: 80 },
|
|
55
65
|
{ suffix: '_hi', w: 2048, q: 86 },
|
|
56
66
|
]
|
|
67
|
+
/* 选择语句必须**在数组定义之后**:放在前面会踩 TDZ(Cannot access before initialization)。 */
|
|
68
|
+
const TIERS = allTiers ? ALL_TIERS : ALL_TIERS.slice(0, 1)
|
|
57
69
|
|
|
58
70
|
if (!existsSync(srcDir)) {
|
|
59
71
|
console.error(`母版目录不存在: ${srcDir}`)
|
|
60
72
|
process.exit(1)
|
|
61
73
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
process.exit(1)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
74
|
+
/* 这里**不**要求每个情绪都有母版:给已有皮肤补一个新情绪(例如后来加的 poked)时,
|
|
75
|
+
别的母版早就没了。缺哪个由下面的循环逐条打印,最后用"一张都没处理"兜底 ——
|
|
76
|
+
既不因为缺一张就整条停下,也不会把"文件名写错"静默吞掉。 */
|
|
68
77
|
mkdirSync(outDir, { recursive: true })
|
|
69
78
|
|
|
70
79
|
const sharp = loadSharp()
|
|
@@ -102,9 +111,20 @@ async function key(srcPath, outPath) {
|
|
|
102
111
|
|
|
103
112
|
console.log(`母版: ${srcDir}\n出图: ${outDir}\n`)
|
|
104
113
|
console.log('mood 键控(不透明/边缘) 档位 尺寸 体积')
|
|
114
|
+
let processed = 0
|
|
105
115
|
for (const mood of MOODS) {
|
|
106
116
|
const src = join(srcDir, `${mood}.png`)
|
|
107
|
-
|
|
117
|
+
/* 缺某个情绪的母版**不是错误**:给已有皮肤补一个新情绪(例如新加的 poked)时,
|
|
118
|
+
别的母版早就没了,只有这一张。以前这里会直接中止,于是"补一个情绪"必须先凑齐全部母版。
|
|
119
|
+
但也不能静默跳过 —— 打出来,好让"文件名写错"和"本来就没有"分得清。 */
|
|
120
|
+
if (!existsSync(src)) {
|
|
121
|
+
console.log(` ${mood.padEnd(7)} 跳过(这个目录里没有 ${mood}.png)`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
/* 抠图后的全尺寸 PNG 是**中间产物**,写到系统临时目录而不是 outDir:
|
|
125
|
+
以前它写在发布目录里、最后再删,删除一旦失败就在 assets/ 里留下 0 字节的隐藏残骸
|
|
126
|
+
(实测 classic 4 个、night 5 个,而且会随包发布)。中间产物根本不该出现在发布目录。 */
|
|
127
|
+
const keyed = join(tmpdir(), `wisp-key-${skin || 'default'}-${mood}-${process.pid}.png`)
|
|
108
128
|
let meta
|
|
109
129
|
try {
|
|
110
130
|
meta = await key(src, keyed)
|
|
@@ -120,20 +140,23 @@ for (const mood of MOODS) {
|
|
|
120
140
|
.webp({ quality: tier.q, alphaQuality: 92, effort: 6 })
|
|
121
141
|
.toFile(out)
|
|
122
142
|
}
|
|
143
|
+
processed++
|
|
123
144
|
console.log(
|
|
124
145
|
` ${mood.padEnd(7)} ${(meta.opaque * 100).toFixed(1)}% / ${(meta.soft * 100).toFixed(2)}%`.padEnd(30),
|
|
125
146
|
(TIERS[0].suffix || '默认').padEnd(8),
|
|
126
147
|
`${Math.round(meta.w / 2)}x${Math.round(meta.h / 2)}`.padEnd(13),
|
|
127
148
|
kb(statSync(join(outDir, `${mood}.webp`)).size),
|
|
128
149
|
)
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
const { rmSync } = await import('node:fs')
|
|
132
|
-
rmSync(keyed, { force: true })
|
|
150
|
+
// 中间产物在系统临时目录里,删不掉也只是临时目录的事,不会污染发布内容
|
|
151
|
+
try { rmSync(keyed, { force: true }) } catch (e) { /* 临时目录的残留无害 */ }
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
if (processed === 0) {
|
|
155
|
+
console.error('\n这个目录里一张母版都没找到 —— 检查 --from 是否指对了目录。')
|
|
156
|
+
process.exit(1)
|
|
157
|
+
}
|
|
135
158
|
if (failed > 0) {
|
|
136
159
|
console.error(`\n${failed} 张母版抠图失败 —— 素材未更新,请检查这些图是否是干净的纯绿背景。`)
|
|
137
160
|
process.exit(1)
|
|
138
161
|
}
|
|
139
|
-
console.log('\n完成。接着跑 `node build.mjs`(默认就用 <mood>.webp
|
|
162
|
+
console.log('\n完成。接着跑 `node build.mjs`(默认就用 <mood>.webp 那一档,要出别的档位加 --all-tiers)。')
|