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/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 (1152×2048) from the card. */
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: `base-${projectId}`,
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 prompt = shotPrompt(project.card, { pose: DEFAULT_POSE })
216
- const graph = textToImageGraph(prompt, size, config, seed, `cosplay_${projectId}_base`)
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 = `base_${Date.now()}_${index + 1}.png`
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 prompt = shotPrompt(project.card, shot)
256
- const graph = textToImageGraph(prompt, size, config, seed, `cosplay_${projectId}_shot${index + 1}`)
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.55: design survives, photo guides). */
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: `refbase-${projectId}`,
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 size = resolveSize(config.baseSize, config.baseSize)
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 { uploadImage } = await import('./comfy.js')
286
- const { readFileSync: read } = await import('node:fs')
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 file = `base_ref_${Date.now()}.png`
294
- writeFileSync(pngPath(dirs.base, file), bytes)
295
- updateProject(projectId, { baseImages: [...(project.baseImages ?? []), file], status: 'awaiting-base-approval' })
296
- return { projectId, baseImages: [file] }
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
- /** Run the Pillow/numpy post-processing: de-oil, JPEG cap, 2×2 preview. */
304
- export function enqueueFinalize(config, layout, projectId) {
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: '去油光 / 导出 JPEG / 生成预览',
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', config.bodyMakeup === false ? '0' : '1',
325
- '--deoil', config.deoil === false ? '0' : '1',
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
  })