dsh-cosplay-studio 2.0.4 → 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/lib/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // 4. post-processing (de-oil / JPEG cap / 2×2 preview / delivery copy);
9
9
  // 5. the JSON API the browser half talks to, plus project media serving.
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
12
12
  import { join, relative, resolve, sep } from 'node:path'
13
13
  import {
14
14
  API_PREFIX, MEDIA_PREFIX, PROJECTS_DIR, dshHome, logLine, patchConfig, readConfig, setupLog, clearLog,
@@ -18,13 +18,15 @@ import {
18
18
  modelStatus, resolveComfyLayout, selfTest, startComfy, stopComfy, waitForComfy,
19
19
  } from './comfy.js'
20
20
  import {
21
- buildCard, defaultDetails, llmConfigured, researchCharacter, ruleScript, shotPrompt, writeScript,
21
+ SCRIPT_SYSTEM, buildCard, chatComplete, defaultDetails, llmConfigured, normalizeShotItems, researchCharacter,
22
+ ruleScript, scriptUserText, shotPrompt, writeScript,
22
23
  } from './card.js'
23
24
  import {
24
- cancelQueue, createProject, enqueueBase, enqueueFinalize, enqueueReferenceBase, enqueueShots,
25
+ cancelQueue, createProject, enqueueBase, enqueueEdit, enqueueFinalize, enqueueShots,
25
26
  listProjects, projectDirs, queueSnapshot, readProject, saveProject, updateProject,
26
27
  } from './project.js'
27
28
  import { recommend } from './hot.js'
29
+ import { createRandomProject, llmText, parseJson, researchState, startResearch } from './research.js'
28
30
 
29
31
  const inject = ['webServer']
30
32
 
@@ -44,11 +46,17 @@ const API = {
44
46
  update: `${API_PREFIX}/project/update`,
45
47
  reference: `${API_PREFIX}/project/reference`,
46
48
  base: `${API_PREFIX}/project/base`,
49
+ baseEdit: `${API_PREFIX}/project/base/edit`,
50
+ shotEdit: `${API_PREFIX}/project/shot/edit`,
51
+ restoreShot: `${API_PREFIX}/project/shot/restore`,
47
52
  script: `${API_PREFIX}/project/script`,
53
+ scriptAdd: `${API_PREFIX}/project/script/add`,
48
54
  generate: `${API_PREFIX}/project/generate`,
49
55
  finalize: `${API_PREFIX}/project/finalize`,
50
56
  queue: `${API_PREFIX}/queue`,
51
57
  openFolder: `${API_PREFIX}/project/open-folder`,
58
+ research: `${API_PREFIX}/project/research`,
59
+ random: `${API_PREFIX}/project/random`,
52
60
  }
53
61
 
54
62
  const TUTORIAL = [
@@ -280,7 +288,7 @@ function referenceSave(id, body) {
280
288
  function applyShots(id, shots) {
281
289
  const project = readProject(id)
282
290
  if (project === null) throw new Error('项目不存在')
283
- const normalized = (Array.isArray(shots) ? shots : []).slice(0, 6).map((shot, index) => ({
291
+ const normalized = (Array.isArray(shots) ? shots : []).slice(0, 10).map((shot, index) => ({
284
292
  id: index + 1,
285
293
  type: String(shot.type ?? 'A'),
286
294
  title: String(shot.title ?? `镜头 ${index + 1}`),
@@ -295,6 +303,81 @@ function applyShots(id, shots) {
295
303
  return updateProject(id, { shots: normalized })
296
304
  }
297
305
 
306
+ function mediaTypeOfName(name) {
307
+ const lower = String(name).toLowerCase()
308
+ if (lower.endsWith('.png')) return 'image/png'
309
+ if (lower.endsWith('.webp')) return 'image/webp'
310
+ return 'image/jpeg'
311
+ }
312
+
313
+ /** Script model path: configured chat endpoint first, then the DSH host model,
314
+ * then the built-in templates. Keeps existing shots for smart additions. */
315
+ async function generateShotList(ctx, project, count, existing = []) {
316
+ const config = readConfig()
317
+ const user = scriptUserText(project.details, count, existing)
318
+ const startIndex = existing.length + 1
319
+ if (llmConfigured(config)) {
320
+ try {
321
+ const text = await chatComplete(config, SCRIPT_SYSTEM, user)
322
+ const parsed = parseJson(text)
323
+ const list = Array.isArray(parsed) ? parsed : parsed?.shots
324
+ return { shots: normalizeShotItems(list, startIndex), note: '镜头脚本由脚本模型生成' }
325
+ } catch {
326
+ // fall through to the host model
327
+ }
328
+ }
329
+ try {
330
+ let lastError = null
331
+ for (const maxTokens of [9000, 12000]) {
332
+ try {
333
+ const text = await llmText(ctx, `${SCRIPT_SYSTEM}\n\n${user}\n\n只输出 JSON 数组,不要输出任何思考过程或解释。`, { maxTokens })
334
+ const parsed = parseJson(text)
335
+ const list = Array.isArray(parsed) ? parsed : parsed?.shots
336
+ return { shots: normalizeShotItems(list, startIndex), note: '镜头脚本由 DSH 模型生成' }
337
+ } catch (error) {
338
+ lastError = error
339
+ }
340
+ }
341
+ throw lastError ?? new Error('脚本模型没有返回内容')
342
+ } catch (error) {
343
+ return { shots: ruleScript(project.details, Math.max(1, Math.min(6, count))), note: `内置脚本模板(${error?.message ?? error})` }
344
+ }
345
+ }
346
+
347
+ /** Smart edit: compare the picture with the reference / approved base, let the
348
+ * vision model name the flaws and produce one English img2img instruction. */
349
+ async function smartEditInstruction(ctx, project, target, file, note = '') {
350
+ const dirs = projectDirs(project.id)
351
+ const images = []
352
+ const add = (folder, name) => {
353
+ if (name === '') return
354
+ const path = join(folder, name)
355
+ if (!existsSync(path) || images.some((image) => image.name === name)) return
356
+ images.push({ data: readFileSync(path), mediaType: mediaTypeOfName(name), name })
357
+ }
358
+ add(target === 'base' ? dirs.base : dirs.raw, file)
359
+ add(dirs.refs, String(project.referenceFile ?? ''))
360
+ add(dirs.base, String(project.approvedBase ?? ''))
361
+ const ask = note === ''
362
+ ? '没有额外要求,请自行找出最需要修的瑕疵(手、脚、五官、服装边缘、穿模)'
363
+ : `用户的修改要求:${note}`
364
+ const prompt = `你在为一套真人 cosplay 照片做精修指导,只输出 JSON。\n\n角色设定(英文):\n${String(project.card ?? '').slice(0, 1800)}\n\n${ask}\n\n请比对图片:第一张是待修改的图片${images.length > 1 ? ',后面的图片是该角色的参考图 / 已确认基准图' : ''}。找出需要修的 1-4 处具体问题,并给出一句可直接用于 img2img 重绘的英文编辑指令:只描述要改的局部,明确保持同一人物、同一姿势、同一机位、同一服装与背景不变;优先修手、脚、五官和衣物边缘这类细节。\n\n只输出:{"issues":["中文问题1","中文问题2"],"fix":"one English editing instruction"}`
365
+ let lastError = null
366
+ for (const maxTokens of [4000, 6000]) {
367
+ try {
368
+ const text = await llmText(ctx, `${prompt}\n\n不要输出任何思考过程。`, { images, maxTokens })
369
+ const parsed = parseJson(text)
370
+ const issues = Array.isArray(parsed?.issues) ? parsed.issues.map((value) => String(value)).filter(Boolean).slice(0, 4) : []
371
+ const fix = String(parsed?.fix ?? '').trim()
372
+ if (fix === '') throw new Error('智能分析没有给出修改指令')
373
+ return { issues, fix }
374
+ } catch (error) {
375
+ lastError = error
376
+ }
377
+ }
378
+ throw lastError ?? new Error('智能分析失败')
379
+ }
380
+
298
381
  /* ----------------------------------------------------------------- apply */
299
382
 
300
383
  export function apply(ctx) {
@@ -342,6 +425,9 @@ export function apply(ctx) {
342
425
  updatedAt: project.updatedAt,
343
426
  finalCount: (project.finals ?? []).length,
344
427
  baseCount: (project.baseImages ?? []).length,
428
+ research: project.research
429
+ ? { status: project.research.status, stage: project.research.stage, stageLabel: project.research.stageLabel }
430
+ : null,
345
431
  }))
346
432
  sendJson(response, 200, {
347
433
  ok: true,
@@ -499,7 +585,10 @@ export function apply(ctx) {
499
585
  sendJson(response, 404, { ok: false, error: '项目不存在' })
500
586
  return
501
587
  }
502
- sendJson(response, 200, { ok: true, project })
588
+ // A host restart leaves research jobs marked running forever; normalise so
589
+ // the panel shows "重新研究" instead of a spinner that never ends.
590
+ const research = researchState(project)
591
+ sendJson(response, 200, { ok: true, project: research === null ? project : { ...project, research } })
503
592
  })
504
593
 
505
594
  route(API.recommend, async (request, response) => {
@@ -535,6 +624,7 @@ export function apply(ctx) {
535
624
  return
536
625
  }
537
626
  const series = String(body?.series ?? '').trim()
627
+ const source = String(body?.source ?? 'name')
538
628
  const config = readConfig()
539
629
  let details = body?.details !== null && typeof body?.details === 'object' ? { ...body.details } : null
540
630
  let note = ''
@@ -544,17 +634,30 @@ export function apply(ctx) {
544
634
  details = await researchCharacter(config, { character, series, referencePath: body?.referenceFile ?? '' })
545
635
  note = '角色卡由脚本模型生成'
546
636
  } catch (error) {
547
- details = defaultDetails(character, series)
637
+ details = defaultDetails(character, series, { reference: source === 'image' })
548
638
  note = `脚本模型不可用(${error?.message ?? error}),已用内置模板`
549
639
  }
550
640
  } else {
551
- details = defaultDetails(character, series)
552
- note = llmConfigured(config) ? '按内置模板生成角色卡' : '未配置脚本模型,按内置模板生成角色卡'
641
+ details = defaultDetails(character, series, { reference: source === 'image' })
642
+ note = source === 'image'
643
+ ? '参考图项目:人物外观以参考图为准,角色卡里的发型/服装/道具只是占位描述,可留空'
644
+ : llmConfigured(config) ? '按内置模板生成角色卡' : '未配置脚本模型,角色卡用通用模板;想做得像请上传参考图'
553
645
  }
554
646
  }
555
647
  const project = createProject({ character, series, details, source: body?.source ?? 'name' })
556
648
  updateProject(project.id, { notes: [note] })
557
- sendJson(response, 200, { ok: true, project: readProject(project.id), note })
649
+ // Name projects start the slow research right away (web search → pictures →
650
+ // vision analysis → draft). Image projects start after the upload arrives.
651
+ let researching = false
652
+ if (body?.research !== false && source !== 'image') {
653
+ try {
654
+ startResearch(ctx, config, project.id)
655
+ researching = true
656
+ } catch (error) {
657
+ updateProject(project.id, { notes: [note, `角色研究未能启动:${error?.message ?? error}`] })
658
+ }
659
+ }
660
+ sendJson(response, 200, { ok: true, project: readProject(project.id), note, researching })
558
661
  })
559
662
 
560
663
  route(API.update, async (request, response) => {
@@ -579,7 +682,14 @@ export function apply(ctx) {
579
682
  if (Array.isArray(body?.shots)) {
580
683
  applyShots(id, body.shots)
581
684
  }
582
- if (typeof body?.status === 'string') patch.status = body.status
685
+ if (typeof body?.status === 'string') {
686
+ patch.status = body.status
687
+ // Remember which base the user approved: later shots are img2img from it.
688
+ if (body.status === 'base-approved') {
689
+ const bases = Array.isArray(project.baseImages) ? project.baseImages : []
690
+ if (bases.length > 0) patch.approvedBase = bases[bases.length - 1]
691
+ }
692
+ }
583
693
  if (Object.keys(patch).length > 0) updateProject(id, patch)
584
694
  sendJson(response, 200, { ok: true, project: readProject(id) })
585
695
  })
@@ -628,19 +738,22 @@ export function apply(ctx) {
628
738
  sendJson(response, 409, { ok: false, error: '正在生成其它任务,请稍候' })
629
739
  return
630
740
  }
631
- const useReference = body?.useReference === true && String(project.referenceFile ?? '') !== ''
632
- if (useReference) enqueueReferenceBase(config, layout, id)
633
- else enqueueBase(config, layout, id, body?.count === 2 ? 2 : 1)
741
+ // Reference projects default to img2img from the uploaded picture; the
742
+ // text-to-image path stays available with an explicit useReference:false.
743
+ const useReference = String(project.referenceFile ?? '') !== '' && body?.useReference !== false
744
+ enqueueBase(config, layout, id, body?.count === 2 ? 2 : 1, { useReference })
634
745
  sendJson(response, 200, { ok: true, queue: queueSnapshot() })
635
746
  })
636
747
 
637
- route(API.script, async (request, response) => {
748
+ // Manual / smart retouch: 'base' appends a new base version, 'shot' replaces
749
+ // one raw final. Smart mode asks the vision model what to fix first.
750
+ const editRoute = (target) => async (request, response) => {
638
751
  if (request.method !== 'POST') {
639
752
  response.writeHead(405, { allow: 'POST' })
640
753
  response.end()
641
754
  return
642
755
  }
643
- const body = await readBody(request, 1024 * 1024)
756
+ const body = await readBody(request, 64 * 1024)
644
757
  const id = String(body?.id ?? '')
645
758
  const project = readProject(id)
646
759
  if (project === null) {
@@ -648,26 +761,194 @@ export function apply(ctx) {
648
761
  return
649
762
  }
650
763
  const config = readConfig()
651
- const count = Math.max(4, Math.min(6, Number(body?.count ?? config.shotCount ?? 6)))
652
- let shots = null
653
- let note = ''
654
- if (body?.useModel !== false && llmConfigured(config)) {
764
+ const { layout } = detectComfyDir(config)
765
+ if (layout === null || (await comfyStats(config.comfyPort)) === null) {
766
+ sendJson(response, 409, { ok: false, error: 'ComfyUI 没有运行,请先在「安装」页启动' })
767
+ return
768
+ }
769
+ if (queueSnapshot().running) {
770
+ sendJson(response, 409, { ok: false, error: '正在生成其它任务,请稍候' })
771
+ return
772
+ }
773
+ const file = String(body?.file ?? '').trim()
774
+ if (target === 'shot' && file === '') {
775
+ sendJson(response, 400, { ok: false, error: '请选择要修改的成片' })
776
+ return
777
+ }
778
+ let instruction = String(body?.instruction ?? '').trim()
779
+ const notes = []
780
+ if (body?.smart === true) {
655
781
  try {
656
- shots = await writeScript(config, project.details, count)
657
- note = '镜头脚本由脚本模型生成'
782
+ const analysis = await smartEditInstruction(ctx, project, target, file, instruction)
783
+ instruction = analysis.fix
784
+ if (analysis.issues.length > 0) notes.push(`智能微调分析:${analysis.issues.join(';')}`)
658
785
  } catch (error) {
659
- shots = ruleScript(project.details, count)
660
- note = `脚本模型不可用(${error?.message ?? error}),已用内置脚本`
786
+ sendJson(response, 500, { ok: false, error: `智能分析失败:${error?.message ?? error}` })
787
+ return
661
788
  }
662
- } else {
663
- shots = ruleScript(project.details, count)
664
- note = '内置脚本模板'
665
789
  }
790
+ if (instruction === '') {
791
+ sendJson(response, 400, { ok: false, error: '请输入修改要求,或改用智能修改' })
792
+ return
793
+ }
794
+ try {
795
+ enqueueEdit(config, layout, id, { target, file, instruction, denoise: Number(body?.denoise), notes })
796
+ sendJson(response, 200, { ok: true, project: readProject(id), issues: notes })
797
+ } catch (error) {
798
+ sendJson(response, 500, { ok: false, error: error?.message ?? String(error) })
799
+ }
800
+ }
801
+ route(API.baseEdit, editRoute('base'))
802
+ route(API.shotEdit, editRoute('shot'))
803
+
804
+ // Undo the most recent retouch of one raw final (backups live in raw/.history).
805
+ route(API.restoreShot, async (request, response) => {
806
+ if (request.method !== 'POST') {
807
+ response.writeHead(405, { allow: 'POST' })
808
+ response.end()
809
+ return
810
+ }
811
+ const body = await readBody(request, 16 * 1024)
812
+ const id = String(body?.id ?? '')
813
+ const project = readProject(id)
814
+ if (project === null) {
815
+ sendJson(response, 404, { ok: false, error: '项目不存在' })
816
+ return
817
+ }
818
+ const file = String(body?.file ?? '').trim()
819
+ if (file === '' || file.includes('/') || file.includes('\\') || file.includes('..')) {
820
+ sendJson(response, 400, { ok: false, error: '文件名不合法' })
821
+ return
822
+ }
823
+ const dirs = projectDirs(id)
824
+ const history = join(dirs.raw, '.history')
825
+ const current = join(dirs.raw, file)
826
+ if (!existsSync(history) || !existsSync(current)) {
827
+ sendJson(response, 404, { ok: false, error: '没有可恢复的历史版本' })
828
+ return
829
+ }
830
+ const versions = readdirSync(history)
831
+ .filter((name) => name.startsWith(`${file}.`) && name.endsWith('.png'))
832
+ .sort()
833
+ if (versions.length === 0) {
834
+ sendJson(response, 404, { ok: false, error: '没有可恢复的历史版本' })
835
+ return
836
+ }
837
+ const latest = versions[versions.length - 1]
838
+ writeFileSync(join(history, `${file}.${Date.now()}.png`), readFileSync(current))
839
+ writeFileSync(current, readFileSync(join(history, latest)))
840
+ const updated = updateProject(id, {
841
+ pendingFinals: [...new Set([...(project.pendingFinals ?? []), file])],
842
+ notes: [...(project.notes ?? []), `已恢复上一版成片:${file}`],
843
+ })
844
+ sendJson(response, 200, { ok: true, project: updated })
845
+ })
846
+
847
+ route(API.research, async (request, response) => {
848
+ if (request.method !== 'POST') {
849
+ response.writeHead(405, { allow: 'POST' })
850
+ response.end()
851
+ return
852
+ }
853
+ const body = await readBody(request, 64 * 1024)
854
+ const id = String(body?.id ?? '')
855
+ if (readProject(id) === null) {
856
+ sendJson(response, 404, { ok: false, error: '项目不存在' })
857
+ return
858
+ }
859
+ const config = readConfig()
860
+ try {
861
+ const project = startResearch(ctx, config, id, { supplement: body?.supplement === true })
862
+ sendJson(response, 200, { ok: true, project })
863
+ } catch (error) {
864
+ sendJson(response, 500, { ok: false, error: error?.message ?? String(error) })
865
+ }
866
+ })
867
+
868
+ route(API.random, async (request, response) => {
869
+ if (request.method !== 'POST') {
870
+ response.writeHead(405, { allow: 'POST' })
871
+ response.end()
872
+ return
873
+ }
874
+ const config = readConfig()
875
+ try {
876
+ const pick = await createRandomProject(ctx, config)
877
+ const project = createProject({
878
+ character: pick.character,
879
+ series: pick.series,
880
+ details: defaultDetails(pick.character, pick.series),
881
+ source: 'random',
882
+ })
883
+ updateProject(project.id, { notes: [pick.reason === '' ? '随机推荐角色' : `随机推荐:${pick.reason}`] })
884
+ startResearch(ctx, config, project.id)
885
+ sendJson(response, 200, { ok: true, project: readProject(project.id), pick })
886
+ } catch (error) {
887
+ sendJson(response, 500, { ok: false, error: error?.message ?? String(error) })
888
+ }
889
+ })
890
+
891
+ route(API.script, async (request, response) => {
892
+ if (request.method !== 'POST') {
893
+ response.writeHead(405, { allow: 'POST' })
894
+ response.end()
895
+ return
896
+ }
897
+ const body = await readBody(request, 1024 * 1024)
898
+ const id = String(body?.id ?? '')
899
+ const project = readProject(id)
900
+ if (project === null) {
901
+ sendJson(response, 404, { ok: false, error: '项目不存在' })
902
+ return
903
+ }
904
+ const config = readConfig()
905
+ const count = Math.max(4, Math.min(6, Number(body?.count ?? config.shotCount ?? 6)))
906
+ const { shots, note } = await generateShotList(ctx, project, count)
666
907
  const updated = applyShots(id, shots)
667
908
  updateProject(id, { status: 'awaiting-script-approval', notes: [...(updated.notes ?? []), note] })
668
909
  sendJson(response, 200, { ok: true, project: readProject(id), note })
669
910
  })
670
911
 
912
+ // Smart add: the script model designs extra shots that must not repeat the
913
+ // ones already in the list.
914
+ route(API.scriptAdd, async (request, response) => {
915
+ if (request.method !== 'POST') {
916
+ response.writeHead(405, { allow: 'POST' })
917
+ response.end()
918
+ return
919
+ }
920
+ const body = await readBody(request, 64 * 1024)
921
+ const id = String(body?.id ?? '')
922
+ const project = readProject(id)
923
+ if (project === null) {
924
+ sendJson(response, 404, { ok: false, error: '项目不存在' })
925
+ return
926
+ }
927
+ const existing = Array.isArray(project.shots) ? project.shots : []
928
+ if (existing.length === 0) {
929
+ sendJson(response, 409, { ok: false, error: '还没有镜头脚本,请先生成' })
930
+ return
931
+ }
932
+ const add = Math.max(1, Math.min(3, Number(body?.count ?? 2)))
933
+ const limit = Math.max(0, 10 - existing.length)
934
+ if (limit === 0) {
935
+ sendJson(response, 409, { ok: false, error: '已经有 10 个分镜,先删除一些再补' })
936
+ return
937
+ }
938
+ const { shots: fresh, note } = await generateShotList(ctx, project, Math.min(add, limit), existing)
939
+ const merged = [...existing, ...fresh].slice(0, 10).map((shot, index) => ({
940
+ ...shot,
941
+ id: index + 1,
942
+ prompt: `${shot.type ?? 'A'}|${shot.title ?? `镜头 ${index + 1}`}`,
943
+ }))
944
+ const updated = updateProject(id, {
945
+ shots: merged,
946
+ status: 'awaiting-script-approval',
947
+ notes: [...(project.notes ?? []), note, `智能补充分镜 ${fresh.length} 个`],
948
+ })
949
+ sendJson(response, 200, { ok: true, project: updated, note })
950
+ })
951
+
671
952
  route(API.generate, async (request, response) => {
672
953
  if (request.method !== 'POST') {
673
954
  response.writeHead(405, { allow: 'POST' })
@@ -718,7 +999,13 @@ export function apply(ctx) {
718
999
  sendJson(response, 409, { ok: false, error: '已有任务在运行' })
719
1000
  return
720
1001
  }
721
- enqueueFinalize(config, layout, id)
1002
+ enqueueFinalize(config, layout, id, {
1003
+ deoil: body?.deoil === true,
1004
+ makeup: body?.makeup === true,
1005
+ flatten: body?.flatten !== false,
1006
+ preview: body?.preview !== false,
1007
+ copy: body?.copy !== false,
1008
+ })
722
1009
  sendJson(response, 200, { ok: true })
723
1010
  })
724
1011