Let queued video, image, and music jobs open in an inspect modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +40,7 @@ export default defineEventHandler((event) => {
|
||||
]
|
||||
return {
|
||||
...summarizeStudioJob(job),
|
||||
source: 'studio' as const,
|
||||
...(full ? {
|
||||
shots: shotSummary,
|
||||
plannedShots: plannedShots && plannedShots.length > 1 ? plannedShots : undefined
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { cancelLiveMusicJob, findInspectJob } from '~/server/utils/queuedInspect'
|
||||
import { cancelStudioJob } from '~/server/utils/studioQueue'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const job = findInspectJob(owner, id)
|
||||
if (!job) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
}
|
||||
if (job.source === 'live' && job.kind === 'music') {
|
||||
await cancelLiveMusicJob(owner, id)
|
||||
return { ok: true }
|
||||
}
|
||||
await cancelStudioJob(owner, id)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { listStudioJobs } from '~/server/utils/studioQueue'
|
||||
import { findInspectJob } from '~/server/utils/queuedInspect'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const job = listStudioJobs(owner).find(item => item.id === id)
|
||||
const job = findInspectJob(owner, id)
|
||||
if (!job) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { persistLoraFields, normalizeLoraStack } from '~/utils/loras'
|
||||
import { listStudioJobs, patchStudioJob, type StudioJobPayload } from '~/server/utils/studioQueue'
|
||||
|
||||
const EDITABLE = new Set(['waiting', 'held', 'error'])
|
||||
|
||||
function text(raw: unknown, max = 8000) {
|
||||
return String(raw ?? '').replace(/\r\n/g, '\n').slice(0, max)
|
||||
}
|
||||
|
||||
function optionalNumber(raw: unknown) {
|
||||
if (raw == null || raw === '') return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function optionalBool(raw: unknown) {
|
||||
if (raw === true || raw === 'true' || raw === 1 || raw === '1') return true
|
||||
if (raw === false || raw === 'false' || raw === 0 || raw === '0') return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const current = listStudioJobs(owner).find(item => item.id === id)
|
||||
if (!current) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
}
|
||||
if (!EDITABLE.has(current.status)) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'That job is already generating. Copy it or save a template.' })
|
||||
}
|
||||
const body = await readBody<Record<string, unknown>>(event).catch(() => ({}))
|
||||
|
||||
const job = await patchStudioJob(owner, id, (row) => {
|
||||
const payload = row.payload
|
||||
if (body.name != null) {
|
||||
const name = text(body.name, 80).trim()
|
||||
payload.name = name
|
||||
row.name = name || payload.prompt.slice(0, 80)
|
||||
}
|
||||
if (body.prompt != null) {
|
||||
payload.prompt = text(body.prompt)
|
||||
row.prompt = payload.prompt
|
||||
}
|
||||
if (body.tags != null && row.kind === 'music') {
|
||||
payload.prompt = text(body.tags, 2000)
|
||||
row.prompt = payload.prompt
|
||||
}
|
||||
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
|
||||
const instrumental = optionalBool(body.instrumental)
|
||||
if (instrumental != null) payload.instrumental = instrumental
|
||||
if (body.promptPre != null) payload.promptPre = text(body.promptPre)
|
||||
if (body.promptMid != null) payload.promptMid = text(body.promptMid)
|
||||
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
|
||||
if (body.negative != null) payload.negative = text(body.negative, 2000)
|
||||
const duration = optionalNumber(body.duration)
|
||||
if (duration != null) payload.duration = Math.min(120, Math.max(0.5, duration))
|
||||
const steps = optionalNumber(body.steps)
|
||||
if (steps != null) payload.steps = Math.min(100, Math.max(1, Math.round(steps)))
|
||||
const cfg = optionalNumber(body.cfg)
|
||||
if (cfg != null) payload.cfg = Math.min(20, Math.max(0, Math.round(cfg * 10) / 10))
|
||||
const fps = optionalNumber(body.fps)
|
||||
if (fps === 12 || fps === 24 || fps === 30) payload.fps = fps
|
||||
const seed = optionalNumber(body.seed)
|
||||
if (seed != null) payload.seed = Math.max(0, Math.min(2_147_483_647, Math.floor(seed)))
|
||||
const turbo = optionalBool(body.turbo)
|
||||
if (turbo != null) payload.turbo = turbo
|
||||
const sound = optionalBool(body.sound)
|
||||
if (sound != null) payload.sound = sound
|
||||
if (body.samplerName != null) payload.samplerName = text(body.samplerName, 40)
|
||||
if (body.scheduler != null) payload.scheduler = text(body.scheduler, 40)
|
||||
if (Array.isArray(body.loraStack) || body.loraName != null) {
|
||||
const fields = persistLoraFields(body.loraStack ?? payload.loraStack ?? body.loraName)
|
||||
payload.loraName = fields.loraName
|
||||
payload.loraStack = fields.loraStack
|
||||
}
|
||||
if (Array.isArray(body.passes) && row.kind === 'edit') {
|
||||
payload.passes = body.passes.map((item) => ({
|
||||
prompt: text((item as { prompt?: unknown })?.prompt)
|
||||
}))
|
||||
row.shotCount = 1 + payload.passes.length
|
||||
}
|
||||
if (Array.isArray(body.extensions) && row.kind !== 'edit') {
|
||||
payload.extensions = body.extensions.map((item, index) => {
|
||||
const rec = (item && typeof item === 'object' ? item : {}) as {
|
||||
prompt?: unknown
|
||||
duration?: unknown
|
||||
loraName?: unknown
|
||||
loraStack?: unknown
|
||||
}
|
||||
const prev = payload.extensions?.[index]
|
||||
const fields = persistLoraFields(rec.loraStack || rec.loraName || prev?.loraStack || prev?.loraName)
|
||||
const shotDuration = optionalNumber(rec.duration)
|
||||
return {
|
||||
...prev,
|
||||
prompt: text(rec.prompt),
|
||||
duration: shotDuration != null ? Math.min(120, Math.max(0.5, shotDuration)) : (prev?.duration || payload.duration),
|
||||
loraName: fields.loraName,
|
||||
loraStack: fields.loraStack
|
||||
}
|
||||
}) as StudioJobPayload['extensions']
|
||||
row.shotCount = 1 + payload.extensions.length
|
||||
}
|
||||
if (normalizeLoraStack(payload.loraStack).length && !payload.loraName) {
|
||||
payload.loraName = payload.loraStack?.[0]?.name
|
||||
}
|
||||
if (body.prompt != null && !row.name) row.name = payload.prompt.slice(0, 80)
|
||||
})
|
||||
|
||||
return job
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type GenerationPreset,
|
||||
type GenerationPresetKind,
|
||||
type ImagePresetSettings,
|
||||
type MusicPresetSettings,
|
||||
type VideoPresetSettings
|
||||
} from '~/utils/generationPresets'
|
||||
import {
|
||||
@@ -35,6 +36,13 @@ import {
|
||||
parseImageV2Task,
|
||||
type ImageV2PresetSettings
|
||||
} from '~/utils/imageV2'
|
||||
import {
|
||||
clampMusicCfg,
|
||||
clampMusicDuration,
|
||||
clampMusicSteps,
|
||||
sanitizeMusicLyrics,
|
||||
sanitizeMusicTags
|
||||
} from '~/utils/music'
|
||||
import { coerceVideoWorkflow, isXaigenStudio, parseVideoWorkflow } from '~/utils/videoModels'
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
@@ -155,9 +163,24 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
|
||||
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
||||
const instrumental = rec.instrumental === true
|
||||
return {
|
||||
tags: sanitizeMusicTags(rec.tags),
|
||||
lyrics: sanitizeMusicLyrics(rec.lyrics, instrumental),
|
||||
instrumental,
|
||||
duration: clampMusicDuration(rec.duration),
|
||||
steps: clampMusicSteps(rec.steps),
|
||||
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
|
||||
cfg: rec.cfg == null ? undefined : clampMusicCfg(rec.cfg)
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSettings(kind: GenerationPresetKind, raw: unknown) {
|
||||
if (kind === 'image') return sanitizeImageSettings(raw)
|
||||
if (kind === 'imagev2') return sanitizeImageV2Settings(raw)
|
||||
if (kind === 'music') return sanitizeMusicSettings(raw)
|
||||
return sanitizeVideoSettings(raw)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { emitJob, getJob, listJobs, type Job } from '~/server/utils/jobs'
|
||||
import { listStudioJobs, type StudioJob } from '~/server/utils/studioQueue'
|
||||
|
||||
const LIVE_ERROR_KEEP_MS = 60 * 60 * 1000
|
||||
|
||||
function liveMusicActive(job: Job) {
|
||||
if (job.kind !== 'music') return false
|
||||
if (job.status === 'queued' || job.status === 'uploading' || job.status === 'running') return true
|
||||
if (job.status === 'error' && Date.now() - job.startedAt < LIVE_ERROR_KEEP_MS) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function listLiveMusicJobs(owner: string) {
|
||||
return listJobs()
|
||||
.filter(job => liveMusicActive(job) && job.library?.ownerKey === owner)
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
}
|
||||
|
||||
export function summarizeLiveMusicJob(job: Job) {
|
||||
const lib = job.library
|
||||
const tags = String(lib?.tags || lib?.prompt || '').trim()
|
||||
return {
|
||||
id: job.id,
|
||||
createdAt: job.startedAt,
|
||||
updatedAt: job.startedAt,
|
||||
status: job.status === 'error' ? 'error' : 'running',
|
||||
kind: 'music' as const,
|
||||
name: (lib?.name || '').trim() || tags.slice(0, 80) || 'Music',
|
||||
prompt: tags,
|
||||
shotCount: 1,
|
||||
liveJobId: job.id,
|
||||
source: 'live' as const,
|
||||
lastError: job.error,
|
||||
hideThumbnail: true,
|
||||
folderLocked: lib?.folderLocked === true
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectStudioJob(job: StudioJob) {
|
||||
return {
|
||||
source: 'studio' as const,
|
||||
id: job.id,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
status: job.status,
|
||||
kind: job.kind === 'edit' ? 'edit' as const : 'video' as const,
|
||||
name: job.name,
|
||||
prompt: job.prompt,
|
||||
shotCount: job.shotCount,
|
||||
familyId: job.familyId,
|
||||
shotQueueId: job.shotQueueId,
|
||||
liveJobId: job.liveJobId,
|
||||
lastError: job.lastError,
|
||||
imagePipeline: job.payload.imagePipeline || 'v1',
|
||||
cutIn: job.cutIn === true,
|
||||
holdForCutIn: job.holdForCutIn === true,
|
||||
pauseAfterCurrent: job.pauseAfterCurrent === true,
|
||||
pausedByUser: job.pausedByUser === true,
|
||||
payload: job.kind === 'music'
|
||||
? { ...job.payload, tags: job.payload.prompt }
|
||||
: job.payload
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectLiveMusicJob(job: Job) {
|
||||
const lib = job.library
|
||||
const tags = String(lib?.tags || lib?.prompt || '').trim()
|
||||
return {
|
||||
source: 'live' as const,
|
||||
id: job.id,
|
||||
createdAt: job.startedAt,
|
||||
updatedAt: job.startedAt,
|
||||
status: job.status === 'error' ? 'error' : job.status === 'cancelled' ? 'cancelled' : 'running',
|
||||
kind: 'music' as const,
|
||||
name: (lib?.name || '').trim() || tags.slice(0, 80) || 'Music',
|
||||
prompt: tags,
|
||||
shotCount: 1,
|
||||
liveJobId: job.id,
|
||||
lastError: job.error,
|
||||
imagePipeline: undefined,
|
||||
payload: {
|
||||
name: lib?.name || '',
|
||||
prompt: tags,
|
||||
tags,
|
||||
lyrics: lib?.lyrics || '',
|
||||
instrumental: lib?.instrumental === true,
|
||||
folderId: lib?.folderId || '',
|
||||
duration: lib?.duration,
|
||||
steps: lib?.steps,
|
||||
seed: lib?.seed,
|
||||
cfg: lib?.cfg,
|
||||
engine: lib?.engine,
|
||||
lyricsStrength: lib?.lyricsStrength,
|
||||
hideThumbnail: true,
|
||||
folderLocked: lib?.folderLocked === true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findInspectJob(owner: string, id: string) {
|
||||
const studio = listStudioJobs(owner).find(item => item.id === id)
|
||||
if (studio) return inspectStudioJob(studio)
|
||||
const live = getJob(id)
|
||||
if (live?.kind === 'music' && live.library?.ownerKey === owner) return inspectLiveMusicJob(live)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function cancelLiveMusicJob(owner: string, id: string) {
|
||||
const live = getJob(id)
|
||||
if (!live || live.kind !== 'music' || live.library?.ownerKey !== owner) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
}
|
||||
live.status = 'cancelled'
|
||||
if (live.library) live.library.stopAfterCurrent = true
|
||||
emitJob(live, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||
const { interruptComfy } = await import('~/server/utils/comfy')
|
||||
await interruptComfy().catch(() => false)
|
||||
}
|
||||
Reference in New Issue
Block a user