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.
@@ -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(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
522
+ .replace(/&lt;/g, '<').replace(/&gt;/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&quot;:&quot;([^&]+?)&quot;/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 }