dsh-cosplay-studio 0.0.0-stage → 2.0.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 +145 -2
- package/cordis.patch.yml +5 -0
- package/lib/card.js +350 -0
- package/lib/client.js +1169 -0
- package/lib/comfy.js +652 -0
- package/lib/hot.js +357 -0
- package/lib/index.js +811 -0
- package/lib/paths.js +175 -0
- package/lib/project.js +355 -0
- package/lib/tools/__pycache__/finalize.cpython-312.pyc +0 -0
- package/lib/tools/__pycache__/skin_makeup.cpython-312.pyc +0 -0
- package/lib/tools/finalize.py +248 -0
- package/lib/tools/skin_makeup.py +241 -0
- package/package.json +52 -4
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Cosplay Studio(dsh-cosplay-studio)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 `cosplay-photo-set` 技能 v2.1 的整套流程做成 DSH 插件:**输入角色名、上传一张参考图,或者什么都不给让插件推荐热门角色 → 真人 cosplay 套图**。
|
|
4
|
+
|
|
5
|
+
所有出图都在本机 ComfyUI(Z-Image-Turbo)上完成,图片不上传云端。
|
|
6
|
+
|
|
7
|
+
## 效果图
|
|
8
|
+
|
|
9
|
+
本机 ComfyUI + Z-Image-Turbo,1440×2560 成片(芙莉莲 / 芙宁娜):
|
|
10
|
+
|
|
11
|
+
| | | |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
|  |  |  |
|
|
14
|
+
|  |  |  |
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 一、安装
|
|
19
|
+
|
|
20
|
+
### 方式 A:装进本机 DSH 桌面端(推荐)
|
|
21
|
+
|
|
22
|
+
1. 把整个 `dsh-cosplay-studio` 目录放进 profile 的 `node_modules`:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
D:\DSH\profiles\desktop\node_modules\dsh-cosplay-studio
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
2. 编辑 `D:\DSH\profiles\desktop\package.json`:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"dsh-cosplay-studio": "2.0.2"
|
|
34
|
+
},
|
|
35
|
+
"dsh": {
|
|
36
|
+
"profile": {
|
|
37
|
+
"bundles": ["...已有项...", "dsh-cosplay-studio"]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
3. **完全退出并重新打开 DSH 桌面端**(关闭窗口通常只是隐藏,需要在托盘/任务栏退出应用),再刷新页面。侧边栏出现 **Cosplay 工作台** 图标;设置页也会出现 **Cosplay Studio** 一节。
|
|
44
|
+
|
|
45
|
+
### 方式 B:打包分发
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pnpm pack # 产出 dsh-cosplay-studio-2.0.2.tgz
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
把 `.tgz` 或整个目录发给别人;对方按方式 A 解压/放入 `node_modules` 并在 `package.json` 登记即可。发布到 npm 后可走插件市场安装。
|
|
52
|
+
|
|
53
|
+
### 方式 C:从 GitHub 安装(插件市场收录后)
|
|
54
|
+
|
|
55
|
+
仓库地址:<https://github.com/Wang-Shuanglin/dsh-cosplay-studio>
|
|
56
|
+
|
|
57
|
+
不打包、不构建也能装:GitHub Release 上有一份预构建的 `dsh-cosplay-studio.tgz`(不含截图,约 60 KB),下载后按方式 A 解压进 `node_modules` 即可。
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 下载最新预构建包
|
|
61
|
+
curl -L -o dsh-cosplay-studio.tgz \
|
|
62
|
+
https://github.com/Wang-Shuanglin/dsh-cosplay-studio/releases/latest/download/dsh-cosplay-studio.tgz
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
解压后目录结构是 `package/`,把它重命名成 `dsh-cosplay-studio` 放进 profile 的 `node_modules`,再按方式 A 第 2、3 步登记并重启。
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 二、第一次使用:教程 → 同意 → 自动部署 → 自动调试
|
|
70
|
+
|
|
71
|
+
插件第一次打开时**不会直接开工**,而是先展示六章教程:
|
|
72
|
+
|
|
73
|
+
1. 这个插件做什么
|
|
74
|
+
2. 部署会装什么(ComfyUI + 三个模型)
|
|
75
|
+
3. 自动调试什么(模型能否加载、采样能否出图、显存是否够)
|
|
76
|
+
4. 硬件与时间(NVIDIA 8GB+;1152×2048 约 75 秒,1440×2560 约 4 分钟)
|
|
77
|
+
5. 两道人工闸门(基准图、镜头脚本)
|
|
78
|
+
6. 内容边界(一律 18 岁以上成人形象)
|
|
79
|
+
|
|
80
|
+
勾选同意后可以选择:
|
|
81
|
+
|
|
82
|
+
- **同意并开始部署**:自动定位/安装 ComfyUI、补齐模型、启动服务,并跑一次 640×1152 / 4 步自检(约 11 秒)。本机已装过 ComfyUI 与模型时只做复用与校验,不会重复下载。
|
|
83
|
+
- **仅同意,稍后手动部署**:只记录同意,不碰 ComfyUI。
|
|
84
|
+
|
|
85
|
+
部署与自检的结果、每一步日志、运行环境(ComfyUI 路径、Python、服务状态、显卡、模型是否齐全)都显示在「安装与设置」页。
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 三、工作台:三种入口
|
|
90
|
+
|
|
91
|
+
| 入口 | 做法 | 说明 |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| A 角色名 | 输入角色名(可加作品名) | 配了对话模型时会联网式补全角色设定;没配就用内置模板 |
|
|
94
|
+
| B 参考图 | 上传一张二次元角色图 | 图是唯一设计依据,按图上服装配饰拆解 |
|
|
95
|
+
| C 随机推荐 | 点「随机推荐 3 个热门角色」/「换一批」 | 内置 28 个近年高热角色池;配了对话模型时会额外合并当季推荐 |
|
|
96
|
+
|
|
97
|
+
选定角色后点「创建项目」。
|
|
98
|
+
|
|
99
|
+
### 两道闸门
|
|
100
|
+
|
|
101
|
+
1. **基准图**:先生成 1152×2048 基准图。确认人物、服装、背景都对了,再点「确认基准图,进入镜头脚本」。未确认前不会出任何成片。
|
|
102
|
+
2. **镜头脚本**:生成 4–6 镜(每镜含情境/表情/动作/手/机位/道具),可逐镜改。确认后才会跑 1440×2560 成片。
|
|
103
|
+
|
|
104
|
+
### 出片与导出
|
|
105
|
+
|
|
106
|
+
成片按技能流程处理:**P7 去油光 → P7b 身体化妆(腿脚肤色统一)→ 导出 JPEG(≤1.95MB)**,并生成:
|
|
107
|
+
|
|
108
|
+
- `00_基准角色_<角色>.jpg`
|
|
109
|
+
- `00_全套预览.jpg`(2×2 拼图)
|
|
110
|
+
- `01_动作名.jpg`、`02_动作名.jpg` …
|
|
111
|
+
- `文案.txt`(标题 3 条 / 正文 / 标签)
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 四、设置页入口与侧边栏开关
|
|
116
|
+
|
|
117
|
+
- 设置页 `设置 → Cosplay Studio` 里可以:打开工作台、重新检测环境、启动/停止 ComfyUI、开关「侧边栏显示 Cosplay 工作台」、开关「成片自动做身体化妆」。
|
|
118
|
+
- 侧边栏图标被隐藏后,主面板依然存在,从设置页点「打开工作台」即可进入。
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 五、配置项(`<DSH_HOME>/data/dsh-cosplay-studio/config.json`)
|
|
123
|
+
|
|
124
|
+
| 键 | 默认 | 说明 |
|
|
125
|
+
| --- | --- | --- |
|
|
126
|
+
| `comfyDir` | 自动探测 | ComfyUI 目录 |
|
|
127
|
+
| `comfyPort` | 8188 | ComfyUI 端口 |
|
|
128
|
+
| `pythonExe` | 自动探测 | 运行 ComfyUI 的 Python |
|
|
129
|
+
| `baseSize` | `1152x2048` | 基准图尺寸 |
|
|
130
|
+
| `finalSize` | `1440x2560` | 成片尺寸 |
|
|
131
|
+
| `steps` / `cfg` / `shift` | 6 / 2 / 3 | 采样参数 |
|
|
132
|
+
| `vaeTileSize` / `vaeOverlap` | 1024 / 128 | 放大重绘的分块,避免背景接缝 |
|
|
133
|
+
| `deoil` | true | P7 去油光 |
|
|
134
|
+
| `bodyMakeup` | true | P7b 身体化妆 |
|
|
135
|
+
| `makeupStrength` / `legStrength` | 0.35 / 0.85 | P7b 强度(与技能脚本一致) |
|
|
136
|
+
| `llm.baseUrl` / `llm.apiKey` / `llm.model` | 空 | 可选:OpenAI 兼容对话模型,用于角色情报与镜头脚本 |
|
|
137
|
+
| `showSidebar` | true | 是否在侧边栏显示入口 |
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 六、已知限制
|
|
142
|
+
|
|
143
|
+
- 只支持 NVIDIA GPU(8GB 显存起步);4K 会 OOM。
|
|
144
|
+
- 姿势、鞋、手套等细节在 6 步采样下仍可能漂移,插件不做自动视觉质检,需要逐镜改与重跑。
|
|
145
|
+
- 未配置对话模型时,角色卡与镜头脚本使用内置模板(姿势与镜头组合固定)。
|
|
146
|
+
- 背景渐变颜色刻意避开米/沙/驼等接近肤色的色系,否则 P7b 的肤色遮罩会把背景一起提亮。
|
package/cordis.patch.yml
ADDED
package/lib/card.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Character card + shot script: the textual core of the cosplay-photo-set skill (v2.1).
|
|
2
|
+
//
|
|
3
|
+
// The card is a nine-paragraph English prompt (opening + BACKDROP / AGE / HEAD AND
|
|
4
|
+
// HAIR / FACE / SKIN / COSTUME / POSE / HANDS / LIGHTING) whose POSE paragraph is
|
|
5
|
+
// the only placeholder. Every shot reuses the card verbatim and swaps only the POSE
|
|
6
|
+
// line, which is what keeps the same face / costume / backdrop across the whole set.
|
|
7
|
+
//
|
|
8
|
+
// When the user configured an OpenAI-compatible chat model the plugin asks it for
|
|
9
|
+
// the character details and for the shooting script; otherwise it falls back to the
|
|
10
|
+
// rule based writer below (character name + palette + safe poses).
|
|
11
|
+
|
|
12
|
+
// Fixed by the skill: only this sentence reliably keeps the feet inside the frame.
|
|
13
|
+
const FRAMING = 'full-length portrait on a clean seamless paper backdrop, framed from a distance of a few steps so the whole figure fits inside the frame, the body occupying about two thirds of the frame height, with a generous strip of empty backdrop above her head and a wide strip of empty floor below her shoes.'
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_POSE = 'standing in a relaxed three-quarter turn toward the camera, weight on the back leg, chin slightly lowered, eyes meeting the lens, shoulders relaxed'
|
|
16
|
+
|
|
17
|
+
// HANDS is a fixed paragraph in v2.1 — it never changes across shots.
|
|
18
|
+
export const DEFAULT_HANDS = 'exactly five fingers on each hand, fingers clearly separated, natural knuckles, no extra or fused fingers'
|
|
19
|
+
|
|
20
|
+
// Poses with a turned body tend to crop the legs. This sentence rides along in
|
|
21
|
+
// the POSE line so every shot still frames the whole figure head to shoes.
|
|
22
|
+
const POSE_FRAMING = 'The entire figure stays inside the frame from the top of the head down to the shoes, with empty backdrop above the head and empty floor around the feet.'
|
|
23
|
+
|
|
24
|
+
// Backdrops stay off the beige / sand / terracotta corner of the colour wheel:
|
|
25
|
+
// the P7b body-makeup mask treats low-saturation tan as skin, so a skin-toned
|
|
26
|
+
// paper backdrop gets brightened and left with visible streaks.
|
|
27
|
+
const PALETTES = [
|
|
28
|
+
['ivory white', 'dusty rose'],
|
|
29
|
+
['sky blue', 'pale grey'],
|
|
30
|
+
['soft lilac', 'deep plum'],
|
|
31
|
+
['mint green', 'sage grey'],
|
|
32
|
+
['cherry red', 'pale grey'],
|
|
33
|
+
['midnight navy', 'silver grey'],
|
|
34
|
+
['rose pink', 'mauve'],
|
|
35
|
+
['teal green', 'charcoal grey'],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const SKIN_TONES = ['warm light beige', 'cool fair porcelain', 'warm honey beige']
|
|
39
|
+
|
|
40
|
+
const DEFAULT_BANGS = 'fine feathery bangs lifted slightly off the forehead'
|
|
41
|
+
|
|
42
|
+
const DEFAULT_FACE = 'a soft oval face with a soft jawline, dark brown almond eyes with the outer corner slightly upturned, gently arched brows, a small straight nose, soft coral lips with the lower lip slightly fuller'
|
|
43
|
+
|
|
44
|
+
function hashText(text) {
|
|
45
|
+
let hash = 0
|
|
46
|
+
for (const char of String(text)) hash = (hash * 31 + char.codePointAt(0)) % 1_000_003
|
|
47
|
+
return hash
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function defaultDetails(character, series = '') {
|
|
51
|
+
const palette = PALETTES[hashText(character + series) % PALETTES.length]
|
|
52
|
+
return {
|
|
53
|
+
character: String(character ?? '').trim() || 'a mysterious heroine',
|
|
54
|
+
series: String(series ?? '').trim(),
|
|
55
|
+
genderWord: 'woman',
|
|
56
|
+
hair: 'long straight jet-black hair falling past the shoulder blades, the ends cleanly cut, a few fine strands framing the jaw',
|
|
57
|
+
face: DEFAULT_FACE,
|
|
58
|
+
eyeColor: 'dark brown',
|
|
59
|
+
skinTone: SKIN_TONES[hashText(character) % SKIN_TONES.length],
|
|
60
|
+
bangsRule: DEFAULT_BANGS,
|
|
61
|
+
eyeCover: '',
|
|
62
|
+
braid: '',
|
|
63
|
+
costume: `a faithful, well-made cosplay rendition of ${String(character ?? 'the character').trim()}'s signature outfit, covering the exact top, skirt or trousers, sleeves, belt and accessories, made in fabric and leather with visible stitching, believable material weight and clean seams, worn over a natural body with realistic proportions, together with the character's matching boots worn on both feet and, where the design calls for them, her gloves`,
|
|
64
|
+
paletteTop: palette[0],
|
|
65
|
+
paletteBottom: palette[1],
|
|
66
|
+
props: 'empty hands',
|
|
67
|
+
personality: 'composed, quietly confident, a little guarded',
|
|
68
|
+
identity: 'a wandering swordswoman',
|
|
69
|
+
element: 'wind',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildCard(details) {
|
|
74
|
+
const d = { ...defaultDetails(details.character ?? '', details.series ?? ''), ...details }
|
|
75
|
+
const from = d.series ? ` from ${d.series}` : ''
|
|
76
|
+
const bangs = String(d.bangsRule ?? '').trim()
|
|
77
|
+
const eyeCover = String(d.eyeCover ?? '').trim()
|
|
78
|
+
const braid = String(d.braid ?? '').trim()
|
|
79
|
+
const skinTone = String(d.skinTone ?? '').trim() || 'warm light beige'
|
|
80
|
+
return [
|
|
81
|
+
`Photorealistic live-action cosplay photograph of a real 18-year-old East Asian ${d.genderWord} cosplaying ${d.character}${from}, ${FRAMING}`,
|
|
82
|
+
'',
|
|
83
|
+
`BACKDROP: a flat seamless paper backdrop with an extremely smooth vertical gradient from ${d.paletteTop} at the top to ${d.paletteBottom} at the bottom, colors taken from the character's signature palette, perfectly smooth, evenly lit and completely empty, the shot contains only the person against the empty backdrop.`,
|
|
84
|
+
'',
|
|
85
|
+
'AGE: eighteen years old, young adult, fresh youthful face, adolescent-to-young-adult proportions, no mature adult styling, no heavy grown-up makeup.',
|
|
86
|
+
'',
|
|
87
|
+
`HEAD AND HAIR: ${d.hair}.${bangs === '' ? '' : ` ${bangs}.`}${eyeCover === '' ? '' : ` ${eyeCover}`} The forehead is kept smooth and clear, no dark patches, no markings.${braid === '' ? '' : ` ${braid}`} The crown is smooth and flat against the head, the hair hanging down in clean smooth locks close to the body, built from very fine thin individual strands, softly separated with soft light between them, matte with no plastic sheen, no greasy clumps, no wet-look shine.`,
|
|
88
|
+
'',
|
|
89
|
+
`FACE: young East Asian bone structure of an eighteen-year-old, ${d.face}, ${d.eyeColor ?? 'dark brown'} eyes of natural size (not oversized, not doll eyes), thin natural eyeliner, soft coral lips. Realistic skin with visible fine pores, one clean natural warm skin tone, dry powder-matte finish on the face, shoulders, arms and back, skin looks freshly powdered like a real cosplayer at a shoot. No beauty retouching, no airbrushing.`,
|
|
90
|
+
'',
|
|
91
|
+
`SKIN: one even ${skinTone} skin tone with a soft peach undertone from her face all the way down to her feet, the neck, shoulders, arms, thighs, knees, shins, ankles and the tops of her feet all the same healthy tone, softly powdered and evenly toned like a sheer body foundation, gentle natural shading kept along the calves and knees, smooth clear skin with fine real texture, the legs warm and softly lit, the same tone as her face, like a cosplayer who has applied body makeup before the shoot.`,
|
|
92
|
+
'',
|
|
93
|
+
`COSTUME: ${d.costume}.`,
|
|
94
|
+
'',
|
|
95
|
+
`POSE: ${DEFAULT_POSE}.`,
|
|
96
|
+
'',
|
|
97
|
+
`HANDS: ${DEFAULT_HANDS}.`,
|
|
98
|
+
'',
|
|
99
|
+
'LIGHTING AND CAMERA: soft key light from camera left slightly above eye level, soft fill from the right, gentle falloff, even exposure with natural skin tone, highlights are soft and controlled. Sony A7R IV, 85mm f/1.8, ISO 800, shallow depth of field, fine film grain. The face and eyes are tack-sharp and in critical focus, no motion blur on the subject, the backdrop stays a smooth even gradient top to bottom. Ultra photorealistic, looks like a real person photographed at a cosplay shoot, not anime, not illustration, not 3D render, no plastic waxy skin. Vertical 9:16 composition.',
|
|
100
|
+
].join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Replace only the POSE line; HANDS / every other paragraph stays verbatim. */
|
|
104
|
+
export function shotPrompt(card, shot) {
|
|
105
|
+
const pose = String(shot.pose ?? '').trim() || DEFAULT_POSE
|
|
106
|
+
const hands = String(shot.hands ?? '').trim()
|
|
107
|
+
const props = String(shot.props ?? '').trim()
|
|
108
|
+
const parts = [pose]
|
|
109
|
+
if (hands !== '') parts.push(hands)
|
|
110
|
+
parts.push(
|
|
111
|
+
props === '' || /^(no props|empty hands|none)$/i.test(props)
|
|
112
|
+
? 'her hands are empty and relaxed'
|
|
113
|
+
: `she holds ${props} lightly in one hand`,
|
|
114
|
+
)
|
|
115
|
+
if (!/inside the frame/i.test(pose)) parts.push(POSE_FRAMING)
|
|
116
|
+
const line = parts.join('. ').replace(/\.\.+/g, '.').replace(/\.$/, '')
|
|
117
|
+
return card.replace(/^POSE: .*$/m, `POSE: ${line}.`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* --------------------------------------------------------------- script */
|
|
121
|
+
|
|
122
|
+
const SHOT_ARCHETYPES = [
|
|
123
|
+
{
|
|
124
|
+
type: 'A 情绪特写',
|
|
125
|
+
title: '迎向镜头的瞬间',
|
|
126
|
+
situation: 'the first second of meeting the viewer, before she has decided how to react',
|
|
127
|
+
expression: 'lips slightly parted, eyes open and direct, a trace of guarded curiosity',
|
|
128
|
+
pose: 'standing square to the camera, chin level, shoulders relaxed, weight even on both feet',
|
|
129
|
+
hands: 'hands loose at her sides, fingers softly curled',
|
|
130
|
+
camera: 'eye level, a few steps back, the full figure centred with air above and below',
|
|
131
|
+
props: 'empty hands',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
type: 'B 不看她',
|
|
135
|
+
title: '侧身望向远处',
|
|
136
|
+
situation: 'she has turned slightly away and is looking past the frame at something we cannot see',
|
|
137
|
+
expression: 'calm, faintly wistful, mouth closed',
|
|
138
|
+
pose: 'body in a three-quarter turn away from the camera, head turned further to the side, one shoulder forward',
|
|
139
|
+
hands: 'one hand resting lightly at her waist, the other hanging relaxed',
|
|
140
|
+
camera: 'slightly off-axis to the side, same distance, full figure',
|
|
141
|
+
props: 'empty hands',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: 'C 动态抓拍',
|
|
145
|
+
title: '转身的余韵',
|
|
146
|
+
situation: 'caught mid-motion just after a turn, hair and clothes still settling',
|
|
147
|
+
expression: 'a small confident half-smile, eyes bright',
|
|
148
|
+
pose: 'mid-step with one foot forward, torso twisted a little, hair and hem swinging',
|
|
149
|
+
hands: 'one hand lifted to chest height with fingers open, the other swung back',
|
|
150
|
+
camera: 'slightly lower than eye level, same framing distance',
|
|
151
|
+
props: 'empty hands',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: 'D 标志动作 + 道具',
|
|
155
|
+
title: '握着她的标志物',
|
|
156
|
+
situation: 'she has just taken hold of her signature prop and is holding it ready',
|
|
157
|
+
expression: 'steady, focused, a quiet confidence',
|
|
158
|
+
pose: 'standing with the body angled, one hand raised to hold the prop at chest height',
|
|
159
|
+
hands: 'the prop held lightly in one hand, the other hand relaxed at her side',
|
|
160
|
+
camera: 'eye level, a step closer so the prop reads clearly in the frame',
|
|
161
|
+
props: 'the character signature prop',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
type: 'E 极端机位',
|
|
165
|
+
title: '从高处俯视',
|
|
166
|
+
situation: 'seen from a high angle a few steps above her, the backdrop filling the frame around her',
|
|
167
|
+
expression: 'looking up at the camera, calm and a little defiant',
|
|
168
|
+
pose: 'standing still with her chin lifted, one hand on her hip, weight on one leg',
|
|
169
|
+
hands: 'one hand on her hip, the other loose at her side',
|
|
170
|
+
camera: 'high angle from slightly above, looking down, the whole figure kept inside the frame',
|
|
171
|
+
props: 'empty hands',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
type: 'F 坐蹲卧',
|
|
175
|
+
title: '坐在地上的片刻',
|
|
176
|
+
situation: 'sitting on the floor in the middle of the backdrop, taking a quiet break',
|
|
177
|
+
expression: 'relaxed, soft gaze lifted to the lens',
|
|
178
|
+
pose: 'sitting on the floor with both knees drawn up and her arms wrapped around her shins, head tipped slightly to one side',
|
|
179
|
+
hands: 'both hands clasped loosely around her knees',
|
|
180
|
+
camera: 'slightly above her eye level, close enough to fill the frame with her while keeping both shoes inside',
|
|
181
|
+
props: 'empty hands',
|
|
182
|
+
},
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
/** Rule based script: the six archetypes, no model required. */
|
|
186
|
+
export function ruleScript(details, count = 6) {
|
|
187
|
+
const wanted = Math.max(1, Math.min(SHOT_ARCHETYPES.length, Number(count) || 6))
|
|
188
|
+
return SHOT_ARCHETYPES.slice(0, wanted).map((shot, index) => ({
|
|
189
|
+
id: index + 1,
|
|
190
|
+
type: shot.type,
|
|
191
|
+
title: shot.title,
|
|
192
|
+
situation: shot.situation,
|
|
193
|
+
expression: shot.expression,
|
|
194
|
+
pose: shot.pose,
|
|
195
|
+
hands: shot.hands,
|
|
196
|
+
camera: shot.camera,
|
|
197
|
+
props: shot.props === 'the character signature prop' ? (details?.props || 'empty hands') : shot.props,
|
|
198
|
+
prompt: `${shot.type}|${shot.title}`,
|
|
199
|
+
}))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* --------------------------------------------------------- model helpers */
|
|
203
|
+
|
|
204
|
+
export function llmConfigured(config) {
|
|
205
|
+
const llm = config?.llm ?? {}
|
|
206
|
+
return typeof llm.baseUrl === 'string' && llm.baseUrl.trim() !== ''
|
|
207
|
+
&& typeof llm.model === 'string' && llm.model.trim() !== ''
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One OpenAI-compatible chat completion. Throws on any failure. */
|
|
211
|
+
export async function chatComplete(config, system, user, timeoutMs = 120_000) {
|
|
212
|
+
const llm = config.llm
|
|
213
|
+
const base = String(llm.baseUrl).trim().replace(/\/+$/, '')
|
|
214
|
+
const endpoint = /\/chat\/completions$/.test(base) ? base : `${base}/chat/completions`
|
|
215
|
+
const controller = new AbortController()
|
|
216
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
217
|
+
try {
|
|
218
|
+
const response = await fetch(endpoint, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: {
|
|
221
|
+
'content-type': 'application/json',
|
|
222
|
+
...(llm.apiKey ? { authorization: `Bearer ${llm.apiKey}` } : {}),
|
|
223
|
+
},
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
model: llm.model,
|
|
226
|
+
temperature: 0.8,
|
|
227
|
+
messages: [
|
|
228
|
+
{ role: 'system', content: system },
|
|
229
|
+
{ role: 'user', content: user },
|
|
230
|
+
],
|
|
231
|
+
}),
|
|
232
|
+
signal: controller.signal,
|
|
233
|
+
})
|
|
234
|
+
if (!response.ok) throw new Error(`对话模型返回 HTTP ${response.status}`)
|
|
235
|
+
const data = await response.json()
|
|
236
|
+
const text = data?.choices?.[0]?.message?.content
|
|
237
|
+
if (typeof text !== 'string' || text.trim() === '') throw new Error('对话模型没有返回内容')
|
|
238
|
+
return text.trim()
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(timer)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function extractJson(text) {
|
|
245
|
+
const fenced = String(text).match(/```(?:json)?\s*([\s\S]*?)```/i)
|
|
246
|
+
const raw = fenced ? fenced[1] : String(text)
|
|
247
|
+
const start = raw.search(/[[{]/)
|
|
248
|
+
const end = Math.max(raw.lastIndexOf('}'), raw.lastIndexOf(']'))
|
|
249
|
+
if (start < 0 || end <= start) throw new Error('回复里没有 JSON')
|
|
250
|
+
return JSON.parse(raw.slice(start, end + 1))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const DETAILS_SYSTEM = `你是「真人 cosplay 影棚」的角色情报整理员。给定角色名与作品名,输出这个角色的摄影可用设定,只输出一个 JSON 对象,不要解释。
|
|
254
|
+
|
|
255
|
+
硬性要求:
|
|
256
|
+
1. 角色一律按 18 岁以上成年人处理;不写任何年龄之外的性化描述。
|
|
257
|
+
2. 只写「画面里要出现什么」的正向描述。禁止出现 not / no / without / avoid 这类负向句,禁止把「不要的东西」写出来(写了反而会被画进画面)。
|
|
258
|
+
3. 禁止出现摄影棚器材实景词:softbox、light stand、strobe、reflector、studio、wide-angle、spotlight、vignette。
|
|
259
|
+
4. 背景永远是纯色渐变纸幕,不写任何实景、道具、家具、窗、植物;渐变颜色必须避开米色、沙色、驼色、浅棕这类与肤色接近的颜色,改用紫、蓝、粉、绿、深红、灰等有明确色相的颜色(否则后期身体化妆的肤色遮罩会把背景一起提亮,留下脏边)。
|
|
260
|
+
5. 服装逐件写清楚:上装、下装/裙、外套、腰带、袜、鞋、配饰,含材质与主色;有标志性靴子/手套必须写进 costume。
|
|
261
|
+
6. props 描一个能单手轻握的标志道具;没有就写 empty hands。
|
|
262
|
+
7. 所有描述都用英文,只有 note 用中文。
|
|
263
|
+
|
|
264
|
+
JSON 字段(全部字符串):
|
|
265
|
+
{
|
|
266
|
+
"character": "角色英文名",
|
|
267
|
+
"series": "作品英文名,未知则空字符串",
|
|
268
|
+
"genderWord": "woman 或 man",
|
|
269
|
+
"hair": "发型发色,含长度、分缝、碎发、发饰",
|
|
270
|
+
"bangsRule": "有刘海写 fine feathery bangs lifted slightly off the forehead;无刘海写 center-parted and swept back off the face",
|
|
271
|
+
"eyeCover": "有一只眼被遮住时写一句英文(例 a long soft fringe swept low across her right brow and falling straight over her right eye, that one eye hidden behind the fringe, the rest of her face clear and her left eye open);不遮眼写空字符串",
|
|
272
|
+
"braid": "有细辫写一句英文(例 one slim braid falling in front of her right shoulder);没有写空字符串",
|
|
273
|
+
"eyeColor": "瞳色英文,例如 dark brown",
|
|
274
|
+
"face": "脸型、眼型、眉、鼻、唇,含 18 岁的骨骼感",
|
|
275
|
+
"skinTone": "warm light beige 或 cool fair porcelain 或 warm honey beige(按角色肤色冷暖选一个)",
|
|
276
|
+
"costume": "整套服装逐件描述:上装、下装/裙、外套、腰带、袜、鞋、配饰,含材质与颜色",
|
|
277
|
+
"paletteTop": "背景渐变上方颜色(英文颜色词,取自角色配色)",
|
|
278
|
+
"paletteBottom": "背景渐变下方颜色",
|
|
279
|
+
"props": "标志性道具的英文描述;没有写 empty hands",
|
|
280
|
+
"personality": "性格三词(英文)",
|
|
281
|
+
"identity": "身份设定(英文短语)",
|
|
282
|
+
"element": "元素/能力(英文短语)",
|
|
283
|
+
"note": "一句中文备注:你判断最难还原的一件配饰"
|
|
284
|
+
}`
|
|
285
|
+
|
|
286
|
+
const SCRIPT_SYSTEM = `你是真人 cosplay 棚拍的摄影指导。给定角色设定,设计一组镜头脚本,只输出 JSON 数组,不要解释。
|
|
287
|
+
|
|
288
|
+
硬性要求:
|
|
289
|
+
1. 每一镜必须同时给出六项:情境(situation)、表情(expression)、动作(pose)、手(hands)、机位(camera)、道具(props)。
|
|
290
|
+
2. 差异化:表情不重样;至少一镜不看镜头;机位至少两种;手的位置每镜不同;动作里包含站、坐/蹲、动态抓拍、道具镜头中的至少三种。
|
|
291
|
+
3. 姿势必须完整落在画面内:全身从头到鞋,头顶留空、脚下留地;不要写只拍半身或裁掉腿的机位。
|
|
292
|
+
4. 只写正向画面,禁止 not / no / without 这类负向句;禁止摄影棚器材实景词(softbox、light stand、strobe、reflector、studio、spotlight)。
|
|
293
|
+
5. 手部宁少勿多:能一手扶腰、一手垂放就够;道具只写单手轻握的一件;没有道具写 empty hands。
|
|
294
|
+
6. 角色一律 18 岁以上成年人;不写性化、暴露或暗示性动作。
|
|
295
|
+
7. 每组 4–6 镜,全部英文(type 与 title 用中文,title 不超过 12 个字)。
|
|
296
|
+
|
|
297
|
+
每镜字段:
|
|
298
|
+
{"type":"A 情绪特写|B 不看她|C 动态抓拍|D 标志动作 + 道具|E 极端机位|F 坐蹲卧","title":"中文小标题","situation":"...","expression":"...","pose":"...","hands":"...","camera":"...","props":"..."}`
|
|
299
|
+
|
|
300
|
+
/** Ask the chat model for the character details (throws when unavailable). */
|
|
301
|
+
export async function researchCharacter(config, { character, series = '' } = {}) {
|
|
302
|
+
if (!llmConfigured(config)) throw new Error('未配置对话模型')
|
|
303
|
+
const user = [
|
|
304
|
+
`角色:${character}`,
|
|
305
|
+
series ? `作品:${series}` : '作品:如果不确定,按这个角色最知名的作品写',
|
|
306
|
+
'按系统提示输出一个 JSON 对象。',
|
|
307
|
+
].join('\n')
|
|
308
|
+
const text = await chatComplete(config, DETAILS_SYSTEM, user)
|
|
309
|
+
const parsed = extractJson(text)
|
|
310
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('角色情报不是对象')
|
|
311
|
+
return {
|
|
312
|
+
...defaultDetails(character, series),
|
|
313
|
+
...parsed,
|
|
314
|
+
character: String(parsed.character ?? character).trim() || character,
|
|
315
|
+
series: String(parsed.series ?? series).trim(),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Ask the chat model for the shot script (throws when unavailable). */
|
|
320
|
+
export async function writeScript(config, details, count = 6) {
|
|
321
|
+
if (!llmConfigured(config)) throw new Error('未配置对话模型')
|
|
322
|
+
const user = [
|
|
323
|
+
`角色:${details?.character ?? ''}(${details?.series || '作品未知'})`,
|
|
324
|
+
`性格:${details?.personality ?? ''}`,
|
|
325
|
+
`身份:${details?.identity ?? ''}`,
|
|
326
|
+
`元素/能力:${details?.element ?? ''}`,
|
|
327
|
+
`标志道具:${details?.props ?? 'empty hands'}`,
|
|
328
|
+
`镜头数量:${count}`,
|
|
329
|
+
'按系统提示输出 JSON 数组。',
|
|
330
|
+
].join('\n')
|
|
331
|
+
const text = await chatComplete(config, SCRIPT_SYSTEM, user)
|
|
332
|
+
const list = extractJson(text)
|
|
333
|
+
if (!Array.isArray(list) || list.length === 0) throw new Error('脚本不是数组')
|
|
334
|
+
return list.slice(0, 8).map((item, index) => {
|
|
335
|
+
const type = String(item.type ?? '').trim() || `${String.fromCharCode(65 + index)} 镜头`
|
|
336
|
+
const title = String(item.title ?? '').trim() || `镜头${index + 1}`
|
|
337
|
+
return {
|
|
338
|
+
id: index + 1,
|
|
339
|
+
type,
|
|
340
|
+
title,
|
|
341
|
+
situation: String(item.situation ?? '').trim(),
|
|
342
|
+
expression: String(item.expression ?? '').trim(),
|
|
343
|
+
pose: String(item.pose ?? '').trim(),
|
|
344
|
+
hands: String(item.hands ?? '').trim(),
|
|
345
|
+
camera: String(item.camera ?? '').trim(),
|
|
346
|
+
props: String(item.props ?? 'empty hands').trim() || 'empty hands',
|
|
347
|
+
prompt: `${type}|${title}`,
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
}
|