Move library collections as a group and map v2 identity refs.
Collapsed collection actions now move, rename, delete, and rerun every part so chains stay together. Identity mode follows the v2u3 Ref2VA picture wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+79
-62
@@ -1,5 +1,5 @@
|
||||
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
|
||||
import { ensureImageComfyReady } from '~/server/utils/imageComfyLifecycle'
|
||||
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage, purgeImageComfyArtifacts, withImageComfyHost } from '~/server/utils/imageComfy'
|
||||
import { pickImageEditHost } from '~/server/utils/imageComfyLifecycle'
|
||||
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
|
||||
import { imageDimensions } from '~/server/utils/resolution'
|
||||
|
||||
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!imageComfyConfigured()) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Image edit ComfyUI is not configured yet. Set IMAGE_COMFY_HOST on this instance.'
|
||||
statusMessage: 'Image edit ComfyUI is not configured yet. Set COMFY_HOST for Beast and IMAGE_COMFY_HOST for Sidecar overflow.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default defineEventHandler(async (event) => {
|
||||
seed,
|
||||
cfg
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Checking sidecar ComfyUI...', progress: 2 })
|
||||
emitJob(job, { type: 'status', message: 'Choosing Beast or Sidecar...', progress: 2 })
|
||||
|
||||
void runEdit(job, {
|
||||
image,
|
||||
@@ -127,7 +127,7 @@ async function runEdit(
|
||||
denoise: number
|
||||
}
|
||||
) {
|
||||
await ensureImageComfyReady((status) => {
|
||||
const target = await pickImageEditHost((status) => {
|
||||
emitJob(job, {
|
||||
type: status.state === 'busy' ? 'busy' : 'status',
|
||||
message: status.message,
|
||||
@@ -135,67 +135,84 @@ async function runEdit(
|
||||
busy: status.state === 'busy'
|
||||
})
|
||||
})
|
||||
job.imageComfyHost = target.host
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Uploading still to Qwen Edit...', progress: 8 })
|
||||
const uploaded = await uploadImageEdit(params.image, job.id)
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
await withImageComfyHost(target.host, async () => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: `Uploading still to ${target.label}...`,
|
||||
progress: 8
|
||||
})
|
||||
const uploaded = await uploadImageEdit(params.image, job.id)
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Queueing Qwen Image Edit Lightning...', progress: 12 })
|
||||
const graph = buildEditWorkflow({
|
||||
prompt: params.prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
denoise: params.denoise,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
|
||||
})
|
||||
const queued = await queueImagePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitJob(job, { type: 'status', message: 'Editing still...', progress: 18, maxStep: params.steps })
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: `Queueing Qwen Image Edit on ${target.label}...`,
|
||||
progress: 12
|
||||
})
|
||||
const graph = buildEditWorkflow({
|
||||
prompt: params.prompt,
|
||||
negative: params.negative,
|
||||
imageName: uploaded.name,
|
||||
steps: params.steps,
|
||||
seed: params.seed,
|
||||
cfg: params.cfg,
|
||||
denoise: params.denoise,
|
||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
|
||||
})
|
||||
const queued = await queueImagePrompt(graph, job.clientId)
|
||||
job.promptId = queued.prompt_id
|
||||
job.status = 'running'
|
||||
emitJob(job, { type: 'status', message: `Editing still on ${target.label}...`, progress: 18, maxStep: params.steps })
|
||||
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
onProgress: (event) => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
const output = await waitForImageEdit({
|
||||
promptId: queued.prompt_id,
|
||||
clientId: job.clientId,
|
||||
onProgress: (event) => {
|
||||
emitJob(job, {
|
||||
type: 'status',
|
||||
message: event.message,
|
||||
progress: event.progress,
|
||||
step: event.step,
|
||||
maxStep: event.maxStep || params.steps,
|
||||
node: event.node
|
||||
})
|
||||
},
|
||||
isCancelled: () => job.status === 'cancelled'
|
||||
})
|
||||
|
||||
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: job.library!.ownerKey,
|
||||
folderId: job.library!.folderId,
|
||||
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.library?.hideInput
|
||||
})
|
||||
job.stillId = still.id
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: 'Edit finished',
|
||||
progress: 100,
|
||||
stillId: still.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: job.library?.folderLocked
|
||||
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||
const buffer = await downloadEditedImage(output)
|
||||
const size = imageDimensions(buffer)
|
||||
const still = await saveStill({
|
||||
ownerKey: job.library!.ownerKey,
|
||||
folderId: job.library!.folderId,
|
||||
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
|
||||
data: buffer,
|
||||
width: size?.width || 0,
|
||||
height: size?.height || 0,
|
||||
hideInput: job.library?.hideInput
|
||||
})
|
||||
job.stillId = still?.id
|
||||
await purgeImageComfyArtifacts({
|
||||
output,
|
||||
inputName: uploaded.name,
|
||||
inputSubfolder: uploaded.subfolder,
|
||||
promptId: job.promptId
|
||||
})
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: `Edit finished on ${target.label}`,
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder,
|
||||
mediaType: 'image',
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: job.library?.folderLocked
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ async function runExtend(
|
||||
copyFileSync(params.sourcePath, params.part1Path)
|
||||
await extractLastFrame(params.part1Path, params.framePath)
|
||||
const frame = readFileSync(params.framePath)
|
||||
if (job.library) job.library.thumb = undefined
|
||||
const sound = await probeHasAudio(params.part1Path)
|
||||
|
||||
await ensureComfyReady((status) => {
|
||||
|
||||
+33
-20
@@ -83,13 +83,20 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const fields: Record<string, string> = {}
|
||||
let image: { filename: string; data: Buffer; type?: string } | null = null
|
||||
const referenceImages: { filename: string; data: Buffer; type?: string }[] = []
|
||||
const referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null> = [null, null, null, null]
|
||||
let compactRefSlot = 0
|
||||
for (const part of form) {
|
||||
if (part.name === 'image' && part.filename && part.data?.length) {
|
||||
image = { filename: part.filename, data: part.data, type: part.type }
|
||||
} else if (part.name && /^refImage(\d)$/.test(part.name) && part.filename && part.data?.length) {
|
||||
const slot = Number(part.name.replace('refImage', ''))
|
||||
if (slot >= 0 && slot < 4) {
|
||||
referenceImages[slot] = { filename: part.filename, data: part.data, type: part.type }
|
||||
}
|
||||
} else if (part.name === 'refImage' && part.filename && part.data?.length) {
|
||||
if (referenceImages.length < 4) {
|
||||
referenceImages.push({ filename: part.filename, data: part.data, type: part.type })
|
||||
if (compactRefSlot < 4) {
|
||||
referenceImages[compactRefSlot] = { filename: part.filename, data: part.data, type: part.type }
|
||||
compactRefSlot += 1
|
||||
}
|
||||
} else if (part.name && part.data) {
|
||||
fields[part.name] = part.data.toString('utf8')
|
||||
@@ -105,7 +112,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const extensions = parseExtensions(fields.extensions)
|
||||
const workflow = parseVideoWorkflow(fields.workflow)
|
||||
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true' && referenceImages.length > 0
|
||||
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true'
|
||||
|
||||
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
||||
const turbo = fields.turbo !== 'false'
|
||||
@@ -134,15 +141,17 @@ export default defineEventHandler(async (event) => {
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
assertFolderExists(event, folderId)
|
||||
const still = await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
const still = isPipelineFrameFilename(image.filename)
|
||||
? null
|
||||
: await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
filename: image.filename,
|
||||
data: image.data,
|
||||
width,
|
||||
height,
|
||||
hideInput
|
||||
})
|
||||
|
||||
const chainTotal = 1 + extensions.length
|
||||
const job = createJob()
|
||||
@@ -167,9 +176,9 @@ export default defineEventHandler(async (event) => {
|
||||
fps,
|
||||
samplerName,
|
||||
scheduler,
|
||||
thumb: image.data,
|
||||
stillId: still.id,
|
||||
stillFilename: still.filename,
|
||||
thumb: isPipelineFrameFilename(image.filename) ? undefined : image.data,
|
||||
stillId: still?.id,
|
||||
stillFilename: still?.filename,
|
||||
duration: durationSeconds,
|
||||
sound,
|
||||
draftId: (fields.draftId || '').trim() || undefined,
|
||||
@@ -285,7 +294,7 @@ type GenerateParams = {
|
||||
workflow: 'v1' | 'v2'
|
||||
duration: number
|
||||
useIdentityRefs: boolean
|
||||
referenceImages: { filename: string; data: Buffer; type?: string }[]
|
||||
referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null>
|
||||
}
|
||||
|
||||
async function queueMiniMax(
|
||||
@@ -308,15 +317,16 @@ async function queueMiniMax(
|
||||
: 'Queueing MiniMax H3 job...'
|
||||
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
|
||||
const uploaded = await uploadImage(params.image, job.id)
|
||||
const referenceNames: string[] = []
|
||||
const referenceNames: string[] = ['', '', '', '']
|
||||
const chainIndex = job.library?.chainIndex || 0
|
||||
if (chainIndex === 0) {
|
||||
for (const [index, ref] of (params.referenceImages || []).entries()) {
|
||||
if (!ref?.data?.length) continue
|
||||
const next = await uploadImage({
|
||||
...ref,
|
||||
filename: `ref${index + 1}_${ref.filename || 'identity.png'}`
|
||||
}, job.id)
|
||||
referenceNames.push(next.name)
|
||||
referenceNames[index] = next.name
|
||||
}
|
||||
}
|
||||
if (job.library) {
|
||||
@@ -378,7 +388,9 @@ async function queueMiniMax(
|
||||
extendPart1Path: job.library.extendPart1Path,
|
||||
familyId: job.library.familyId,
|
||||
parentClipId: job.library.parentClipId,
|
||||
chainIndex: job.library.chainIndex
|
||||
chainIndex: job.library.chainIndex,
|
||||
stillId: job.library.stillId,
|
||||
sound: job.library.sound
|
||||
})
|
||||
}
|
||||
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||
@@ -430,6 +442,7 @@ async function runGeneration(
|
||||
job.library.chainStep = i + 2
|
||||
job.library.chainLabel = `Extension ${i + 1}`
|
||||
job.library.extendPart1Path = undefined
|
||||
job.library.thumb = undefined
|
||||
|
||||
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||
await sleep(3000)
|
||||
|
||||
+40
-12
@@ -1,23 +1,36 @@
|
||||
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
||||
import { comfyConfigured } from '~/server/utils/comfy'
|
||||
import { imageComfyConfigured } from '~/server/utils/imageComfy'
|
||||
import { checkImageComfyHttp, fetchImageQueue } from '~/server/utils/imageComfyLifecycle'
|
||||
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
|
||||
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const videoConfigured = comfyConfigured()
|
||||
const imageConfigured = imageComfyConfigured()
|
||||
const [comfy, queue, imageComfy, imageQueue] = await Promise.all([
|
||||
const beastHost = getBeastImageHost()
|
||||
const sidecarHost = getSidecarImageHost()
|
||||
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
|
||||
videoConfigured
|
||||
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
||||
videoConfigured ? fetchQueue() : Promise.resolve({ running: 0, pending: 0 }),
|
||||
imageConfigured
|
||||
? checkImageComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
||||
imageConfigured ? fetchImageQueue() : Promise.resolve({ running: 0, pending: 0 })
|
||||
beastHost
|
||||
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
|
||||
: Promise.resolve({ ok: false, host: beastHost, stats: null }),
|
||||
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 }),
|
||||
sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost))
|
||||
? checkImageComfyHttp(2500, sidecarHost).catch(() => ({ ok: false, host: sidecarHost, stats: null }))
|
||||
: Promise.resolve({ ok: false, host: sidecarHost, stats: null }),
|
||||
sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost))
|
||||
? fetchQueueAt(sidecarHost)
|
||||
: Promise.resolve({ running: 0, pending: 0 })
|
||||
])
|
||||
const busy = Boolean(comfy.ok && queue.running > 0)
|
||||
const imageBusy = Boolean(imageComfy.ok && imageQueue.running > 0)
|
||||
const beastBusy = Boolean(beast.ok && beastQueue.running > 0)
|
||||
const sidecarBusy = Boolean(sidecar.ok && sidecarQueue.running > 0)
|
||||
const sidecarConfigured = Boolean(sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost)))
|
||||
const imageOk = Boolean(beast.ok || sidecar.ok)
|
||||
const imageBusy = Boolean(imageConfigured && ((beastBusy && (!sidecarConfigured || sidecarBusy || !sidecar.ok)) || (!beast.ok && sidecarBusy)))
|
||||
const preferredHost = beast.ok && !beastBusy ? beastHost : (sidecar.ok ? sidecarHost : beastHost)
|
||||
return {
|
||||
ok: true,
|
||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||
@@ -31,11 +44,26 @@ export default defineEventHandler(async () => {
|
||||
},
|
||||
imageComfy: {
|
||||
configured: imageConfigured,
|
||||
ok: imageComfy.ok,
|
||||
ok: imageOk,
|
||||
busy: imageBusy,
|
||||
host: imageComfy.host || '',
|
||||
queueRunning: imageQueue.running,
|
||||
queuePending: imageQueue.pending
|
||||
host: preferredHost || '',
|
||||
box: beast.ok && !beastBusy ? 'beast' : (sidecar.ok ? 'sidecar' : (beast.ok ? 'beast' : '')),
|
||||
queueRunning: (beast.ok && !beastBusy ? beastQueue.running : sidecarQueue.running) || 0,
|
||||
queuePending: (beast.ok && !beastBusy ? beastQueue.pending : sidecarQueue.pending) || 0,
|
||||
beast: {
|
||||
label: 'Beast',
|
||||
configured: Boolean(beastHost),
|
||||
ok: Boolean(beast.ok),
|
||||
busy: beastBusy,
|
||||
host: beastHost || ''
|
||||
},
|
||||
sidecar: {
|
||||
label: 'Sidecar',
|
||||
configured: sidecarConfigured,
|
||||
ok: Boolean(sidecar.ok),
|
||||
busy: sidecarBusy,
|
||||
host: sidecarHost || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (jobId) deletePendingJob(jobId)
|
||||
const kind = job?.kind || (body?.kind === 'edit' ? 'edit' : 'video')
|
||||
const ok = kind === 'edit'
|
||||
? await interruptImageComfy()
|
||||
? await interruptImageComfy(job?.imageComfyHost)
|
||||
: await interruptComfy()
|
||||
if (job) {
|
||||
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||
|
||||
@@ -3,6 +3,8 @@ export default defineEventHandler(async (event) => {
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const clip = getClip(owner, id)
|
||||
assertFolderAccess(event, clip.folderId)
|
||||
await deleteClip(owner, id)
|
||||
const family = ['1', 'true'].includes(String(getQuery(event).family || '').toLowerCase())
|
||||
if (family) await deleteClipFamily(owner, id)
|
||||
else await deleteClip(owner, id)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{ folderId?: string; name?: string }>(event).catch(() => ({} as { folderId?: string; name?: string }))
|
||||
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
|
||||
const folderId = String(body?.folderId || '')
|
||||
const hasName = typeof body?.name === 'string'
|
||||
const family = body?.family === true
|
||||
if (!folderId && !hasName) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||
}
|
||||
@@ -11,8 +12,12 @@ export default defineEventHandler(async (event) => {
|
||||
assertFolderAccess(event, clip.folderId)
|
||||
if (folderId) {
|
||||
assertFolderExists(event, folderId)
|
||||
await moveClip(owner, id, folderId)
|
||||
if (family) await moveClipFamily(owner, id, folderId)
|
||||
else await moveClip(owner, id, folderId)
|
||||
}
|
||||
if (hasName) {
|
||||
if (family) await renameClipFamily(owner, id, body.name || '')
|
||||
else await renameClip(owner, id, body.name || '')
|
||||
}
|
||||
if (hasName) await renameClip(owner, id, body.name || '')
|
||||
return publicLibrary(event)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ export default defineEventHandler(async (event) => {
|
||||
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
||||
if (folderId) await importMissingComfyVideos(owner, folderId)
|
||||
await ensureClipThumbnails(owner)
|
||||
await sweepOwnerExtensionStills(owner)
|
||||
} catch {
|
||||
return publicLibrary(event)
|
||||
}
|
||||
|
||||
@@ -33,5 +33,5 @@ export default defineEventHandler(async (event) => {
|
||||
height: Number(fields.height || 0),
|
||||
hideInput: fields.hideInput === 'true'
|
||||
})
|
||||
return { still, ...publicLibrary(event) }
|
||||
return { still: still || undefined, ...publicLibrary(event) }
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"141": {
|
||||
"inputs": {
|
||||
"image": ""
|
||||
"image": "Mokker (1).jpeg"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
@@ -71,7 +71,7 @@
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"image": ""
|
||||
"image": "Mokker (1).jpeg"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"143": {
|
||||
"inputs": {
|
||||
"image": ""
|
||||
"image": "Mokker (1).jpeg"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
@@ -107,7 +107,10 @@
|
||||
"127",
|
||||
1
|
||||
],
|
||||
"length": 241,
|
||||
"length": [
|
||||
"174",
|
||||
1
|
||||
],
|
||||
"ref_image_size": "match",
|
||||
"clip": [
|
||||
"170",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"unet_name": "split_files\\diffusion_models\\qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
@@ -39,21 +39,22 @@
|
||||
"class_type": "LoadImage",
|
||||
"_meta": { "title": "Load input still" }
|
||||
},
|
||||
"5": {
|
||||
"17": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": ["2", 0]
|
||||
"prompt": "",
|
||||
"clip": ["2", 0],
|
||||
"vae": ["3", 0],
|
||||
"image": ["4", 0]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "Positive prompt" }
|
||||
"class_type": "TextEncodeQwenImageEdit",
|
||||
"_meta": { "title": "Qwen Image Edit prompt" }
|
||||
},
|
||||
"6": {
|
||||
"19": {
|
||||
"inputs": {
|
||||
"text": "blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers",
|
||||
"clip": ["2", 0]
|
||||
"conditioning": ["17", 0]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "Negative prompt" }
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"_meta": { "title": "Zero negative" }
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
@@ -70,10 +71,10 @@
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 0.75,
|
||||
"denoise": 1,
|
||||
"model": ["11", 0],
|
||||
"positive": ["5", 0],
|
||||
"negative": ["6", 0],
|
||||
"positive": ["17", 0],
|
||||
"negative": ["19", 0],
|
||||
"latent_image": ["7", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { sweepAllExtensionStills } from '../utils/library'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
try {
|
||||
const result = sweepAllExtensionStills()
|
||||
if (result.deleted) {
|
||||
console.log(JSON.stringify({
|
||||
src: 'library',
|
||||
event: 'purge-extension-stills',
|
||||
owners: result.owners,
|
||||
deleted: result.deleted,
|
||||
filenames: result.filenames.slice(0, 40)
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({
|
||||
src: 'library',
|
||||
event: 'purge-extension-stills-error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}))
|
||||
}
|
||||
})
|
||||
+166
-14
@@ -1,17 +1,22 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { getComfyHost } from '~/server/utils/comfy'
|
||||
|
||||
const imageHostAls = new AsyncLocalStorage<string>()
|
||||
let imageComfyHostOverride = ''
|
||||
|
||||
export type ImageEditBox = 'beast' | 'sidecar'
|
||||
|
||||
export function withImageComfyHost<T>(host: string, fn: () => Promise<T>) {
|
||||
return imageHostAls.run(String(host || '').replace(/\/$/, ''), fn)
|
||||
}
|
||||
|
||||
export function setImageComfyHostOverride(url: string) {
|
||||
imageComfyHostOverride = String(url || '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function getImageComfyHost() {
|
||||
if (imageComfyHostOverride) return imageComfyHostOverride
|
||||
const config = useRuntimeConfig()
|
||||
let host = String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim().replace(/\/$/, '')
|
||||
const port = String(config.imageComfyPort || process.env.IMAGE_COMFY_PORT || '').trim()
|
||||
if (!host) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'IMAGE_COMFY_HOST is not configured' })
|
||||
}
|
||||
function normalizeHost(raw: string, port = '') {
|
||||
let host = String(raw || '').trim().replace(/\/$/, '')
|
||||
if (!host) return ''
|
||||
if (!/^https?:\/\//i.test(host)) host = `http://${host}`
|
||||
try {
|
||||
const url = new URL(host)
|
||||
@@ -22,13 +27,46 @@ export function getImageComfyHost() {
|
||||
}
|
||||
}
|
||||
|
||||
export function imageComfyConfigured() {
|
||||
export function getBeastImageHost() {
|
||||
const config = useRuntimeConfig()
|
||||
return Boolean(String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim())
|
||||
const explicit = String(config.imageComfyPrimaryHost || process.env.IMAGE_COMFY_PRIMARY_HOST || '').trim()
|
||||
if (explicit) return normalizeHost(explicit)
|
||||
try {
|
||||
return getComfyHost()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getSidecarImageHost() {
|
||||
const config = useRuntimeConfig()
|
||||
const fallback = String(config.imageComfyFallbackHost || process.env.IMAGE_COMFY_FALLBACK_HOST || '').trim()
|
||||
if (fallback) return normalizeHost(fallback)
|
||||
const legacy = String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim()
|
||||
const port = String(config.imageComfyPort || process.env.IMAGE_COMFY_PORT || '').trim()
|
||||
return normalizeHost(legacy, port)
|
||||
}
|
||||
|
||||
export function getImageComfyHost() {
|
||||
const fromAls = imageHostAls.getStore()
|
||||
if (fromAls) return fromAls
|
||||
if (imageComfyHostOverride) return imageComfyHostOverride
|
||||
return getBeastImageHost() || getSidecarImageHost()
|
||||
}
|
||||
|
||||
export function imageComfyConfigured() {
|
||||
return Boolean(getBeastImageHost() || getSidecarImageHost())
|
||||
}
|
||||
|
||||
export function sameImageHost(a: string, b: string) {
|
||||
return Boolean(a && b && a.replace(/\/$/, '') === b.replace(/\/$/, ''))
|
||||
}
|
||||
|
||||
export async function imageComfyFetch(path: string, init?: RequestInit) {
|
||||
const host = getImageComfyHost()
|
||||
if (!host) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'No image ComfyUI host is configured' })
|
||||
}
|
||||
try {
|
||||
return await fetch(`${host}${path}`, init)
|
||||
} catch (error) {
|
||||
@@ -88,10 +126,14 @@ export async function queueImagePrompt(graph: unknown, clientId: string) {
|
||||
return payload as { prompt_id: string; number?: number }
|
||||
}
|
||||
|
||||
export async function interruptImageComfy() {
|
||||
if (!imageComfyConfigured()) return false
|
||||
const res = await imageComfyFetch('/interrupt', { method: 'POST' })
|
||||
return res.ok
|
||||
export async function interruptImageComfy(host?: string) {
|
||||
if (!imageComfyConfigured() && !host) return false
|
||||
const run = async () => {
|
||||
const res = await imageComfyFetch('/interrupt', { method: 'POST' })
|
||||
return res.ok
|
||||
}
|
||||
if (host) return withImageComfyHost(host, run)
|
||||
return run()
|
||||
}
|
||||
|
||||
export async function fetchImageHistory(promptId: string) {
|
||||
@@ -253,3 +295,113 @@ export async function waitForImageEdit(opts: {
|
||||
})
|
||||
}
|
||||
|
||||
function purgeImageEnabled() {
|
||||
return useRuntimeConfig().purgeComfyOutputs !== false
|
||||
}
|
||||
|
||||
async function deleteSidecarFile(file: { filename: string; subfolder?: string; type?: string }) {
|
||||
const payload = {
|
||||
filename: file.filename,
|
||||
subfolder: file.subfolder || '',
|
||||
type: file.type || 'output'
|
||||
}
|
||||
for (const path of ['/delete', '/aigen/purge']) {
|
||||
try {
|
||||
const res = await imageComfyFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(4000)
|
||||
})
|
||||
if (res.ok) return true
|
||||
} catch {
|
||||
/* Comfy may not expose this route */
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function purgeImageOnDesktop(opts: {
|
||||
inputName?: string
|
||||
inputSubfolder?: string
|
||||
output?: { filename: string; subfolder: string; type: string }
|
||||
}) {
|
||||
const config = useRuntimeConfig()
|
||||
const host = getImageComfyHost()
|
||||
const sidecar = getSidecarImageHost()
|
||||
const useSidecarAgent = sidecar && sameImageHost(host, sidecar)
|
||||
const controlUrl = String(
|
||||
useSidecarAgent
|
||||
? (config.imageComfyControlUrl || process.env.IMAGE_COMFY_CONTROL_URL || '')
|
||||
: (config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '')
|
||||
).replace(/\/$/, '')
|
||||
const token = String(
|
||||
useSidecarAgent
|
||||
? (config.imageComfyControlToken || process.env.IMAGE_COMFY_CONTROL_TOKEN || '')
|
||||
: (config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
|
||||
)
|
||||
if (!controlUrl) return
|
||||
try {
|
||||
await fetch(`${controlUrl}/purge`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
imageName: opts.inputName || '',
|
||||
imageSubfolder: opts.inputSubfolder || '',
|
||||
output: opts.output || null
|
||||
}),
|
||||
signal: AbortSignal.timeout(4000)
|
||||
})
|
||||
} catch {
|
||||
/* Sidecar host agent is optional; Comfy HTTP delete is the primary path. */
|
||||
}
|
||||
}
|
||||
|
||||
export async function purgeImageComfyArtifacts(opts: {
|
||||
output?: { filename: string; subfolder: string; type: string }
|
||||
inputName?: string
|
||||
inputSubfolder?: string
|
||||
promptId?: string
|
||||
}) {
|
||||
if (!purgeImageEnabled()) return
|
||||
const files: { filename: string; subfolder: string; type: string }[] = []
|
||||
if (opts.output?.filename) {
|
||||
files.push({
|
||||
filename: opts.output.filename,
|
||||
subfolder: opts.output.subfolder || '',
|
||||
type: opts.output.type || 'output'
|
||||
})
|
||||
}
|
||||
if (opts.inputName) {
|
||||
files.push({
|
||||
filename: opts.inputName,
|
||||
subfolder: opts.inputSubfolder || '',
|
||||
type: 'input'
|
||||
})
|
||||
}
|
||||
for (const file of files) {
|
||||
await deleteSidecarFile(file)
|
||||
}
|
||||
await purgeImageOnDesktop({
|
||||
inputName: opts.inputName,
|
||||
inputSubfolder: opts.inputSubfolder,
|
||||
output: opts.output
|
||||
})
|
||||
if (opts.promptId) {
|
||||
try {
|
||||
await imageComfyFetch('/history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ delete: [opts.promptId] }),
|
||||
signal: AbortSignal.timeout(4000)
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured } from '~/server/utils/imageComfy'
|
||||
import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost, withImageComfyHost, type ImageEditBox } from '~/server/utils/imageComfy'
|
||||
|
||||
export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline'
|
||||
|
||||
@@ -6,11 +6,18 @@ export interface ImageComfyStatus {
|
||||
state: ImageComfyState
|
||||
message: string
|
||||
host: string
|
||||
box?: ImageEditBox
|
||||
processRunning?: boolean
|
||||
queueRunning?: number
|
||||
queuePending?: number
|
||||
}
|
||||
|
||||
export interface ImageEditTarget {
|
||||
host: string
|
||||
box: ImageEditBox
|
||||
label: string
|
||||
}
|
||||
|
||||
type StatusFn = (status: ImageComfyStatus) => void
|
||||
|
||||
let gate: Promise<unknown> = Promise.resolve()
|
||||
@@ -51,6 +58,7 @@ export async function checkImageComfyHttp(timeoutMs = 2500, host?: string) {
|
||||
if (!imageComfyConfigured()) return { ok: false as const, host: '', status: 0, stats: null }
|
||||
resolved = getImageComfyHost()
|
||||
}
|
||||
if (!resolved) return { ok: false as const, host: '', status: 0, stats: null }
|
||||
const res = await fetch(`${resolved}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
if (!res.ok) return { ok: false as const, host: resolved, status: res.status, stats: null }
|
||||
const stats = await res.json().catch(() => null)
|
||||
@@ -60,9 +68,9 @@ export async function checkImageComfyHttp(timeoutMs = 2500, host?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImageQueue() {
|
||||
export async function fetchQueueAt(host: string) {
|
||||
if (!host) return { running: 0, pending: 0 }
|
||||
try {
|
||||
const host = getImageComfyHost()
|
||||
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
|
||||
if (!res.ok) return { running: 0, pending: 0 }
|
||||
const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] }
|
||||
@@ -75,6 +83,10 @@ export async function fetchImageQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImageQueue() {
|
||||
return fetchQueueAt(getImageComfyHost())
|
||||
}
|
||||
|
||||
async function controlRequest(path: string, method = 'GET') {
|
||||
const { controlUrl, controlToken } = settings()
|
||||
if (!controlUrl) return null
|
||||
@@ -216,3 +228,70 @@ export function ensureImageComfyReady(onStatus: StatusFn = () => undefined) {
|
||||
gate = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
export async function pickImageEditHost(onStatus: StatusFn = () => undefined): Promise<ImageEditTarget> {
|
||||
const beast = getBeastImageHost()
|
||||
const sidecar = getSidecarImageHost()
|
||||
if (!beast && !sidecar) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'No image ComfyUI host is configured. Set COMFY_HOST (Beast) and/or IMAGE_COMFY_HOST (Sidecar).'
|
||||
})
|
||||
}
|
||||
|
||||
if (beast) {
|
||||
const health = await checkImageComfyHttp(2500, beast)
|
||||
if (health.ok) {
|
||||
const queue = await fetchQueueAt(beast)
|
||||
if (queue.running === 0) {
|
||||
onStatus({
|
||||
state: 'online',
|
||||
message: 'Beast is free. Editing on this machine.',
|
||||
host: beast,
|
||||
box: 'beast',
|
||||
queueRunning: 0,
|
||||
queuePending: queue.pending
|
||||
})
|
||||
return { host: beast, box: 'beast', label: 'Beast' }
|
||||
}
|
||||
const canOverflow = Boolean(sidecar && !sameImageHost(beast, sidecar))
|
||||
onStatus({
|
||||
state: 'busy',
|
||||
message: canOverflow
|
||||
? 'Beast is busy. Rolling over to Sidecar...'
|
||||
: `Beast is busy (${queue.running} running). Waiting for a free slot...`,
|
||||
host: beast,
|
||||
box: 'beast',
|
||||
queueRunning: queue.running,
|
||||
queuePending: queue.pending
|
||||
})
|
||||
if (!canOverflow) {
|
||||
await withImageComfyHost(beast, () => waitWhileBusy(onStatus))
|
||||
return { host: beast, box: 'beast', label: 'Beast' }
|
||||
}
|
||||
} else if (sidecar && !sameImageHost(beast, sidecar)) {
|
||||
onStatus({
|
||||
state: 'offline',
|
||||
message: 'Beast is offline. Trying Sidecar...',
|
||||
host: beast,
|
||||
box: 'beast'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (sidecar && (!beast || !sameImageHost(beast, sidecar))) {
|
||||
onStatus({
|
||||
state: 'starting',
|
||||
message: 'Checking Sidecar for this edit...',
|
||||
host: sidecar,
|
||||
box: 'sidecar'
|
||||
})
|
||||
await withImageComfyHost(sidecar, () => ensureImageComfyReady(onStatus))
|
||||
return { host: sidecar, box: 'sidecar', label: 'Sidecar' }
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Beast is unavailable and Sidecar is not configured.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,31 +20,40 @@ export interface EditParams {
|
||||
|
||||
export function buildEditWorkflow(params: EditParams) {
|
||||
const graph = structuredClone(template) as WorkflowGraph
|
||||
if (graph['4']) graph['4'].inputs.image = params.imageName
|
||||
if (graph['5']) graph['5'].inputs.text = params.prompt
|
||||
if (graph['6']) graph['6'].inputs.text = (params.negative || DEFAULT_EDIT_NEGATIVE).trim() || DEFAULT_EDIT_NEGATIVE
|
||||
if (graph['8']) {
|
||||
graph['8'].inputs.seed = params.seed
|
||||
graph['8'].inputs.steps = params.steps
|
||||
graph['8'].inputs.cfg = params.cfg
|
||||
graph['8'].inputs.denoise = params.denoise
|
||||
graph['8'].inputs.sampler_name = params.samplerName || 'euler'
|
||||
graph['8'].inputs.scheduler = params.scheduler || 'simple'
|
||||
const loadImage = nodeByClass(graph, 'LoadImage') || graph['4']
|
||||
const qwenPrompt = nodeByClass(graph, 'TextEncodeQwenImageEdit') || graph['17']
|
||||
const sampler = nodeByClass(graph, 'KSampler') || graph['8']
|
||||
const save = nodeByClass(graph, 'SaveImage') || graph['10']
|
||||
if (loadImage) loadImage.inputs.image = params.imageName
|
||||
if (qwenPrompt) {
|
||||
if ('prompt' in qwenPrompt.inputs) qwenPrompt.inputs.prompt = params.prompt
|
||||
if ('text' in qwenPrompt.inputs) qwenPrompt.inputs.text = params.prompt
|
||||
}
|
||||
if (graph['10'] && params.filenamePrefix) {
|
||||
graph['10'].inputs.filename_prefix = params.filenamePrefix
|
||||
if (sampler) {
|
||||
sampler.inputs.seed = params.seed
|
||||
sampler.inputs.steps = params.steps
|
||||
sampler.inputs.cfg = params.cfg
|
||||
sampler.inputs.denoise = params.denoise
|
||||
sampler.inputs.sampler_name = params.samplerName || 'euler'
|
||||
sampler.inputs.scheduler = params.scheduler || 'simple'
|
||||
}
|
||||
if (save && params.filenamePrefix) save.inputs.filename_prefix = params.filenamePrefix
|
||||
return graph
|
||||
}
|
||||
|
||||
function nodeByClass(graph: WorkflowGraph, classType: string, index = 0) {
|
||||
const matches = Object.values(graph).filter(node => node?.class_type === classType)
|
||||
return matches[index]
|
||||
}
|
||||
|
||||
export const EDIT_NODE_LABELS: Record<string, string> = {
|
||||
'1': 'Loading Qwen Edit UNET',
|
||||
'11': 'Applying Lightning LoRA',
|
||||
'2': 'Loading Qwen CLIP',
|
||||
'3': 'Loading Qwen VAE',
|
||||
'4': 'Loading input still',
|
||||
'5': 'Encoding prompt',
|
||||
'6': 'Encoding negative',
|
||||
'17': 'Encoding Qwen edit prompt',
|
||||
'19': 'Preparing negative',
|
||||
'7': 'Encoding still',
|
||||
'8': 'Sampling edit',
|
||||
'9': 'Decoding still',
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface Job {
|
||||
clipId?: string
|
||||
stillId?: string
|
||||
hideThumbnail?: boolean
|
||||
imageComfyHost?: string
|
||||
library?: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
|
||||
+221
-20
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||
@@ -39,6 +39,9 @@ export interface LibraryClip {
|
||||
parentClipId?: string
|
||||
chainIndex?: number
|
||||
workflow?: 'v1' | 'v2'
|
||||
stillId?: string
|
||||
sound?: boolean
|
||||
hideInput?: boolean
|
||||
}
|
||||
|
||||
export interface LibraryStill {
|
||||
@@ -211,16 +214,19 @@ function ensureOwner(owner: string) {
|
||||
function readCatalog(owner: string): Catalog {
|
||||
ensureOwner(owner)
|
||||
const path = catalogPath(owner)
|
||||
let catalog: Catalog
|
||||
try {
|
||||
return normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
} catch {
|
||||
if (existsSync(path)) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Library catalog could not be read' })
|
||||
}
|
||||
const created = emptyCatalog()
|
||||
writeFileSync(path, JSON.stringify(created, null, 2))
|
||||
return created
|
||||
catalog = emptyCatalog()
|
||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||
return catalog
|
||||
}
|
||||
if (purgeStrayExtensionStills(owner, catalog).count) writeCatalog(owner, catalog)
|
||||
return catalog
|
||||
}
|
||||
|
||||
function writeCatalog(owner: string, catalog: Catalog) {
|
||||
@@ -249,6 +255,10 @@ export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
|
||||
return fromPrompt || 'Untitled clip'
|
||||
}
|
||||
|
||||
export function familyBaseName(name: string) {
|
||||
return String(name || '').trim().replace(/\s+p\d+$/i, '')
|
||||
}
|
||||
|
||||
export function nextClipPartName(name: string) {
|
||||
const trimmed = (name || '').trim()
|
||||
if (!trimmed) return ''
|
||||
@@ -259,6 +269,30 @@ export function nextClipPartName(name: string) {
|
||||
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||
}
|
||||
|
||||
export function sortFamilyParts(a: LibraryClip, b: LibraryClip) {
|
||||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||||
if (chain) return chain
|
||||
return a.createdAt - b.createdAt
|
||||
}
|
||||
|
||||
export function clipsInSameFamily(catalog: Catalog, clip: LibraryClip) {
|
||||
const folderClips = catalog.clips.filter(item => item.folderId === clip.folderId)
|
||||
const base = familyBaseName(clip.name || '').toLowerCase()
|
||||
const ids = new Set<string>([clip.id])
|
||||
if (clip.familyId) {
|
||||
for (const item of folderClips) {
|
||||
if (item.familyId === clip.familyId) ids.add(item.id)
|
||||
}
|
||||
}
|
||||
if (base) {
|
||||
for (const item of folderClips) {
|
||||
if (item.familyId && item.familyId !== clip.familyId) continue
|
||||
if (familyBaseName(item.name || '').toLowerCase() === base) ids.add(item.id)
|
||||
}
|
||||
}
|
||||
return folderClips.filter(item => ids.has(item.id))
|
||||
}
|
||||
|
||||
export function safeDownloadName(name: string) {
|
||||
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
||||
return cleaned.slice(0, 80) || 'aigen'
|
||||
@@ -322,7 +356,7 @@ export function publicLibrary(event: H3Event) {
|
||||
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
|
||||
hasVideo: existsSync(clipVideoPath(owner, clip.id))
|
||||
})),
|
||||
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)),
|
||||
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId) && !isStrayExtensionStill(catalog, still)),
|
||||
drafts: catalog.drafts
|
||||
}
|
||||
}
|
||||
@@ -652,6 +686,123 @@ function bytesHash(data: Buffer) {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
function fileBytesHash(path: string) {
|
||||
if (!existsSync(path)) return ''
|
||||
try {
|
||||
return bytesHash(readFileSync(path))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function isExtensionClip(clip: LibraryClip) {
|
||||
return Boolean(clip.parentClipId) || (clip.chainIndex || 0) > 0
|
||||
}
|
||||
|
||||
function lastFrameStillName(name: string) {
|
||||
return /last[\s._-]*frame/i.test(String(name || '').trim())
|
||||
}
|
||||
|
||||
export function isPipelineFrameFilename(name: string) {
|
||||
return lastFrameStillName(name)
|
||||
}
|
||||
|
||||
function clipIdFromStillFilename(name: string) {
|
||||
const match = String(name || '').trim().match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i)
|
||||
return match ? match[1].toLowerCase() : ''
|
||||
}
|
||||
|
||||
function isStrayExtensionStill(catalog: Catalog, still: LibraryStill) {
|
||||
if (lastFrameStillName(still.filename)) return true
|
||||
const clipId = clipIdFromStillFilename(still.filename)
|
||||
if (!clipId) return false
|
||||
return catalog.clips.some(clip => clip.id.toLowerCase() === clipId && isExtensionClip(clip))
|
||||
}
|
||||
|
||||
function originalInputHashes(owner: string, catalog: Catalog) {
|
||||
const hashes = new Set<string>()
|
||||
for (const clip of catalog.clips) {
|
||||
if (isExtensionClip(clip)) continue
|
||||
const hash = fileBytesHash(clipThumbPath(owner, clip.id))
|
||||
if (hash) hashes.add(hash)
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
function extensionFrameHashes(owner: string, catalog: Catalog) {
|
||||
const hashes = new Set<string>()
|
||||
for (const clip of catalog.clips) {
|
||||
if (!isExtensionClip(clip)) continue
|
||||
const last = fileBytesHash(clipLastFramePath(owner, clip.id))
|
||||
if (last) hashes.add(last)
|
||||
const rawThumb = fileBytesHash(join(clipDir(owner, clip.id), 'thumb'))
|
||||
if (rawThumb) hashes.add(rawThumb)
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
function allLastFrameHashes(owner: string, catalog: Catalog) {
|
||||
const hashes = new Set<string>()
|
||||
for (const clip of catalog.clips) {
|
||||
const last = fileBytesHash(clipLastFramePath(owner, clip.id))
|
||||
if (last) hashes.add(last)
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
|
||||
const protectedIds = new Set((catalog.drafts || []).map(draft => draft.stillId).filter(Boolean) as string[])
|
||||
const originalHashes = originalInputHashes(owner, catalog)
|
||||
const extIds = new Set(catalog.clips.filter(isExtensionClip).map(clip => clip.id.toLowerCase()))
|
||||
const extHashes = extensionFrameHashes(owner, catalog)
|
||||
const lastHashes = allLastFrameHashes(owner, catalog)
|
||||
const keep: LibraryStill[] = []
|
||||
const drop: LibraryStill[] = []
|
||||
for (const still of catalog.stills) {
|
||||
const clipId = clipIdFromStillFilename(still.filename)
|
||||
const namedLastFrame = lastFrameStillName(still.filename)
|
||||
const namedExtClip = Boolean(clipId && extIds.has(clipId))
|
||||
const matchesPipelineFrame = Boolean(still.hash && (lastHashes.has(still.hash) || extHashes.has(still.hash)))
|
||||
const isOriginal = Boolean(still.hash && originalHashes.has(still.hash))
|
||||
const dropThis = !protectedIds.has(still.id)
|
||||
&& (namedLastFrame || namedExtClip || (matchesPipelineFrame && !isOriginal))
|
||||
if (dropThis) drop.push(still)
|
||||
else keep.push(still)
|
||||
}
|
||||
if (!drop.length) return { count: 0, filenames: [] as string[] }
|
||||
catalog.stills = keep
|
||||
for (const still of drop) {
|
||||
rmSync(stillPath(owner, still.id), { force: true })
|
||||
}
|
||||
return { count: drop.length, filenames: drop.map(still => still.filename) }
|
||||
}
|
||||
|
||||
export function sweepOwnerExtensionStills(owner: string) {
|
||||
return mutate(owner, (catalog) => purgeStrayExtensionStills(owner, catalog))
|
||||
}
|
||||
|
||||
export function sweepAllExtensionStills() {
|
||||
const users = join(libraryRoot(), 'users')
|
||||
let owners = 0
|
||||
const filenames: string[] = []
|
||||
if (!existsSync(users)) return { owners, deleted: 0, filenames }
|
||||
for (const entry of readdirSync(users, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
owners += 1
|
||||
const path = catalogPath(entry.name)
|
||||
if (!existsSync(path)) continue
|
||||
try {
|
||||
const catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
const removed = purgeStrayExtensionStills(entry.name, catalog)
|
||||
if (removed.count) writeCatalog(entry.name, catalog)
|
||||
filenames.push(...removed.filenames)
|
||||
} catch {
|
||||
/* leave a bad catalog for the next authenticated read */
|
||||
}
|
||||
}
|
||||
return { owners, deleted: filenames.length, filenames }
|
||||
}
|
||||
|
||||
export async function saveStill(params: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
@@ -660,13 +811,15 @@ export async function saveStill(params: {
|
||||
width?: number
|
||||
height?: number
|
||||
hideInput?: boolean
|
||||
}) {
|
||||
}): Promise<LibraryStill | null> {
|
||||
if (lastFrameStillName(params.filename)) return null
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||
const hash = bytesHash(params.data)
|
||||
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
|
||||
if (existing) {
|
||||
if (lastFrameStillName(existing.filename)) return null
|
||||
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
|
||||
await mutate(params.ownerKey, (next) => {
|
||||
const item = next.stills.find(still => still.id === existing.id)
|
||||
@@ -676,6 +829,9 @@ export async function saveStill(params: {
|
||||
}
|
||||
return existing
|
||||
}
|
||||
if (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash)) {
|
||||
return null
|
||||
}
|
||||
const still: LibraryStill = {
|
||||
id: crypto.randomUUID(),
|
||||
folderId: folder.id,
|
||||
@@ -814,6 +970,8 @@ export async function saveClip(params: {
|
||||
parentClipId?: string
|
||||
chainIndex?: number
|
||||
workflow?: 'v1' | 'v2'
|
||||
stillId?: string
|
||||
sound?: boolean
|
||||
}) {
|
||||
const catalog = readCatalog(params.ownerKey)
|
||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||
@@ -839,7 +997,10 @@ export async function saveClip(params: {
|
||||
familyId: params.familyId,
|
||||
parentClipId: params.parentClipId,
|
||||
chainIndex: params.chainIndex,
|
||||
workflow: params.workflow
|
||||
workflow: params.workflow,
|
||||
stillId: params.stillId,
|
||||
sound: params.sound,
|
||||
hideInput: params.hideInput === true
|
||||
}
|
||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||
@@ -858,18 +1019,8 @@ export async function saveClip(params: {
|
||||
} else {
|
||||
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
||||
}
|
||||
const saveInputStill = Boolean(params.thumb?.length) && !params.parentClipId && !(params.chainIndex || 0)
|
||||
if (saveInputStill) {
|
||||
await saveStill({
|
||||
ownerKey: params.ownerKey,
|
||||
folderId: folder.id,
|
||||
filename: `${clip.id}.png`,
|
||||
data: params.thumb!,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
hideInput: params.hideInput
|
||||
})
|
||||
}
|
||||
const isInitial = !params.parentClipId && !(params.chainIndex || 0)
|
||||
if (!isInitial) clip.stillId = undefined
|
||||
await mutate(params.ownerKey, (next) => {
|
||||
next.clips.unshift(clip)
|
||||
})
|
||||
@@ -882,6 +1033,12 @@ export function getClip(owner: string, id: string) {
|
||||
return clip
|
||||
}
|
||||
|
||||
function stampFamilyId(family: LibraryClip[], fallback?: string) {
|
||||
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
|
||||
for (const item of family) item.familyId = familyId
|
||||
return familyId
|
||||
}
|
||||
|
||||
export function deleteClip(owner: string, id: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
@@ -892,6 +1049,20 @@ export function deleteClip(owner: string, id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteClipFamily(owner: string, id: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||
const family = clipsInSameFamily(catalog, clip)
|
||||
const ids = new Set(family.map(item => item.id))
|
||||
catalog.clips = catalog.clips.filter(item => !ids.has(item.id))
|
||||
for (const item of family) {
|
||||
rmSync(clipDir(owner, item.id), { recursive: true, force: true })
|
||||
}
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export function moveClip(owner: string, id: string, folderId: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
@@ -903,6 +1074,19 @@ export function moveClip(owner: string, id: string, folderId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function moveClipFamily(owner: string, id: string, folderId: string) {
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||
const folder = catalog.folders.find(item => item.id === folderId)
|
||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||
const family = clipsInSameFamily(catalog, clip)
|
||||
stampFamilyId(family, clip.familyId)
|
||||
for (const item of family) item.folderId = folder.id
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export function renameClip(owner: string, id: string, name: string) {
|
||||
const trimmed = name.trim().slice(0, 80)
|
||||
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||
@@ -914,6 +1098,23 @@ export function renameClip(owner: string, id: string, name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function renameClipFamily(owner: string, id: string, name: string) {
|
||||
const trimmed = name.trim().slice(0, 80)
|
||||
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||
return mutate(owner, (catalog) => {
|
||||
const clip = catalog.clips.find(item => item.id === id)
|
||||
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||
const family = [...clipsInSameFamily(catalog, clip)].sort(sortFamilyParts)
|
||||
const base = familyBaseName(trimmed) || trimmed
|
||||
stampFamilyId(family, clip.familyId)
|
||||
family.forEach((item, index) => {
|
||||
const suffix = index === 0 ? '' : ` p${index + 1}`
|
||||
item.name = `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||
})
|
||||
return family
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
||||
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
||||
let lastStatus = 0
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface PendingJob {
|
||||
familyId?: string
|
||||
parentClipId?: string
|
||||
chainIndex?: number
|
||||
stillId?: string
|
||||
sound?: boolean
|
||||
}
|
||||
|
||||
function pendingRoot() {
|
||||
@@ -142,7 +144,9 @@ export async function completePendingIfReady(pending: PendingJob) {
|
||||
comfyFilename: video.filename,
|
||||
familyId: pending.familyId,
|
||||
parentClipId: pending.parentClipId,
|
||||
chainIndex: pending.chainIndex
|
||||
chainIndex: pending.chainIndex,
|
||||
stillId: pending.stillId,
|
||||
sound: pending.sound
|
||||
})
|
||||
await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId })
|
||||
deletePendingJob(pending.jobId)
|
||||
|
||||
@@ -183,7 +183,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
hideThumbnail: job.library.hideThumbnail,
|
||||
hideInput: job.library.hideInput,
|
||||
video: buffer,
|
||||
thumb: job.library.thumb,
|
||||
thumb: (job.library.parentClipId || (job.library.chainIndex || 0) > 0 || /last[\s._-]*frame/i.test(job.library.imageName || ''))
|
||||
? undefined
|
||||
: job.library.thumb,
|
||||
comfyFilename: video.filename,
|
||||
cfg: job.library.cfg,
|
||||
fps: job.library.fps,
|
||||
@@ -192,7 +194,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
familyId: job.library.familyId || (job.library.familyId = crypto.randomUUID()),
|
||||
parentClipId: job.library.parentClipId,
|
||||
chainIndex: job.library.chainIndex,
|
||||
workflow: job.library.workflow
|
||||
workflow: job.library.workflow,
|
||||
stillId: job.library.stillId,
|
||||
sound: job.library.sound
|
||||
})
|
||||
job.clipId = clip.id
|
||||
job.savedPromptId = job.promptId
|
||||
|
||||
+23
-22
@@ -1,6 +1,7 @@
|
||||
// Nitro bundles these JSON graphs into the production server output.
|
||||
import workflowV1 from '../assets/workflow_minimax_video.json'
|
||||
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
|
||||
import { buildIdentityPrompt } from '~/utils/identityPrompt'
|
||||
|
||||
export type VideoWorkflowId = 'v1' | 'v2'
|
||||
|
||||
@@ -69,8 +70,10 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
const graph = structuredClone(templateFor(version))
|
||||
const width = snap32(params.width)
|
||||
const height = snap32(params.height)
|
||||
const refs = (params.referenceImageNames || []).map(name => String(name || '').trim()).filter(Boolean).slice(0, 4)
|
||||
const useRefs = version === 'v2' && params.useIdentityRefs === true && refs.length > 0
|
||||
const refs = Array.from({ length: 4 }, (_, index) => String(params.referenceImageNames?.[index] || '').trim())
|
||||
const extraPictures = refs.flatMap((name, index) => name ? [index + 2] : [])
|
||||
const useRefs = version === 'v2' && params.useIdentityRefs === true
|
||||
const graphPrompt = useRefs ? buildIdentityPrompt(params.prompt, extraPictures) : params.prompt
|
||||
|
||||
for (const [id, node] of Object.entries(graph)) {
|
||||
if (IMAGE_CLASSES.has(node.class_type) && 'image' in node.inputs && id === '114') {
|
||||
@@ -78,19 +81,19 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
}
|
||||
|
||||
if (PROMPT_CLASSES.has(node.class_type) || node.class_type.includes('Qwen3')) {
|
||||
if ('prompt' in node.inputs) node.inputs.prompt = params.prompt
|
||||
if ('text' in node.inputs) node.inputs.text = params.prompt
|
||||
if ('prompt' in node.inputs) node.inputs.prompt = graphPrompt
|
||||
if ('text' in node.inputs) node.inputs.text = graphPrompt
|
||||
}
|
||||
|
||||
if (node.class_type === 'MiniMaxH3ImageToVideo') {
|
||||
node.inputs.prompt = params.prompt
|
||||
node.inputs.prompt = graphPrompt
|
||||
if (typeof node.inputs.width === 'number') node.inputs.width = width
|
||||
if (typeof node.inputs.height === 'number') node.inputs.height = height
|
||||
if (typeof node.inputs.length === 'number') node.inputs.length = params.length
|
||||
}
|
||||
|
||||
if (node.class_type === 'MiniMaxH3ReferenceToVideo') {
|
||||
node.inputs.prompt = params.prompt
|
||||
node.inputs.prompt = graphPrompt
|
||||
if (!Array.isArray(node.inputs.length)) node.inputs.length = ['174', 1]
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
}
|
||||
}
|
||||
|
||||
if (version === 'v2') applyV2IdentityPath(graph, params, useRefs, refs)
|
||||
if (version === 'v2') applyV2IdentityPath(graph, params, useRefs, refs, graphPrompt)
|
||||
|
||||
if (params.sound === false) {
|
||||
delete graph['105:23']
|
||||
@@ -207,33 +210,31 @@ function applyV2IdentityPath(
|
||||
graph: WorkflowGraph,
|
||||
params: GenerateParams,
|
||||
useRefs: boolean,
|
||||
refs: string[]
|
||||
refs: string[],
|
||||
graphPrompt: string
|
||||
) {
|
||||
if (useRefs && graph['155'] && graph['167'] && graph['168']) {
|
||||
graph['167'].inputs.latent_image = ['155', 1]
|
||||
graph['168'].inputs.conditioning = ['155', 0]
|
||||
graph['155'].inputs.prompt = params.prompt
|
||||
graph['155'].inputs.prompt = graphPrompt
|
||||
graph['155'].inputs.length = ['174', 1]
|
||||
delete graph['173']
|
||||
if (graph['169']?.inputs) {
|
||||
graph['169'].inputs.unet_name = 'minimax_h3_ref2va_pruned_int8_convrot.safetensors'
|
||||
}
|
||||
if (params.turbo && graph['176']?.inputs) {
|
||||
graph['176'].inputs.lora_name = 'minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors'
|
||||
}
|
||||
V2_REF_LOADERS.forEach((id, index) => {
|
||||
const name = refs[index]
|
||||
const inputKey = `ref_images.ref_image_${index + 1}`
|
||||
if (name && graph[id]) {
|
||||
graph[id].inputs.image = name
|
||||
} else {
|
||||
delete graph[id]
|
||||
if (graph['155']?.inputs) delete graph['155'].inputs[inputKey]
|
||||
return
|
||||
}
|
||||
delete graph[id]
|
||||
if (graph['155']?.inputs) delete graph['155'].inputs[`ref_images.ref_image_${index + 1}`]
|
||||
})
|
||||
graph['v2:cfg'] = {
|
||||
class_type: 'FluxGuidance',
|
||||
inputs: {
|
||||
guidance: params.cfg,
|
||||
conditioning: ['155', 0]
|
||||
},
|
||||
_meta: { title: 'Guidance scale' }
|
||||
}
|
||||
graph['168'].inputs.conditioning = ['v2:cfg', 0]
|
||||
if (graph['150']?.inputs) graph['150'].inputs.value = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user