Save each Comfy run as a new clip even if the output filename repeats.
Purging desktop files let MiniMax reuse MiniMax_H3_00001_, so the next job returned the previous clip. Ignore stale events from the last job so a second generate cannot paste the first video over the new one.
This commit is contained in:
+26
-10
@@ -487,6 +487,7 @@ let timer: ReturnType<typeof setInterval> | null = null
|
|||||||
let source: EventSource | null = null
|
let source: EventSource | null = null
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let settledUi = false
|
let settledUi = false
|
||||||
|
let listenGen = 0
|
||||||
|
|
||||||
const elapsedLabel = computed(() => {
|
const elapsedLabel = computed(() => {
|
||||||
const seconds = Math.floor(elapsedMs.value / 1000)
|
const seconds = Math.floor(elapsedMs.value / 1000)
|
||||||
@@ -739,7 +740,7 @@ async function resumeActiveJob() {
|
|||||||
jobId.value = id
|
jobId.value = id
|
||||||
concealOutput.value = hidden
|
concealOutput.value = hidden
|
||||||
settledUi = false
|
settledUi = false
|
||||||
applyJobEvent(snap, hidden, locked)
|
applyJobEvent(snap, hidden, locked, id)
|
||||||
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running' || snap.type === 'snapshot'
|
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running' || snap.type === 'snapshot'
|
||||||
if (running && !settledUi) {
|
if (running && !settledUi) {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
@@ -828,10 +829,14 @@ async function lockFolder(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clipVideoUrl(clipId: string) {
|
||||||
|
return `/api/library/clips/${clipId}/video?t=${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
function openClip(item: LibraryClip) {
|
function openClip(item: LibraryClip) {
|
||||||
lockedSave.value = false
|
lockedSave.value = false
|
||||||
concealOutput.value = item.hideThumbnail
|
concealOutput.value = item.hideThumbnail
|
||||||
videoUrl.value = `/api/library/clips/${item.id}/video`
|
videoUrl.value = clipVideoUrl(item.id)
|
||||||
awaitingReveal.value = item.hideThumbnail
|
awaitingReveal.value = item.hideThumbnail
|
||||||
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
|
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
|
||||||
browseFolderId.value = item.folderId
|
browseFolderId.value = item.folderId
|
||||||
@@ -874,6 +879,8 @@ async function generate() {
|
|||||||
}
|
}
|
||||||
statusBusy.value = false
|
statusBusy.value = false
|
||||||
busy.value = true
|
busy.value = true
|
||||||
|
listenGen += 1
|
||||||
|
stopListen()
|
||||||
settledUi = false
|
settledUi = false
|
||||||
progress.value = 1
|
progress.value = 1
|
||||||
videoUrl.value = ''
|
videoUrl.value = ''
|
||||||
@@ -905,6 +912,7 @@ async function generate() {
|
|||||||
jobId.value = started.jobId
|
jobId.value = started.jobId
|
||||||
maxStep.value = started.steps
|
maxStep.value = started.steps
|
||||||
playerSize.value = { width: started.width, height: started.height }
|
playerSize.value = { width: started.width, height: started.height }
|
||||||
|
settledUi = false
|
||||||
persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
||||||
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -927,7 +935,10 @@ function stopListen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
|
function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean, expectedJobId: string) {
|
||||||
|
if (expectedJobId && jobId.value && expectedJobId !== jobId.value) return
|
||||||
|
if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return
|
||||||
|
|
||||||
if (payload.message) statusMessage.value = payload.message
|
if (payload.message) statusMessage.value = payload.message
|
||||||
statusBusy.value = payload.type === 'busy' || payload.busy === true
|
statusBusy.value = payload.type === 'busy' || payload.busy === true
|
||||||
if (typeof payload.progress === 'number') progress.value = payload.progress
|
if (typeof payload.progress === 'number') progress.value = payload.progress
|
||||||
@@ -952,12 +963,12 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
browseFolderId.value = folderId.value
|
browseFolderId.value = folderId.value
|
||||||
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
|
||||||
} else if (payload.clipId) {
|
} else if (payload.clipId) {
|
||||||
videoUrl.value = `/api/library/clips/${payload.clipId}/video`
|
videoUrl.value = clipVideoUrl(payload.clipId)
|
||||||
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||||||
concealOutput.value = awaitingReveal.value
|
concealOutput.value = awaitingReveal.value
|
||||||
lockedSave.value = false
|
lockedSave.value = false
|
||||||
} else {
|
} else {
|
||||||
videoUrl.value = `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}`
|
videoUrl.value = `/api/media?filename=${encodeURIComponent(payload.filename)}&subfolder=${encodeURIComponent(payload.subfolder || 'video')}&type=${encodeURIComponent(payload.mediaType || 'output')}&t=${Date.now()}`
|
||||||
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||||||
concealOutput.value = awaitingReveal.value
|
concealOutput.value = awaitingReveal.value
|
||||||
lockedSave.value = false
|
lockedSave.value = false
|
||||||
@@ -983,26 +994,31 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listen(id: string, hidden: boolean, folderLocked = false) {
|
function listen(id: string, hidden: boolean, folderLocked = false) {
|
||||||
|
const gen = ++listenGen
|
||||||
stopListen()
|
stopListen()
|
||||||
source = new EventSource(`/api/generate/${id}/stream`)
|
source = new EventSource(`/api/generate/${id}/stream`)
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
applyJobEvent(JSON.parse(event.data), hidden, folderLocked)
|
if (gen !== listenGen) return
|
||||||
|
applyJobEvent(JSON.parse(event.data), hidden, folderLocked, id)
|
||||||
}
|
}
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (settledUi) {
|
if (gen !== listenGen || settledUi) {
|
||||||
stopListen()
|
if (gen === listenGen && settledUi) stopListen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
|
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
|
||||||
applyJobEvent(snap, hidden, folderLocked)
|
if (gen !== listenGen) return
|
||||||
|
applyJobEvent(snap, hidden, folderLocked, id)
|
||||||
} catch {
|
} catch {
|
||||||
|
if (gen !== listenGen) return
|
||||||
try {
|
try {
|
||||||
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
|
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { jobId: id }
|
body: { jobId: id }
|
||||||
})
|
})
|
||||||
applyJobEvent(recovered, hidden, folderLocked)
|
if (gen !== listenGen) return
|
||||||
|
applyJobEvent(recovered, hidden, folderLocked, id)
|
||||||
} catch {
|
} catch {
|
||||||
// Job snapshot can 404 briefly after a restart; keep polling while busy.
|
// Job snapshot can 404 briefly after a restart; keep polling while busy.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
return {
|
return {
|
||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
jobId,
|
||||||
message: 'Reconnecting to ComfyUI...',
|
message: 'Reconnecting to ComfyUI...',
|
||||||
progress: 90,
|
progress: 90,
|
||||||
promptId: pending.promptId,
|
promptId: pending.promptId,
|
||||||
elapsedMs: Date.now() - pending.startedAt
|
elapsedMs: Date.now() - pending.startedAt,
|
||||||
|
hideThumbnail: pending.hideThumbnail,
|
||||||
|
folderLocked: pending.folderLocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Job not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ export default defineEventHandler((event) => {
|
|||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', 'video/mp4')
|
setHeader(event, 'Content-Type', 'video/mp4')
|
||||||
setHeader(event, 'Content-Disposition', `inline; filename="${safeDownloadName(clipTitle(clip))}.mp4"`)
|
setHeader(event, 'Content-Disposition', `inline; filename="${safeDownloadName(clipTitle(clip))}.mp4"`)
|
||||||
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
setHeader(event, 'Cache-Control', 'private, no-store')
|
||||||
return readFileSync(path)
|
return readFileSync(path)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface JobEvent {
|
|||||||
queueRunning?: number
|
queueRunning?: number
|
||||||
queuePending?: number
|
queuePending?: number
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
|
jobId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
@@ -89,6 +90,7 @@ export function listJobs() {
|
|||||||
|
|
||||||
export function jobSnapshot(job: Job) {
|
export function jobSnapshot(job: Job) {
|
||||||
return {
|
return {
|
||||||
|
jobId: job.id,
|
||||||
type: 'snapshot' as const,
|
type: 'snapshot' as const,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
message: job.message,
|
message: job.message,
|
||||||
@@ -108,6 +110,7 @@ export function jobSnapshot(job: Job) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function emitJob(job: Job, event: JobEvent) {
|
export function emitJob(job: Job, event: JobEvent) {
|
||||||
|
event.jobId = job.id
|
||||||
if (event.message) job.message = event.message
|
if (event.message) job.message = event.message
|
||||||
if (typeof event.progress === 'number') job.progress = event.progress
|
if (typeof event.progress === 'number') job.progress = event.progress
|
||||||
if (typeof event.step === 'number') job.step = event.step
|
if (typeof event.step === 'number') job.step = event.step
|
||||||
|
|||||||
@@ -637,9 +637,6 @@ export async function saveClip(params: {
|
|||||||
const catalog = readCatalog(params.ownerKey)
|
const catalog = readCatalog(params.ownerKey)
|
||||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||||
if (params.comfyFilename && catalog.clips.some(clip => clip.comfyFilename === params.comfyFilename)) {
|
|
||||||
return catalog.clips.find(clip => clip.comfyFilename === params.comfyFilename) as LibraryClip
|
|
||||||
}
|
|
||||||
const clip: LibraryClip = {
|
const clip: LibraryClip = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
folderId: folder.id,
|
folderId: folder.id,
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export async function completePendingIfReady(pending: PendingJob) {
|
|||||||
status: 'complete' as const,
|
status: 'complete' as const,
|
||||||
message: pending.folderLocked ? 'Saved to the locked folder. Unlock it to view.' : 'Video ready',
|
message: pending.folderLocked ? 'Saved to the locked folder. Unlock it to view.' : 'Video ready',
|
||||||
progress: 100,
|
progress: 100,
|
||||||
|
jobId: pending.jobId,
|
||||||
clipId: clip.id,
|
clipId: clip.id,
|
||||||
filename: pending.folderLocked ? undefined : video.filename,
|
filename: pending.folderLocked ? undefined : video.filename,
|
||||||
subfolder: pending.folderLocked ? undefined : video.subfolder,
|
subfolder: pending.folderLocked ? undefined : video.subfolder,
|
||||||
|
|||||||
Reference in New Issue
Block a user