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:
Towsty
2026-08-26 22:56:37 -05:00
co-authored by Cursor
parent f54e8da857
commit e334cb7a7a
28 changed files with 1125 additions and 293 deletions
+166 -14
View File
@@ -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 */
}
}
}
+82 -3
View File
@@ -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.'
})
}
+23 -14
View File
@@ -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',
+1
View File
@@ -42,6 +42,7 @@ export interface Job {
clipId?: string
stillId?: string
hideThumbnail?: boolean
imageComfyHost?: string
library?: {
ownerKey: string
folderId: string
+221 -20
View File
@@ -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
+5 -1
View File
@@ -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)
+6 -2
View File
@@ -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
View File
@@ -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
}