dsh-her 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +113 -0
- package/cordis.patch.yml +5 -0
- package/examples/workshop/README.md +20 -0
- package/lib/client.js +1602 -0
- package/lib/core/character.js +50 -0
- package/lib/core/cues.js +62 -0
- package/lib/core/echo.js +27 -0
- package/lib/core/index.js +10 -0
- package/lib/core/paths.js +14 -0
- package/lib/core/prompt.js +63 -0
- package/lib/core/sentences.js +115 -0
- package/lib/core/speakable.js +26 -0
- package/lib/core/voices.js +43 -0
- package/lib/core/wav.js +71 -0
- package/lib/core/workshop.js +330 -0
- package/lib/dsh/api.js +156 -0
- package/lib/dsh/body-install.js +137 -0
- package/lib/dsh/body-launcher.js +48 -0
- package/lib/dsh/config.js +27 -0
- package/lib/dsh/her-session.js +282 -0
- package/lib/dsh/import-status.js +6 -0
- package/lib/dsh/plugin.js +1010 -0
- package/lib/dsh/workshop.js +222 -0
- package/lib/index.js +11 -0
- package/lib/memory/digest.js +119 -0
- package/lib/memory/search.js +52 -0
- package/lib/memory/store.js +267 -0
- package/lib/protocol/index.js +39 -0
- package/lib/server/brain-server.js +219 -0
- package/lib/types/core/character.d.ts +32 -0
- package/lib/types/core/cues.d.ts +20 -0
- package/lib/types/core/echo.d.ts +10 -0
- package/lib/types/core/index.d.ts +10 -0
- package/lib/types/core/paths.d.ts +11 -0
- package/lib/types/core/prompt.d.ts +39 -0
- package/lib/types/core/sentences.d.ts +40 -0
- package/lib/types/core/speakable.d.ts +6 -0
- package/lib/types/core/voices.d.ts +23 -0
- package/lib/types/core/wav.d.ts +9 -0
- package/lib/types/core/workshop.d.ts +156 -0
- package/lib/types/dsh/api.d.ts +163 -0
- package/lib/types/dsh/body-install.d.ts +58 -0
- package/lib/types/dsh/body-launcher.d.ts +28 -0
- package/lib/types/dsh/config.d.ts +34 -0
- package/lib/types/dsh/her-session.d.ts +215 -0
- package/lib/types/dsh/import-status.d.ts +17 -0
- package/lib/types/dsh/plugin.d.ts +34 -0
- package/lib/types/dsh/workshop.d.ts +94 -0
- package/lib/types/index.d.ts +11 -0
- package/lib/types/memory/digest.d.ts +33 -0
- package/lib/types/memory/search.d.ts +17 -0
- package/lib/types/memory/store.d.ts +132 -0
- package/lib/types/protocol/index.d.ts +434 -0
- package/lib/types/server/brain-server.d.ts +71 -0
- package/lib/types/voice/cloud-key.d.ts +10 -0
- package/lib/types/voice/cosyvoice.d.ts +47 -0
- package/lib/types/voice/edge.d.ts +52 -0
- package/lib/types/voice/engine.d.ts +30 -0
- package/lib/types/voice/models.d.ts +21 -0
- package/lib/types/voice/speech-queue.d.ts +55 -0
- package/lib/types/voice/voice.d.ts +75 -0
- package/lib/types/voice/worker.d.ts +37 -0
- package/lib/voice/cloud-key.js +23 -0
- package/lib/voice/cosyvoice.js +194 -0
- package/lib/voice/edge.js +199 -0
- package/lib/voice/engine.js +101 -0
- package/lib/voice/models.js +69 -0
- package/lib/voice/speech-queue.js +118 -0
- package/lib/voice/voice.js +171 -0
- package/lib/voice/worker.js +47 -0
- package/package.json +61 -4
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 形象工坊 (docs/形象工坊与多场景.md §4): a look as a project the user fills in,
|
|
3
|
+
* and the prompts and files made from it. Pure: the brain writes the files
|
|
4
|
+
* (dsh/workshop.ts), the "她" page edits the project.
|
|
5
|
+
*
|
|
6
|
+
* Prompts come in three layers:
|
|
7
|
+
* - rules: fixed, by kind (desk pet or scene);
|
|
8
|
+
* - who she is: one sentence from the character card, the same in every
|
|
9
|
+
* image and every clip, so she stays the same person;
|
|
10
|
+
* - the action: one per clip, preset or the user's own.
|
|
11
|
+
*/
|
|
12
|
+
export const PROJECT_FORMAT = 'dsh-her-workshop@1';
|
|
13
|
+
export const PROJECT_FILE = '形象.json';
|
|
14
|
+
export const REFERENCE_FILE = '参考图.png';
|
|
15
|
+
export const DESK_DIR = '桌宠';
|
|
16
|
+
export const sceneDir = (scene) => `场景-${safeName(scene.name)}`;
|
|
17
|
+
/** A folder or file name from what the user typed. */
|
|
18
|
+
export function safeName(text) {
|
|
19
|
+
return text.trim().replace(/[\\/:*?"<>|]+/g, '-').replace(/^\.+/, '') || '未命名';
|
|
20
|
+
}
|
|
21
|
+
/** Choices for each field of the card ("自己写" is always possible). Ages are adults only. */
|
|
22
|
+
export const CARD_CHOICES = {
|
|
23
|
+
gender: ['女', '男'],
|
|
24
|
+
age: ['二十出头', '二十多岁', '三十岁左右', '三十多岁'],
|
|
25
|
+
skin: ['白皙', '自然肤色', '小麦色', '深色'],
|
|
26
|
+
hairStyle: ['长直发', '长卷发', '中长发', '短发', '马尾', '丸子头'],
|
|
27
|
+
hairColor: ['黑色', '深棕色', '栗色', '金色', '银白色'],
|
|
28
|
+
build: ['纤细', '匀称', '健美', '丰满'],
|
|
29
|
+
outfit: ['家居服', '衬衫连衣裙', '卫衣和牛仔裤', '吊带和短裙', '西装套装', '针织衫和长裙', '汉服'],
|
|
30
|
+
accessories: ['无', '细框眼镜', '发卡', '耳环', '项链'],
|
|
31
|
+
style: ['写实照片', '日系动漫', '3D 卡通'],
|
|
32
|
+
};
|
|
33
|
+
export const CARD_LABELS = {
|
|
34
|
+
gender: '性别', age: '年龄', skin: '肤色', hairStyle: '发型', hairColor: '发色', build: '身材',
|
|
35
|
+
outfit: '衣服', accessories: '配饰', style: '画风', extra: '其他特征', sentence: '角色描述',
|
|
36
|
+
};
|
|
37
|
+
export const DEFAULT_CARD = {
|
|
38
|
+
gender: '女', age: '二十多岁', skin: '自然肤色', hairStyle: '长卷发', hairColor: '深棕色', build: '匀称',
|
|
39
|
+
outfit: '家居服', accessories: '无', style: '写实照片', extra: '', sentence: '',
|
|
40
|
+
};
|
|
41
|
+
/** Card fields that are about how she looks (not the art style). */
|
|
42
|
+
const subject = (card) => (card.gender === '男' ? '成年男性' : card.gender === '女' ? '成年女性' : `成年人(${card.gender})`);
|
|
43
|
+
/** "二十多岁的成年女性,自然肤色,深棕色长卷发,戴细框眼镜,穿家居服,身材匀称" — or the user's own sentence. */
|
|
44
|
+
export function describe(card) {
|
|
45
|
+
if (card.sentence.trim() !== '')
|
|
46
|
+
return card.sentence.trim();
|
|
47
|
+
const parts = [
|
|
48
|
+
`${card.age}的${subject(card)}`,
|
|
49
|
+
`${card.skin}皮肤`,
|
|
50
|
+
`${card.hairColor}${card.hairStyle}`,
|
|
51
|
+
...(card.accessories === '无' || card.accessories.trim() === '' ? [] : [`戴${card.accessories}`]),
|
|
52
|
+
`穿${card.outfit}`,
|
|
53
|
+
`身材${card.build}`,
|
|
54
|
+
...(card.extra.trim() === '' ? [] : [card.extra.trim()]),
|
|
55
|
+
];
|
|
56
|
+
return parts.join(',');
|
|
57
|
+
}
|
|
58
|
+
/** The art style as words for a prompt. */
|
|
59
|
+
const styleWords = (card) => (card.style === '写实照片' ? '写实摄影风格,真实皮肤质感,自然光' : card.style === '日系动漫' ? '日系动漫画风,干净线条' : card.style === '3D 卡通' ? '3D 卡通渲染风格' : card.style);
|
|
60
|
+
/** Green clothes would be keyed away with the green screen: then the background is grey. */
|
|
61
|
+
export function screenColor(card) {
|
|
62
|
+
return /绿/.test(`${card.outfit}${card.extra}${card.sentence}`) ? { name: '纯灰色', hex: '#808080' } : { name: '纯绿色', hex: '#00B140' };
|
|
63
|
+
}
|
|
64
|
+
export const POSE_WORDS = { stand: '站着', sit: '坐着', lie: '躺着' };
|
|
65
|
+
const WEB_HOW = '一段段生成:上传首帧,粘贴提示词(有"负面提示词"框就把负面提示词也粘进去,有"尾帧"就也放首帧那张图),下载后按文件名存进对应的文件夹。「生成清单.md」里每段都列好了。';
|
|
66
|
+
export const GENERATORS = {
|
|
67
|
+
minimax: {
|
|
68
|
+
name: 'MiniMax Code', label: 'MiniMax Code(给它任务文件,自动生成下载)',
|
|
69
|
+
settings: 'MiniMax-Hailuo-2.3 图生视频,1080P,6 秒。',
|
|
70
|
+
how: '在 MiniMax Code 里说:"按 <项目文件夹>/MiniMax生成任务.md 生成",它会一段段生成、下载、存好。',
|
|
71
|
+
},
|
|
72
|
+
comfyui: {
|
|
73
|
+
name: 'ComfyUI', label: 'ComfyUI(本地,Wan 2.2 等图生视频工作流)',
|
|
74
|
+
settings: '图生视频工作流(比如 Wan 2.2 I2V),尺寸和首帧同比例、短边 720 以上,约 5 秒(16 fps 时 81 帧),负面提示词放进负面节点,每段用同一套参数。',
|
|
75
|
+
how: '用你的图生视频工作流一段段跑,或者用脚本读「ComfyUI生成清单.json」(每段的首帧、提示词、负面提示词、保存路径)批量跑。输出存成清单里的文件名。',
|
|
76
|
+
},
|
|
77
|
+
jimeng: { name: '即梦', label: '即梦(网页 / App)', settings: '图生视频,1080P,5 秒。', how: `在即梦里${WEB_HOW}` },
|
|
78
|
+
kling: { name: '可灵', label: '可灵(网页 / App)', settings: '图生视频,1080P,5 秒。', how: `在可灵里${WEB_HOW}` },
|
|
79
|
+
hailuo: { name: '海螺 AI', label: '海螺 AI(MiniMax 的网页版)', settings: '图生视频,1080P,6 秒。', how: `在海螺 AI 里${WEB_HOW}` },
|
|
80
|
+
wanx: { name: '通义万相', label: '通义万相(网页 / 阿里云百炼)', settings: '图生视频,1080P,5 秒。', how: `在通义万相里${WEB_HOW}` },
|
|
81
|
+
vidu: { name: 'Vidu', label: 'Vidu(网页)', settings: '图生视频,1080P,4–5 秒。', how: `在 Vidu 里${WEB_HOW}` },
|
|
82
|
+
};
|
|
83
|
+
export const MANUAL_LABEL = '手工填写(别的工具,自己写名字和设置)';
|
|
84
|
+
/** The project's tool, a custom one filled in from `custom`. */
|
|
85
|
+
export function generatorInfo(project) {
|
|
86
|
+
if (project.generator !== 'manual' && project.generator in GENERATORS)
|
|
87
|
+
return GENERATORS[project.generator];
|
|
88
|
+
const name = project.custom?.name.trim() || '你用的工具';
|
|
89
|
+
const settings = project.custom?.settings.trim() || '图生视频,和首帧同比例,1080P,5–6 秒,每段用同一套设置。';
|
|
90
|
+
return { name, label: MANUAL_LABEL, settings, how: `在${name}里${WEB_HOW}` };
|
|
91
|
+
}
|
|
92
|
+
/** Preset actions; `{pose}` reads 站着 / 坐着 / 躺着. */
|
|
93
|
+
const PRESETS = [
|
|
94
|
+
{ role: 'idle', n: 1, required: true, action: '安静地{pose},自然呼吸,偶尔眨眼,头极轻微地晃动,嘴巴闭合,浅浅地笑' },
|
|
95
|
+
{ role: 'talk', n: 1, required: true, action: '看着镜头温柔地说话,嘴巴自然持续开合,轻轻点头,手有很小的动作。最后一秒闭嘴回到原姿势' },
|
|
96
|
+
{ role: 'talk', n: 2, required: true, action: '看着镜头说话,换一种节奏,略带笑意,嘴巴自然持续开合,一只手轻轻抬起又放下。最后一秒闭嘴回到原姿势' },
|
|
97
|
+
{ role: 'idle', n: 2, action: '安静地{pose},轻轻把一缕头发拨到耳后,然后恢复原姿势' },
|
|
98
|
+
{ role: 'listen', n: 1, action: '认真地听,嘴巴闭着,轻轻点头两次,眼神专注看着镜头' },
|
|
99
|
+
{ role: 'think', n: 1, action: '眼睛看向右上方像在思考,食指轻碰下巴,然后放下手,视线回到镜头' },
|
|
100
|
+
{ role: 'happy', n: 1, action: '开心地笑出来,眼睛弯起,肩膀轻微耸动,然后恢复浅笑' },
|
|
101
|
+
{ role: 'shy', n: 1, action: '害羞地低头,手指卷一下发梢,再抬眼看镜头,恢复原姿势' },
|
|
102
|
+
{ role: 'wave', n: 1, action: '看到你很开心,抬起右手朝镜头挥手,然后放下手恢复原姿势' },
|
|
103
|
+
{ role: 'touched', n: 1, action: '像被轻轻戳了一下,微微一惊,然后看着镜头笑,恢复原姿势' },
|
|
104
|
+
{ role: 'sleepy', n: 1, action: '打一个小哈欠,用手背轻揉眼睛,然后对镜头笑一下,恢复原姿势' },
|
|
105
|
+
];
|
|
106
|
+
/** The preset clips for a pose: the minimum ticked, the rest offered unticked. */
|
|
107
|
+
export function presetClips(pose) {
|
|
108
|
+
return PRESETS.map(p => ({
|
|
109
|
+
file: `${pose}_${p.role}_${String(p.n).padStart(2, '0')}`,
|
|
110
|
+
action: p.action.replaceAll('{pose}', POSE_WORDS[pose]),
|
|
111
|
+
...(p.required === true ? { required: true } : { off: true }),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
/** A clip of the user's own: an action name from what they typed (`<pose>_<name>_01`), not clashing with the others. */
|
|
115
|
+
export function customClip(pose, existing, action) {
|
|
116
|
+
let n = 1;
|
|
117
|
+
while (existing.some(c => c.file === `${pose}_act${n}_01`))
|
|
118
|
+
n++;
|
|
119
|
+
return { file: `${pose}_act${n}_01`, action: action.trim() };
|
|
120
|
+
}
|
|
121
|
+
export function newProject(id, name, now = new Date()) {
|
|
122
|
+
return {
|
|
123
|
+
format: PROJECT_FORMAT, id, name, card: { ...DEFAULT_CARD },
|
|
124
|
+
desk: { on: true, clips: presetClips('stand') },
|
|
125
|
+
scenes: [],
|
|
126
|
+
generator: 'minimax',
|
|
127
|
+
createdAt: now.toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function newScene(id, name, pose = 'stand') {
|
|
131
|
+
return { id, name, place: '', pose, clips: presetClips(pose) };
|
|
132
|
+
}
|
|
133
|
+
/** Fixed rules every clip carries, for its kind and the generator. */
|
|
134
|
+
export function rules(project, kind, scene) {
|
|
135
|
+
const common = [
|
|
136
|
+
'固定机位,单一连续镜头,不推拉摇移,不变焦,不切镜',
|
|
137
|
+
'脸、五官、发型、妆容、服装、身材比例全程完全不变',
|
|
138
|
+
'动作自然、克制、幅度小,真实的呼吸和眨眼',
|
|
139
|
+
'画面中不出现任何文字、字幕、水印、Logo,无背景音乐',
|
|
140
|
+
'视频开始和结束时,人物姿势与参考图完全一致',
|
|
141
|
+
];
|
|
142
|
+
if (kind === 'desk') {
|
|
143
|
+
const color = screenColor(project.card);
|
|
144
|
+
return [`参考图中的同一位${subject(project.card)},全身站立,${color.name}背景`, ...common,
|
|
145
|
+
`背景始终是均匀的${color.name}(${color.hex}),无地面、无阴影、无渐变、无任何物体,光照不变`,
|
|
146
|
+
'双脚始终站在原地,身体始终完整地在画面内(头顶和脚都不出画)'].join('。') + '。';
|
|
147
|
+
}
|
|
148
|
+
return [`参考图中的同一位${subject(project.card)},在${scene?.name ?? '场景'}里${POSE_WORDS[scene?.pose ?? 'stand']}`, ...common,
|
|
149
|
+
'背景与参考图完全一致且静止,光照不变', '人物不离开原位,身体始终在画面内'].join('。') + '。';
|
|
150
|
+
}
|
|
151
|
+
export const NEGATIVE = '换脸, 身份漂移, 发型变化, 服装变化, 年龄变化, 多余人物, 多余手指, 手部畸形, 肢体扭曲, 脚步移动, 走出画面, 瞬移, 镜头移动, 变焦, 切镜, 背景变化, 光线闪烁, 阴影出现, 文字, 字幕, 水印, logo, 夸张表情, 卡通化, 塑料皮肤, 模糊, 低清';
|
|
152
|
+
/** The full prompt of one clip: rules, who she is, the action. */
|
|
153
|
+
export function clipPrompt(project, kind, clip, scene) {
|
|
154
|
+
const prefix = project.generator === 'manual' ? project.custom?.prefix.trim() ?? '' : '';
|
|
155
|
+
return `${prefix === '' ? '' : `${prefix.replace(/[,。,.\s]+$/, '')}。`}${rules(project, kind, scene)}她是:${describe(project.card)}。${clip.action}。`;
|
|
156
|
+
}
|
|
157
|
+
/** The reference picture: her on a plain screen, full length. It is also the desk pet's anchor. */
|
|
158
|
+
export function referencePrompt(project) {
|
|
159
|
+
const color = screenColor(project.card);
|
|
160
|
+
return [
|
|
161
|
+
`${describe(project.card)}`,
|
|
162
|
+
'全身正面站立,双手自然垂在身侧,表情温和,看着镜头',
|
|
163
|
+
`${color.name}背景(${color.hex}),背景完全均匀,无地面、无阴影、无渐变、无任何物体`,
|
|
164
|
+
'人物居中,头顶和脚下各留一点空,竖版 9:16,1080×1920',
|
|
165
|
+
styleWords(project.card),
|
|
166
|
+
].join('。') + '。';
|
|
167
|
+
}
|
|
168
|
+
/** A scene's full-screen photo, made with the reference picture so she stays the same person. */
|
|
169
|
+
export function scenePhotoPrompt(project, scene, screen) {
|
|
170
|
+
const size = screen === undefined ? '横版 16:10,2560 宽以上' : `横版,和屏幕一样的比例,${screen.width}×${screen.height}`;
|
|
171
|
+
return [
|
|
172
|
+
`${scene.place.trim() === '' ? scene.name : scene.place.trim()}`,
|
|
173
|
+
`画面里有一位${describe(project.card)},${POSE_WORDS[scene.pose]},看着镜头`,
|
|
174
|
+
'用参考图保持人物完全一致(同一张脸、同样的发型和衣服)',
|
|
175
|
+
'人物完整地在画面中间偏一侧,头顶和脚下留出空间,人物高度约占画面的六成',
|
|
176
|
+
size,
|
|
177
|
+
styleWords(project.card),
|
|
178
|
+
].join('。') + '。';
|
|
179
|
+
}
|
|
180
|
+
/** Every clip to make, with where it goes and its prompt. */
|
|
181
|
+
export function plannedClips(project) {
|
|
182
|
+
const out = [];
|
|
183
|
+
if (project.desk.on) {
|
|
184
|
+
for (const clip of project.desk.clips.filter(c => c.off !== true)) {
|
|
185
|
+
out.push({ part: '桌宠', folder: DESK_DIR, first: REFERENCE_FILE, clip, prompt: clipPrompt(project, 'desk', clip) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const scene of project.scenes) {
|
|
189
|
+
for (const clip of scene.clips.filter(c => c.off !== true)) {
|
|
190
|
+
out.push({ part: scene.name, folder: sceneDir(scene), first: `${sceneDir(scene)}/crop.png`, clip, prompt: clipPrompt(project, 'scene', clip, scene) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/** 提示词.txt of one part (the desk pet, or a scene): every clip's prompt, ready to copy. */
|
|
196
|
+
export function promptsFile(project, part) {
|
|
197
|
+
const kind = part === 'desk' ? 'desk' : 'scene';
|
|
198
|
+
const scene = part === 'desk' ? undefined : part;
|
|
199
|
+
const clips = part === 'desk' ? project.desk.clips : part.clips;
|
|
200
|
+
const first = part === 'desk' ? `上一层的 ${REFERENCE_FILE}(复制进来改名叫 anchor_stand.png 也行)` : 'crop.png(先在「她」页面里框好人物区)';
|
|
201
|
+
return [
|
|
202
|
+
`${project.name} · ${part === 'desk' ? '桌宠' : part.name} · 视频提示词`,
|
|
203
|
+
'',
|
|
204
|
+
`用${generatorInfo(project).name}:${generatorInfo(project).settings}`,
|
|
205
|
+
`首帧(和尾帧,工具支持的话):${first}。比例和首帧一致。`,
|
|
206
|
+
'生成好的视频按下面的文件名命名(.mp4),放进这个文件夹。',
|
|
207
|
+
'',
|
|
208
|
+
`负面提示词:${NEGATIVE}`,
|
|
209
|
+
'',
|
|
210
|
+
...clips.filter(c => c.off !== true).flatMap(c => [`${c.file}.mp4${c.required === true ? '(必须)' : ''}`, clipPrompt(project, kind, c, scene), '']),
|
|
211
|
+
].join('\n');
|
|
212
|
+
}
|
|
213
|
+
/** 生成清单.md: every clip with its first frame, where it goes and its prompt, for any tool. */
|
|
214
|
+
export function clipList(project, root) {
|
|
215
|
+
const tool = generatorInfo(project);
|
|
216
|
+
const lines = [
|
|
217
|
+
`# ${project.name} · 视频片段清单(${tool.name})`,
|
|
218
|
+
'',
|
|
219
|
+
`统一设置:${tool.settings}`,
|
|
220
|
+
'提示词原样使用;首尾接不上没关系,导入时会自动处理成循环。已经有的 mp4 不用重做。',
|
|
221
|
+
'',
|
|
222
|
+
`负面提示词(工具有这个框就用):${NEGATIVE}`,
|
|
223
|
+
'',
|
|
224
|
+
];
|
|
225
|
+
for (const item of plannedClips(project)) {
|
|
226
|
+
lines.push(`## ${item.part} / ${item.clip.file}${item.clip.required === true ? '(必须)' : ''}`, `- 首帧:\`${root}/${item.first}\``, `- 保存到:\`${root}/${item.folder}/${item.clip.file}.mp4\``, `- 提示词:${item.prompt}`, '');
|
|
227
|
+
}
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
/** ComfyUI生成清单.json: the same list for a script that drives a ComfyUI workflow. */
|
|
231
|
+
export function comfyuiList(project, root) {
|
|
232
|
+
return `${JSON.stringify({
|
|
233
|
+
format: 'dsh-her-clips@1',
|
|
234
|
+
settings: GENERATORS.comfyui.settings,
|
|
235
|
+
negative: NEGATIVE,
|
|
236
|
+
clips: plannedClips(project).map(item => ({
|
|
237
|
+
part: item.part, name: item.clip.file, required: item.clip.required === true,
|
|
238
|
+
image: `${root}/${item.first}`, output: `${root}/${item.folder}/${item.clip.file}.mp4`, prompt: item.prompt,
|
|
239
|
+
})),
|
|
240
|
+
}, null, 2)}\n`;
|
|
241
|
+
}
|
|
242
|
+
/** The task file for MiniMax Code: one uniform setting, at most 5 at a time, download as each finishes. */
|
|
243
|
+
export function minimaxTask(project, root) {
|
|
244
|
+
const lines = [
|
|
245
|
+
`# ${project.name} · 视频片段生成任务(给 MiniMax Code)`,
|
|
246
|
+
'',
|
|
247
|
+
'按下面的清单生成视频,下载保存到指定路径。所有片段用完全相同的设置。',
|
|
248
|
+
'',
|
|
249
|
+
'## 统一设置',
|
|
250
|
+
'- 模型:**MiniMax-Hailuo-2.3**,图生视频,只给首帧(每段写了用哪张图)。',
|
|
251
|
+
'- 分辨率 **1080P**,时长 **6 秒**,静音,不加水印,不配乐。',
|
|
252
|
+
'- 提示词原样使用,不要改写、不要翻译、不要加任何前缀或说明。',
|
|
253
|
+
'- 首尾接不上没关系,导入时会自动处理成循环。',
|
|
254
|
+
'',
|
|
255
|
+
'## 怎么跑',
|
|
256
|
+
`- 临时文件放在 \`${root}/.minimax-work/\`,不要放进任何代码仓库。`,
|
|
257
|
+
'- 已经存在的 mp4 跳过,不要覆盖。',
|
|
258
|
+
'- 同时最多 5 个任务在生成:满了就先查询已提交的任务,**每段一完成就立刻下载**到它的保存路径(下载链接几小时后失效),腾出位置再提交下一段。',
|
|
259
|
+
'- 失败的段重试一次;还失败就记下来跳过,不要卡住。',
|
|
260
|
+
'- 除了新生成的 MP4,不要改动、删除这些文件夹里的任何文件。',
|
|
261
|
+
'- 全部结束后报告:每段成功/失败、分辨率、时长,失败的原因。',
|
|
262
|
+
'',
|
|
263
|
+
'## 清单',
|
|
264
|
+
'',
|
|
265
|
+
];
|
|
266
|
+
for (const item of plannedClips(project)) {
|
|
267
|
+
lines.push(`### ${item.part} / ${item.clip.file}`, `- 首帧:\`${root}/${item.first}\``, `- 保存到:\`${root}/${item.folder}/${item.clip.file}.mp4\``, `- 提示词:${item.prompt}`, '');
|
|
268
|
+
}
|
|
269
|
+
return lines.join('\n');
|
|
270
|
+
}
|
|
271
|
+
/** An age that is clearly grown up: 二十多岁, 三十岁左右, 25 岁, 成年 … */
|
|
272
|
+
export function isAdult(age) {
|
|
273
|
+
if (/成年|二十|三十|四十|五十|六十|七十/.test(age))
|
|
274
|
+
return true;
|
|
275
|
+
const years = [...age.matchAll(/(\d{1,3})\s*岁?/g)].map(m => Number(m[1]));
|
|
276
|
+
return years.length > 0 && years.every(n => n >= 18);
|
|
277
|
+
}
|
|
278
|
+
/** Check a project read from disk or sent by the page. */
|
|
279
|
+
export function validateProject(value) {
|
|
280
|
+
const p = value;
|
|
281
|
+
if (typeof p !== 'object' || p === null || p.format !== PROJECT_FORMAT)
|
|
282
|
+
throw new Error('不是形象工坊的项目');
|
|
283
|
+
if (typeof p.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(p.id))
|
|
284
|
+
throw new Error('项目 id 不对');
|
|
285
|
+
if (typeof p.name !== 'string' || p.name.trim() === '')
|
|
286
|
+
throw new Error('先给形象起个名字');
|
|
287
|
+
if (typeof p.card !== 'object' || p.card === null)
|
|
288
|
+
throw new Error('缺少角色卡');
|
|
289
|
+
for (const key of Object.keys(DEFAULT_CARD)) {
|
|
290
|
+
if (typeof p.card[key] !== 'string')
|
|
291
|
+
throw new Error(`角色卡缺少「${CARD_LABELS[key]}」`);
|
|
292
|
+
}
|
|
293
|
+
if (!isAdult(p.card.age))
|
|
294
|
+
throw new Error('年龄只能是成年人(比如"二十多岁"或"25 岁")');
|
|
295
|
+
if (!Array.isArray(p.scenes) || typeof p.desk !== 'object' || !Array.isArray(p.desk?.clips))
|
|
296
|
+
throw new Error('项目内容不完整');
|
|
297
|
+
if (!p.desk.on && p.scenes.length === 0)
|
|
298
|
+
throw new Error('桌宠和场景至少要有一个');
|
|
299
|
+
if (p.generator !== 'manual' && !(p.generator in GENERATORS))
|
|
300
|
+
throw new Error('不认识这个生成工具');
|
|
301
|
+
if (p.custom !== undefined && (typeof p.custom !== 'object' || p.custom === null || [p.custom.name, p.custom.settings, p.custom.prefix].some(v => typeof v !== 'string')))
|
|
302
|
+
throw new Error('手工填写的工具信息不对');
|
|
303
|
+
const names = new Set();
|
|
304
|
+
for (const scene of p.scenes) {
|
|
305
|
+
if (typeof scene.id !== 'string' || typeof scene.name !== 'string' || scene.name.trim() === '' || !Array.isArray(scene.clips))
|
|
306
|
+
throw new Error('场景要有名字');
|
|
307
|
+
if (names.has(sceneDir(scene)))
|
|
308
|
+
throw new Error(`有两个场景都叫「${scene.name}」`);
|
|
309
|
+
names.add(sceneDir(scene));
|
|
310
|
+
}
|
|
311
|
+
return p;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Example pictures and clips each card shows, by file name under
|
|
315
|
+
* `examples/workshop/` (in the plugin; a file of the same name in
|
|
316
|
+
* `<home>/examples/workshop/` wins). A missing one shows as a placeholder
|
|
317
|
+
* naming the file, until the finished showroom's material is put there.
|
|
318
|
+
*/
|
|
319
|
+
export const EXAMPLE_SLOTS = {
|
|
320
|
+
look: [{ file: 'look.png', label: '做好的她(角色卡这样填出来的样子)' }],
|
|
321
|
+
reference: [{ file: 'reference.png', label: '参考图:纯色背景、全身、正面站立' }],
|
|
322
|
+
desk: [{ file: 'desk.mp4', label: '桌宠片段(绿幕原片)' }, { file: 'desk-keyed.png', label: '抠好后浮在桌面上' }],
|
|
323
|
+
scene: [
|
|
324
|
+
{ file: 'scene-plate.png', label: '场景的全屏照片' },
|
|
325
|
+
{ file: 'scene-crop.png', label: '框好的人物区(crop.png)' },
|
|
326
|
+
{ file: 'scene.mp4', label: '场景片段' },
|
|
327
|
+
],
|
|
328
|
+
import: [{ file: 'result.png', label: '导入后桌面上的样子' }],
|
|
329
|
+
};
|
|
330
|
+
export const EXAMPLE_FILES = new Set(Object.values(EXAMPLE_SLOTS).flatMap(slots => slots.map(s => s.file)));
|
package/lib/dsh/api.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP endpoints for the "她" page, on DSH's own authenticated `/api` channel
|
|
3
|
+
* (`ctx.connection.fetch`), so every request has already passed DSH's browser
|
|
4
|
+
* authentication before it gets here.
|
|
5
|
+
*
|
|
6
|
+
* Every endpoint is POST with a JSON body and answers `{ ok: true, … }` or
|
|
7
|
+
* `{ ok: false, message }`. The page polls `/state`; changes answer with the
|
|
8
|
+
* fresh state, so the page never shows something the brain does not have.
|
|
9
|
+
*/
|
|
10
|
+
export const API_BASE = '/api/dsh-her';
|
|
11
|
+
class ApiError extends Error {
|
|
12
|
+
}
|
|
13
|
+
const str = (v, name) => {
|
|
14
|
+
if (typeof v !== 'string')
|
|
15
|
+
throw new ApiError(`${name} 不对`);
|
|
16
|
+
return v;
|
|
17
|
+
};
|
|
18
|
+
const num = (v, name) => {
|
|
19
|
+
const n = Number(v);
|
|
20
|
+
if (!Number.isFinite(n))
|
|
21
|
+
throw new ApiError(`${name} 不对`);
|
|
22
|
+
return n;
|
|
23
|
+
};
|
|
24
|
+
const ids = (v) => (Array.isArray(v) ? v.map(String).filter(Boolean) : []);
|
|
25
|
+
const MODES = new Set(['overlay', 'wallpaper', 'window']);
|
|
26
|
+
export function apiHandlers(rt) {
|
|
27
|
+
const withState = (extra = {}) => ({ ...extra, state: rt.state() });
|
|
28
|
+
return {
|
|
29
|
+
'/state': async () => withState(),
|
|
30
|
+
'/character': async (body) => {
|
|
31
|
+
const patch = {};
|
|
32
|
+
if (body.name !== undefined)
|
|
33
|
+
patch.name = str(body.name, '名字').trim();
|
|
34
|
+
if (body.persona !== undefined)
|
|
35
|
+
patch.persona = str(body.persona, '人设').trim();
|
|
36
|
+
if (body.callYou !== undefined)
|
|
37
|
+
patch.callYou = str(body.callYou, '称呼').trim();
|
|
38
|
+
if (body.pronoun !== undefined) {
|
|
39
|
+
const p = str(body.pronoun, '人称');
|
|
40
|
+
if (p !== '他' && p !== '她' && p !== 'TA')
|
|
41
|
+
throw new ApiError('人称只能是 他 / 她 / TA');
|
|
42
|
+
patch.pronoun = p;
|
|
43
|
+
}
|
|
44
|
+
if (body.voice !== undefined || body.speed !== undefined) {
|
|
45
|
+
const current = rt.state().character.voice;
|
|
46
|
+
patch.voice = { id: body.voice === undefined ? current.id : str(body.voice, '音色'), speed: body.speed === undefined ? current.speed : num(body.speed, '语速') };
|
|
47
|
+
}
|
|
48
|
+
if (body.onboarded !== undefined)
|
|
49
|
+
patch.onboarded = body.onboarded === true;
|
|
50
|
+
if (body.model !== undefined) {
|
|
51
|
+
if (body.model === null)
|
|
52
|
+
patch.model = undefined;
|
|
53
|
+
else {
|
|
54
|
+
const m = body.model;
|
|
55
|
+
patch.model = { provider: str(m.provider, '模型'), model: str(m.model, '模型'), ...(m.effort ? { effort: str(m.effort, '思考强度') } : {}) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
rt.saveCharacter(patch);
|
|
59
|
+
return withState();
|
|
60
|
+
},
|
|
61
|
+
'/persona/expand': async (body) => ({
|
|
62
|
+
persona: await rt.expandPersona({ name: str(body.name, '名字'), idea: str(body.idea, '描述'), callYou: str(body.callYou ?? '', '称呼'), pronoun: str(body.pronoun ?? '他', '人称') }),
|
|
63
|
+
}),
|
|
64
|
+
'/persona/try': async (body) => ({
|
|
65
|
+
reply: await rt.tryPersona({
|
|
66
|
+
name: str(body.name, '名字'), persona: str(body.persona, '人设'), callYou: str(body.callYou ?? '', '称呼'), pronoun: str(body.pronoun ?? '他', '人称'),
|
|
67
|
+
history: Array.isArray(body.history) ? body.history.slice(-6) : [],
|
|
68
|
+
message: str(body.message, '消息'),
|
|
69
|
+
}),
|
|
70
|
+
}),
|
|
71
|
+
'/voice/preview': async (body) => rt.previewVoice(str(body.voice, '音色'), num(body.speed ?? 1, '语速'), str(body.text ?? '你好呀,我在你桌面上等你好久啦。', '试听的话')),
|
|
72
|
+
'/body/start': async () => withState({ message: rt.startBody() }),
|
|
73
|
+
'/body/mode': async (body) => {
|
|
74
|
+
const mode = str(body.mode, '模式');
|
|
75
|
+
if (!MODES.has(mode))
|
|
76
|
+
throw new ApiError('模式不对');
|
|
77
|
+
rt.setMode(mode);
|
|
78
|
+
return withState();
|
|
79
|
+
},
|
|
80
|
+
'/pack/pick': async () => {
|
|
81
|
+
rt.pickPack();
|
|
82
|
+
return withState();
|
|
83
|
+
},
|
|
84
|
+
'/pack/use': async (body) => {
|
|
85
|
+
rt.usePack(str(body.packId, '外形包'));
|
|
86
|
+
return withState();
|
|
87
|
+
},
|
|
88
|
+
'/look/set': async (body) => {
|
|
89
|
+
const change = {};
|
|
90
|
+
if (typeof body.auto === 'boolean')
|
|
91
|
+
change.auto = body.auto;
|
|
92
|
+
if (typeof body.locked === 'boolean')
|
|
93
|
+
change.locked = body.locked;
|
|
94
|
+
if (typeof body.go === 'string')
|
|
95
|
+
change.go = body.go;
|
|
96
|
+
if (typeof body.on === 'object' && body.on !== null) {
|
|
97
|
+
change.on = Object.fromEntries(Object.entries(body.on).filter(([, v]) => typeof v === 'boolean'));
|
|
98
|
+
}
|
|
99
|
+
rt.setLook(change);
|
|
100
|
+
return withState();
|
|
101
|
+
},
|
|
102
|
+
'/body/install': async () => {
|
|
103
|
+
rt.installBody();
|
|
104
|
+
return withState();
|
|
105
|
+
},
|
|
106
|
+
'/body/restart': async () => {
|
|
107
|
+
await rt.restartBody();
|
|
108
|
+
return withState();
|
|
109
|
+
},
|
|
110
|
+
'/workshop/list': async () => ({ projects: rt.workshopList() }),
|
|
111
|
+
'/workshop/new': async (body) => rt.workshopNew(str(body.name, '名字').trim()),
|
|
112
|
+
'/workshop/save': async (body) => rt.workshopSave(body.project),
|
|
113
|
+
'/workshop/export': async (body) => (await rt.workshopExport(str(body.id, '项目'), body.media === true)) ?? { cancelled: true },
|
|
114
|
+
'/workshop/import-project': async () => (await rt.workshopImportProject()) ?? { cancelled: true },
|
|
115
|
+
'/workshop/examples': async () => ({ examples: rt.workshopExamples() }),
|
|
116
|
+
'/workshop/open': async (body) => {
|
|
117
|
+
rt.workshopOpen(str(body.id, '项目'));
|
|
118
|
+
return {};
|
|
119
|
+
},
|
|
120
|
+
'/workshop/frame': async (body) => {
|
|
121
|
+
rt.workshopFrame(str(body.id, '项目'), str(body.scene, '场景'));
|
|
122
|
+
return {};
|
|
123
|
+
},
|
|
124
|
+
'/workshop/import': async (body) => {
|
|
125
|
+
rt.workshopImport(str(body.id, '项目'));
|
|
126
|
+
return withState();
|
|
127
|
+
},
|
|
128
|
+
'/memory/remove': async (body) => withState({ removed: rt.memoryRemove(ids(body.ids)) }),
|
|
129
|
+
'/memory/confirm': async (body) => withState({ confirmed: rt.memoryConfirm(ids(body.ids)) }),
|
|
130
|
+
'/memory/digest': async () => withState({ report: await rt.digest() }),
|
|
131
|
+
'/memory/export': async () => ({ markdown: rt.exportMemory() }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function json(status, value) {
|
|
135
|
+
return new Response(JSON.stringify(value), {
|
|
136
|
+
status,
|
|
137
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
export async function handle(handler, request) {
|
|
141
|
+
let body = {};
|
|
142
|
+
try {
|
|
143
|
+
const text = await request.text();
|
|
144
|
+
body = text === '' ? {} : JSON.parse(text);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return json(400, { ok: false, message: '请求不是 JSON' });
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return json(200, { ok: true, ...(await handler(body)) });
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
+
return json(error instanceof ApiError ? 400 : 500, { ok: false, message });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installed body: downloaded from the release (body/scripts/package.mjs
|
|
3
|
+
* makes it), checked, unpacked into `<home>/body/<version>/`, and kept up to
|
|
4
|
+
* date. The release has `body-latest.json`:
|
|
5
|
+
*
|
|
6
|
+
* { "format": "dsh-her-body@1", "version": "0.1.0",
|
|
7
|
+
* "assets": { "darwin-arm64": { "file": "dsh-her-body-0.1.0-darwin-arm64.zip", "size": …, "sha256": "…" } } }
|
|
8
|
+
*
|
|
9
|
+
* `<home>/body/current.json` says which version is installed. A download
|
|
10
|
+
* that does not match its SHA-256 is thrown away; the previous version stays
|
|
11
|
+
* until the new one is in place, and one older version is kept to go back to.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { Readable } from 'node:stream';
|
|
18
|
+
import { pipeline } from 'node:stream/promises';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
const run = promisify(execFile);
|
|
21
|
+
export const MANIFEST = 'body-latest.json';
|
|
22
|
+
const APP = 'dsh-her.app';
|
|
23
|
+
/** This computer's asset key in the manifest. */
|
|
24
|
+
export const platformKey = (platform = process.platform, arch = process.arch) => `${platform}-${arch}`;
|
|
25
|
+
/** -1, 0, 1: "0.1.0-dev.0" < "0.1.0" < "0.1.1" < "0.2.0". */
|
|
26
|
+
export function compareVersions(a, b) {
|
|
27
|
+
const parse = (v) => {
|
|
28
|
+
const [main = '', pre] = v.split('-', 2);
|
|
29
|
+
return { nums: main.split('.').map(n => Number.parseInt(n, 10) || 0), pre };
|
|
30
|
+
};
|
|
31
|
+
const x = parse(a);
|
|
32
|
+
const y = parse(b);
|
|
33
|
+
for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
|
|
34
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
|
|
35
|
+
if (d !== 0)
|
|
36
|
+
return Math.sign(d);
|
|
37
|
+
}
|
|
38
|
+
if (x.pre === y.pre)
|
|
39
|
+
return 0;
|
|
40
|
+
if (x.pre === undefined)
|
|
41
|
+
return 1;
|
|
42
|
+
if (y.pre === undefined)
|
|
43
|
+
return -1;
|
|
44
|
+
return x.pre < y.pre ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
export function validateManifest(value) {
|
|
47
|
+
const m = value;
|
|
48
|
+
if (typeof m !== 'object' || m === null || m.format !== 'dsh-her-body@1' || typeof m.version !== 'string' || typeof m.assets !== 'object' || m.assets === null) {
|
|
49
|
+
throw new Error('发布里的 body-latest.json 不对');
|
|
50
|
+
}
|
|
51
|
+
for (const asset of Object.values(m.assets)) {
|
|
52
|
+
if (typeof asset?.file !== 'string' || !/^[\w.-]+\.zip$/.test(asset.file) || typeof asset.size !== 'number' || !/^[0-9a-f]{64}$/.test(asset.sha256)) {
|
|
53
|
+
throw new Error('发布里的 body-latest.json 有一项不对');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return m;
|
|
57
|
+
}
|
|
58
|
+
const bodyRoot = (home) => join(home, 'body');
|
|
59
|
+
export function installedBody(home) {
|
|
60
|
+
try {
|
|
61
|
+
const current = JSON.parse(readFileSync(join(bodyRoot(home), 'current.json'), 'utf8'));
|
|
62
|
+
return existsSync(current.app) ? current : undefined;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** How to start the installed body. */
|
|
69
|
+
export function installedPlan(home) {
|
|
70
|
+
const installed = installedBody(home);
|
|
71
|
+
if (installed === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (process.platform === 'darwin')
|
|
74
|
+
return { binary: join(installed.app, 'Contents', 'MacOS', 'Electron'), args: [], cwd: join(installed.app, '..') };
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/** `base` + file: the release's download address ends with a slash, or the file is appended after one. */
|
|
78
|
+
const at = (base, file) => `${base.replace(/\/?$/, '/')}${file}`;
|
|
79
|
+
export async function fetchManifest(base) {
|
|
80
|
+
const res = await fetch(at(base, MANIFEST), { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
|
81
|
+
if (!res.ok)
|
|
82
|
+
throw new Error(`读不到发布信息(${res.status}):${at(base, MANIFEST)}`);
|
|
83
|
+
return validateManifest(await res.json());
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Download this computer's asset of `manifest`, check it, unpack it next to
|
|
87
|
+
* the installed version and make it current. Progress in bytes.
|
|
88
|
+
*/
|
|
89
|
+
export async function install(home, base, manifest, onProgress) {
|
|
90
|
+
if (process.platform !== 'darwin')
|
|
91
|
+
throw new Error('这个系统的身体程序还没有发布(现在只有 macOS 版)');
|
|
92
|
+
const asset = manifest.assets[platformKey()];
|
|
93
|
+
if (asset === undefined)
|
|
94
|
+
throw new Error(`发布里没有这台电脑(${platformKey()})的身体程序`);
|
|
95
|
+
const root = bodyRoot(home);
|
|
96
|
+
const downloads = join(root, 'download');
|
|
97
|
+
mkdirSync(downloads, { recursive: true });
|
|
98
|
+
const zip = join(downloads, asset.file);
|
|
99
|
+
const res = await fetch(at(base, asset.file), { redirect: 'follow' });
|
|
100
|
+
if (!res.ok || res.body === null)
|
|
101
|
+
throw new Error(`下载失败(${res.status})`);
|
|
102
|
+
const hash = createHash('sha256');
|
|
103
|
+
let done = 0;
|
|
104
|
+
const counted = Readable.fromWeb(res.body).on('data', (chunk) => {
|
|
105
|
+
hash.update(chunk);
|
|
106
|
+
done += chunk.length;
|
|
107
|
+
onProgress(done, asset.size);
|
|
108
|
+
});
|
|
109
|
+
await pipeline(counted, createWriteStream(`${zip}.part`));
|
|
110
|
+
if (done !== asset.size || hash.digest('hex') !== asset.sha256) {
|
|
111
|
+
rmSync(`${zip}.part`, { force: true });
|
|
112
|
+
throw new Error('下载的文件校验不对(可能没下完或被改过),已经删掉,再试一次');
|
|
113
|
+
}
|
|
114
|
+
renameSync(`${zip}.part`, zip);
|
|
115
|
+
const target = join(root, manifest.version);
|
|
116
|
+
const unpacking = `${target}.unpacking`;
|
|
117
|
+
rmSync(unpacking, { recursive: true, force: true });
|
|
118
|
+
mkdirSync(unpacking, { recursive: true });
|
|
119
|
+
// ditto keeps the app's symlinks, permissions and signature.
|
|
120
|
+
await run('ditto', ['-x', '-k', zip, unpacking], { timeout: 300_000 });
|
|
121
|
+
if (!existsSync(join(unpacking, APP)))
|
|
122
|
+
throw new Error('下载的包里没有身体程序');
|
|
123
|
+
rmSync(target, { recursive: true, force: true });
|
|
124
|
+
renameSync(unpacking, target);
|
|
125
|
+
rmSync(zip, { force: true });
|
|
126
|
+
const previous = installedBody(home);
|
|
127
|
+
const installed = { version: manifest.version, app: join(target, APP), installedAt: new Date().toISOString() };
|
|
128
|
+
writeFileSync(join(root, 'current.json.tmp'), `${JSON.stringify(installed, null, 2)}\n`);
|
|
129
|
+
renameSync(join(root, 'current.json.tmp'), join(root, 'current.json'));
|
|
130
|
+
// Keep this version and the one before it; older ones go.
|
|
131
|
+
for (const entry of readdirSync(root)) {
|
|
132
|
+
const keep = entry === manifest.version || entry === previous?.version || entry === 'download' || entry.endsWith('.json');
|
|
133
|
+
if (!keep)
|
|
134
|
+
rmSync(join(root, entry), { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
return installed;
|
|
137
|
+
}
|