Post stillId with video generate and do not queue I2V behind a phantom busy job.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 17:37:03 -05:00
co-authored by Cursor
parent b48531d3c2
commit 2171ad3b44
3 changed files with 150 additions and 32 deletions
+68 -13
View File
@@ -1825,6 +1825,7 @@ const outputPanel = ref<HTMLElement | null>(null)
const editLightbox = ref<HTMLElement | null>(null) const editLightbox = ref<HTMLElement | null>(null)
const editLightboxUrl = ref('') const editLightboxUrl = ref('')
const file = ref<File | null>(null) const file = ref<File | null>(null)
const inputStillId = ref('')
const preview = ref('') const preview = ref('')
const dragging = ref(false) const dragging = ref(false)
const prompt = ref('') const prompt = ref('')
@@ -2106,14 +2107,24 @@ const liveElapsedLabel = computed(() => (
const playingLastSavedWhileBusy = computed(() => ( const playingLastSavedWhileBusy = computed(() => (
videoBusy.value && Boolean(videoUrl.value) && !watchingLibrary.value && !lockedSave.value videoBusy.value && Boolean(videoUrl.value) && !watchingLibrary.value && !lockedSave.value
)) ))
const hasInputStill = computed(() => {
if (file.value || inputStillId.value) return true
const selected = selectedLibraryEntries().filter(entry => entry.kind === 'still')
return selected.length === 1 && Boolean(selected[0].stillGroup?.head.id)
})
const outputQueuedJob = computed(() => {
const id = outputStudioJobId.value
if (!id) return null
return studioJobs.value.find(job => job.id === id && (job.status === 'waiting' || job.status === 'held')) || null
})
function idleInputStatus(kind: 'video' | 'edit') { function idleInputStatus(kind: 'video' | 'edit') {
if (kind === 'edit') return file.value ? 'Still ready. Edit when you are ready.' : 'Waiting for a still.' if (kind === 'edit') return hasInputStill.value ? 'Still ready. Edit when you are ready.' : 'Waiting for a still.'
if (textToVideo.value) { if (textToVideo.value) {
return file.value return hasInputStill.value
? 'Still loaded (optional). Generate when you are ready.' ? 'Still loaded (optional). Generate when you are ready.'
: 'Ready for text-to-video.' : 'Ready for text-to-video.'
} }
return file.value ? 'Still ready. Generate when you are ready.' : 'Waiting for a still.' return hasInputStill.value ? 'Still ready. Generate when you are ready.' : 'Waiting for a still.'
} }
const liveStatusMessage = computed(() => { const liveStatusMessage = computed(() => {
@@ -2122,6 +2133,7 @@ const liveStatusMessage = computed(() => {
return idleInputStatus('edit') return idleInputStatus('edit')
} }
if (videoBusy.value || videoUrl.value || lockedSave.value) return statusMessage.value if (videoBusy.value || videoUrl.value || lockedSave.value) return statusMessage.value
if (outputQueuedJob.value) return statusMessage.value || 'Queued. Waiting for Beast to be free.'
return idleInputStatus('video') return idleInputStatus('video')
}) })
const liveStatusBusy = computed(() => ( const liveStatusBusy = computed(() => (
@@ -2240,7 +2252,7 @@ const canUseGeneratedStill = computed(() => Boolean(
editResultUrl.value && !editLockedSave.value && !editAwaitingReveal.value editResultUrl.value && !editLockedSave.value && !editAwaitingReveal.value
)) ))
const videoBlockReason = computed(() => { const videoBlockReason = computed(() => {
if (!textToVideo.value && !file.value) return 'Load a still first. Image-to-video needs an input still.' if (!textToVideo.value && !hasInputStill.value) return 'Load a still first. Image-to-video needs an input still.'
if (!prompt.value.trim()) return 'Write a motion prompt first.' if (!prompt.value.trim()) return 'Write a motion prompt first.'
if (!folderId.value) return 'Choose a library folder before generating.' if (!folderId.value) return 'Choose a library folder before generating.'
if (!queueReady.value) { if (!queueReady.value) {
@@ -3327,6 +3339,7 @@ async function copyPrompt(text: string) {
function resetImage() { function resetImage() {
if (preview.value) URL.revokeObjectURL(preview.value) if (preview.value) URL.revokeObjectURL(preview.value)
file.value = null file.value = null
inputStillId.value = ''
preview.value = '' preview.value = ''
imageWidth.value = 0 imageWidth.value = 0
imageHeight.value = 0 imageHeight.value = 0
@@ -3349,9 +3362,15 @@ function clearVideoForm() {
activeDraftId = '' activeDraftId = ''
} }
function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) { function stillIdFromLibraryUrl(url: string) {
const match = String(url || '').match(/\/api\/library\/stills\/([^/?#]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
function readFile(next: File, opts?: { keepThumbnailPref?: boolean; stillId?: string }) {
if (preview.value) URL.revokeObjectURL(preview.value) if (preview.value) URL.revokeObjectURL(preview.value)
file.value = next file.value = next
inputStillId.value = opts?.stillId || ''
preview.value = URL.createObjectURL(next) preview.value = URL.createObjectURL(next)
if (!opts?.keepThumbnailPref) applyThumbnailDefault() if (!opts?.keepThumbnailPref) applyThumbnailDefault()
const img = new Image() const img = new Image()
@@ -3514,7 +3533,7 @@ function onPickedImage(next: File, hidden = false, stillId?: string) {
return return
} }
if (hidden && showPrivacyToggles.value) hideInputPreview.value = true if (hidden && showPrivacyToggles.value) hideInputPreview.value = true
readFile(next) readFile(next, stillId ? { stillId } : undefined)
} }
async function persistDroppedStill(next: File) { async function persistDroppedStill(next: File) {
@@ -3524,7 +3543,9 @@ async function persistDroppedStill(next: File) {
body.append('folderId', folderId.value) body.append('folderId', folderId.value)
body.append('hideInput', String(hideInputPreview.value)) body.append('hideInput', String(hideInputPreview.value))
try { try {
applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body })) const result = await $fetch<{ still?: { id: string } }>('/api/library/stills', { method: 'POST', body })
applyLibrary(result)
if (result.still?.id) inputStillId.value = result.still.id
} catch (error: any) { } catch (error: any) {
toast(error?.data?.statusMessage || 'Could not save still to the library') toast(error?.data?.statusMessage || 'Could not save still to the library')
} }
@@ -3765,7 +3786,7 @@ async function restoreStudioJob(id: string) {
const stillId = payload.stillId const stillId = payload.stillId
if (stillId) { if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
readFile(new File([blob], payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true }) readFile(new File([blob], payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
} else { } else {
resetImage() resetImage()
} }
@@ -3969,7 +3990,7 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
if (stillId) { if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (still?.hideInput && showPrivacyToggles.value) hideInputPreview.value = true if (still?.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true }) readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
return true return true
} }
const blob = await $fetch<Blob>(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' })
@@ -4070,7 +4091,7 @@ async function loadDraft(draft: RetryDraft) {
try { try {
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
const filename = draft.stillFilename || 'held-still.png' const filename = draft.stillFilename || 'held-still.png'
readFile(new File([blob], filename, { type: blob.type || 'image/png' }), { keepThumbnailPref: true }) readFile(new File([blob], filename, { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId: draft.stillId })
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.' statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
} catch { } catch {
if (textToVideo.value) { if (textToVideo.value) {
@@ -4266,7 +4287,7 @@ async function useStillAsInput(still: LibraryStill) {
try { try {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' })) readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }), { stillId: still.id })
toast('Loaded as input. It stays in Output, not the upload library.') toast('Loaded as input. It stays in Output, not the upload library.')
} catch (error: any) { } catch (error: any) {
toast(error?.data?.statusMessage || 'Could not load that still') toast(error?.data?.statusMessage || 'Could not load that still')
@@ -4801,7 +4822,9 @@ async function useEditAsInput() {
try { try {
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' }) const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
if (!blob || !blob.size) throw new Error('empty still') if (!blob || !blob.size) throw new Error('empty still')
readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' })) readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }), {
stillId: stillIdFromLibraryUrl(editResultUrl.value)
})
toast('Edited still is now the input') toast('Edited still is now the input')
} catch { } catch {
toast('Could not load that still as input') toast('Could not load that still as input')
@@ -4833,6 +4856,25 @@ function applyVideoWorkflow(raw?: string) {
minimaxGraph.value = minimaxGraphOf(parsed) minimaxGraph.value = minimaxGraphOf(parsed)
} }
async function ensureVideoInputStill() {
if (inputStillId.value && file.value) return
let stillId = inputStillId.value
if (!stillId) {
const selected = selectedLibraryEntries().filter(entry => entry.kind === 'still')
if (selected.length === 1) stillId = selected[0].stillGroup?.head.id || ''
}
if (!stillId) return
inputStillId.value = stillId
if (file.value) return
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (!blob?.size) throw new Error('Could not load that still')
const still = stills.value.find(item => item.id === stillId)
readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), {
keepThumbnailPref: true,
stillId
})
}
async function generate() { async function generate() {
if (!ltxEnabled.value && isLtxWorkflow(videoWorkflow.value)) { if (!ltxEnabled.value && isLtxWorkflow(videoWorkflow.value)) {
videoEngine.value = 'minimax' videoEngine.value = 'minimax'
@@ -4848,6 +4890,16 @@ async function generate() {
toast('Choose a library folder before generating.') toast('Choose a library folder before generating.')
return return
} }
try {
await ensureVideoInputStill()
} catch {
toast('Could not load the selected still for image-to-video.')
return
}
if (!textToVideo.value && !file.value && !inputStillId.value) {
toast('Load a still first. Image-to-video needs an input still.')
return
}
const shots = shotScriptMode.value ? parsedShots.value : null const shots = shotScriptMode.value ? parsedShots.value : null
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim() const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
const queued = shots const queued = shots
@@ -4858,6 +4910,7 @@ async function generate() {
try { try {
const body = new FormData() const body = new FormData()
if (file.value) body.append('image', file.value) if (file.value) body.append('image', file.value)
if (inputStillId.value) body.append('stillId', inputStillId.value)
body.append('prompt', initialPrompt) body.append('prompt', initialPrompt)
body.append('aspect', aspect.value) body.append('aspect', aspect.value)
body.append('width', String(outputSize.value.width)) body.append('width', String(outputSize.value.width))
@@ -4922,7 +4975,9 @@ async function generate() {
body body
}) })
if (started.queued) { if (started.queued) {
clearVideoForm() outputStudioJobId.value = started.studioJobId || ''
outputFocus.value = 'video'
statusMessage.value = 'Queued. Waiting for Beast to be free.'
const n = 1 + queued.length const n = 1 + queued.length
toast(n > 1 toast(n > 1
? `Queued ${n} shots behind the current job.` ? `Queued ${n} shots behind the current job.`
+24 -3
View File
@@ -1,3 +1,4 @@
import { existsSync, readFileSync } from 'node:fs'
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue' import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras' import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras' import type { LoraStackItem } from '~/utils/loras'
@@ -112,6 +113,27 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE }) throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
} }
const textToVideo = isTextToVideo(workflow) const textToVideo = isTextToVideo(workflow)
const ownerKey = libraryOwnerKey(event)
const postedStillId = (fields.stillId || '').trim()
let libraryStill: { id: string; filename: string } | null = null
if (postedStillId) {
const path = stillPath(ownerKey, postedStillId)
if (existsSync(path)) {
try {
const existing = getStill(ownerKey, postedStillId)
libraryStill = { id: existing.id, filename: existing.filename }
if (!image) {
image = {
filename: existing.filename || 'still.png',
data: readFileSync(path),
type: 'image/png'
}
}
} catch {
libraryStill = null
}
}
}
if (!image && !textToVideo) { if (!image && !textToVideo) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' }) throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
} }
@@ -158,7 +180,6 @@ export default defineEventHandler(async (event) => {
const hideInput = fields.hideInput === 'true' const hideInput = fields.hideInput === 'true'
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false' const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
const clipName = (fields.name || '').trim().slice(0, 80) const clipName = (fields.name || '').trim().slice(0, 80)
const ownerKey = libraryOwnerKey(event)
const library = publicLibrary(event) const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === fields.folderId) const folderId = library.folders.some(folder => folder.id === fields.folderId)
? fields.folderId ? fields.folderId
@@ -169,7 +190,7 @@ export default defineEventHandler(async (event) => {
const destFolder = library.folders.find(folder => folder.id === folderId) const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked) const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
assertFolderExists(event, folderId) assertFolderExists(event, folderId)
const still = image const still = libraryStill || (image
? await rememberInputStill({ ? await rememberInputStill({
ownerKey, ownerKey,
folderId, folderId,
@@ -179,7 +200,7 @@ export default defineEventHandler(async (event) => {
height, height,
hideInput hideInput
}) })
: null : null)
const referenceStillIds: Array<string | null> = [null, null, null, null] const referenceStillIds: Array<string | null> = [null, null, null, null]
if (useIdentityRefs) { if (useIdentityRefs) {
for (const [index, ref] of referenceImages.entries()) { for (const [index, ref] of referenceImages.entries()) {
+58 -16
View File
@@ -220,11 +220,35 @@ export function summarizeStudioJob(job: StudioJob) {
} }
} }
export function videoJobsBusy() { const QUEUED_GRACE_MS = 4 * 60 * 1000
if (listJobs().some(job => job.status === 'queued' || job.status === 'uploading' || job.status === 'running')) { const PENDING_ORPHAN_MS = 2 * 60 * 1000
return true
function liveJobIsActive(job: Job) {
if (job.status === 'uploading' || job.status === 'running') return true
if (job.status === 'queued') return Date.now() - job.startedAt < QUEUED_GRACE_MS
return false
} }
return listPendingJobs().some(job => Boolean(job.promptId))
function sweepStaleLiveJobs() {
const now = Date.now()
for (const job of listJobs()) {
if (job.status !== 'queued') continue
if (job.promptId) continue
if (now - job.startedAt < QUEUED_GRACE_MS) continue
job.status = 'error'
job.error = job.error || 'Job never started'
}
}
export function videoJobsBusy() {
sweepStaleLiveJobs()
if (listJobs().some(liveJobIsActive)) return true
return listPendingJobs().some((pending) => {
if (!pending.promptId) return false
const live = getJob(pending.jobId)
if (live) return liveJobIsActive(live)
return Date.now() - pending.startedAt < PENDING_ORPHAN_MS
})
} }
export function applyPersistedPauseToJob(job: Job) { export function applyPersistedPauseToJob(job: Job) {
@@ -534,7 +558,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
} }
async function dispatchStudioQueue() { async function dispatchStudioQueue() {
if (videoJobsBusy()) return sweepStaleLiveJobs()
for (const owner of listOwnersWithStudioQueues()) { for (const owner of listOwnersWithStudioQueues()) {
const store = await mutateStore(owner, (current) => { const store = await mutateStore(owner, (current) => {
migrateGlobalPause(current) migrateGlobalPause(current)
@@ -547,17 +571,22 @@ async function dispatchStudioQueue() {
jobs: current.jobs.map(item => structuredClone(item)) jobs: current.jobs.map(item => structuredClone(item))
} }
}) })
if (videoJobsBusy()) return
const cutIn = pickCutIn(store.jobs) const cutIn = pickCutIn(store.jobs)
if (cutIn) { if (cutIn) {
await startStudioJob(cutIn) await startStudioJob(cutIn)
return return
} }
const waiting = pickWaiting(store.jobs)
const held = pickHeld(store.jobs) const held = pickHeld(store.jobs)
if (held?.shotQueueId) { if (waiting && (!held || waiting.createdAt >= held.updatedAt)) {
await resumeHeldStudioJob(held) await startStudioJob(waiting)
return return
} }
const waiting = pickWaiting(store.jobs) if (held?.shotQueueId) {
const resumed = await resumeHeldStudioJob(held)
if (resumed) return
}
if (waiting) { if (waiting) {
await startStudioJob(waiting) await startStudioJob(waiting)
return return
@@ -571,7 +600,7 @@ async function resumeHeldStudioJob(item: StudioJob) {
job.status = 'complete' job.status = 'complete'
job.holdForCutIn = false job.holdForCutIn = false
}) })
return return false
} }
const { startQueueBurst } = await import('~/server/utils/videoChain') const { startQueueBurst } = await import('~/server/utils/videoChain')
const count = item.resumeAutoRun ? 'all' as const : 1 const count = item.resumeAutoRun ? 'all' as const : 1
@@ -583,6 +612,7 @@ async function resumeHeldStudioJob(item: StudioJob) {
job.holdForCutIn = false job.holdForCutIn = false
job.cutIn = false job.cutIn = false
}) })
return true
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
await patchStudioJob(item.ownerKey, item.id, (job) => { await patchStudioJob(item.ownerKey, item.id, (job) => {
@@ -590,10 +620,12 @@ async function resumeHeldStudioJob(item: StudioJob) {
job.lastError = message job.lastError = message
job.holdForCutIn = false job.holdForCutIn = false
}) })
return false
} }
} }
async function startStudioEditJob(item: StudioJob) { async function startStudioEditJob(item: StudioJob) {
let live: Job | undefined
try { try {
const { stillPath } = await import('~/server/utils/library') const { stillPath } = await import('~/server/utils/library')
const { existsSync, readFileSync } = await import('node:fs') const { existsSync, readFileSync } = await import('node:fs')
@@ -618,7 +650,7 @@ async function startStudioEditJob(item: StudioJob) {
: null : null
const passes = payload.passes || [] const passes = payload.passes || []
const job = createEditLiveJob({ live = createEditLiveJob({
steps: payload.steps, steps: payload.steps,
hideThumbnail: payload.hideThumbnail, hideThumbnail: payload.hideThumbnail,
library: { library: {
@@ -648,28 +680,32 @@ async function startStudioEditJob(item: StudioJob) {
} }
}) })
await markStudioLive(item.ownerKey, item.id, job.id) await markStudioLive(item.ownerKey, item.id, live.id)
void runEdit(job, { void runEdit(live, {
image, image,
reference, reference,
prompt: payload.prompt, prompt: payload.prompt,
passes, passes,
negative: payload.negative || '', negative: payload.negative || '',
steps: payload.steps, steps: payload.steps,
seed: job.library?.seed || payload.seed, seed: live.library?.seed || payload.seed,
cfg: payload.cfg, cfg: payload.cfg,
scaleToTotalPixels: payload.scaleToTotalPixels === true, scaleToTotalPixels: payload.scaleToTotalPixels === true,
scaleMegapixels: payload.scaleMegapixels, scaleMegapixels: payload.scaleMegapixels,
...persistLoraFields(payload.loraStack || payload.loraName) ...persistLoraFields(payload.loraStack || payload.loraName)
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
if (job.status !== 'error' && job.status !== 'cancelled' && job.status !== 'deferred') { if (live && live.status !== 'error' && live.status !== 'cancelled' && live.status !== 'deferred') {
job.status = 'error' live.status = 'error'
job.error = message live.error = message
} }
}) })
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
live.status = 'error'
live.error = message
}
await patchStudioJob(item.ownerKey, item.id, (job) => { await patchStudioJob(item.ownerKey, item.id, (job) => {
job.status = 'error' job.status = 'error'
job.lastError = message job.lastError = message
@@ -683,6 +719,7 @@ export async function startStudioJob(item: StudioJob) {
await startStudioEditJob(item) await startStudioEditJob(item)
return return
} }
let live: Job | undefined
try { try {
const { createJob } = await import('~/server/utils/jobs') const { createJob } = await import('~/server/utils/jobs')
const { runGeneration, frameLength } = await import('~/server/utils/videoChain') const { runGeneration, frameLength } = await import('~/server/utils/videoChain')
@@ -702,6 +739,7 @@ export async function startStudioJob(item: StudioJob) {
payload.shotPermanenceRefs payload.shotPermanenceRefs
) )
const job = createJob() const job = createJob()
live = job
job.kind = 'video' job.kind = 'video'
job.maxStep = payload.steps job.maxStep = payload.steps
job.hideThumbnail = payload.hideThumbnail job.hideThumbnail = payload.hideThumbnail
@@ -840,6 +878,10 @@ export async function startStudioJob(item: StudioJob) {
}) })
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
live.status = 'error'
live.error = message
}
await patchStudioJob(item.ownerKey, item.id, (job) => { await patchStudioJob(item.ownerKey, item.id, (job) => {
job.status = 'error' job.status = 'error'
job.lastError = message job.lastError = message