Ship duration control, hidden previews, job resume, and desktop file purge.
The slider never left this machine, so deploy the generate UI with separate input/output hide toggles, reconnect in-progress jobs after refresh, and delete Comfy input/output files after they are saved to the library.
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
function parseDuration(raw: string) {
|
||||
const seconds = Number(raw)
|
||||
if (!Number.isFinite(seconds)) return 5
|
||||
return Math.min(120, Math.max(0.5, seconds))
|
||||
}
|
||||
|
||||
function frameLength(seconds: number) {
|
||||
const raw = Math.max(5, Math.round(seconds * 24))
|
||||
return raw + (5 - (raw % 17) + 17) % 17
|
||||
@@ -33,7 +39,7 @@ export default defineEventHandler(async (event) => {
|
||||
const seed = fields.seed && fields.seed !== 'random'
|
||||
? Number(fields.seed)
|
||||
: Math.floor(Math.random() * 2_147_483_647)
|
||||
const length = frameLength(Number(fields.duration || 5))
|
||||
const length = frameLength(parseDuration(fields.duration || '5'))
|
||||
const hideThumbnail = fields.hideThumbnail === 'true'
|
||||
const clipName = (fields.name || '').trim().slice(0, 80)
|
||||
const ownerKey = libraryOwnerKey(event)
|
||||
@@ -44,7 +50,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (!folderId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Create a library folder before generating' })
|
||||
}
|
||||
assertFolderAccess(event, folderId)
|
||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||
assertFolderExists(event, folderId)
|
||||
await saveStill({
|
||||
ownerKey,
|
||||
folderId,
|
||||
@@ -61,6 +69,7 @@ export default defineEventHandler(async (event) => {
|
||||
ownerKey,
|
||||
folderId,
|
||||
hideThumbnail,
|
||||
folderLocked,
|
||||
name: clipName,
|
||||
prompt,
|
||||
aspect: fields.aspect || 'auto',
|
||||
@@ -90,7 +99,8 @@ export default defineEventHandler(async (event) => {
|
||||
steps,
|
||||
turbo,
|
||||
folderId,
|
||||
hideThumbnail
|
||||
hideThumbnail,
|
||||
folderLocked
|
||||
}
|
||||
})
|
||||
|
||||
@@ -149,6 +159,7 @@ async function runGeneration(
|
||||
ownerKey: job.library.ownerKey,
|
||||
folderId: job.library.folderId,
|
||||
hideThumbnail: job.library.hideThumbnail,
|
||||
folderLocked: job.library.folderLocked,
|
||||
name: job.library.name,
|
||||
prompt: job.library.prompt,
|
||||
aspect: job.library.aspect,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const owner = libraryOwnerKey(event)
|
||||
const live = listJobs()
|
||||
.filter(job => job.library?.ownerKey === owner && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))
|
||||
.sort((a, b) => b.startedAt - a.startedAt)[0]
|
||||
if (live) {
|
||||
return {
|
||||
jobId: live.id,
|
||||
hideThumbnail: live.hideThumbnail === true,
|
||||
folderLocked: live.library?.folderLocked === true,
|
||||
...jobSnapshot(live)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = listPendingJobs()
|
||||
.filter(job => job.ownerKey === owner)
|
||||
.sort((a, b) => b.startedAt - a.startedAt)[0]
|
||||
if (pending) {
|
||||
return {
|
||||
jobId: pending.jobId,
|
||||
type: 'snapshot',
|
||||
status: 'running',
|
||||
message: 'Reconnecting to ComfyUI...',
|
||||
progress: Math.max(8, Math.min(90, Math.round((Date.now() - pending.startedAt) / 1000))),
|
||||
promptId: pending.promptId,
|
||||
elapsedMs: Date.now() - pending.startedAt,
|
||||
hideThumbnail: pending.hideThumbnail === true,
|
||||
folderLocked: pending.folderLocked === true
|
||||
}
|
||||
}
|
||||
|
||||
return { jobId: null }
|
||||
})
|
||||
@@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!folderId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Create a library folder first' })
|
||||
}
|
||||
assertFolderAccess(event, folderId)
|
||||
assertFolderExists(event, folderId)
|
||||
const still = await saveStill({
|
||||
ownerKey: owner,
|
||||
folderId,
|
||||
|
||||
+36
-1
@@ -1,4 +1,11 @@
|
||||
let comfyHostOverride = ''
|
||||
|
||||
export function setComfyHostOverride(url: string) {
|
||||
comfyHostOverride = String(url || '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function comfyBase() {
|
||||
if (comfyHostOverride) return comfyHostOverride
|
||||
const config = useRuntimeConfig()
|
||||
let host = String(config.comfyHost || process.env.COMFY_HOST || '').trim().replace(/\/$/, '')
|
||||
const port = String(config.comfyPort || process.env.COMFY_PORT || '').trim()
|
||||
@@ -204,7 +211,34 @@ export function isOurComfyVideo(video: { filename: string; subfolder: string })
|
||||
}
|
||||
|
||||
export function purgeComfyEnabled() {
|
||||
return Boolean(useRuntimeConfig().purgeComfyOutputs)
|
||||
return useRuntimeConfig().purgeComfyOutputs !== false
|
||||
}
|
||||
|
||||
async function purgeOnDesktop(opts: {
|
||||
video?: { filename: string; subfolder: string; type: string }
|
||||
imageName?: string
|
||||
}) {
|
||||
const config = useRuntimeConfig()
|
||||
const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '')
|
||||
const token = String(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.imageName || '',
|
||||
video: opts.video || null
|
||||
}),
|
||||
signal: AbortSignal.timeout(8000)
|
||||
})
|
||||
} catch {
|
||||
// Host agent may be down; Comfy HTTP purge is the fallback.
|
||||
}
|
||||
}
|
||||
|
||||
export async function purgeComfyArtifacts(opts: {
|
||||
@@ -213,6 +247,7 @@ export async function purgeComfyArtifacts(opts: {
|
||||
promptId?: string
|
||||
}) {
|
||||
if (!purgeComfyEnabled()) return
|
||||
await purgeOnDesktop(opts)
|
||||
const files: { filename: string; subfolder: string; type: string }[] = []
|
||||
if (opts.video?.filename) files.push(opts.video)
|
||||
if (opts.imageName) files.push({ filename: opts.imageName, subfolder: '', type: 'input' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { getComfyHost, setComfyHostOverride } from '~/server/utils/comfy'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -40,15 +41,14 @@ function settings() {
|
||||
controlUrl: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
|
||||
controlToken: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || ''),
|
||||
healthTimeoutMs: 2500,
|
||||
bootPollAttempts: 5,
|
||||
bootPollDelayMs: 2000,
|
||||
startTimeoutMs: 30_000,
|
||||
bootPollAttempts: Number(process.env.COMFY_BOOT_POLL_ATTEMPTS || 20),
|
||||
bootPollDelayMs: 3000,
|
||||
startTimeoutMs: Number(process.env.COMFY_START_TIMEOUT_MS || 180_000),
|
||||
busyWaitMs: Number(process.env.COMFY_BUSY_WAIT_MS || 180_000)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkComfyHttp(timeoutMs = 2500) {
|
||||
const host = getComfyHost()
|
||||
export async function checkComfyHttp(timeoutMs = 2500, host = getComfyHost()) {
|
||||
try {
|
||||
const res = await fetch(`${host}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
if (!res.ok) return { ok: false as const, host, status: res.status, stats: null }
|
||||
@@ -93,6 +93,9 @@ async function controlRequest(path: string, method = 'GET') {
|
||||
process?: boolean
|
||||
pid?: number
|
||||
started?: boolean
|
||||
already?: boolean
|
||||
port?: number | null
|
||||
proxyPort?: number | null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -200,6 +203,16 @@ async function waitWhileBusy(onStatus: StatusFn) {
|
||||
})
|
||||
}
|
||||
|
||||
function hostWithPort(host: string, port: number) {
|
||||
try {
|
||||
const url = new URL(host)
|
||||
url.port = String(port)
|
||||
return url.origin
|
||||
} catch {
|
||||
return host
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUnlocked(onStatus: StatusFn) {
|
||||
const cfg = settings()
|
||||
const host = cfg.host
|
||||
@@ -214,6 +227,31 @@ async function ensureUnlocked(onStatus: StatusFn) {
|
||||
}
|
||||
|
||||
log('http-fail', { host })
|
||||
const remote = await controlRequest('/status')
|
||||
if (remote?.http && remote.proxyPort) {
|
||||
const proxyUrl = hostWithPort(host, remote.proxyPort)
|
||||
onStatus({
|
||||
state: 'booting',
|
||||
message: `Configured Comfy port is blocked. Trying the desktop LAN proxy on ${remote.proxyPort}...`,
|
||||
host: proxyUrl,
|
||||
processRunning: true
|
||||
})
|
||||
const viaProxy = await checkComfyHttp(cfg.healthTimeoutMs, proxyUrl)
|
||||
if (viaProxy.ok) {
|
||||
setComfyHostOverride(proxyUrl)
|
||||
log('http-ok-proxy', { host: proxyUrl, localPort: remote.port || null })
|
||||
await waitWhileBusy(onStatus)
|
||||
onStatus({ state: 'online', message: 'ComfyUI online', host: proxyUrl, processRunning: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (remote?.http) {
|
||||
const localPort = remote.port ? ` Port ${remote.port} is healthy on the desktop.` : ''
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: `Comfy Desktop is open and the local API is up, but this server cannot reach ${host}.${localPort} Open the MiniMax instance “ComfyUI (1)” and wait until the graph editor loads — the instance picker is not enough.`
|
||||
})
|
||||
}
|
||||
const running = await processRunning()
|
||||
if (running) {
|
||||
onStatus({
|
||||
@@ -273,7 +311,7 @@ async function ensureUnlocked(onStatus: StatusFn) {
|
||||
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Started ComfyUI but it did not become healthy within 30 seconds.'
|
||||
statusMessage: 'Started Comfy Desktop but the API never answered. If the instance picker is showing, click “ComfyUI (1)” (MiniMax) and wait until the graph editor is loaded. Cold start can take a couple of minutes.'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -17,6 +17,7 @@ export interface JobEvent {
|
||||
busy?: boolean
|
||||
queueRunning?: number
|
||||
queuePending?: number
|
||||
folderLocked?: boolean
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -36,6 +37,7 @@ export interface Job {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
hideThumbnail: boolean
|
||||
folderLocked?: boolean
|
||||
name?: string
|
||||
prompt: string
|
||||
aspect: string
|
||||
@@ -81,6 +83,10 @@ export function getJob(id: string) {
|
||||
return jobs.get(id)
|
||||
}
|
||||
|
||||
export function listJobs() {
|
||||
return [...jobs.values()]
|
||||
}
|
||||
|
||||
export function jobSnapshot(job: Job) {
|
||||
return {
|
||||
type: 'snapshot' as const,
|
||||
@@ -91,12 +97,13 @@ export function jobSnapshot(job: Job) {
|
||||
maxStep: job.maxStep,
|
||||
promptId: job.promptId,
|
||||
elapsedMs: Date.now() - job.startedAt,
|
||||
filename: job.video?.filename,
|
||||
subfolder: job.video?.subfolder,
|
||||
mediaType: job.video?.type,
|
||||
filename: job.library?.folderLocked ? undefined : job.video?.filename,
|
||||
subfolder: job.library?.folderLocked ? undefined : job.video?.subfolder,
|
||||
mediaType: job.library?.folderLocked ? undefined : job.video?.type,
|
||||
clipId: job.clipId,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
error: job.error
|
||||
error: job.error,
|
||||
folderLocked: job.library?.folderLocked
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -49,6 +49,7 @@ export interface PublicFolder {
|
||||
thumbnailDefault: boolean
|
||||
protected: boolean
|
||||
unlocked: boolean
|
||||
clipCount: number
|
||||
}
|
||||
|
||||
interface Catalog {
|
||||
@@ -221,21 +222,26 @@ export function folderUnlocked(event: H3Event, folder: LibraryFolder) {
|
||||
return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash)
|
||||
}
|
||||
|
||||
export function publicFolder(event: H3Event, folder: LibraryFolder): PublicFolder {
|
||||
export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0): PublicFolder {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
createdAt: folder.createdAt,
|
||||
thumbnailDefault: folder.thumbnailDefault,
|
||||
protected: Boolean(folder.passwordHash),
|
||||
unlocked: folderUnlocked(event, folder)
|
||||
unlocked: folderUnlocked(event, folder),
|
||||
clipCount
|
||||
}
|
||||
}
|
||||
|
||||
export function publicLibrary(event: H3Event) {
|
||||
const owner = libraryOwnerKey(event)
|
||||
const catalog = readCatalog(owner)
|
||||
const folders = catalog.folders.map(folder => publicFolder(event, folder))
|
||||
const folders = catalog.folders.map(folder => publicFolder(
|
||||
event,
|
||||
folder,
|
||||
catalog.clips.filter(clip => clip.folderId === folder.id).length
|
||||
))
|
||||
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
||||
return {
|
||||
folders,
|
||||
@@ -253,10 +259,15 @@ export function assertLibraryOwner(event: H3Event) {
|
||||
return { owner, catalog: readCatalog(owner) }
|
||||
}
|
||||
|
||||
export function assertFolderAccess(event: H3Event, folderId: string) {
|
||||
export function assertFolderExists(event: H3Event, folderId: string) {
|
||||
const { owner, catalog } = assertLibraryOwner(event)
|
||||
const folder = catalog.folders.find(item => item.id === folderId)
|
||||
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||
return { owner, catalog, folder }
|
||||
}
|
||||
|
||||
export function assertFolderAccess(event: H3Event, folderId: string) {
|
||||
const { owner, catalog, folder } = assertFolderExists(event, folderId)
|
||||
if (!folderUnlocked(event, folder)) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Folder is locked' })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface PendingJob {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
hideThumbnail: boolean
|
||||
folderLocked?: boolean
|
||||
name?: string
|
||||
prompt: string
|
||||
aspect: string
|
||||
@@ -88,12 +89,13 @@ export async function completePendingIfReady(pending: PendingJob) {
|
||||
return {
|
||||
type: 'complete' as const,
|
||||
status: 'complete' as const,
|
||||
message: 'Video ready',
|
||||
message: pending.folderLocked ? 'Saved to the locked folder. Unlock it to view.' : 'Video ready',
|
||||
progress: 100,
|
||||
clipId: clip.id,
|
||||
filename: video.filename,
|
||||
subfolder: video.subfolder,
|
||||
mediaType: video.type,
|
||||
hideThumbnail: pending.hideThumbnail
|
||||
filename: pending.folderLocked ? undefined : video.filename,
|
||||
subfolder: pending.folderLocked ? undefined : video.subfolder,
|
||||
mediaType: pending.folderLocked ? undefined : video.type,
|
||||
hideThumbnail: pending.hideThumbnail,
|
||||
folderLocked: pending.folderLocked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,13 +85,14 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
type: 'complete',
|
||||
message: 'Video ready',
|
||||
message: job.library?.folderLocked ? 'Saved to the locked folder. Unlock it to view.' : 'Video ready',
|
||||
progress: 100,
|
||||
filename: video.filename,
|
||||
subfolder: video.subfolder,
|
||||
mediaType: video.type,
|
||||
filename: job.library?.folderLocked ? undefined : video.filename,
|
||||
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
|
||||
mediaType: job.library?.folderLocked ? undefined : video.type,
|
||||
clipId: job.clipId,
|
||||
hideThumbnail: job.hideThumbnail
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: job.library?.folderLocked
|
||||
})
|
||||
deletePendingJob(job.id)
|
||||
} catch (saveError) {
|
||||
|
||||
Reference in New Issue
Block a user