dsh-cosplay-studio 2.0.3 → 2.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/README.md +33 -16
- package/lib/card.js +156 -52
- package/lib/client.js +376 -24
- package/lib/comfy.js +10 -2
- package/lib/index.js +333 -34
- package/lib/project.js +147 -32
- package/lib/research.js +719 -0
- package/lib/tools/finalize.py +100 -17
- package/package.json +3 -2
package/lib/research.js
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// Character research: the slow, staged "who is this character" step before the base image.
|
|
2
|
+
//
|
|
3
|
+
// Two entry modes share one pipeline:
|
|
4
|
+
// name → web search → download character / cosplay photos → vision analysis → rough draft
|
|
5
|
+
// image → vision analysis of the upload (+ optional cosplay photos) → rough draft
|
|
6
|
+
//
|
|
7
|
+
// The heavy lifting comes from the DSH host itself: ctx.web searches and fetches
|
|
8
|
+
// pages, ctx.llm (deepseek-account, image capable) reads pictures, the local
|
|
9
|
+
// ComfyUI renders a cheap 640×1024 draft. Every stage is written back to
|
|
10
|
+
// project.research so the panel can show 正在检索信息 / 正在分析特征 … while it runs.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_POSE, buildCard, defaultDetails, referenceBasePrompt, shotPrompt,
|
|
16
|
+
} from './card.js'
|
|
17
|
+
import {
|
|
18
|
+
downloadImage, imageToImageGraph, resolveSize, runGraph, textToImageGraph, uploadImage,
|
|
19
|
+
} from './comfy.js'
|
|
20
|
+
import { logLine } from './paths.js'
|
|
21
|
+
import { projectDirs, readProject, updateProject } from './project.js'
|
|
22
|
+
|
|
23
|
+
const RESEARCH_IDLE = 15 * 60 * 1000
|
|
24
|
+
const MAX_DOWNLOADS = 4
|
|
25
|
+
|
|
26
|
+
/* --------------------------------------------------------------- the job */
|
|
27
|
+
|
|
28
|
+
/** Read a project's research block, marking a job stuck by a host restart as failed. */
|
|
29
|
+
export function researchState(project) {
|
|
30
|
+
const research = project?.research
|
|
31
|
+
if (!research) return null
|
|
32
|
+
const started = Date.parse(research.startedAt ?? '')
|
|
33
|
+
if (research.status === 'running' && Number.isFinite(started) && Date.now() - started > RESEARCH_IDLE) {
|
|
34
|
+
return { ...research, status: 'failed', stageLabel: '研究被中断(宿主可能重启过),请重新研究' }
|
|
35
|
+
}
|
|
36
|
+
return research
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const running = new Map()
|
|
40
|
+
|
|
41
|
+
/** Kick off research for one project. Returns the updated project immediately. */
|
|
42
|
+
export function startResearch(ctx, config, projectId, options = {}) {
|
|
43
|
+
const project = readProject(projectId)
|
|
44
|
+
if (project === null) throw new Error('项目不存在')
|
|
45
|
+
if (running.has(projectId)) return project
|
|
46
|
+
const job = runResearch(ctx, config, projectId, options)
|
|
47
|
+
.catch((error) => {
|
|
48
|
+
const message = error?.message ?? String(error)
|
|
49
|
+
logLine(`角色研究失败(${projectId}):${message}`, 'error')
|
|
50
|
+
return updateProject(projectId, {
|
|
51
|
+
research: {
|
|
52
|
+
...(readProject(projectId)?.research ?? {}),
|
|
53
|
+
status: 'failed',
|
|
54
|
+
stage: 'failed',
|
|
55
|
+
stageLabel: '研究失败',
|
|
56
|
+
error: message,
|
|
57
|
+
finishedAt: new Date().toISOString(),
|
|
58
|
+
},
|
|
59
|
+
notes: [...(readProject(projectId)?.notes ?? []), `角色研究失败:${message}`],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
.finally(() => { running.delete(projectId) })
|
|
63
|
+
running.set(projectId, job)
|
|
64
|
+
return readProject(projectId)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function researchOf(projectId) {
|
|
68
|
+
return readProject(projectId)?.research ?? { steps: [] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Merge a research patch and a step log line into the project. */
|
|
72
|
+
function patchResearch(projectId, patch, step = null) {
|
|
73
|
+
const project = readProject(projectId)
|
|
74
|
+
if (project === null) return null
|
|
75
|
+
const previous = project.research ?? { steps: [] }
|
|
76
|
+
const steps = Array.isArray(previous.steps) ? [...previous.steps] : []
|
|
77
|
+
if (step !== null) steps.push({ at: new Date().toISOString(), ...step })
|
|
78
|
+
return updateProject(projectId, { research: { ...previous, ...patch, steps: steps.slice(-24) } })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ------------------------------------------------------------- pipeline */
|
|
82
|
+
|
|
83
|
+
async function runResearch(ctx, config, projectId, options = {}) {
|
|
84
|
+
const project = readProject(projectId)
|
|
85
|
+
if (project === null) throw new Error('项目不存在')
|
|
86
|
+
const mode = project.source === 'image' && String(project.referenceFile ?? '') !== '' ? 'image' : 'name'
|
|
87
|
+
const character = String(project.character ?? '')
|
|
88
|
+
const series = String(project.series ?? '')
|
|
89
|
+
|
|
90
|
+
patchResearch(projectId, {
|
|
91
|
+
status: 'running',
|
|
92
|
+
mode,
|
|
93
|
+
stage: 'prepare',
|
|
94
|
+
stageLabel: '准备研究角色…',
|
|
95
|
+
startedAt: new Date().toISOString(),
|
|
96
|
+
finishedAt: '',
|
|
97
|
+
error: '',
|
|
98
|
+
sources: [],
|
|
99
|
+
images: [],
|
|
100
|
+
features: null,
|
|
101
|
+
summary: '',
|
|
102
|
+
draft: '',
|
|
103
|
+
detailLine: '',
|
|
104
|
+
}, { label: '开始研究角色形象', status: 'done' })
|
|
105
|
+
|
|
106
|
+
const sources = []
|
|
107
|
+
const images = []
|
|
108
|
+
|
|
109
|
+
/* 1. web search */
|
|
110
|
+
try {
|
|
111
|
+
patchResearch(projectId, { stage: 'search', stageLabel: '正在联网检索角色资料…' })
|
|
112
|
+
const queries = mode === 'image'
|
|
113
|
+
? [character ? `${character} ${series}` : '']
|
|
114
|
+
: [`${character} ${series}`, `${character} ${series} cosplay`]
|
|
115
|
+
const results = []
|
|
116
|
+
for (const query of queries.filter(Boolean)) {
|
|
117
|
+
const found = await searchWeb(ctx, query)
|
|
118
|
+
if (found === null) continue
|
|
119
|
+
results.push({ query, found })
|
|
120
|
+
for (const source of found.sources ?? []) {
|
|
121
|
+
if (sources.length >= 12) break
|
|
122
|
+
if (source?.url && !sources.some((item) => item.url === source.url)) {
|
|
123
|
+
sources.push({ url: source.url, title: source.title ?? '', snippet: source.snippet ?? '' })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
patchResearch(projectId, {
|
|
128
|
+
sources,
|
|
129
|
+
stageLabel: `检索完成,找到 ${sources.length} 条资料`,
|
|
130
|
+
}, { label: `联网检索:${sources.length} 条资料`, status: 'done' })
|
|
131
|
+
var searchResults = results
|
|
132
|
+
} catch (error) {
|
|
133
|
+
patchResearch(projectId, {}, { label: `联网检索失败:${error?.message ?? error}`, status: 'error' })
|
|
134
|
+
var searchResults = []
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* 2. collect + download reference photos (each candidate is checked by the vision model) */
|
|
138
|
+
try {
|
|
139
|
+
if (mode === 'image' && options.supplement !== true) {
|
|
140
|
+
patchResearch(projectId, { images: [], stageLabel: '参考图项目:外观以上传的参考图为准' }, { label: '参考图片:使用上传的参考图', status: 'done' })
|
|
141
|
+
} else {
|
|
142
|
+
const urls = []
|
|
143
|
+
const push = (list) => {
|
|
144
|
+
for (const url of list) if (!urls.includes(url)) urls.push(url)
|
|
145
|
+
}
|
|
146
|
+
// Image search first: quoted exact name avoids Bing fuzzy-matching a single
|
|
147
|
+
// character of the name (薇斯纳 → 薇 → dictionary glyph pages).
|
|
148
|
+
const imageQueries = imageSearchQueries(character, series)
|
|
149
|
+
for (const query of imageQueries) {
|
|
150
|
+
try {
|
|
151
|
+
const page = await fetchPage(ctx, `https://www.bing.com/images/search?q=${encodeURIComponent(query)}`)
|
|
152
|
+
if (page !== null) push(imagesFromHtml(page.html, page.url))
|
|
153
|
+
} catch {
|
|
154
|
+
// image search is best-effort
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Article pages still help for cosplay write-ups with real photos, but they
|
|
158
|
+
// are noisy, so the vision check below decides what is kept.
|
|
159
|
+
const pages = new Set()
|
|
160
|
+
const cosplay = character !== '' ? await searchWeb(ctx, `"${character}" ${series} cosplay`) : null
|
|
161
|
+
const candidatePages = [
|
|
162
|
+
...(cosplay?.sources ?? []),
|
|
163
|
+
...searchResults.flatMap((item) => item.found?.sources ?? []),
|
|
164
|
+
]
|
|
165
|
+
for (const source of candidatePages.slice(0, 4)) {
|
|
166
|
+
if (pages.size >= 3) break
|
|
167
|
+
if (!source?.url || pages.has(source.url) || !/^https?:/i.test(source.url)) continue
|
|
168
|
+
pages.add(source.url)
|
|
169
|
+
const page = await fetchPage(ctx, source.url)
|
|
170
|
+
if (page === null) continue
|
|
171
|
+
push(imagesFromHtml(page.html, source.url))
|
|
172
|
+
}
|
|
173
|
+
const unique = urls.slice(0, 24)
|
|
174
|
+
patchResearch(projectId, { stage: 'images', stageLabel: `正在挑选角色参考图(0 张可用 / 已看 ${Math.min(unique.length, 20)} 张)…` })
|
|
175
|
+
let attempts = 0
|
|
176
|
+
for (const url of unique) {
|
|
177
|
+
if (images.length >= MAX_DOWNLOADS || attempts >= 20) break
|
|
178
|
+
attempts += 1
|
|
179
|
+
const image = await downloadPicture(url)
|
|
180
|
+
if (image === null) continue
|
|
181
|
+
const verdict = await classifyReferenceImage(ctx, image, { character, series, mode })
|
|
182
|
+
if (!verdict.usable) {
|
|
183
|
+
patchResearch(projectId, {}, { label: `跳过:${verdict.kind}${verdict.reason ? `(${verdict.reason})` : ''}`, status: 'done' })
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
const dir = projectDirs(projectId)
|
|
187
|
+
const ext = image.mediaType === 'image/png' ? 'png' : image.mediaType === 'image/webp' ? 'webp' : 'jpg'
|
|
188
|
+
const file = `research_${Date.now()}_${images.length + 1}.${ext}`
|
|
189
|
+
writeFileSync(join(dir.refs, file), image.data)
|
|
190
|
+
images.push({ file, url, width: image.width, height: image.height, kind: verdict.kind })
|
|
191
|
+
patchResearch(projectId, {
|
|
192
|
+
images: [...images],
|
|
193
|
+
stageLabel: `正在挑选角色参考图(${images.length} 张可用 / 已看 ${attempts} 张)…`,
|
|
194
|
+
}, { label: `采用参考图:${verdict.kind}(${image.width}×${image.height})`, status: 'done' })
|
|
195
|
+
}
|
|
196
|
+
patchResearch(projectId, {}, { label: `参考图片:${images.length} 张可用`, status: images.length > 0 ? 'done' : 'error' })
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
patchResearch(projectId, {}, { label: `下载参考图失败:${error?.message ?? error}`, status: 'error' })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* 3. vision analysis */
|
|
203
|
+
let features = null
|
|
204
|
+
try {
|
|
205
|
+
patchResearch(projectId, { stage: 'analyze', stageLabel: '正在分析外观特征…' })
|
|
206
|
+
const inputs = []
|
|
207
|
+
if (mode === 'image') {
|
|
208
|
+
const projectNow = readProject(projectId)
|
|
209
|
+
const reference = String(projectNow?.referenceFile ?? '')
|
|
210
|
+
if (reference !== '') {
|
|
211
|
+
const path = join(projectDirs(projectId).refs, reference)
|
|
212
|
+
if (existsSync(path)) inputs.push({ data: readFileSync(path), mediaType: mediaTypeOf(reference), name: reference })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const image of images.slice(0, 3)) {
|
|
216
|
+
const path = join(projectDirs(projectId).refs, image.file)
|
|
217
|
+
if (existsSync(path)) inputs.push({ data: readFileSync(path), mediaType: mediaTypeOf(image.file), name: image.file })
|
|
218
|
+
}
|
|
219
|
+
const snippets = searchResults
|
|
220
|
+
.flatMap((item) => (item.found?.sources ?? []).map((source) => `${source.title ?? ''}:${source.snippet ?? ''}`))
|
|
221
|
+
.filter((line) => line.trim() !== '')
|
|
222
|
+
.slice(0, 12)
|
|
223
|
+
.join('\n')
|
|
224
|
+
const prompt = analysisPrompt({ character, series, mode, snippets })
|
|
225
|
+
const text = await llmText(ctx, prompt, { images: inputs, maxTokens: 5000 })
|
|
226
|
+
features = normalizeFeatures(parseJson(text))
|
|
227
|
+
patchResearch(projectId, {
|
|
228
|
+
features,
|
|
229
|
+
summary: features.summary,
|
|
230
|
+
}, { label: `外观分析完成:置信度 ${(features.confidence * 100).toFixed(0)}%`, status: 'done' })
|
|
231
|
+
} catch (error) {
|
|
232
|
+
patchResearch(projectId, {}, { label: `外观分析失败:${error?.message ?? error}`, status: 'error' })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* 4. write the card + detail line */
|
|
236
|
+
let details = null
|
|
237
|
+
if (features !== null) {
|
|
238
|
+
details = detailsFromFeatures(features, { character, series, mode })
|
|
239
|
+
const card = buildCard(details)
|
|
240
|
+
const dir = projectDirs(projectId)
|
|
241
|
+
writeFileSync(join(dir.root, 'character_card.txt'), card, 'utf8')
|
|
242
|
+
updateProject(projectId, {
|
|
243
|
+
details,
|
|
244
|
+
card,
|
|
245
|
+
character: details.character || character,
|
|
246
|
+
series: details.series || series,
|
|
247
|
+
notes: [...(readProject(projectId)?.notes ?? []), `角色研究:${features.summary}`],
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
const detailLine = features === null ? '' : compactDetails(features)
|
|
251
|
+
patchResearch(projectId, { detailLine })
|
|
252
|
+
|
|
253
|
+
/* 5. rough draft */
|
|
254
|
+
try {
|
|
255
|
+
patchResearch(projectId, { stage: 'draft', stageLabel: '正在生成粗略草图…' })
|
|
256
|
+
const draft = await renderDraft(ctx, config, projectId, mode, details, detailLine)
|
|
257
|
+
if (draft !== '') {
|
|
258
|
+
patchResearch(projectId, { draft }, { label: `粗略草图已生成(${Math.round(draft.width / 10) * 10}×${draft.height})`, status: 'done' })
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
patchResearch(projectId, {}, { label: `草图失败(不影响继续):${error?.message ?? error}`, status: 'error' })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const final = updateProject(projectId, {
|
|
265
|
+
research: {
|
|
266
|
+
...(researchOf(projectId)),
|
|
267
|
+
status: 'done',
|
|
268
|
+
stage: 'done',
|
|
269
|
+
stageLabel: '研究完成,可以确认特征并生成基准图',
|
|
270
|
+
finishedAt: new Date().toISOString(),
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
logLine(`角色研究完成(${projectId}):${final?.research?.stageLabel ?? ''}`)
|
|
274
|
+
return final
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ----------------------------------------------------------- draft render */
|
|
278
|
+
|
|
279
|
+
async function renderDraft(ctx, config, projectId, mode, details, detailLine) {
|
|
280
|
+
const port = Number(config.comfyPort)
|
|
281
|
+
const reachable = await fetch(`http://127.0.0.1:${port}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
|
|
282
|
+
if (!reachable) return ''
|
|
283
|
+
const project = readProject(projectId)
|
|
284
|
+
if (project === null) return ''
|
|
285
|
+
const dirs = projectDirs(projectId)
|
|
286
|
+
const size = resolveSize('640x1024', '640x1024')
|
|
287
|
+
const draftConfig = { ...config, steps: Math.max(3, Math.min(4, Number(config.steps) || 6)) }
|
|
288
|
+
const seed = Math.floor(Math.random() * 2_147_483_000)
|
|
289
|
+
let graph
|
|
290
|
+
if (mode === 'image' && String(project.referenceFile ?? '') !== '') {
|
|
291
|
+
const path = join(dirs.refs, project.referenceFile)
|
|
292
|
+
if (!existsSync(path)) return ''
|
|
293
|
+
const uploaded = await uploadImage(port, readFileSync(path), project.referenceFile)
|
|
294
|
+
graph = imageToImageGraph(referenceBasePrompt(project.research?.detailLine ?? detailLine), uploaded, size, draftConfig, seed, 0.75, `cosplay_${projectId}_draft`)
|
|
295
|
+
} else {
|
|
296
|
+
const card = details === null ? (project.card ?? '') : buildCard(details)
|
|
297
|
+
graph = textToImageGraph(card, size, draftConfig, seed, `cosplay_${projectId}_draft`)
|
|
298
|
+
}
|
|
299
|
+
const images = await runGraph(port, graph, {})
|
|
300
|
+
const bytes = await downloadImage(port, images[0])
|
|
301
|
+
const file = `draft_${Date.now()}.png`
|
|
302
|
+
writeFileSync(join(dirs.research, file), bytes)
|
|
303
|
+
return { file, width: 640, height: 1024 }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* --------------------------------------------------------------- details */
|
|
307
|
+
|
|
308
|
+
function detailsFromFeatures(features, { character, series, mode }) {
|
|
309
|
+
const base = defaultDetails(character, series, { reference: mode === 'image' })
|
|
310
|
+
const palette = Array.isArray(features.palette) ? features.palette.filter((item) => String(item ?? '').trim() !== '') : []
|
|
311
|
+
return {
|
|
312
|
+
...base,
|
|
313
|
+
character: features.character || character,
|
|
314
|
+
series: features.series || series,
|
|
315
|
+
hair: features.hair || base.hair,
|
|
316
|
+
face: features.face || base.face,
|
|
317
|
+
eyeColor: features.eyes || base.eyeColor,
|
|
318
|
+
costume: features.costume || base.costume,
|
|
319
|
+
props: features.prop || base.props,
|
|
320
|
+
paletteTop: palette[0] || base.paletteTop,
|
|
321
|
+
paletteBottom: palette[1] || base.paletteBottom,
|
|
322
|
+
personality: features.personality || base.personality,
|
|
323
|
+
identity: features.identity || base.identity,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function compactDetails(features) {
|
|
328
|
+
return [features.hair, features.eyes, features.costume, features.accessories, features.prop]
|
|
329
|
+
.map((item) => String(item ?? '').trim())
|
|
330
|
+
.filter((item) => item !== '')
|
|
331
|
+
.join(', ')
|
|
332
|
+
.slice(0, 700)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* -------------------------------------------------------------- llm chat */
|
|
336
|
+
|
|
337
|
+
export async function resolveModel(ctx) {
|
|
338
|
+
const llm = ctx.get('llm')
|
|
339
|
+
if (!llm) throw new Error('宿主没有 llm 服务')
|
|
340
|
+
let provider = ''
|
|
341
|
+
let model = ''
|
|
342
|
+
try {
|
|
343
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
|
|
344
|
+
provider = String(selection?.provider ?? '')
|
|
345
|
+
model = String(selection?.model ?? '')
|
|
346
|
+
} catch {
|
|
347
|
+
// fall through to the first image capable provider
|
|
348
|
+
}
|
|
349
|
+
let providers = []
|
|
350
|
+
try {
|
|
351
|
+
providers = await llm.listProviders()
|
|
352
|
+
} catch {
|
|
353
|
+
providers = []
|
|
354
|
+
}
|
|
355
|
+
if (!Array.isArray(providers)) providers = []
|
|
356
|
+
if (provider === '' || model === '') {
|
|
357
|
+
for (const entry of providers) {
|
|
358
|
+
let models = []
|
|
359
|
+
try {
|
|
360
|
+
models = await llm.listModels(entry.id)
|
|
361
|
+
} catch {
|
|
362
|
+
models = []
|
|
363
|
+
}
|
|
364
|
+
if (!Array.isArray(models)) models = []
|
|
365
|
+
const pick = models.find((item) => Array.isArray(item.inputModalities) && item.inputModalities.includes('image')) ?? models[0]
|
|
366
|
+
if (pick) {
|
|
367
|
+
provider = entry.id
|
|
368
|
+
model = pick.id
|
|
369
|
+
break
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (provider === '' || model === '') throw new Error('没有可用的对话模型')
|
|
374
|
+
let imageCapable = false
|
|
375
|
+
try {
|
|
376
|
+
const models = await llm.listModels(provider)
|
|
377
|
+
imageCapable = Array.isArray(models) && models.some((item) => item.id === model && Array.isArray(item.inputModalities) && item.inputModalities.includes('image'))
|
|
378
|
+
} catch {
|
|
379
|
+
imageCapable = false
|
|
380
|
+
}
|
|
381
|
+
return { llm, provider, model, imageCapable }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function llmText(ctx, prompt, { images = [], maxTokens = 1200 } = {}) {
|
|
385
|
+
const { llm, provider, model, imageCapable } = await resolveModel(ctx)
|
|
386
|
+
const attachments = ctx.get('attachments')
|
|
387
|
+
const content = []
|
|
388
|
+
let usable = []
|
|
389
|
+
if (imageCapable && attachments && images.length > 0) {
|
|
390
|
+
for (const image of images.slice(0, 4)) {
|
|
391
|
+
try {
|
|
392
|
+
const ref = await attachments.saveImage({ data: new Uint8Array(image.data), mediaType: image.mediaType, name: image.name })
|
|
393
|
+
content.push({ type: 'image', attachment: ref })
|
|
394
|
+
usable.push(image.name)
|
|
395
|
+
} catch {
|
|
396
|
+
// skip an image the store refuses; text alone still helps
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
content.push({ type: 'text', text: prompt })
|
|
401
|
+
let text = ''
|
|
402
|
+
let failure = ''
|
|
403
|
+
for await (const chunk of llm.stream({ provider, model, messages: [{ role: 'user', content }], maxTokens })) {
|
|
404
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
405
|
+
else if (chunk?.type === 'finish' && chunk.reason?.kind && chunk.reason.kind !== 'success') {
|
|
406
|
+
failure = chunk.reason.failure?.message ?? `对话模型返回 ${chunk.reason.kind}`
|
|
407
|
+
// A truncated JSON answer is still usable when the object closed in time.
|
|
408
|
+
if (text.trim() === '') throw new Error(failure)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (text.trim() === '') throw new Error(failure || '对话模型没有返回内容')
|
|
412
|
+
return text.trim()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function analysisPrompt({ character, series, mode, snippets }) {
|
|
416
|
+
return `你是真人 cosplay 摄影的角色设定分析员。根据${mode === 'image' ? '用户上传的参考图(以及补充的图片资料)' : '联网检索到的资料和参考图片'},提取这位角色在真人摄影里必须还原的外观特征。
|
|
417
|
+
|
|
418
|
+
角色:${character}${series ? `(作品:${series})` : ''}
|
|
419
|
+
|
|
420
|
+
${snippets ? `检索到的文字资料(可能不完整,仅供核对):\n${snippets}\n` : ''}
|
|
421
|
+
只输出一个 JSON 对象,不要输出解释、思考过程或 Markdown 代码块。字段全部是字符串(palette 是字符串数组):
|
|
422
|
+
{
|
|
423
|
+
"character": "角色通用名",
|
|
424
|
+
"series": "作品名,未知填空字符串",
|
|
425
|
+
"summary": "中文两三句:发型发色、瞳色、服装主色与结构、标志道具、性格",
|
|
426
|
+
"hair": "英文:发型+发色+长度+发饰,具体到分缝和碎发",
|
|
427
|
+
"eyes": "英文:瞳色与眼型",
|
|
428
|
+
"face": "英文:脸型、眉、鼻、唇的真人化描述",
|
|
429
|
+
"costume": "英文:服装逐件(上装/下装/外套/腰带/袜/鞋),含材质和颜色",
|
|
430
|
+
"accessories": "英文:配饰,没有填空字符串",
|
|
431
|
+
"prop": "英文:单手可握的标志道具;没有写 empty hands",
|
|
432
|
+
"palette": ["英文背景上色", "英文背景下色"],
|
|
433
|
+
"personality": "英文三个形容词",
|
|
434
|
+
"identity": "英文身份短语",
|
|
435
|
+
"distinct": "英文:任何标志性细节(纹样、宝石、翅膀、机械关节等)",
|
|
436
|
+
"confidence": 0.0,
|
|
437
|
+
"notes": "中文:哪些细节你不确定"
|
|
438
|
+
}`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function normalizeFeatures(value) {
|
|
442
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
443
|
+
const text = (key, fallback = '') => String(source[key] ?? fallback).trim()
|
|
444
|
+
const palette = Array.isArray(source.palette) ? source.palette.map((item) => String(item ?? '').trim()).filter((item) => item !== '') : []
|
|
445
|
+
return {
|
|
446
|
+
character: text('character'),
|
|
447
|
+
series: text('series'),
|
|
448
|
+
summary: text('summary'),
|
|
449
|
+
hair: text('hair'),
|
|
450
|
+
eyes: text('eyes'),
|
|
451
|
+
face: text('face'),
|
|
452
|
+
costume: text('costume'),
|
|
453
|
+
accessories: text('accessories'),
|
|
454
|
+
prop: text('prop'),
|
|
455
|
+
palette,
|
|
456
|
+
personality: text('personality'),
|
|
457
|
+
identity: text('identity'),
|
|
458
|
+
distinct: text('distinct'),
|
|
459
|
+
confidence: Math.max(0, Math.min(1, Number(source.confidence) || 0)),
|
|
460
|
+
notes: text('notes'),
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function parseJson(text) {
|
|
465
|
+
const raw = String(text).replace(/```(?:json)?/gi, '')
|
|
466
|
+
const start = raw.search(/[[{]/)
|
|
467
|
+
const end = Math.max(raw.lastIndexOf('}'), raw.lastIndexOf(']'))
|
|
468
|
+
if (start < 0 || end <= start) throw new Error('分析结果里没有 JSON')
|
|
469
|
+
return JSON.parse(raw.slice(start, end + 1))
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ------------------------------------------------------- web + pictures */
|
|
473
|
+
|
|
474
|
+
async function searchWeb(ctx, query) {
|
|
475
|
+
const web = ctx.get('web')
|
|
476
|
+
if (!web) throw new Error('宿主没有 web 检索服务')
|
|
477
|
+
// ctx.web.search needs a DeepSeek credential. A plugin HTTP handler has no
|
|
478
|
+
// initiating agent session, so the account token is unavailable; fall back to
|
|
479
|
+
// fetching a public search page through the credential-free fetch provider.
|
|
480
|
+
try {
|
|
481
|
+
if (typeof web.searchReady !== 'function' || await web.searchReady('deepseek-account')) {
|
|
482
|
+
const result = await web.search({ query, maxResults: 6 })
|
|
483
|
+
if (Array.isArray(result?.sources) && result.sources.length > 0) return result
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
// fall through to the HTML search
|
|
487
|
+
}
|
|
488
|
+
const html = await searchPageHtml(ctx, query)
|
|
489
|
+
return { sources: parseSearchHtml(html), truncated: false }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function searchPageHtml(ctx, query) {
|
|
493
|
+
// Bing's HTML endpoint works without credentials; DuckDuckGo blocks the host.
|
|
494
|
+
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`
|
|
495
|
+
try {
|
|
496
|
+
const web = ctx.get('web')
|
|
497
|
+
if (web && typeof web.fetch === 'function') {
|
|
498
|
+
const page = await web.fetch({ url })
|
|
499
|
+
if (page && page.statusCode < 400) {
|
|
500
|
+
const body = String(page.body?.content ?? '')
|
|
501
|
+
if (parseSearchHtml(body).length > 0) return body
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} catch {
|
|
505
|
+
// fall through to a direct request
|
|
506
|
+
}
|
|
507
|
+
const response = await fetch(url, { headers: BROWSER_HEADERS })
|
|
508
|
+
if (!response.ok) throw new Error(`搜索页返回 ${response.status}`)
|
|
509
|
+
return response.text()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const BROWSER_HEADERS = {
|
|
513
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
|
|
514
|
+
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Extract result links + snippets from a DuckDuckGo or Bing HTML page. */
|
|
518
|
+
function parseSearchHtml(html) {
|
|
519
|
+
const text = String(html ?? '')
|
|
520
|
+
const decode = (value) => String(value)
|
|
521
|
+
.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
|
|
522
|
+
.replace(/</g, '<').replace(/>/g, '>')
|
|
523
|
+
const strip = (value) => decode(String(value).replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
524
|
+
const sources = []
|
|
525
|
+
const push = (url, title, snippet) => {
|
|
526
|
+
let target = decode(url).trim()
|
|
527
|
+
if (target.startsWith('//')) target = `https:${target}`
|
|
528
|
+
const uddg = /[?&]uddg=([^&]+)/.exec(target)
|
|
529
|
+
if (uddg) target = decodeURIComponent(uddg[1])
|
|
530
|
+
if (!/^https?:/i.test(target) || sources.some((item) => item.url === target)) return
|
|
531
|
+
sources.push({ url: target, title: strip(title).slice(0, 160), snippet: strip(snippet).slice(0, 300) })
|
|
532
|
+
}
|
|
533
|
+
// DuckDuckGo HTML results.
|
|
534
|
+
const ddg = /<a[^>]+class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
|
|
535
|
+
let match
|
|
536
|
+
const ddgLinks = []
|
|
537
|
+
while ((match = ddg.exec(text)) !== null) ddgLinks.push({ url: match[1], title: match[2] })
|
|
538
|
+
const snippets = [...text.matchAll(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map((item) => item[1])
|
|
539
|
+
ddgLinks.forEach((link, index) => push(link.url, link.title, snippets[index] ?? ''))
|
|
540
|
+
if (sources.length > 0) return sources
|
|
541
|
+
// Bing organic results: one <li class="b_algo"> block per result.
|
|
542
|
+
const blocks = text.split(/<li class="b_algo"/).slice(1)
|
|
543
|
+
for (const block of blocks) {
|
|
544
|
+
const link = /<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/.exec(block)
|
|
545
|
+
if (!link) continue
|
|
546
|
+
const snippet = /<p[^>]*>([\s\S]*?)<\/p>/.exec(block)
|
|
547
|
+
push(link[1], link[2], snippet ? snippet[1] : '')
|
|
548
|
+
}
|
|
549
|
+
return sources
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function fetchPage(ctx, url) {
|
|
553
|
+
const web = ctx.get('web')
|
|
554
|
+
if (web && typeof web.fetch === 'function') {
|
|
555
|
+
try {
|
|
556
|
+
const page = await web.fetch({ url })
|
|
557
|
+
if (page && page.statusCode < 400) {
|
|
558
|
+
const html = String(page.body?.content ?? '')
|
|
559
|
+
if (html.trim() !== '') return { html, url }
|
|
560
|
+
}
|
|
561
|
+
} catch {
|
|
562
|
+
// fall through to a direct request
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const response = await fetch(url, { headers: BROWSER_HEADERS, signal: AbortSignal.timeout(20_000) })
|
|
567
|
+
if (!response.ok) return null
|
|
568
|
+
return { html: await response.text(), url }
|
|
569
|
+
} catch {
|
|
570
|
+
return null
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const BLOCKED_IMAGE_HOSTS = /(?:hanyuguoxue\.com|zdic\.net|gushici\.net|hanyu\.baidu\.com|zidian)/i
|
|
575
|
+
|
|
576
|
+
/** Image-search queries: the quoted exact name keeps Bing from matching one
|
|
577
|
+
* character of the name (薇斯纳 → 薇 → dictionary glyphs / plant photos). */
|
|
578
|
+
function imageSearchQueries(character, series) {
|
|
579
|
+
const name = String(character ?? '').trim()
|
|
580
|
+
if (name === '') return []
|
|
581
|
+
const exact = `"${name}"`
|
|
582
|
+
const work = String(series ?? '').trim()
|
|
583
|
+
const suffix = work === '' ? '' : ` ${work}`
|
|
584
|
+
return [
|
|
585
|
+
`${exact}${suffix} cosplay`,
|
|
586
|
+
`${exact}${suffix} 立绘`,
|
|
587
|
+
`${exact}${suffix}`,
|
|
588
|
+
]
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Vision check for one downloaded candidate. Keeping a text screenshot or a
|
|
593
|
+
* landscape photo poisons the analysis pass, so ask the model before saving.
|
|
594
|
+
* If the model is unavailable the picture is accepted (research must not fail
|
|
595
|
+
* just because the check cannot run).
|
|
596
|
+
*/
|
|
597
|
+
async function classifyReferenceImage(ctx, image, { character, series }) {
|
|
598
|
+
try {
|
|
599
|
+
const target = String(character ?? '').trim() === ''
|
|
600
|
+
? '(项目没有给出角色名,按上传的参考图判断)'
|
|
601
|
+
: `目标角色:${String(character).trim()}${String(series ?? '').trim() === '' ? '' : `,作品:${String(series).trim()}`}`
|
|
602
|
+
const prompt = `你在为真人 cosplay 项目挑选角色外观参考图。${target}
|
|
603
|
+
|
|
604
|
+
看这张图片,判断它能否作为角色的外观参考:角色立绘、官方设定图、动画/游戏截图、真人 cosplay 正片都算可用;文字截图、字典字帖、风景照、商品广告、无关人物、纯色/图标不算。
|
|
605
|
+
只输出一个 JSON 对象,不要解释:
|
|
606
|
+
{"usable":true,"kind":"character_art|cosplay|text|scenery|object|other","reason":"一句中文理由"}`
|
|
607
|
+
const text = await llmText(ctx, prompt, { images: [image], maxTokens: 300 })
|
|
608
|
+
const parsed = parseJson(text)
|
|
609
|
+
return {
|
|
610
|
+
usable: parsed?.usable === true,
|
|
611
|
+
kind: String(parsed?.kind ?? 'other'),
|
|
612
|
+
reason: String(parsed?.reason ?? '').slice(0, 60),
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
return { usable: true, kind: 'unverified', reason: '' }
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function imagesFromHtml(html, baseUrl) {
|
|
620
|
+
const urls = []
|
|
621
|
+
// Bing image results encode the full-size URL in an HTML-escaped JSON blob.
|
|
622
|
+
const patterns = [/murl":"([^&]+?)"/g, /"murl":"([^"]+)"/g]
|
|
623
|
+
for (const pattern of patterns) {
|
|
624
|
+
let match
|
|
625
|
+
while ((match = pattern.exec(html)) !== null && urls.length < 16) {
|
|
626
|
+
const raw = String(match[1]).replace(/\\u002f/gi, '/').replace(/\\\//g, '/')
|
|
627
|
+
if (/^https?:/i.test(raw) && !BLOCKED_IMAGE_HOSTS.test(raw) && !urls.includes(raw)) urls.push(raw)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const imgPattern = /<img[^>]+(?:src|data-src|data-original|srcset)="([^"]+)"/gi
|
|
631
|
+
let imgMatch
|
|
632
|
+
while ((imgMatch = imgPattern.exec(html)) !== null && urls.length < 16) {
|
|
633
|
+
const raw = imgMatch[1].split(' ')[0].trim()
|
|
634
|
+
if (raw === '' || raw.startsWith('data:') || /\.svg(\?|$)/i.test(raw)) continue
|
|
635
|
+
try {
|
|
636
|
+
const absolute = new URL(raw, baseUrl).toString()
|
|
637
|
+
if (/\.(jpe?g|png|webp)(\?|$)/i.test(absolute) && !urls.includes(absolute)) urls.push(absolute)
|
|
638
|
+
} catch {
|
|
639
|
+
// ignore malformed URLs
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return urls
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function downloadPicture(url) {
|
|
646
|
+
try {
|
|
647
|
+
const response = await fetch(url, {
|
|
648
|
+
headers: {
|
|
649
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
|
|
650
|
+
accept: 'image/avif,image/webp,image/png,image/jpeg,*/*',
|
|
651
|
+
},
|
|
652
|
+
signal: AbortSignal.timeout(30_000),
|
|
653
|
+
})
|
|
654
|
+
if (!response.ok) return null
|
|
655
|
+
const mediaType = String(response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
|
|
656
|
+
if (!/^image\/(png|jpe?g|webp)$/.test(mediaType)) return null
|
|
657
|
+
const length = Number(response.headers.get('content-length'))
|
|
658
|
+
if (Number.isFinite(length) && length > 12 * 1024 * 1024) return null
|
|
659
|
+
const data = Buffer.from(await response.arrayBuffer())
|
|
660
|
+
if (data.length < 40 * 1024 || data.length > 12 * 1024 * 1024) return null
|
|
661
|
+
const size = imageDimensions(data)
|
|
662
|
+
if (size === null || Math.min(size.width, size.height) < 320) return null
|
|
663
|
+
return { data, mediaType: mediaType === 'image/jpg' ? 'image/jpeg' : mediaType, ...size }
|
|
664
|
+
} catch {
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function imageDimensions(buffer) {
|
|
670
|
+
if (buffer.length > 24 && buffer[0] === 0x89 && buffer[1] === 0x50) {
|
|
671
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
|
672
|
+
}
|
|
673
|
+
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
674
|
+
let offset = 2
|
|
675
|
+
while (offset + 9 < buffer.length) {
|
|
676
|
+
if (buffer[offset] !== 0xff) { offset += 1; continue }
|
|
677
|
+
const marker = buffer[offset + 1]
|
|
678
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
679
|
+
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
|
|
680
|
+
}
|
|
681
|
+
offset += 2 + buffer.readUInt16BE(offset + 2)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (buffer.length > 30 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') {
|
|
685
|
+
const chunk = buffer.toString('latin1', 12, 16)
|
|
686
|
+
if (chunk === 'VP8X') return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) }
|
|
687
|
+
}
|
|
688
|
+
return null
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function mediaTypeOf(name) {
|
|
692
|
+
const ext = String(name).slice(String(name).lastIndexOf('.') + 1).toLowerCase()
|
|
693
|
+
if (ext === 'png') return 'image/png'
|
|
694
|
+
if (ext === 'webp') return 'image/webp'
|
|
695
|
+
return 'image/jpeg'
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/* ------------------------------------------------------------- random pick */
|
|
699
|
+
|
|
700
|
+
const RANDOM_SYSTEM = `你是一个真人 cosplay 选题策划。根据给定的近期热门资料,选一个外观辨识度高、适合做成真人 cosplay 套图的角色。只输出一个 JSON 对象,不要输出思考过程或 Markdown:{"character":"角色通用名","series":"作品名","reason":"一句中文理由"}。角色一律按 18 岁以上成年人处理。`
|
|
701
|
+
|
|
702
|
+
/** Pick a random but popular character from web results, then create + research it. */
|
|
703
|
+
export async function createRandomProject(ctx, config) {
|
|
704
|
+
const found = await searchWeb(ctx, '2025 2026 热门 二次元 游戏 角色 cosplay 人气 排行')
|
|
705
|
+
const snippets = (found?.sources ?? [])
|
|
706
|
+
.map((source) => `${source.title ?? ''}:${source.snippet ?? ''}`)
|
|
707
|
+
.filter((line) => line.trim() !== '')
|
|
708
|
+
.slice(0, 15)
|
|
709
|
+
.join('\n')
|
|
710
|
+
if (snippets === '' && !found?.content) throw new Error('联网检索没有返回内容')
|
|
711
|
+
const text = await llmText(ctx, `${RANDOM_SYSTEM}\n\n资料:\n${snippets || String(found?.content ?? '').slice(0, 4000)}`, { maxTokens: 1500 })
|
|
712
|
+
const pick = parseJson(text)
|
|
713
|
+
const character = String(pick.character ?? '').trim()
|
|
714
|
+
if (character === '') throw new Error('模型没有选出角色')
|
|
715
|
+
const series = String(pick.series ?? '').trim()
|
|
716
|
+
return { character, series, reason: String(pick.reason ?? '') }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export { DEFAULT_POSE, referenceBasePrompt, shotPrompt }
|