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/lib/client.js CHANGED
@@ -198,6 +198,119 @@ window.__ModuleLoader__.load({
198
198
  return h('span', { style: ui.chip(color) }, STATUS_LABEL[status] ?? status)
199
199
  }
200
200
 
201
+ // Slow, staged character research: web search → downloaded pictures →
202
+ // vision analysis → rough draft. The host writes every stage into
203
+ // project.research, so this card mainly renders it and offers a retry.
204
+ const RESEARCH_STAGE_VALUE = { prepare: 0.08, search: 0.3, images: 0.5, analyze: 0.75, draft: 0.9, done: 1, failed: 1 }
205
+
206
+ function ResearchCard({ project, actions, busy }) {
207
+ const research = project?.research ?? null
208
+ const status = research?.status ?? 'idle'
209
+ const running = status === 'running'
210
+ const done = status === 'done'
211
+ const failed = status === 'failed'
212
+ const steps = Array.isArray(research?.steps) ? research.steps.slice(-6).reverse() : []
213
+ const images = Array.isArray(research?.images) ? research.images : []
214
+ const features = research?.features ?? null
215
+ const draft = research?.draft ?? null
216
+ const value = RESEARCH_STAGE_VALUE[research?.stage] ?? (running ? 0.05 : 0)
217
+ const modeLabel = research?.mode === 'image' ? '参考图分析' : '联网检索'
218
+ const start = () => {
219
+ void actions.startResearch(project.id).catch((error) => actions.reportError(errText(error)))
220
+ }
221
+ return h('div', { style: ui.card },
222
+ h('div', { style: ui.row, marginBottom: 6 },
223
+ h('span', { style: { fontWeight: 600 } }, '角色形象研究'),
224
+ h('span', { style: { ...ui.hint, marginLeft: 8 } }, modeLabel),
225
+ h('span', { style: { flex: 1 } }),
226
+ h('span', {
227
+ style: {
228
+ fontSize: 11, padding: '2px 7px', borderRadius: 999,
229
+ background: running ? 'rgba(255,159,10,0.16)' : done ? 'rgba(48,209,88,0.16)' : failed ? 'rgba(255,69,58,0.16)' : 'rgba(128,128,128,0.14)',
230
+ color: running ? '#ff9f0a' : done ? '#30d158' : failed ? '#ff6b6b' : 'inherit',
231
+ },
232
+ }, running ? '研究中' : done ? '已完成' : failed ? '失败' : '未开始')),
233
+
234
+ h('div', { style: { ...ui.hint, lineHeight: 1.6 } },
235
+ running
236
+ ? (research.stageLabel || '正在研究角色…')
237
+ : done
238
+ ? '研究完成。下面是根据资料/参考图提取的特征,可以直接修改,或点「重新研究」。'
239
+ : failed
240
+ ? `研究失败:${research.error || '未知原因'}`
241
+ : (project.source === 'image'
242
+ ? '上传的参考图就是唯一设计依据。点「开始研究」:分析图片特征 → 生成一张粗略草图确认(约 1 分钟);如果一直不像,点「补充网络参考图」把网上的 cosplay 图加进来再分析一次。'
243
+ : '点「开始研究」:联网检索角色资料 → 下载角色图/别人的 cosplay 图 → 分析发型、服装、性格等特征 → 生成一张粗略草图(约 1–3 分钟)。')),
244
+
245
+ running ? h('div', { style: { marginTop: 8 } }, h(Progress, { value, label: research.stageLabel || '研究中' })) : null,
246
+
247
+ steps.length > 0
248
+ ? h('div', { style: { marginTop: 8, display: 'grid', gap: 3 } },
249
+ steps.map((step, index) => h('div', {
250
+ key: `${index}-${step.at ?? ''}`,
251
+ style: { ...ui.hint, opacity: step.status === 'error' ? 0.95 : 0.7, color: step.status === 'error' ? '#ff6b6b' : 'inherit' },
252
+ }, `${step.status === 'error' ? '×' : '·'} ${step.label ?? ''}`)))
253
+ : null,
254
+
255
+ features !== null
256
+ ? h('div', { style: { marginTop: 10, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 } },
257
+ features.hair ? h('div', { style: ui.col }, h('span', { style: ui.label }, '发型'), h('div', { style: { ...ui.hint, opacity: 0.85 } }, features.hair)) : null,
258
+ features.eyes ? h('div', { style: ui.col }, h('span', { style: ui.label }, '眼睛'), h('div', { style: { ...ui.hint, opacity: 0.85 } }, features.eyes)) : null,
259
+ features.costume ? h('div', { style: ui.col }, h('span', { style: ui.label }, '服装'), h('div', { style: { ...ui.hint, opacity: 0.85 } }, features.costume)) : null,
260
+ features.props || features.accessories
261
+ ? h('div', { style: ui.col }, h('span', { style: ui.label }, '道具 / 配饰'), h('div', { style: { ...ui.hint, opacity: 0.85 } }, features.props || features.accessories))
262
+ : null,
263
+ features.personality ? h('div', { style: ui.col }, h('span', { style: ui.label }, '性格'), h('div', { style: { ...ui.hint, opacity: 0.85 } }, features.personality)) : null,
264
+ Array.isArray(features.palette) && features.palette.length > 0
265
+ ? h('div', { style: ui.col }, h('span', { style: ui.label }, '配色'), h('div', { style: { ...ui.hint, opacity: 0.85 } }, features.palette.join(' / ')))
266
+ : null,
267
+ features.confidence > 0
268
+ ? h('div', { style: ui.col }, h('span', { style: ui.label }, '置信度'), h('div', { style: { ...ui.hint, opacity: 0.85 } }, `${Math.round(features.confidence * 100)}%`))
269
+ : null)
270
+ : null,
271
+
272
+ research?.summary
273
+ ? h('div', { style: { ...ui.hint, marginTop: 8, opacity: 0.85, lineHeight: 1.6 } }, research.summary)
274
+ : null,
275
+
276
+ images.length > 0
277
+ ? h('div', { style: { ...ui.grid, marginTop: 10 } },
278
+ images.map((image) => h('div', { key: image.file, style: ui.col },
279
+ h(Thumb, {
280
+ src: mediaUrl(project.id, `refs/${image.file}`),
281
+ style: ui.thumb,
282
+ onOpen: () => actions.openFolder(project.id, 'refs'),
283
+ }),
284
+ h('span', { style: { ...ui.mono, fontSize: 10.5, opacity: 0.6, wordBreak: 'break-all' } }, `${image.width}×${image.height}`))))
285
+ : null,
286
+
287
+ draft !== null && draft !== ''
288
+ ? h('div', { style: { marginTop: 10 } },
289
+ h('div', { style: { ...ui.label, marginBottom: 4 } }, '粗略草图(确认外观用,不是最终质量)'),
290
+ h(Thumb, {
291
+ src: mediaUrl(project.id, `research/${draft.file}`),
292
+ style: { width: '100%', maxWidth: 300, borderRadius: 8, display: 'block' },
293
+ onOpen: () => actions.openFolder(project.id, 'research'),
294
+ }))
295
+ : null,
296
+
297
+ h('div', { style: { ...ui.row, marginTop: 10 } },
298
+ h(Btn, { kind: running || done ? undefined : 'primary', disabled: busy || running, onClick: start }, done || failed ? '重新研究' : '开始研究'),
299
+ done && project.source === 'image'
300
+ ? h(Btn, {
301
+ disabled: busy || running,
302
+ onClick: () => {
303
+ void actions.startResearch(project.id, { supplement: true }).catch((error) => actions.reportError(errText(error)))
304
+ },
305
+ }, '补充网络参考图')
306
+ : null,
307
+ h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'research') }, '打开研究文件夹'),
308
+ h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'refs') }, '打开参考图文件夹')),
309
+ done && project.source === 'image'
310
+ ? h('div', { style: { ...ui.hint, marginTop: 6, opacity: 0.75 } }, '参考图项目以你上传的图为准;点「补充网络参考图」可把网上的 cosplay 图加进分析,让特征更完整。')
311
+ : null)
312
+ }
313
+
201
314
  /* ------------------------------------------------------------ icon */
202
315
 
203
316
  function SidebarIcon(props) {
@@ -418,6 +531,12 @@ window.__ModuleLoader__.load({
418
531
  function ProjectView({ state, project, actions, busy, tab, setTab }) {
419
532
  const [details, setDetails] = useState(null)
420
533
  const [shots, setShots] = useState(null)
534
+ const [deoil, setDeoil] = useState(false)
535
+ const [bodyMakeup, setBodyMakeup] = useState(false)
536
+ const [baseEdit, setBaseEdit] = useState('')
537
+ const [shotEditFile, setShotEditFile] = useState('')
538
+ const [shotEdit, setShotEdit] = useState('')
539
+ const [editDenoise, setEditDenoise] = useState(0.42)
421
540
  const queue = state?.queue ?? {}
422
541
  const deployment = state?.deployment ?? {}
423
542
  const env = state?.env ?? {}
@@ -427,6 +546,16 @@ window.__ModuleLoader__.load({
427
546
  setShots(project?.shots ? project.shots.map((shot) => ({ ...shot })) : null)
428
547
  }, [project?.id, project?.updatedAt])
429
548
 
549
+ useEffect(() => {
550
+ setDeoil(project?.finalizeOptions?.deoil === true)
551
+ setBodyMakeup(project?.finalizeOptions?.makeup === true)
552
+ }, [project?.id, project?.finalizeOptions?.deoil, project?.finalizeOptions?.makeup])
553
+
554
+ useEffect(() => {
555
+ const files = project?.pendingFinals ?? []
556
+ setShotEditFile((current) => (files.includes(current) ? current : (files[0] ?? '')))
557
+ }, [project?.id, project?.updatedAt])
558
+
430
559
  if (!project) {
431
560
  return h('div', { style: { ...ui.scroll, display: 'flex', alignItems: 'center', justifyContent: 'center' } },
432
561
  h('div', { style: { textAlign: 'center', opacity: 0.55, maxWidth: 380, lineHeight: 1.8 } },
@@ -442,6 +571,9 @@ window.__ModuleLoader__.load({
442
571
  const baseJobId = `base-${project.id}`
443
572
  const baseRefJobId = `refbase-${project.id}`
444
573
  const baseRunning = queue.running === true && (queue.current === baseJobId || queue.current === baseRefJobId)
574
+ const editJobId = `edit-${project.id}`
575
+ const editRunning = queue.running === true && queue.current === editJobId
576
+ const hasReference = String(project.referenceFile ?? '') !== ''
445
577
 
446
578
  const saveDetails = () => {
447
579
  void actions.updateProject(project.id, { details }).catch((error) => actions.reportError(errText(error)))
@@ -485,11 +617,18 @@ window.__ModuleLoader__.load({
485
617
  },
486
618
  }, key === 'base' ? '① 基准图' : key === 'script' ? '② 镜头脚本' : '③ 成片导出'))),
487
619
  h('div', { style: { ...ui.hint, marginTop: 6 } },
488
- baseApproved ? '基准图已确认,后续成片会逐字复用这张角色卡,只替换姿态与手部描述。' : '基准图是整套图的地基:人物、服装、背景、灯光都由它定下来,确认后才继续。'),
620
+ baseApproved
621
+ ? (hasReference
622
+ ? '基准图已确认,后续成片会以这张基准图为底图做 img2img,保留人物与服装。'
623
+ : '基准图已确认,后续成片会逐字复用这张角色卡,只替换姿态与手部描述。')
624
+ : (hasReference
625
+ ? '参考图项目:基准图以你上传的参考图为底(img2img,denoise 0.75),人物、发型、服装、道具都跟着参考图走。确认后才继续。'
626
+ : '基准图是整套图的地基:人物、服装、背景、灯光都由它定下来,确认后才继续。')),
489
627
  !env.running ? h('div', { style: { ...ui.hint, color: '#ff9f0a', marginTop: 4 } }, 'ComfyUI 当前没有运行,生成前请到「安装」页启动。') : null),
490
628
 
491
629
  h('div', { style: { ...ui.scroll, paddingTop: 12 } },
492
630
  tab === 'base' ? h('div', null,
631
+ h(ResearchCard, { project, actions, busy }),
493
632
  h('div', { style: ui.card },
494
633
  h('div', { style: ui.row, marginBottom: 8 },
495
634
  h('span', { style: { fontWeight: 600 } }, '角色特征'),
@@ -511,15 +650,17 @@ window.__ModuleLoader__.load({
511
650
  h('div', { style: ui.card },
512
651
  h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '基准图 1152×2048'),
513
652
  h('div', { style: ui.row },
514
- h(Btn, { kind: 'primary', disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 1, false) }, '生成 1 张基准图'),
515
- h(Btn, { disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 2, false) }, '生成 2 张(对比)'),
516
- project.referenceFile
517
- ? h(Btn, { disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 1, true) }, '按参考图生成')
653
+ h(Btn, { kind: 'primary', disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 1, hasReference) }, hasReference ? '按参考图生成基准图' : '生成 1 张基准图'),
654
+ h(Btn, { disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 2, hasReference) }, '生成 2 张(对比)'),
655
+ hasReference
656
+ ? h(Btn, { disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 1, false) }, '不用参考图(按角色卡)')
518
657
  : null,
519
658
  h('span', { style: { flex: 1 } }),
520
659
  h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'base') }, '打开基准图文件夹')),
521
660
  h('div', { style: { ...ui.hint, marginTop: 8 } },
522
- '生成一张约 75 秒。同一张角色卡换随机种子,多张可选最像的一张。' + (project.referenceFile ? '参考图模式用 img2img(denoise 0.6),会保留参考图的构图与配色倾向。' : '')),
661
+ hasReference
662
+ ? '按参考图生成用 img2img(denoise 0.75):人物、发型、服装、道具都以参考图为准,约 75–150 秒。角色卡只影响后面的镜头文案,不影响这张基准图。'
663
+ : '当前项目没有参考图:直接按角色卡文生图,约 75 秒。像不像取决于角色卡和模型对角色名的了解;想做得像请上传参考图后重新创建项目。'),
523
664
  baseRunning
524
665
  ? h('div', { style: { marginTop: 10 } },
525
666
  h(Progress, { value: queueFraction(queue), label: queue.label || '生成基准图' }),
@@ -527,7 +668,7 @@ window.__ModuleLoader__.load({
527
668
  : null,
528
669
  (project.baseImages ?? []).length > 0
529
670
  ? h('div', { style: { ...ui.grid, marginTop: 12 } },
530
- (project.baseImages ?? []).map((file) => h('div', { key: file, style: ui.col },
671
+ [...(project.baseImages ?? [])].reverse().map((file) => h('div', { key: file, style: ui.col },
531
672
  h(Thumb, {
532
673
  src: mediaUrl(project.id, `base/${file}`),
533
674
  style: ui.thumb,
@@ -538,7 +679,42 @@ window.__ModuleLoader__.load({
538
679
  (project.baseImages ?? []).length > 0 && !baseApproved
539
680
  ? h('div', { style: { ...ui.row, marginTop: 12 } },
540
681
  h(Btn, { kind: 'primary', disabled: busy, onClick: confirmBase }, '确认基准图,进入镜头脚本'))
541
- : null)) : null,
682
+ : null),
683
+
684
+ (project.baseImages ?? []).length > 0
685
+ ? h('div', { style: ui.card },
686
+ h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '微调基准图(不用重新生成)'),
687
+ h(Field, {
688
+ label: '想改的地方(留空则让智能修改自己找问题)',
689
+ value: baseEdit, onChange: setBaseEdit, textarea: true, rows: 2,
690
+ }),
691
+ h('div', { style: { ...ui.row, marginTop: 8 } },
692
+ h(Btn, {
693
+ disabled: busy || queue.running || !env.running || baseEdit.trim() === '',
694
+ onClick: () => actions.baseEdit(project.id, { instruction: baseEdit, denoise: editDenoise }).then(() => setBaseEdit('')),
695
+ }, '按我的要求微调'),
696
+ h(Btn, {
697
+ disabled: busy || queue.running || !env.running,
698
+ onClick: () => actions.baseEdit(project.id, { smart: true, instruction: baseEdit, denoise: editDenoise }).then(() => setBaseEdit('')),
699
+ }, '智能修改(比对参考图)'),
700
+ h('select', {
701
+ value: String(editDenoise),
702
+ onChange: (event) => setEditDenoise(Number(event.target.value)),
703
+ style: { ...ui.input, width: 150 },
704
+ title: '改动幅度:越大越能改动作,但越容易改变人物/构图',
705
+ }, [
706
+ { value: 0.38, label: '只修细节' },
707
+ { value: 0.42, label: '小改(默认)' },
708
+ { value: 0.55, label: '中改' },
709
+ { value: 0.7, label: '大改(可能动姿势)' },
710
+ ].map((option) => h('option', { key: option.value, value: String(option.value) }, option.label)))),
711
+ h('div', { style: { ...ui.hint, marginTop: 8 } },
712
+ '微调以最新一张基准图为底、低重绘(denoise 0.42),只改你指定的细节;结果作为新版本追加,旧图保留。智能修改会先让视觉模型比对参考图/角色卡,再针对手、脚、五官、衣边给出修改。'),
713
+ editRunning
714
+ ? h('div', { style: { marginTop: 10 } },
715
+ h(Progress, { value: queueFraction(queue), label: queue.label || '微调基准图' }))
716
+ : null)
717
+ : null) : null,
542
718
 
543
719
  tab === 'script' ? h('div', null,
544
720
  h('div', { style: ui.card },
@@ -546,9 +722,13 @@ window.__ModuleLoader__.load({
546
722
  h('div', { style: ui.row },
547
723
  h(Btn, { kind: 'primary', disabled: busy, onClick: () => actions.script(project.id, 6) }, '生成 6 镜脚本'),
548
724
  h(Btn, { disabled: busy, onClick: () => actions.script(project.id, 4) }, '生成 4 镜脚本'),
549
- h(Btn, { disabled: busy, onClick: () => { setShots([...(shots ?? []), { type: 'A 情绪特写', title: `镜头 ${(shots ?? []).length + 1}`, situation: '', expression: '', pose: '', hands: '', camera: '', props: 'no props' }]) } }, '手动加一镜')),
725
+ h(Btn, { disabled: busy, onClick: () => { setShots([...(shots ?? []), { type: 'A 情绪特写', title: `镜头 ${(shots ?? []).length + 1}`, situation: '', expression: '', pose: '', hands: '', camera: '', props: 'no props' }]) } }, '手动加一镜'),
726
+ h(Btn, {
727
+ disabled: busy || (shots ?? []).length === 0 || (shots ?? []).length >= 10,
728
+ onClick: () => actions.scriptAdd(project.id, 2),
729
+ }, '智能补 2 镜(不重复)')),
550
730
  h('div', { style: { ...ui.hint, marginTop: 8 } },
551
- '脚本规矩:表情不重样、至少一张不看镜头、机位至少两种、手的位置各不相同。一行一镜,确认后才开始出成片(每张约 230 秒)。')),
731
+ '脚本规矩:表情不重样、至少一张不看镜头、机位至少两种、手的位置各不相同。一行一镜,确认后才开始出成片(每张约 120–150 秒,直接出 1440×2560 原图,不额外锐化/压缩)。' + (hasReference ? '参考图项目:成片以已确认的基准图为底图做 img2img(denoise 0.72),人物与服装保持一致,姿态变化会比纯文生图保守;背景按角色配色换成满幅渐变。' : ''))),
552
732
  (shots ?? []).length === 0
553
733
  ? h('div', { style: { ...ui.card, opacity: 0.6 } }, '还没有脚本。先生成或手动添加。')
554
734
  : (shots ?? []).map((shot, index) => h(ShotCard, {
@@ -561,7 +741,7 @@ window.__ModuleLoader__.load({
561
741
  ? h('div', { style: { ...ui.row, marginTop: 4, marginBottom: 12 } },
562
742
  h(Btn, { disabled: busy, onClick: saveShots }, '保存脚本'),
563
743
  h(Btn, { kind: 'primary', disabled: busy || queue.running || !env.running, onClick: confirmShots }, `确认脚本,开始出 ${(shots ?? []).length} 张成片`),
564
- h('span', { style: ui.hint }, `预计 ${Math.round((shots ?? []).length * 3.9)} 分钟`))
744
+ h('span', { style: ui.hint }, `预计 ${Math.round((shots ?? []).length * 2.5)} 分钟`))
565
745
  : null) : null,
566
746
 
567
747
  tab === 'final' ? h('div', null,
@@ -569,20 +749,90 @@ window.__ModuleLoader__.load({
569
749
  h('div', { style: { ...ui.row, marginBottom: 8 } },
570
750
  h('span', { style: { fontWeight: 600 } }, '成片与导出'),
571
751
  h('span', { style: { flex: 1 } }),
572
- h(Btn, { kind: 'primary', disabled: busy || queue.running || !env.running, onClick: () => actions.generate(project.id) }, '重新生成未完成的镜头')),
573
- h('div', { style: ui.row },
574
- h(Btn, { disabled: busy || queue.running || (project.pendingFinals ?? []).length === 0, onClick: () => actions.finalize(project.id) }, '去油光 / 压 JPEG / 生成预览与文案'),
752
+ h(Btn, { kind: 'primary', disabled: busy || queue.running || !env.running, onClick: () => actions.generate(project.id) }, '生成成片(按镜头脚本)')),
753
+ h('div', { style: { ...ui.hint, marginTop: 2 } },
754
+ '先出原图(无损 PNG 存 raw/,可点「打开原图文件夹」逐张看);满意后再导出 JPEG。导出时会自动把参考图带来的白色海报边补成角色配色的满幅渐变;去油光 / 身体化妆会压高光、磨皮,默认关闭。'),
755
+ h('div', { style: { ...ui.row, marginTop: 10, gap: 14 } },
756
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12.5, cursor: 'pointer' } },
757
+ h('input', { type: 'checkbox', checked: deoil, onChange: (event) => setDeoil(event.target.checked) }), '去油光'),
758
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12.5, cursor: 'pointer' } },
759
+ h('input', { type: 'checkbox', checked: bodyMakeup, onChange: (event) => setBodyMakeup(event.target.checked) }), '身体化妆'),
575
760
  h('span', { style: { flex: 1 } }),
576
- h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'final') }, '打开成片文件夹'),
577
- h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'deliver') }, '打开导出文件夹')),
578
- h('div', { style: { ...ui.hint, marginTop: 8 } },
579
- '导出会按 skill 规矩输出:00_基准角色_角色.jpg、01_动作名.jpg……、00_全套预览.jpg(2×2,单格 720×1280)、文案.txt,并复制到 deliver/。'),
761
+ h(Btn, { kind: 'primary', disabled: busy || queue.running || (project.pendingFinals ?? []).length === 0, onClick: () => actions.finalize(project.id, { deoil, makeup: bodyMakeup }) }, '导出成片(JPEG+预览+文案)')),
762
+ h('div', { style: { ...ui.row, marginTop: 8 } },
763
+ h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'raw') }, '打开原图文件夹'),
764
+ h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'final') }, '打开导出文件夹'),
765
+ h(Btn, { disabled: busy, onClick: () => actions.openFolder(project.id, 'deliver') }, '打开发布文件夹')),
580
766
  queue.running && (queue.current === `shots-${project.id}` || queue.current === `final-${project.id}`)
581
767
  ? h('div', { style: { marginTop: 10 } },
582
768
  h(Progress, { value: queueFraction(queue), label: queue.label }),
583
769
  h('div', { style: { ...ui.row, marginTop: 8 } }, h(Btn, { onClick: () => actions.cancelQueue() }, '取消剩余镜头')))
584
770
  : null),
585
771
 
772
+ (project.pendingFinals ?? []).length > 0
773
+ ? h('div', { style: ui.card },
774
+ h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '微调成片(逐张)'),
775
+ h('select', {
776
+ value: shotEditFile || (project.pendingFinals ?? [])[0] || '',
777
+ onChange: (event) => setShotEditFile(event.target.value),
778
+ style: { ...ui.input, width: '100%' },
779
+ }, (project.pendingFinals ?? []).map((file) => h('option', { key: file, value: file }, file))),
780
+ h('div', { style: { marginTop: 8 } },
781
+ h(Field, {
782
+ label: '想改的地方(手、脚、五官、衣边、穿模…)',
783
+ value: shotEdit, onChange: setShotEdit, textarea: true, rows: 2,
784
+ })),
785
+ h('div', { style: { ...ui.row, marginTop: 8 } },
786
+ h(Btn, {
787
+ disabled: busy || queue.running || !env.running || shotEdit.trim() === '',
788
+ onClick: () => actions.shotEdit(project.id, {
789
+ file: shotEditFile || (project.pendingFinals ?? [])[0],
790
+ instruction: shotEdit,
791
+ denoise: editDenoise,
792
+ }).then(() => setShotEdit('')),
793
+ }, '按我的要求微调'),
794
+ h(Btn, {
795
+ disabled: busy || queue.running || !env.running,
796
+ onClick: () => actions.shotEdit(project.id, {
797
+ file: shotEditFile || (project.pendingFinals ?? [])[0],
798
+ smart: true,
799
+ instruction: shotEdit,
800
+ denoise: editDenoise,
801
+ }).then(() => setShotEdit('')),
802
+ }, '智能修改(比对参考图/基准图)'),
803
+ h('select', {
804
+ value: String(editDenoise),
805
+ onChange: (event) => setEditDenoise(Number(event.target.value)),
806
+ style: { ...ui.input, width: 150 },
807
+ title: '改动幅度:越大越能改动作,但越容易改变人物/构图',
808
+ }, [
809
+ { value: 0.38, label: '只修细节' },
810
+ { value: 0.42, label: '小改(默认)' },
811
+ { value: 0.55, label: '中改' },
812
+ { value: 0.7, label: '大改(可能动姿势)' },
813
+ ].map((option) => h('option', { key: option.value, value: String(option.value) }, option.label))),
814
+ h(Btn, {
815
+ disabled: busy || queue.running || !(shotEditFile || (project.pendingFinals ?? [])[0]),
816
+ onClick: () => actions.restoreShot(project.id, shotEditFile || (project.pendingFinals ?? [])[0]),
817
+ }, '撤销上次微调')),
818
+ h('div', { style: { ...ui.hint, marginTop: 8 } },
819
+ '微调直接覆盖 raw/ 里的这张原图(旧版本备份到 raw/.history/,可撤销),denoise 越小越只动局部;改完再点上面的导出。智能修改会先比对参考图/已确认基准图,再只修最需要改的瑕疵。'),
820
+ editRunning
821
+ ? h('div', { style: { marginTop: 10 } },
822
+ h(Progress, { value: queueFraction(queue), label: queue.label || '微调成片' }))
823
+ : null)
824
+ : null,
825
+
826
+ (project.pendingFinals ?? []).length > 0
827
+ ? h('div', { style: ui.card },
828
+ h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '成片原图(未处理,PNG)'),
829
+ h('div', { style: { ...ui.grid } },
830
+ (project.pendingFinals ?? []).map((file) => h('div', { key: file, style: ui.col },
831
+ h('a', { href: mediaUrl(project.id, `raw/${file}`), target: '_blank', rel: 'noreferrer' },
832
+ h(Thumb, { src: mediaUrl(project.id, `raw/${file}`), style: ui.thumb, onOpen: () => actions.openFolder(project.id, 'raw') })),
833
+ h('span', { style: { ...ui.mono, fontSize: 10.5, opacity: 0.7, wordBreak: 'break-all' } }, file)))))
834
+ : null,
835
+
586
836
  (project.finals ?? []).length > 0 || project.preview
587
837
  ? h('div', null,
588
838
  project.preview
@@ -617,18 +867,51 @@ window.__ModuleLoader__.load({
617
867
 
618
868
  /* ------------------------------------------------------------- panel */
619
869
 
870
+ // Remember what the user was looking at. The panel unmounts when the user
871
+ // switches to a chat and mounts again on return, so without this the
872
+ // workbench always reopened on the empty studio list.
873
+ const PANEL_STATE_KEY = 'dsh-cosplay-studio:panel-state'
874
+ const PANEL_TABS = ['base', 'script', 'final']
875
+
876
+ function readPanelState() {
877
+ try {
878
+ const raw = window.localStorage.getItem(PANEL_STATE_KEY)
879
+ const parsed = raw === null ? null : JSON.parse(raw)
880
+ return {
881
+ view: parsed?.view === 'studio' || parsed?.view === 'install' ? parsed.view : null,
882
+ selectedId: typeof parsed?.selectedId === 'string' ? parsed.selectedId : '',
883
+ tab: PANEL_TABS.includes(parsed?.tab) ? parsed.tab : 'base',
884
+ }
885
+ } catch {
886
+ return { view: null, selectedId: '', tab: 'base' }
887
+ }
888
+ }
889
+
890
+ function writePanelState(next) {
891
+ try {
892
+ window.localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(next))
893
+ } catch {
894
+ // localStorage can be unavailable (private mode); the panel still works.
895
+ }
896
+ }
897
+
620
898
  function Panel() {
621
899
  const [state, setState] = useState(null)
622
900
  const [project, setProject] = useState(null)
623
- const [selectedId, setSelectedId] = useState('')
624
- const [tab, setTab] = useState('base')
625
- const [view, setView] = useState(null)
901
+ const [selectedId, setSelectedId] = useState(() => readPanelState().selectedId)
902
+ const [tab, setTab] = useState(() => readPanelState().tab)
903
+ const [view, setView] = useState(() => readPanelState().view)
626
904
  const [busy, setBusy] = useState(false)
627
905
  const [error, setError] = useState('')
628
906
  const [form, setForm] = useState({ character: '', series: '', useModel: true, file: null, details: null })
629
907
  const [picks, setPicks] = useState([])
630
908
  const fileRef = useRef(null)
631
909
 
910
+ // Persist the current screen / project / tab for the next mount.
911
+ useEffect(() => {
912
+ writePanelState({ view, selectedId, tab })
913
+ }, [view, selectedId, tab])
914
+
632
915
  // null = follow the environment: a ready install opens on the workbench,
633
916
  // anything else opens on the install page. Picking a tab pins the view.
634
917
  const applyState = useCallback((next) => {
@@ -677,6 +960,18 @@ window.__ModuleLoader__.load({
677
960
  }
678
961
  }, [state?.queue?.running, state?.queue?.done, state?.queue?.lastResult])
679
962
 
963
+ // Research runs in the background: poll the project itself while it is
964
+ // running so the stage text and step log move without a page refresh.
965
+ useEffect(() => {
966
+ if (selectedId === '' || project?.research?.status !== 'running') return undefined
967
+ const timer = setInterval(() => {
968
+ api(`/project?id=${encodeURIComponent(selectedId)}`)
969
+ .then((data) => setProject(data.project))
970
+ .catch(() => {})
971
+ }, 1500)
972
+ return () => clearInterval(timer)
973
+ }, [selectedId, project?.research?.status])
974
+
680
975
  // Pinning only happens for a known state: before the first /state reply the
681
976
  // view stays automatic, so a ready install opens on the workbench.
682
977
  useEffect(() => {
@@ -751,6 +1046,13 @@ window.__ModuleLoader__.load({
751
1046
  })
752
1047
  await api('/project/reference', { id: created.id, dataUrl })
753
1048
  created = (await api(`/project?id=${encodeURIComponent(created.id)}`)).project
1049
+ // The reference is on disk now; kick off the picture-analysis pass.
1050
+ try {
1051
+ const started = await api('/project/research', { id: created.id })
1052
+ created = started.project
1053
+ } catch (error_) {
1054
+ setError(errText(error_))
1055
+ }
754
1056
  }
755
1057
  setForm({ character: '', series: '', useModel: form.useModel, file: null, details: null })
756
1058
  if (fileRef.current) fileRef.current.value = ''
@@ -786,17 +1088,58 @@ window.__ModuleLoader__.load({
786
1088
  setProject(data.project)
787
1089
  await load()
788
1090
  }),
1091
+ scriptAdd: (id, count = 2) => run(async () => {
1092
+ const data = await api('/project/script/add', { id, count })
1093
+ setProject(data.project)
1094
+ await load()
1095
+ }),
1096
+ baseEdit: async (id, options = {}) => {
1097
+ await api('/project/base/edit', {
1098
+ id,
1099
+ instruction: options.instruction ?? '',
1100
+ smart: options.smart === true,
1101
+ denoise: Number(options.denoise) || undefined,
1102
+ })
1103
+ await load()
1104
+ },
1105
+ shotEdit: async (id, options = {}) => {
1106
+ await api('/project/shot/edit', {
1107
+ id,
1108
+ file: options.file ?? '',
1109
+ instruction: options.instruction ?? '',
1110
+ smart: options.smart === true,
1111
+ denoise: Number(options.denoise) || undefined,
1112
+ })
1113
+ await load()
1114
+ },
1115
+ restoreShot: async (id, file) => {
1116
+ await api('/project/shot/restore', { id, file })
1117
+ await load()
1118
+ },
789
1119
  generate: (id) => run(async () => {
790
1120
  await api('/project/generate', { id })
791
1121
  await load()
792
1122
  }),
793
- finalize: (id) => run(async () => {
794
- await api('/project/finalize', { id })
1123
+ finalize: (id, options = {}) => run(async () => {
1124
+ await api('/project/finalize', { id, deoil: options.deoil === true, makeup: options.makeup === true })
795
1125
  await load()
796
1126
  }),
797
1127
  openFolder: (id, sub) => run(async () => {
798
1128
  await api('/project/open-folder', { id, sub: sub ?? 'base' })
799
1129
  }),
1130
+ startResearch: async (id, options = {}) => {
1131
+ const data = await api('/project/research', { id, supplement: options.supplement === true })
1132
+ setProject(data.project)
1133
+ await load()
1134
+ },
1135
+ randomProject: () => run(async () => {
1136
+ const data = await api('/project/random', {})
1137
+ setSelectedId(data.project.id)
1138
+ setProject(data.project)
1139
+ setTab('base')
1140
+ setView('studio')
1141
+ await load()
1142
+ }),
800
1143
  cancelQueue: () => run(async () => {
801
1144
  await api('/queue', {})
802
1145
  await load()
@@ -882,7 +1225,7 @@ window.__ModuleLoader__.load({
882
1225
  onChange: (event) => setForm({ ...form, file: event.target.files?.[0] ?? null }),
883
1226
  }),
884
1227
  form.file
885
- ? h('div', { style: { ...ui.hint, marginTop: 4, wordBreak: 'break-all' } }, `已选:${form.file.name}`)
1228
+ ? h('div', { style: { ...ui.hint, marginTop: 4, wordBreak: 'break-all' } }, `已选:${form.file.name}(基准图会以这张图为本,角色卡不覆盖它的设计)`)
886
1229
  : null,
887
1230
  h('label', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 8, fontSize: 11.5, cursor: 'pointer', opacity: state.config?.llm?.hasKey && state.config?.llm?.model ? 1 : 0.55 } },
888
1231
  h('input', {
@@ -890,11 +1233,20 @@ window.__ModuleLoader__.load({
890
1233
  onChange: (event) => setForm({ ...form, useModel: event.target.checked }),
891
1234
  }),
892
1235
  '用对话模型补全角色特征'),
1236
+ h('div', { style: { ...ui.hint, marginTop: 6, lineHeight: 1.55 } },
1237
+ state.config?.llm?.hasKey && state.config?.llm?.model
1238
+ ? '写角色名时由对话模型补全角色特征;上传参考图时,人物外观以参考图为准。'
1239
+ : '还没配置对话模型:只写角色名会得到通用模板,人物可能不像。想做得像,请上传一张参考图(基准图直接以它为本)。'),
893
1240
  h(Btn, {
894
1241
  kind: 'primary', disabled: busy,
895
1242
  style: { width: '100%', marginTop: 8 },
896
1243
  onClick: () => run(async () => { await actions.createProject() }),
897
1244
  }, '创建项目'),
1245
+ h(Btn, {
1246
+ disabled: busy,
1247
+ style: { width: '100%', marginTop: 6 },
1248
+ onClick: () => { void actions.randomProject() },
1249
+ }, '随机热门角色(自动研究)'),
898
1250
  h('div', { style: { marginTop: 12, paddingTop: 10, borderTop: '1px dashed rgba(128,128,128,0.25)' } },
899
1251
  h('div', { style: { ...ui.hint, marginBottom: 6, lineHeight: 1.5 } },
900
1252
  '没有想好做谁?让插件从热门角色里随机挑几个。'),
package/lib/comfy.js CHANGED
@@ -231,6 +231,10 @@ export function stopComfy(port) {
231
231
 
232
232
  const snap = (value) => Math.max(16, Math.round(value / 16) * 16)
233
233
 
234
+ // Appended to every negative prompt: hands/feet are the local model's weakest
235
+ // area and the positive LIMB_DETAIL line alone does not suppress the failures.
236
+ const EXTRA_NEGATIVE = 'extra fingers, fused fingers, missing fingers, malformed hands, extra toes, missing toes, deformed feet, melted shoes, extra limbs'
237
+
234
238
  export function resolveSize(baseSize, finalSize) {
235
239
  const parse = (value) => {
236
240
  const match = /^\s*(\d+)\s*[xX*]\s*(\d+)\s*$/.exec(String(value ?? ''))
@@ -259,9 +263,13 @@ function loaders(config) {
259
263
  }
260
264
 
261
265
  function conditioning(prompt, negative, cfg) {
266
+ // Anatomy failures the local z-image model still makes on its own. Appending
267
+ // them here keeps the user's config.negative editable without having to
268
+ // repeat these terms in every project.
269
+ const combined = [String(negative ?? '').trim(), EXTRA_NEGATIVE].filter((part) => part !== '').join(', ')
262
270
  const nodes = { 27: { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['30', 0] } } }
263
- if (Number(cfg) > 1.01 && String(negative ?? '').trim() !== '') {
264
- nodes[33] = { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['30', 0] } }
271
+ if (Number(cfg) > 1.01 && combined !== '') {
272
+ nodes[33] = { class_type: 'CLIPTextEncode', inputs: { text: combined, clip: ['30', 0] } }
265
273
  } else {
266
274
  nodes[33] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['27', 0] } }
267
275
  }