dsh-wisp 0.6.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/lib/index.js ADDED
@@ -0,0 +1,19 @@
1
+ // Host half of @local/dsh-wisp.
2
+ //
3
+ // This plugin deliberately does nothing on the host side. Everything the wisp
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.
8
+ //
9
+ // If you later want the wisp to react to host-side facts (model, token usage,
10
+ // job state), add a `host.call` handler here and a matching `host.call(...)`
11
+ // from lib/client.js.
12
+
13
+ export const name = 'wisp'
14
+
15
+ export function apply() {
16
+ // Intentionally empty — see the note above.
17
+ }
18
+
19
+ export default { name, apply }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "dsh-wisp",
3
+ "version": "0.6.0",
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
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "dsh",
10
+ "deepseek-harness",
11
+ "cordis-plugin",
12
+ "desktop-companion",
13
+ "desktop-pet",
14
+ "deepseek-chan"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "default": "./lib/index.js"
19
+ },
20
+ "./client": {
21
+ "default": "./lib/client.js"
22
+ },
23
+ "./package.json": "./package.json",
24
+ "./cordis.patch.yml": "./cordis.patch.yml"
25
+ },
26
+ "files": [
27
+ "lib/index.js",
28
+ "lib/client.js",
29
+ "lib/client.template.js",
30
+ "cordis.patch.yml",
31
+ "build.mjs",
32
+ "verify-wisp.mjs",
33
+ "tools/keyout.mjs",
34
+ "tools/assets.mjs",
35
+ "tools/CHARACTER-PROMPT.md",
36
+ "assets/",
37
+ "README.md",
38
+ "NOTICE.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "build": "node build.mjs",
43
+ "verify": "node verify-wisp.mjs",
44
+ "test": "node build.mjs && node verify-wisp.mjs",
45
+ "prepublishOnly": "node build.mjs && node verify-wisp.mjs"
46
+ },
47
+ "dependencies": {},
48
+ "peerDependencies": {},
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/969246694/dsh-wisp.git"
52
+ },
53
+ "homepage": "https://github.com/969246694/dsh-wisp#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/969246694/dsh-wisp/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "dsh": {
61
+ "bundle": {
62
+ "patch": "./cordis.patch.yml"
63
+ },
64
+ "client": {
65
+ "platform": "web",
66
+ "title": {
67
+ "en": "DeepSeek-chan",
68
+ "zh": "DeepSeek娘"
69
+ }
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,88 @@
1
+ # 角色提示词(可复用母版)
2
+
3
+ **这是本插件形象的唯一权威来源。** 改形象 = 改这里 + 重跑生成 + `node tools/assets.mjs`。
4
+ 每次出图必须**逐字复制**下面的段落,只替换【姿态】那一节 —— 形象靠提示词锚定,不能靠参考图。
5
+
6
+ ---
7
+
8
+ ## 生成参数(每一张都必须一致)
9
+
10
+ | 参数 | 值 |
11
+ |---|---|
12
+ | `model` | `"gpt"` |
13
+ | `resolution` | `"4K"` |
14
+ | `target_size` | `"2048x3072"` |
15
+ | `aspect_ratio` | `"2:3"` |
16
+ | `quality` | `"high"` |
17
+ | `thinking_level` | `"high"` |
18
+ | `transparent` | **省略(即 false)** ← 见下方「为什么不用透明背景」 |
19
+ | 调用方式 | **一张素材一次调用**,不要在 batch 里混多张 |
20
+ | 参考图 | **绝对不传**(`reference_image` / `reference_images` 都不传) |
21
+ | 一张图里几个角色 | **只有一个**,提示词里必须写明 |
22
+
23
+ ---
24
+
25
+ ## 提示词正文(逐字复制)
26
+
27
+ ```
28
+ 一位留着深蓝色超长双马尾的成年女性:两条深蓝色马尾又长又粗、从头顶两侧一直垂到大腿两侧,必须完整画出来;发梢渐变为浅蓝;齐刘海,两侧鬓发垂到肩下。
29
+
30
+ 身后从腰部长出一条蓝色鲸尾,尾鳍自然向下,必须在画面中清楚可见。
31
+
32
+ 宝蓝色大眼睛;头顶戴白色荷叶边女仆发箍,发箍上别一枚蓝色鲸鱼造型发夹,耳侧有鲸鱼鳍造型耳饰;颈部一条细蓝色颈饰。
33
+
34
+ 【体型】成年女性的成熟身型:高挑、长腿、细腰,肩颈与锁骨线条优美,曲线明显但不夸张。
35
+
36
+ 【服装】性感的单薄女仆装:深蓝色一字肩细吊带连衣短裙,单层,低领口露出锁骨与肩线;裙摆极短、单层、自然垂落,露出一整条腿;身前罩一件单层白色半身围裙,围裙的荷叶边是深蓝色,围裙带在腰后系成一个深蓝色蝴蝶结;不穿长袜,直接露出双腿;脚穿白色细带高跟凉鞋。
37
+
38
+ 【画风】高质量的现代二次元插画,商业级插画精度:精细的线稿,高密度分明的发丝刻画,眼睛有多层高光与渐变瞳孔,皮肤与布料用柔和细腻的多层过渡阴影与通透的配色,光影层次丰富。保持二次元的动漫面部与身材比例(大而亮的眼睛、小巧的鼻与嘴)。不要照片写实、不要三维渲染感、不要粗糙平涂、不要塑料感。
39
+
40
+ 【姿态】__按情绪替换,见下表__
41
+
42
+ 【画面完整性】画面中只有一个角色。头发必须是深蓝色超长双马尾,不要画成短发、不要盘发、不要白色头发;鲸尾必须可见且与身体连接;发尾干净完整;手指完整;五官清晰。
43
+
44
+ 【背景】纯绿色背景(绿幕):整张背景是一整片均匀的纯绿色,没有任何渐变、纹理、阴影或其他物体;角色完全被纯绿色包围,不要在地面投下阴影。
45
+
46
+ 【构图】全身直立、脚底贴住画面底边、角色居中。
47
+ ```
48
+
49
+ ## 【姿态】四选一(对应四个 sprite)
50
+
51
+ | 文件 | 姿态 |
52
+ |---|---|
53
+ | `idle.png` | 一手叉腰、一腿微屈、身体微微侧倾,自信而妩媚地看向镜头,嘴角含笑。 |
54
+ | `happy.png` | 双手举在身体两侧欢呼,闭着眼睛开心大笑,脸颊微红,身体微微后仰。 |
55
+ | `sleepy.png` | 怀里抱着一只蓝色鲸鱼抱枕,抱在胸腹前;半闭着眼睛打盹,困倦地微微低头。 |
56
+ | `work.png` | 一手托着一台打开的笔记本电脑,另一手在键盘上打字,认真专注地看着屏幕。 |
57
+
58
+ ---
59
+
60
+ ## 为什么不用透明背景
61
+
62
+ 服务端的 `transparent: true` **会把不该抠的抠掉**:角色是深蓝/浅蓝/白配色,浅蓝渐变的**发梢**与
63
+ **鲸尾**最接近浅色底,于是被当成背景切掉 —— 表现是「生成结果变成了短发、鲸尾消失」,极易误判成
64
+ 模型跑形。实测同一批提示词走服务端透明背景时四张全部丢失发梢与鲸尾。
65
+
66
+ 所以:**出图用绿幕,抠图自己做**(`node tools/assets.mjs`),只有 0.4%–0.7% 的边缘像素需要处理,
67
+ 发丝一根不丢。
68
+
69
+ ## 三个踩过的坑
70
+
71
+ 1. **绿幕要求必须写在第一段**,否则模型可能改画黑底 —— 四角实测 `rgb(0,0,0)`,抠图直接失效。
72
+ 若 `assets.mjs` 报「键控几乎无效」,就是这张没听话,重出即可。
73
+ 2. **不要写「高质量二次元插画」这种笼统说法**,它会推向韩系半写实(头发变短、体型成熟化),
74
+ 角色设定随之崩掉;必须写成「商业级二次元插画 + 二次元的动漫面部与身材比例 + 不要照片写实/
75
+ 三维渲染感/粗糙平涂」。
76
+ 3. **不要把「少女体型」和「性感」写在一起。** 当前设定是**成年女性**;这一点不要在改服装时改掉。
77
+
78
+ ## 全流程
79
+
80
+ ```bash
81
+ # 1. 按上面的参数与提示词,为四个情绪各生成一张绿幕图,存成 <mood>.png
82
+ # 2. 绿幕 -> 抠图 -> 四档素材,一步到位
83
+ node tools/assets.mjs --from <绿幕母版目录>
84
+ # 3. 把素材打进客户端半包
85
+ node build.mjs --tier=hi # hi 档 = 2048x3072,与母版同尺寸,仅重新编码
86
+ # 4. 预检
87
+ node verify-wisp.mjs
88
+ ```
@@ -0,0 +1,139 @@
1
+ /* ============================================================================
2
+ assets.mjs — 一条命令把"绿幕母版"变成可入包的素材。
3
+
4
+ 输入:一个目录,里面是四张绿幕母版 <mood>.png(2048x3072,纯绿背景)
5
+ 输出:assets/ 下的四档素材
6
+ <mood>.webp 1024x1536 默认档
7
+ <mood>_md.webp 512x768
8
+ <mood>_sm.webp 256x384
9
+ <mood>_hi.webp 2048x3072 与母版同尺寸,仅重新编码
10
+
11
+ 每一步都会报告键控质量;任何一张的键控异常都会让整条命令以非 0 退出,
12
+ 避免"某一张没抠干净"悄悄混进包里 —— 这种错误很难从最终效果反推。
13
+
14
+ RUN
15
+ node tools/assets.mjs --from ./绿幕母版
16
+ node tools/assets.mjs --from ./绿幕母版 --out ./assets
17
+ ========================================================================== */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
20
+ import { createRequire } from 'node:module'
21
+ import { dirname, join, resolve } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ const here = resolve(dirname(fileURLToPath(import.meta.url)), '..')
25
+ const require = createRequire(import.meta.url)
26
+
27
+ function loadSharp() {
28
+ const dir = process.env.DSH_PROFILE_DIR
29
+ const candidates = [
30
+ process.env.WISP_SHARP,
31
+ dir ? join(dir, '..', 'node_modules', 'sharp') : null,
32
+ 'sharp',
33
+ ].filter(Boolean)
34
+ for (const c of candidates) {
35
+ try { return require(c) } catch (e) { /* 试下一个 */ }
36
+ }
37
+ throw new Error('找不到 sharp:请设置环境变量 WISP_SHARP 指向 sharp 包目录')
38
+ }
39
+
40
+ const argv = process.argv.slice(2)
41
+ const flag = (name, fallback) => {
42
+ const at = argv.indexOf(name)
43
+ return at >= 0 && argv[at + 1] !== undefined ? argv[at + 1] : fallback
44
+ }
45
+ const MOODS = ['idle', 'happy', 'sleepy', 'work']
46
+ const srcDir = resolve(here, flag('--from', 'masters'))
47
+ const outDir = resolve(here, flag('--out', 'assets'))
48
+
49
+ const T0 = 14
50
+ const T1 = 96
51
+ const TIERS = [
52
+ { suffix: '', w: 1024, q: 84 },
53
+ { suffix: '_md', w: 512, q: 82 },
54
+ { suffix: '_sm', w: 256, q: 80 },
55
+ { suffix: '_hi', w: 2048, q: 86 },
56
+ ]
57
+
58
+ if (!existsSync(srcDir)) {
59
+ console.error(`母版目录不存在: ${srcDir}`)
60
+ process.exit(1)
61
+ }
62
+ for (const mood of MOODS) {
63
+ if (!existsSync(join(srcDir, `${mood}.png`))) {
64
+ console.error(`缺少母版: ${join(srcDir, `${mood}.png`)}`)
65
+ process.exit(1)
66
+ }
67
+ }
68
+ mkdirSync(outDir, { recursive: true })
69
+
70
+ const sharp = loadSharp()
71
+ const kb = (n) => `${(n / 1024).toFixed(0)} KB`
72
+ let failed = 0
73
+
74
+ /** 绿幕键控:返回 { png, opaque, soft } 或抛错。 */
75
+ async function key(srcPath, outPath) {
76
+ const { data, info } = await sharp(srcPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
77
+ const { width: W, height: H, channels } = info
78
+ if (channels !== 4) throw new Error(`期望 4 通道,实得 ${channels}`)
79
+ const px = Buffer.alloc(W * H * 4)
80
+ let opaque = 0
81
+ let soft = 0
82
+ for (let i = 0, p = 0; i < W * H; i++, p += 4) {
83
+ const r = data[p]
84
+ const g = data[p + 1]
85
+ const b = data[p + 2]
86
+ let a = 1 - ((g - Math.max(r, b)) - T0) / (T1 - T0)
87
+ a = a < 0 ? 0 : a > 1 ? 1 : a
88
+ if (a >= 1) opaque++
89
+ else if (a > 0) { soft++; data[p + 1] = Math.min(g, Math.max(r, b)) }
90
+ px[p] = data[p]
91
+ px[p + 1] = data[p + 1]
92
+ px[p + 2] = b
93
+ px[p + 3] = Math.round(a * 255)
94
+ }
95
+ const opaqueRatio = opaque / (W * H)
96
+ const softRatio = soft / (W * H)
97
+ if (opaqueRatio > 0.9) throw new Error(`键控几乎无效(不透明 ${(opaqueRatio * 100).toFixed(1)}%)—— 这张多半不是绿幕底`)
98
+ if (softRatio > 0.05) throw new Error(`半透明边缘占比 ${(softRatio * 100).toFixed(1)}% 过高 —— 源图可能不是干净绿幕`)
99
+ await sharp(px, { raw: { width: W, height: H, channels: 4 } }).png().toFile(outPath)
100
+ return { opaque: opaqueRatio, soft: softRatio, w: W, h: H }
101
+ }
102
+
103
+ console.log(`母版: ${srcDir}\n出图: ${outDir}\n`)
104
+ console.log('mood 键控(不透明/边缘) 档位 尺寸 体积')
105
+ for (const mood of MOODS) {
106
+ const src = join(srcDir, `${mood}.png`)
107
+ const keyed = join(outDir, `.${mood}.keyed.png`)
108
+ let meta
109
+ try {
110
+ meta = await key(src, keyed)
111
+ } catch (error) {
112
+ failed++
113
+ console.error(` ${mood.padEnd(7)} 抠图失败: ${error.message}`)
114
+ continue
115
+ }
116
+ for (const tier of TIERS) {
117
+ const out = join(outDir, `${mood}${tier.suffix}.webp`)
118
+ await sharp(keyed)
119
+ .resize(tier.w, Math.round(tier.w * 1.5), { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
120
+ .webp({ quality: tier.q, alphaQuality: 92, effort: 6 })
121
+ .toFile(out)
122
+ }
123
+ console.log(
124
+ ` ${mood.padEnd(7)} ${(meta.opaque * 100).toFixed(1)}% / ${(meta.soft * 100).toFixed(2)}%`.padEnd(30),
125
+ (TIERS[0].suffix || '默认').padEnd(8),
126
+ `${Math.round(meta.w / 2)}x${Math.round(meta.h / 2)}`.padEnd(13),
127
+ kb(statSync(join(outDir, `${mood}.webp`)).size),
128
+ )
129
+ // 中间产物不留:它是全尺寸 PNG,四张能占几十 MB
130
+ writeFileSync(keyed, '')
131
+ const { rmSync } = await import('node:fs')
132
+ rmSync(keyed, { force: true })
133
+ }
134
+
135
+ if (failed > 0) {
136
+ console.error(`\n${failed} 张母版抠图失败 —— 素材未更新,请检查这些图是否是干净的纯绿背景。`)
137
+ process.exit(1)
138
+ }
139
+ console.log('\n完成。接着跑 `node build.mjs`(默认就用 <mood>.webp 那一档,要用 4K 档加 --tier=hi)。')
@@ -0,0 +1,104 @@
1
+ /* ============================================================================
2
+ keyout.mjs — 绿幕抠图(单张)。
3
+
4
+ 为什么不用服务端的透明背景:它会把浅蓝渐变的发梢与鲸尾当成浅色背景切掉,
5
+ 表现成"生成结果变成了短发、鲸尾消失",极易误判成模型跑形。绿幕不存在这种
6
+ 歧义:角色是深蓝/浅蓝/白配色,与纯绿没有色相重叠,所以可以做得比通用抠图
7
+ 保守得多。
8
+
9
+ 原理:以"绿色占优量" g - max(r, b) 作为键控信号,线性映射成 alpha,
10
+ 并对半透明像素做绿色溢出抑制(把 g 压到 max(r,b)),避免发丝边缘泛绿。
11
+
12
+ RUN
13
+ node tools/keyout.mjs <绿幕图.png> <输出.png>
14
+ node tools/keyout.mjs in.png out.png --t0 14 --t1 96 # 调宽容忍度
15
+ ========================================================================== */
16
+
17
+ import { createRequire } from 'node:module'
18
+ import { join } from 'node:path'
19
+
20
+ const require = createRequire(import.meta.url)
21
+
22
+ /** sharp 不在插件包里(插件本体零依赖),从 DSH profile 的 node_modules 取。 */
23
+ function loadSharp() {
24
+ const dir = process.env.DSH_PROFILE_DIR
25
+ const candidates = [
26
+ process.env.WISP_SHARP,
27
+ dir ? join(dir, '..', 'node_modules', 'sharp') : null,
28
+ 'sharp',
29
+ ].filter(Boolean)
30
+ for (const c of candidates) {
31
+ try { return require(c) } catch (e) { /* 试下一个 */ }
32
+ }
33
+ throw new Error('找不到 sharp:请设置环境变量 WISP_SHARP 指向 sharp 包目录')
34
+ }
35
+
36
+ const argv = process.argv.slice(2)
37
+ const flag = (name, fallback) => {
38
+ const at = argv.indexOf(name)
39
+ return at >= 0 && argv[at + 1] !== undefined ? Number(argv[at + 1]) : fallback
40
+ }
41
+ const paths = argv.filter((a) => !a.startsWith('--') && !/^[\d.]+$/.test(a))
42
+ const [inPath, outPath] = paths
43
+
44
+ if (!inPath || !outPath) {
45
+ console.error('用法: node tools/keyout.mjs <绿幕图.png> <输出.png> [--t0 14] [--t1 96]')
46
+ process.exit(1)
47
+ }
48
+
49
+ // 判定为「绿」的占优阈值:< T0 完全不透明,> T1 完全透明
50
+ const T0 = flag('--t0', 14)
51
+ const T1 = flag('--t1', 96)
52
+
53
+ const sharp = loadSharp()
54
+ const { data, info } = await sharp(inPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
55
+ const { width: W, height: H, channels } = info
56
+ if (channels !== 4) throw new Error(`期望 4 通道,实得 ${channels}`)
57
+
58
+ const out = Buffer.alloc(W * H * 4)
59
+ let opaque = 0
60
+ let clear = 0
61
+ let soft = 0
62
+ let minX = W, minY = H, maxX = -1, maxY = -1
63
+
64
+ for (let i = 0, p = 0; i < W * H; i++, p += 4) {
65
+ const r = data[p]
66
+ const g = data[p + 1]
67
+ const b = data[p + 2]
68
+ const dom = g - Math.max(r, b)
69
+ let a = 1 - (dom - T0) / (T1 - T0)
70
+ a = a < 0 ? 0 : a > 1 ? 1 : a
71
+ if (a >= 1) opaque++
72
+ else if (a <= 0) clear++
73
+ else {
74
+ soft++
75
+ data[p + 1] = Math.min(g, Math.max(r, b)) // 去绿边
76
+ }
77
+ out[p] = data[p]
78
+ out[p + 1] = data[p + 1]
79
+ out[p + 2] = b
80
+ out[p + 3] = Math.round(a * 255)
81
+ if (a > 0.5) {
82
+ const x = i % W
83
+ const y = (i - x) / W
84
+ if (x < minX) minX = x
85
+ if (x > maxX) maxX = x
86
+ if (y < minY) minY = y
87
+ if (y > maxY) maxY = y
88
+ }
89
+ }
90
+
91
+ await sharp(out, { raw: { width: W, height: H, channels: 4 } }).png().toFile(outPath)
92
+
93
+ const pct = (n) => `${(n / (W * H) * 100).toFixed(2)}%`
94
+ console.log(`${inPath.split(/[\\/]/).pop()} -> ${outPath.split(/[\\/]/).pop()}`)
95
+ console.log(` 不透明 ${pct(opaque)} 半透明边缘 ${pct(soft)} 全透明 ${pct(clear)}`)
96
+ if (maxX < 0) {
97
+ console.error(' 警告:整张图都被判成背景,输出是全透明的 —— 多半是这张图不是绿幕底')
98
+ process.exit(2)
99
+ }
100
+ console.log(` 角色范围 x ${minX}..${maxX} (${((maxX - minX + 1) / W * 100).toFixed(1)}% 宽) y ${minY}..${maxY} (${((maxY - minY + 1) / H * 100).toFixed(1)}% 高)`)
101
+ if (soft / (W * H) > 0.05) {
102
+ console.error(' 警告:半透明边缘占比过高,源图可能不是干净的绿幕')
103
+ process.exit(2)
104
+ }