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:
2026-08-25 20:25:36 -05:00
parent 1a271c05a4
commit c5ed4d7ac4
16 changed files with 651 additions and 104 deletions
+14 -3
View File
@@ -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,
+33
View File
@@ -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 }
})
+1 -1
View File
@@ -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
View File
@@ -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' })
+44 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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' })
}
+7 -5
View File
@@ -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
}
}
+6 -5
View File
@@ -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) {