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/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 +314 -27
- 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/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,
|
|
515
|
-
h(Btn, { disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 2,
|
|
516
|
-
|
|
517
|
-
? h(Btn, { disabled: busy || queue.running || !env.running, onClick: () => actions.base(project.id, 1,
|
|
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
|
-
|
|
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)
|
|
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
|
-
'脚本规矩:表情不重样、至少一张不看镜头、机位至少两种、手的位置各不相同。一行一镜,确认后才开始出成片(每张约
|
|
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 *
|
|
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.
|
|
574
|
-
|
|
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.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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(
|
|
625
|
-
const [view, setView] = useState(
|
|
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 &&
|
|
264
|
-
nodes[33] = { class_type: 'CLIPTextEncode', inputs: { text:
|
|
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
|
}
|