dsh-cosplay-studio 2.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -16
- package/lib/card.js +156 -52
- package/lib/client.js +376 -24
- package/lib/comfy.js +10 -2
- package/lib/index.js +333 -34
- package/lib/project.js +147 -32
- package/lib/research.js +719 -0
- package/lib/tools/finalize.py +100 -17
- package/package.json +3 -2
package/lib/project.js
CHANGED
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { PROJECTS_DIR, logLine, slugify } from './paths.js'
|
|
10
|
-
import { downloadImage, imageToImageGraph, resolveSize, runGraph, textToImageGraph } from './comfy.js'
|
|
11
|
-
import { DEFAULT_POSE, buildCard, shotPrompt } from './card.js'
|
|
10
|
+
import { downloadImage, imageToImageGraph, resolveSize, runGraph, textToImageGraph, uploadImage } from './comfy.js'
|
|
11
|
+
import { DEFAULT_POSE, buildCard, referenceBasePrompt, referenceShotPrompt, shotPrompt } from './card.js'
|
|
12
|
+
|
|
13
|
+
/** Character palette for the backdrop (top / bottom colours of the gradient). */
|
|
14
|
+
export function projectPalette(project) {
|
|
15
|
+
return {
|
|
16
|
+
top: String(project?.details?.paletteTop ?? ''),
|
|
17
|
+
bottom: String(project?.details?.paletteBottom ?? ''),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
12
20
|
|
|
13
21
|
export function projectDirs(id) {
|
|
14
22
|
const root = join(PROJECTS_DIR, id)
|
|
@@ -19,6 +27,7 @@ export function projectDirs(id) {
|
|
|
19
27
|
raw: join(root, 'raw'),
|
|
20
28
|
final: join(root, 'final'),
|
|
21
29
|
deliver: join(root, 'deliver'),
|
|
30
|
+
research: join(root, 'research'),
|
|
22
31
|
}
|
|
23
32
|
for (const dir of Object.values(dirs)) {
|
|
24
33
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
@@ -196,27 +205,42 @@ function pngPath(dir, name) {
|
|
|
196
205
|
return join(dir, name)
|
|
197
206
|
}
|
|
198
207
|
|
|
199
|
-
/** Generate 1–2 base images
|
|
200
|
-
export function enqueueBase(config, layout, projectId, count = 1) {
|
|
208
|
+
/** Generate 1–2 base images from the card, or from the uploaded reference picture. */
|
|
209
|
+
export function enqueueBase(config, layout, projectId, count = 1, options = {}) {
|
|
210
|
+
const project0 = readProject(projectId)
|
|
211
|
+
const reference = options.useReference === true && String(project0?.referenceFile ?? '') !== ''
|
|
201
212
|
return enqueue({
|
|
202
|
-
id:
|
|
203
|
-
label: `生成基准图(${config.baseSize})`,
|
|
213
|
+
id: `${reference ? 'refbase' : 'base'}-${projectId}`,
|
|
214
|
+
label: reference ? '按参考图生成基准图' : `生成基准图(${config.baseSize})`,
|
|
204
215
|
total: count,
|
|
205
216
|
async run() {
|
|
206
217
|
const project = readProject(projectId)
|
|
207
218
|
if (project === null) throw new Error('项目不存在')
|
|
208
219
|
const dirs = projectDirs(projectId)
|
|
209
220
|
const size = resolveSize(config.baseSize, config.baseSize)
|
|
221
|
+
let uploaded = ''
|
|
222
|
+
if (reference) {
|
|
223
|
+
const referencePath = join(dirs.refs, project.referenceFile)
|
|
224
|
+
if (!existsSync(referencePath)) throw new Error('参考图文件不存在,请重新上传')
|
|
225
|
+
uploaded = await uploadImage(config.comfyPort, readFileSync(referencePath), project.referenceFile)
|
|
226
|
+
}
|
|
227
|
+
const prompt = reference
|
|
228
|
+
? referenceBasePrompt(project.research?.detailLine ?? '', projectPalette(project))
|
|
229
|
+
: shotPrompt(project.card, { pose: DEFAULT_POSE })
|
|
230
|
+
const denoise = Number(config.referenceDenoise) > 0 ? Number(config.referenceDenoise) : 0.75
|
|
210
231
|
const saved = []
|
|
211
232
|
for (let index = 0; index < count; index += 1) {
|
|
212
233
|
if (queue.cancelRequested) throw new Error('任务已取消')
|
|
213
234
|
const seed = Math.floor(Math.random() * 2_147_483_000)
|
|
214
|
-
queue.label = count > 1 ? `生成基准图 ${index + 1}/${count}` : '生成基准图'
|
|
215
|
-
const
|
|
216
|
-
|
|
235
|
+
queue.label = count > 1 ? `生成基准图 ${index + 1}/${count}` : (reference ? '按参考图生成基准图' : '生成基准图')
|
|
236
|
+
const graph = reference
|
|
237
|
+
? imageToImageGraph(prompt, uploaded, size, config, seed, denoise, `cosplay_${projectId}_refbase`)
|
|
238
|
+
: textToImageGraph(prompt, size, config, seed, `cosplay_${projectId}_base`)
|
|
217
239
|
const images = await runGraph(config.comfyPort, graph, { onProgress: itemProgress() })
|
|
218
240
|
const bytes = await downloadImage(config.comfyPort, images[0])
|
|
219
|
-
const file =
|
|
241
|
+
const file = reference
|
|
242
|
+
? `base_ref_${Date.now()}_${index + 1}.png`
|
|
243
|
+
: `base_${Date.now()}_${index + 1}.png`
|
|
220
244
|
writeFileSync(pngPath(dirs.base, file), bytes)
|
|
221
245
|
saved.push(file)
|
|
222
246
|
queue.done = index + 1
|
|
@@ -246,14 +270,32 @@ export function enqueueShots(config, layout, projectId) {
|
|
|
246
270
|
if (!Array.isArray(project.shots) || project.shots.length === 0) throw new Error('脚本还没有确认')
|
|
247
271
|
const dirs = projectDirs(projectId)
|
|
248
272
|
const size = resolveSize(config.baseSize, config.finalSize)
|
|
273
|
+
// Shots render straight at the delivery size: sampling at the base size
|
|
274
|
+
// and then upscaling with a 0.25-denoise refine pass softened faces and
|
|
275
|
+
// fabric (the "oil painting" look). One img2img pass at 1440x2560 keeps
|
|
276
|
+
// the detail; with base === target the graph skips the refine tail.
|
|
277
|
+
const shotSize = { ...size, baseWidth: size.width, baseHeight: size.height }
|
|
278
|
+
// Reference projects keep the character via img2img from the approved
|
|
279
|
+
// base (or the original reference when no base was approved). Text-only
|
|
280
|
+
// projects keep the card-driven text-to-image path.
|
|
281
|
+
const baseName = String(project.approvedBase ?? '').trim()
|
|
282
|
+
const anchor =
|
|
283
|
+
baseName !== '' && existsSync(join(dirs.base, baseName))
|
|
284
|
+
? { dir: dirs.base, name: baseName }
|
|
285
|
+
: String(project.referenceFile ?? '') !== '' && existsSync(join(dirs.refs, project.referenceFile))
|
|
286
|
+
? { dir: dirs.refs, name: project.referenceFile }
|
|
287
|
+
: null
|
|
288
|
+
const uploaded = anchor === null ? '' : await uploadImage(config.comfyPort, readFileSync(join(anchor.dir, anchor.name)), anchor.name)
|
|
289
|
+
const denoise = Number(config.shotDenoise) > 0 ? Number(config.shotDenoise) : 0.72
|
|
249
290
|
const raws = []
|
|
250
291
|
for (let index = 0; index < project.shots.length; index += 1) {
|
|
251
292
|
if (queue.cancelRequested) throw new Error('任务已取消')
|
|
252
293
|
const shot = project.shots[index]
|
|
253
294
|
queue.label = `生成成片 ${index + 1}/${project.shots.length}|${shot.title ?? ''}`
|
|
254
295
|
const seed = Math.floor(Math.random() * 2_147_483_000)
|
|
255
|
-
const
|
|
256
|
-
|
|
296
|
+
const graph = anchor === null
|
|
297
|
+
? textToImageGraph(shotPrompt(project.card, shot), shotSize, config, seed, `cosplay_${projectId}_shot${index + 1}`)
|
|
298
|
+
: imageToImageGraph(referenceShotPrompt(shot, project.research?.detailLine ?? '', projectPalette(project)), uploaded, shotSize, config, seed, denoise, `cosplay_${projectId}_shot${index + 1}`)
|
|
257
299
|
const images = await runGraph(config.comfyPort, graph, { onProgress: itemProgress() })
|
|
258
300
|
const bytes = await downloadImage(config.comfyPort, images[0])
|
|
259
301
|
const file = `shot_${String(index + 1).padStart(2, '0')}.png`
|
|
@@ -270,41 +312,110 @@ export function enqueueShots(config, layout, projectId) {
|
|
|
270
312
|
})
|
|
271
313
|
}
|
|
272
314
|
|
|
273
|
-
/** Reference-photo guided base image (denoise 0.
|
|
315
|
+
/** Reference-photo guided base image (img2img, denoise 0.75: the picture carries the design). */
|
|
274
316
|
export function enqueueReferenceBase(config, layout, projectId) {
|
|
317
|
+
return enqueueBase(config, layout, projectId, 1, { useReference: true })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Instruction-driven retouch of one existing picture.
|
|
322
|
+
* - target 'base': a new base_edit_* version is appended (the old ones stay).
|
|
323
|
+
* - target 'shot': the raw PNG is replaced in place (original moved to raw/.history).
|
|
324
|
+
* `options.instruction` is the English edit line: either typed by the user or
|
|
325
|
+
* produced by the smart-analysis pass in the index route.
|
|
326
|
+
*/
|
|
327
|
+
export function enqueueEdit(config, layout, projectId, options = {}) {
|
|
328
|
+
const target = options.target === 'shot' ? 'shot' : 'base'
|
|
275
329
|
return enqueue({
|
|
276
|
-
id: `
|
|
277
|
-
label: '
|
|
330
|
+
id: `edit-${projectId}`,
|
|
331
|
+
label: target === 'base' ? '微调基准图' : '微调成片',
|
|
278
332
|
async run() {
|
|
279
333
|
const project = readProject(projectId)
|
|
280
334
|
if (project === null) throw new Error('项目不存在')
|
|
281
|
-
if (!project.referenceFile) throw new Error('项目没有参考图')
|
|
282
335
|
const dirs = projectDirs(projectId)
|
|
283
|
-
const
|
|
336
|
+
const fromBase = target === 'base'
|
|
337
|
+
const file = String(options.file ?? '').trim()
|
|
338
|
+
|| (fromBase ? ([...(project.baseImages ?? [])].reverse()[0] ?? '') : '')
|
|
339
|
+
if (file === '') throw new Error(fromBase ? '还没有基准图' : '没有指定要修改的成片')
|
|
340
|
+
if (file.includes('/') || file.includes('\\') || file.includes('..')) throw new Error('文件名不合法')
|
|
341
|
+
const sourcePath = join(fromBase ? dirs.base : dirs.raw, file)
|
|
342
|
+
if (!existsSync(sourcePath)) throw new Error('要修改的图片不存在')
|
|
343
|
+
|
|
344
|
+
const size = resolveSize(config.baseSize, fromBase ? config.baseSize : config.finalSize)
|
|
345
|
+
// Editing a shot renders at the delivery size, so the edit itself is the
|
|
346
|
+
// only sampling pass (no refine tail that softens the result).
|
|
347
|
+
const graphSize = fromBase ? size : { ...size, baseWidth: size.width, baseHeight: size.height }
|
|
348
|
+
const uploaded = await uploadImage(config.comfyPort, readFileSync(sourcePath), file)
|
|
349
|
+
let prompt = ''
|
|
350
|
+
if (fromBase) {
|
|
351
|
+
prompt = String(project.referenceFile ?? '') === ''
|
|
352
|
+
? shotPrompt(project.card, { pose: DEFAULT_POSE })
|
|
353
|
+
: referenceBasePrompt(project.research?.detailLine ?? '', projectPalette(project))
|
|
354
|
+
} else {
|
|
355
|
+
const index = Number.parseInt(file.replace(/\D+/g, ''), 10)
|
|
356
|
+
const shot = Array.isArray(project.shots) ? project.shots[Math.max(0, index - 1)] : null
|
|
357
|
+
const anchored = String(project.approvedBase ?? '') !== '' || String(project.referenceFile ?? '') !== ''
|
|
358
|
+
prompt = shot
|
|
359
|
+
? (anchored
|
|
360
|
+
? referenceShotPrompt(shot, project.research?.detailLine ?? '', projectPalette(project))
|
|
361
|
+
: shotPrompt(project.card, shot))
|
|
362
|
+
: referenceBasePrompt(project.research?.detailLine ?? '', projectPalette(project))
|
|
363
|
+
}
|
|
364
|
+
const instruction = String(options.instruction ?? '').trim()
|
|
365
|
+
if (instruction !== '') {
|
|
366
|
+
prompt += `\n\nEDIT INSTRUCTION (apply exactly this change and keep the same person, face, pose, camera, costume and background otherwise): ${instruction}`
|
|
367
|
+
}
|
|
284
368
|
const seed = Math.floor(Math.random() * 2_147_483_000)
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
const bytesIn = read(join(dirs.refs, project.referenceFile))
|
|
288
|
-
const uploaded = await uploadImage(config.comfyPort, bytesIn, project.referenceFile)
|
|
289
|
-
const prompt = shotPrompt(project.card, { pose: DEFAULT_POSE })
|
|
290
|
-
const graph = imageToImageGraph(prompt, uploaded, size, config, seed, 0.6, `cosplay_${projectId}_refbase`)
|
|
369
|
+
const denoise = Number(options.denoise) > 0 ? Number(options.denoise) : 0.42
|
|
370
|
+
const graph = imageToImageGraph(prompt, uploaded, graphSize, config, seed, denoise, `cosplay_${projectId}_edit`)
|
|
291
371
|
const images = await runGraph(config.comfyPort, graph, { onProgress: itemProgress() })
|
|
292
372
|
const bytes = await downloadImage(config.comfyPort, images[0])
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
373
|
+
const extraNotes = Array.isArray(options.notes) ? options.notes : []
|
|
374
|
+
if (fromBase) {
|
|
375
|
+
const out = `base_edit_${Date.now()}_1.png`
|
|
376
|
+
writeFileSync(join(dirs.base, out), bytes)
|
|
377
|
+
const next = updateProject(projectId, {
|
|
378
|
+
baseImages: [...(project.baseImages ?? []), out],
|
|
379
|
+
status: 'awaiting-base-approval',
|
|
380
|
+
notes: [...(project.notes ?? []), ...extraNotes, `微调基准图:${out}`],
|
|
381
|
+
})
|
|
382
|
+
return { projectId, target, file: out, baseImages: next?.baseImages ?? [] }
|
|
383
|
+
}
|
|
384
|
+
const history = join(dirs.raw, '.history')
|
|
385
|
+
if (!existsSync(history)) mkdirSync(history, { recursive: true })
|
|
386
|
+
renameSync(sourcePath, join(history, `${file}.${Date.now()}.png`))
|
|
387
|
+
writeFileSync(sourcePath, bytes)
|
|
388
|
+
const pending = [...new Set([...(project.pendingFinals ?? []), file])]
|
|
389
|
+
updateProject(projectId, {
|
|
390
|
+
status: 'generated',
|
|
391
|
+
pendingFinals: pending,
|
|
392
|
+
notes: [...(project.notes ?? []), ...extraNotes, `微调成片:${file}`],
|
|
393
|
+
})
|
|
394
|
+
return { projectId, target, file, pendingFinals: pending }
|
|
395
|
+
},
|
|
396
|
+
onError(message) {
|
|
397
|
+
logLine(`微调失败(${projectId}):${message}`, 'error')
|
|
297
398
|
},
|
|
298
399
|
})
|
|
299
400
|
}
|
|
300
401
|
|
|
301
402
|
/* ------------------------------------------------------------- finalize */
|
|
302
403
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Optional post-processing for the finished shots: JPEG export (near-original
|
|
406
|
+
* quality), optional de-oil / body makeup, optional 2×2 preview + 文案 copy.
|
|
407
|
+
* The raw PNGs in raw/ stay untouched, so this step can be re-run with
|
|
408
|
+
* different options without regenerating anything.
|
|
409
|
+
*/
|
|
410
|
+
export function enqueueFinalize(config, layout, projectId, options = {}) {
|
|
411
|
+
const deoil = options.deoil === true
|
|
412
|
+
const makeup = options.makeup === true
|
|
413
|
+
const flatten = options.flatten !== false
|
|
414
|
+
const preview = options.preview !== false
|
|
415
|
+
const copy = options.copy !== false
|
|
305
416
|
return enqueue({
|
|
306
417
|
id: `final-${projectId}`,
|
|
307
|
-
label: '去油光 /
|
|
418
|
+
label: deoil || makeup ? '去油光 / 导出成片' : '导出成片(JPEG + 预览 + 文案)',
|
|
308
419
|
async run() {
|
|
309
420
|
const { spawn } = await import('node:child_process')
|
|
310
421
|
const dirs = projectDirs(projectId)
|
|
@@ -321,8 +432,11 @@ export function enqueueFinalize(config, layout, projectId) {
|
|
|
321
432
|
script,
|
|
322
433
|
dirs.root,
|
|
323
434
|
'--report', reportPath,
|
|
324
|
-
'--makeup',
|
|
325
|
-
'--deoil',
|
|
435
|
+
'--makeup', makeup ? '1' : '0',
|
|
436
|
+
'--deoil', deoil ? '1' : '0',
|
|
437
|
+
'--flatten', flatten ? '1' : '0',
|
|
438
|
+
'--preview', preview ? '1' : '0',
|
|
439
|
+
'--copy', copy ? '1' : '0',
|
|
326
440
|
'--strength', String(config.makeupStrength ?? 0.35),
|
|
327
441
|
'--leg-strength', String(config.legStrength ?? 0.85),
|
|
328
442
|
], {
|
|
@@ -346,6 +460,7 @@ export function enqueueFinalize(config, layout, projectId) {
|
|
|
346
460
|
status: 'done',
|
|
347
461
|
finals: report?.finals ?? [],
|
|
348
462
|
preview: report?.preview ?? '',
|
|
463
|
+
finalizeOptions: { deoil, makeup, flatten, preview, copy },
|
|
349
464
|
completedAt: new Date().toISOString(),
|
|
350
465
|
notes: report?.notes ?? [],
|
|
351
466
|
})
|