Let queued video, image, and music jobs open in an inspect modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-03 00:15:02 -05:00
co-authored by Cursor
parent 53990192e4
commit f05f8f3597
12 changed files with 1257 additions and 37 deletions
+154 -7
View File
@@ -1177,7 +1177,7 @@
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
<p class="mt-1 text-xs text-zinc-500">Each submit is one job. Shots inside a job live on the Queue page.</p>
<p class="mt-1 text-xs text-zinc-500">Click a job to inspect it. Shots inside a job live on the Queue page.</p>
</div>
<NuxtLink to="/queue" class="shrink-0 text-xs text-amber-200 hover:text-white">Open Queue</NuxtLink>
</div>
@@ -1192,17 +1192,17 @@
? 'border-sky-300/30 bg-sky-400/5'
: 'border-white/10 bg-zinc-950/50'"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
<button type="button" class="min-w-0 flex-1 text-left" @click="inspectJobId = job.id">
<p class="truncate text-sm font-medium text-zinc-100 hover:text-white">{{ job.name }}</p>
<p class="mt-0.5 text-xs text-zinc-500">
<span v-if="job.kind === 'music'" class="mr-1 text-amber-200/80">MUSIC ·</span>
<span v-else-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ job.imagePipeline === 'v2' ? 'IMAGE V2' : ((job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE') }} ·</span>
{{ studioJobStatusLabel(job) }}
· {{ studioJobShotLine(job) }}
</p>
</div>
</button>
<button
v-if="job.status === 'waiting'"
v-if="job.status === 'waiting' && job.kind !== 'music'"
type="button"
class="shrink-0 rounded-full border border-sky-300/40 px-2.5 py-1 text-xs text-sky-100 hover:border-sky-200 hover:text-white"
@click="cutInStudioJob(job.id)"
@@ -1210,7 +1210,7 @@
{{ job.cutIn ? 'Next' : (canCutInStudioJob ? 'Run next' : 'Start now') }}
</button>
<button
v-if="job.status === 'error' || (job.status === 'held' && !job.holdForCutIn)"
v-if="job.status === 'error' || (job.status === 'held' && !job.holdForCutIn && job.kind !== 'music')"
type="button"
class="shrink-0 rounded-full border border-amber-300/40 px-2.5 py-1 text-xs text-amber-100 hover:border-amber-200 hover:text-white"
@click="retryStudioJob(job.id)"
@@ -2193,6 +2193,13 @@
</div>
</Teleport>
<QueuedJobModal
:job-id="inspectJobId"
@close="inspectJobId = ''"
@saved="refreshStudioQueue"
@loaded="onQueuedJobLoaded"
/>
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
<div
v-for="toast in toasts"
@@ -2258,6 +2265,7 @@ import {
VIDEO_CFG_STEP,
type GenerationPreset
} from '~/utils/generationPresets'
import { LOAD_QUEUED_JOB_KEY } from '~/utils/queuedJob'
import {
clipSettingsRows,
inferStillPipeline,
@@ -2781,6 +2789,7 @@ const studioJobs = ref<Array<{
pendingCount?: number
imagePipeline?: string
}>>([])
const inspectJobId = ref('')
const outputStudioJobId = ref('')
const videoAwaitingBeast = ref(false)
const pickerOpen = ref(false)
@@ -3980,6 +3989,7 @@ onMounted(async () => {
await loadPromptPresets()
if (route.query.clip || route.query.still) applyIncomingLibraryItem()
else applyIncomingDirectorScript()
await applyIncomingQueuedJob()
window.addEventListener('keydown', onStudioKey)
})
@@ -4416,6 +4426,27 @@ function applyIncomingDirectorScript() {
} catch { /* ignore */ }
}
async function applyIncomingQueuedJob() {
let id = ''
try {
id = sessionStorage.getItem(LOAD_QUEUED_JOB_KEY) || ''
if (id) sessionStorage.removeItem(LOAD_QUEUED_JOB_KEY)
} catch {
return
}
if (!id) return
try {
await restoreStudioJob(id)
toast('Queued job loaded into Input.')
} catch (error: any) {
toast(error?.data?.statusMessage || error?.message || 'Could not load that queued job')
}
}
function onQueuedJobLoaded() {
void applyIncomingQueuedJob()
}
async function loadGenerationPresets() {
const [video, image, imagev2] = await Promise.all([
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })),
@@ -5205,7 +5236,7 @@ async function refreshStudioQueue() {
}>
}>('/api/studio-queue').catch(() => ({ jobs: [] }))
studioJobs.value = data.jobs || []
const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId)
const running = studioJobs.value.find(job => job.status === 'running' && job.liveJobId && job.kind !== 'music')
if (running?.liveJobId) videoAwaitingBeast.value = false
if (watchingLiveVideo() && running?.liveJobId && running.liveJobId !== videoJobId.value) {
return
@@ -5274,6 +5305,102 @@ async function dismissStudioJob(id: string) {
}
}
async function restoreStudioEditJob(payload: {
prompt?: string
name?: string
folderId?: string
aspect?: string
width?: number
height?: number
stillId?: string
stillFilename?: string
hideInput?: boolean
promptPre?: string
promptPost?: string
loraName?: string
loraStack?: LoraStackItem[]
negative?: string
imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
engine?: 'flux' | 'krea'
snofsModel?: number
snofsClip?: number
consistencyModel?: number
consistencyClip?: number
refineStrength?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
steps?: number
cfg?: number
turbo?: boolean
referenceStillId?: string
}) {
closeExtend()
applyPromptRestore(payload.prompt || '', payload)
clipName.value = payload.name || ''
if (payload.folderId) {
folderId.value = payload.folderId
browseFolderId.value = payload.folderId
}
const v2 = payload.imagePipeline === 'v2'
if (v2) {
studioMode.value = 'editv2'
outputFocus.value = 'edit'
const mode = payload.v2Mode === 'compose' || payload.v2Mode === 'refine' || payload.v2Mode === 'generate'
? payload.v2Mode
: 'edit'
setV2Mode(mode)
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
v2Engine.value = parseImageV2Engine(payload.engine, 'flux')
if (typeof payload.negative === 'string') v2Negative.value = payload.negative
if (payload.steps != null) v2Steps.value = clampImageSteps(payload.steps, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)
if (payload.cfg != null) v2Cfg.value = clampImageCfg(payload.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)
if (payload.scaleMegapixels != null) v2Megapixels.value = clampImageScaleMegapixels(payload.scaleMegapixels, 1)
v2ScaleOn.value = mode !== 'generate' && payload.scaleToTotalPixels !== false
v2Turbo.value = payload.turbo === true
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && payload.refineStrength != null) {
v2Denoise.value = clampImageV2Denoise(payload.refineStrength)
}
aspect.value = parseImageAspect(payload.aspect)
if (mode === 'generate') {
const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width
v2Height.value = size.height
}
if (mode === 'compose' && payload.v2Task) v2Task.value = payload.v2Task
imageV2LoraStack.value = mergeLegacyImageV2Loras(readLoraStack(payload), {
engine: v2Engine.value,
mode,
xaigen: v2ConceptLoraEnabled.value,
snofsModel: payload.snofsModel,
snofsClip: payload.snofsClip,
consistencyModel: payload.consistencyModel,
consistencyClip: payload.consistencyClip,
kreaConcept: String(runtimeConfig.public.kreaConceptLora || IMAGE_V2_KREA_CONCEPT_LORA || '').trim()
})
} else {
studioMode.value = 'edit'
outputFocus.value = 'edit'
if (payload.steps != null) editSteps.value = clampImageSteps(payload.steps)
if (payload.cfg != null) editCfg.value = clampImageCfg(payload.cfg)
if (typeof payload.negative === 'string') editNegative.value = payload.negative
editScaleToTotalPixels.value = payload.scaleToTotalPixels === true
if (payload.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(payload.scaleMegapixels)
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(payload), ltxEnabled.value)
aspect.value = parseImageAspect(payload.aspect)
}
const generate = v2 && payload.v2Mode === 'generate'
if (!generate && payload.stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${payload.stillId}`, { responseType: 'blob' })
if (payload.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], stillFileName(stills.value.find(item => item.id === payload.stillId)) || payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { stillId: payload.stillId })
}
if (v2 && payload.v2Mode === 'compose' && payload.referenceStillId) {
await loadStillAsEditRef(payload.referenceStillId).catch(() => null)
}
}
async function restoreStudioJob(id: string) {
const job = await $fetch<{
payload?: {
@@ -5309,10 +5436,30 @@ async function restoreStudioJob(id: string) {
loraStack?: LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: LoraStackItem[][]
negative?: string
imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose' | 'refine' | 'generate'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock' | 'refine' | 't2i'
engine?: 'flux' | 'krea'
snofsModel?: number
snofsClip?: number
consistencyModel?: number
consistencyClip?: number
refineStrength?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
steps?: number
referenceStillId?: string
}
kind?: string
imagePipeline?: string
}>(`/api/studio-queue/${id}`)
const payload = job.payload
if (!payload) throw new Error('That job has no saved inputs')
if (job.kind === 'edit' || payload.imagePipeline === 'v2') {
await restoreStudioEditJob(payload)
return
}
studioMode.value = 'video'
outputFocus.value = 'video'
closeExtend()