Files
aigen/pages/queue.vue
T

1304 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-dvh min-w-0">
<AppHeader
subtitle="Queue"
current="queue"
:queue-count="waitingCount || pendingCount"
@gpu="loadQueues"
/>
<main class="mx-auto min-w-0 max-w-7xl space-y-6 px-4 py-6 sm:px-6">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">Queue</h2>
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
Click a job to inspect it, fix a waiting one, copy it, or save a template. A long shot script is one job; its shots are the list underneath.
</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<p v-if="waitingCount" class="text-sm text-amber-100">{{ waitingCount }} job{{ waitingCount === 1 ? '' : 's' }} waiting</p>
<p v-else-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
<button
type="button"
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
:class="pauseToggleClass(pauseArmed)"
:disabled="pauseBusy || (!anythingGenerating && !pauseArmed)"
:aria-pressed="pauseArmed"
:title="pauseDisabledReason || pauseToggleTitle"
@click="togglePause()"
>
{{ pauseToggleLabel }}
</button>
<button
type="button"
class="min-h-10 rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!clearableCount || Boolean(busyId) || forceClearing"
@click="clearAll"
>
Clear waiting
</button>
<button
type="button"
class="min-h-10 rounded-full bg-red-500/90 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="forceClearing || Boolean(busyId)"
title="Cancel running and waiting jobs, wipe shot batches, and kill Comfy if it is wedged"
@click="forceClearAll"
>
{{ forceClearing ? 'Force clearing…' : 'Force clear' }}
</button>
</div>
</div>
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
<p v-else-if="forceClearNote" class="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{{ forceClearNote }}</p>
<p v-else-if="willPauseLatch" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Will pause after this video. The current clip keeps generating; this batch will stop after it finishes.
</p>
<p v-else-if="heldPaused" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
This batch is paused. It will not continue until you resume.
</p>
<p v-else-if="!jobs.length && !orphans.length" class="glass rounded-3xl px-5 py-10 text-center text-sm text-zinc-400">
Nothing queued. Generate from Studio or Music — a 30-shot script takes one slot, and you can line up more jobs behind it.
</p>
<section
v-for="job in jobs"
:key="job.id"
class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-4"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<button type="button" class="flex min-w-0 items-start gap-3 text-left" @click="inspectJobId = job.id">
<img
v-if="jobThumb(job)"
:src="jobThumb(job)"
alt=""
class="h-12 w-12 shrink-0 rounded-lg bg-black/40 object-cover"
>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">{{ jobKindLabel(job) }}</p>
<h3 class="truncate font-display text-lg font-semibold text-zinc-100 hover:text-white">{{ job.name }}</h3>
<p class="mt-1 text-sm text-zinc-300">{{ jobLine(job) }}</p>
<p v-if="job.shots && liveMessage[job.shots.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.shots.id] }}</p>
<p v-else-if="liveMessage[job.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[job.id] }}</p>
<p v-if="job.status === 'waiting'" class="mt-1 text-xs text-zinc-400">{{ job.waitReason || 'In queue. Waiting for GPU availability.' }}</p>
<p v-if="job.lastError" class="mt-1 text-xs text-red-300">{{ job.lastError }}</p>
</div>
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-if="job.status === 'waiting' && job.kind !== 'music'"
type="button"
class="rounded-full border border-sky-300/40 px-3 py-1.5 text-xs text-sky-100 hover:border-sky-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="cutIn(job)"
>
{{ job.cutIn ? 'Next after current shot' : (canCutIn ? 'Run next' : 'Start now') }}
</button>
<button
v-if="job.status === 'error'"
type="button"
class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100 hover:border-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="retryJob(job)"
>
Retry
</button>
<template v-if="job.shots">
<template v-if="canProcessShots(job)">
<button
type="button"
class="rounded-full bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busyId === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount === 0"
@click="process(job.shots, 1)"
>
{{ continueLabel(job.shots) }}
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busyId === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount < 2"
@click="process(job.shots, Math.min(3, job.shots.pendingCount))"
>
Next {{ Math.min(3, job.shots.pendingCount) || 3 }}
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busyId === job.shots.id || job.shots.status === 'complete' || job.shots.pendingCount === 0"
@click="process(job.shots, 'all')"
>
Run remaining
</button>
</template>
<button
v-if="canPauseJob(job)"
type="button"
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
:class="pauseToggleClass(jobIsArmed(job))"
:disabled="pauseBusy"
:aria-pressed="jobIsArmed(job)"
:title="jobPauseTitle(job)"
@click="togglePause({ job })"
>
{{ jobPauseLabel(job) }}
</button>
</template>
<button
v-else-if="canPauseJob(job)"
type="button"
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
:class="pauseToggleClass(jobIsArmed(job))"
:disabled="pauseBusy"
:aria-pressed="jobIsArmed(job)"
:title="jobPauseTitle(job)"
@click="togglePause({ job })"
>
{{ jobPauseLabel(job) }}
</button>
<button
v-if="canRemoveJob(job)"
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
:disabled="Boolean(busyId)"
@click="askRemoveJob(job)"
>
{{ job.status === 'running' ? 'Cancel job' : 'Remove job' }}
</button>
</div>
</div>
<ol v-if="job.shots" class="space-y-2">
<li
v-for="segment in job.shots.segments"
:key="segment.index"
class="rounded-2xl border px-4 py-3"
:class="segmentClass(job.shots, segment)"
>
<div class="flex items-start gap-3">
<span
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="markClass(job.shots, segment)"
>{{ markLabel(job.shots, segment) }}</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-100">
Shot {{ segment.index + 1 }}
<span class="font-normal text-zinc-500">{{ segmentCaption(job.shots, segment) }}</span>
</p>
<span class="text-xs text-zinc-500">{{ segment.duration }}s</span>
</div>
<p
v-if="segment.status === 'complete'"
class="mt-1 truncate text-xs text-zinc-400"
>{{ segment.prompt }}</p>
<textarea
v-else
v-model="segment.prompt"
rows="3"
class="mt-2 max-h-36 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70"
:disabled="segment.status === 'running' || job.shots.status === 'running'"
@focus="markDirty(job.shots.id, segment.index)"
@change="saveSegment(job.shots, segment)"
/>
<DurationField
v-if="segment.status !== 'complete' && segment.status !== 'running'"
v-model="segment.duration"
class="mt-3"
label="Duration"
@update:modelValue="saveSegment(job.shots, segment)"
/>
<LoraSelect
v-if="segment.status !== 'complete' && segment.status !== 'running'"
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
:triggers="loraTriggers"
:aliases="loraAliases"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(job.shots, segment) }"
@insert-trigger="insertSegmentTrigger(job.shots, segment, $event)"
@update-trigger="saveLoraTrigger"
/>
<p v-else-if="segmentLoraLabel(job.shots, segment)" class="mt-1 text-[11px] text-zinc-500">{{ segmentLoraLabel(job.shots, segment) }}</p>
<div v-if="segment.clipId" class="mt-2">
<a
class="text-xs text-amber-200 hover:text-amber-100"
:href="`/api/library/clips/${segment.clipId}/video`"
target="_blank"
rel="noreferrer"
>Open saved clip</a>
</div>
<p v-if="segment.error" class="mt-2 text-xs text-red-300">{{ segment.error }}</p>
</div>
</div>
</li>
</ol>
<ol v-else-if="job.plannedShots?.length" class="space-y-2">
<li
v-for="(shot, index) in job.plannedShots"
:key="index"
class="rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"
>
<p class="text-sm font-medium text-zinc-100">{{ job.kind === 'edit' ? `Pass ${index + 1}` : `Shot ${index + 1}` }} <span class="font-normal text-zinc-500">· queued with this job</span></p>
<p class="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
<p v-if="loraStackLabel(readLoraStack(shot), loraAliases)" class="mt-1 text-[11px] text-zinc-500">{{ loraStackLabel(readLoraStack(shot), loraAliases) }}</p>
</li>
</ol>
</section>
<section
v-for="queue in orphans"
:key="queue.id"
class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-4"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Shot batch</p>
<h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ queue.name }}</h3>
<p class="mt-1 text-sm text-zinc-300">{{ progressLine(queue) }}</p>
<p v-if="liveMessage[queue.id]" class="mt-1 text-xs text-amber-100">{{ liveMessage[queue.id] }}</p>
<p v-if="queue.lastError" class="mt-1 text-xs text-red-300">{{ queue.lastError }}</p>
</div>
<div class="flex flex-wrap gap-2">
<template v-if="canProcessQueue(queue)">
<button
type="button"
class="rounded-full bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busyId === queue.id || queue.status === 'complete' || queue.pendingCount === 0"
@click="process(queue, 1)"
>
{{ continueLabel(queue) }}
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busyId === queue.id || queue.status === 'complete' || queue.pendingCount < 2"
@click="process(queue, Math.min(3, queue.pendingCount))"
>
Next {{ Math.min(3, queue.pendingCount) || 3 }}
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-200 hover:border-amber-300/40 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busyId === queue.id || queue.status === 'complete' || queue.pendingCount === 0"
@click="process(queue, 'all')"
>
Run remaining
</button>
</template>
<button
v-if="queue.status === 'running' || queue.stopAfterCurrent"
type="button"
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
:class="pauseToggleClass(queueIsArmed(queue))"
:disabled="pauseBusy"
:aria-pressed="queueIsArmed(queue)"
:title="queuePauseTitle(queue)"
@click="togglePause({ queue })"
>
{{ queuePauseLabel(queue) }}
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
:disabled="Boolean(busyId)"
@click="askRemoveQueue(queue)"
>
{{ queue.status === 'running' ? 'Cancel job' : 'Remove job' }}
</button>
</div>
</div>
<ol class="space-y-2">
<li
v-for="segment in queue.segments"
:key="segment.index"
class="rounded-2xl border px-4 py-3"
:class="segmentClass(queue, segment)"
>
<div class="flex items-start gap-3">
<span
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="markClass(queue, segment)"
>{{ markLabel(queue, segment) }}</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-100">
Shot {{ segment.index + 1 }}
<span class="font-normal text-zinc-500">{{ segmentCaption(queue, segment) }}</span>
</p>
<span class="text-xs text-zinc-500">{{ segment.duration }}s</span>
</div>
<p
v-if="segment.status === 'complete'"
class="mt-1 truncate text-xs text-zinc-400"
>{{ segment.prompt }}</p>
<textarea
v-else
v-model="segment.prompt"
rows="3"
class="mt-2 max-h-36 w-full overflow-y-auto rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-70"
:disabled="segment.status === 'running' || queue.status === 'running'"
@focus="markDirty(queue.id, segment.index)"
@change="saveSegment(queue, segment)"
/>
<DurationField
v-if="segment.status !== 'complete' && segment.status !== 'running'"
v-model="segment.duration"
class="mt-3"
label="Duration"
@update:modelValue="saveSegment(queue, segment)"
/>
<LoraSelect
v-if="segment.status !== 'complete' && segment.status !== 'running'"
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
:triggers="loraTriggers"
:aliases="loraAliases"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(queue, segment) }"
@insert-trigger="insertSegmentTrigger(queue, segment, $event)"
@update-trigger="saveLoraTrigger"
/>
<p v-else-if="segmentLoraLabel(queue, segment)" class="mt-1 text-[11px] text-zinc-500">{{ segmentLoraLabel(queue, segment) }}</p>
<div v-if="segment.clipId" class="mt-2">
<a
class="text-xs text-amber-200 hover:text-amber-100"
:href="`/api/library/clips/${segment.clipId}/video`"
target="_blank"
rel="noreferrer"
>Open saved clip</a>
</div>
<p v-if="segment.error" class="mt-2 text-xs text-red-300">{{ segment.error }}</p>
</div>
</div>
</li>
</ol>
</section>
<section v-if="logEntries.length" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-4">
<div>
<h2 class="font-display text-xl font-semibold text-zinc-100">Recent generations</h2>
<p class="mt-1 text-sm text-zinc-400">
Kept even after the queue clears or a zombie job is wiped. Re-queue to run again, or load the prompt into Studio.
</p>
</div>
<ul class="space-y-2">
<li
v-for="entry in logEntries"
:key="entry.id"
class="flex flex-wrap items-start justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"
>
<div class="min-w-0 flex-1">
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">
{{ logKindLabel(entry) }} · {{ logStatusLabel(entry.status) }}
<span class="normal-case tracking-normal text-zinc-600"> · {{ formatLogWhen(entry.updatedAt || entry.createdAt) }}</span>
</p>
<p class="mt-1 truncate text-sm font-medium text-zinc-100">{{ entry.name || 'Untitled' }}</p>
<p class="mt-0.5 line-clamp-2 text-xs text-zinc-400">{{ promptSnippet(entry.prompt) }}</p>
<p v-if="entry.lastError" class="mt-1 text-xs text-red-300">{{ entry.lastError }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100 hover:border-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="requeueLog(entry)"
>
Re-queue
</button>
<button
type="button"
class="rounded-full border border-sky-300/40 px-3 py-1.5 text-xs text-sky-100 hover:border-sky-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="loadLog(entry)"
>
Load
</button>
<button
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="dismissLog(entry)"
>
Dismiss
</button>
</div>
</li>
</ul>
</section>
</main>
<Teleport to="body">
<div
v-if="pendingRemove"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="remove-job-title"
@click.self="pendingRemove = null"
>
<div class="glass w-full max-w-md rounded-3xl p-5 sm:p-6">
<h2 id="remove-job-title" class="font-display text-xl font-bold">{{ pendingRemove.title }}</h2>
<p class="mt-2 text-sm font-medium text-zinc-100">{{ pendingRemove.name }}</p>
<p v-if="pendingRemove.detail" class="mt-1 text-xs text-zinc-500">{{ pendingRemove.detail }}</p>
<p class="mt-3 text-sm text-zinc-400">{{ pendingRemove.consequence }}</p>
<div class="mt-5 flex flex-wrap justify-end gap-2">
<button
type="button"
class="rounded-full border border-white/15 px-4 py-2 text-sm text-zinc-300 hover:text-white"
@click="pendingRemove = null"
>
Keep it
</button>
<button
type="button"
class="rounded-full border border-red-400/40 bg-red-400/10 px-4 py-2 text-sm font-semibold text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="Boolean(busyId)"
@click="confirmRemove"
>
Remove this job
</button>
</div>
</div>
</div>
</Teleport>
<QueuedJobModal
:job-id="inspectJobId"
@close="inspectJobId = ''"
@saved="loadQueues"
/>
</div>
</template>
<script setup lang="ts">
import { insertActivationPhrase, loraIdentityKey, loraMatchesUse, loraStackLabel, persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
import { LOAD_GENERATION_PAYLOAD_KEY } from '~/utils/queuedJob'
type Segment = {
index: number
prompt: string
duration: number
status: 'pending' | 'running' | 'complete' | 'error'
clipId?: string
error?: string
loraName?: string
loraStack?: LoraStackItem[]
}
type Queue = {
id: string
name: string
status: string
lastError?: string
waitReason?: string
pendingCount: number
completedCount: number
totalCount: number
currentJobId?: string
stopAfterCurrent?: boolean
loraName?: string
loraStack?: LoraStackItem[]
segments: Segment[]
}
type StudioJobRow = {
id: string
status: string
kind?: string
name: string
prompt?: string
shotCount: number
liveJobId?: string
shotQueueId?: string
stillId?: string
hideThumbnail?: boolean
folderLocked?: boolean
cutIn?: boolean
holdForCutIn?: boolean
pauseAfterCurrent?: boolean
pausedByUser?: boolean
lastError?: string
waitReason?: string
shots?: Queue | null
plannedShots?: { prompt: string; duration: number; loraName?: string; loraStack?: LoraStackItem[] }[]
completedCount?: number
imagePipeline?: string
}
type PendingRemove = {
title: string
name: string
detail: string
consequence: string
job?: StudioJobRow
queue?: Queue
}
const videoLoras = ref<string[]>([])
const loraTriggers = ref<Record<string, string>>({})
const loraAliases = ref<Record<string, string>>({})
const inspectJobId = ref('')
type GenerationLogEntry = {
id: string
createdAt: number
updatedAt: number
kind: string
name: string
prompt: string
status: string
lastError?: string
waitReason?: string
payload: Record<string, any>
imagePipeline?: string
}
const jobs = ref<StudioJobRow[]>([])
const orphans = ref<Queue[]>([])
const logEntries = ref<GenerationLogEntry[]>([])
const waitingCount = ref(0)
const pendingCount = ref(0)
const queuePaused = ref(false)
const error = ref('')
const busyId = ref('')
const forceClearing = ref(false)
const forceClearNote = ref('')
const liveMessage = ref<Record<string, string>>({})
const dirty = new Set<string>()
const sources = new Map<string, EventSource>()
const pendingRemove = ref<PendingRemove | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const videoLoraOptions = computed(() => {
const names = videoLoras.value.filter(name => loraMatchesUse(name, 'minimax') || loraMatchesUse(name, 'ltx'))
for (const queue of [...jobs.value.map(job => job.shots), ...orphans.value]) {
if (!queue) continue
if (queue.loraName && !names.includes(queue.loraName)) names.push(queue.loraName)
for (const item of queue.loraStack || []) {
if (item.name && !names.includes(item.name)) names.push(item.name)
}
for (const segment of queue.segments) {
if (segment.loraName && !names.includes(segment.loraName)) names.push(segment.loraName)
for (const item of segment.loraStack || []) {
if (item.name && !names.includes(item.name)) names.push(item.name)
}
}
}
return names
})
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
const pauseBusy = computed(() => busyId.value === 'pause')
const anythingGenerating = computed(() => (
jobs.value.some(job => jobIsGenerating(job))
|| orphans.value.some(queue => queue.status === 'running')
))
const willPauseLatch = computed(() => (
jobs.value.some(job => jobIsLatched(job))
|| orphans.value.some(queue => queueIsLatched(queue))
))
const heldPaused = computed(() => (
jobs.value.some(job => jobIsHeldPaused(job))
|| orphans.value.some(queue => queueIsHeldPaused(queue))
))
const pauseArmed = computed(() => willPauseLatch.value || heldPaused.value)
const pauseToggleLabel = computed(() => {
if (willPauseLatch.value) return 'Will pause after this'
if (heldPaused.value) return 'Resume queue'
return 'Pause after current'
})
const pauseToggleTitle = computed(() => {
if (willPauseLatch.value) return 'Click to keep going after this video'
if (heldPaused.value) return 'Resume the paused batch'
return 'Finish the current video, then stop. Does not cancel the one that’s generating.'
})
const pauseDisabledReason = computed(() => {
if (pauseBusy.value) return 'Updating pause…'
if (!anythingGenerating.value && !pauseArmed.value) return 'Nothing is generating, so there is nothing to pause after'
return ''
})
const clearableCount = computed(() => (
jobs.value.filter(job => job.status === 'waiting' || job.status === 'held' || job.status === 'error').length
+ orphans.value.filter(queue => queue.status !== 'running').length
))
function dirtyKey(queueId: string, index: number) {
return `${queueId}:${index}`
}
function markDirty(queueId: string, index: number) {
dirty.add(dirtyKey(queueId, index))
}
function nextSegment(queue: Queue) {
return queue.segments.find(segment => segment.status === 'pending' || segment.status === 'error')
|| queue.segments.find(segment => segment.status === 'running')
}
function progressLine(queue: Queue) {
const next = nextSegment(queue)
if (queue.status === 'complete') return `All ${queue.totalCount} shots saved.`
if (queue.status === 'running' && next) {
const pauseNote = queue.stopAfterCurrent ? ' Will pause after this shot.' : ''
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.${pauseNote}`
}
if (queueIsHeldPaused(queue) && next) {
return `Paused. ${queue.completedCount} of ${queue.totalCount} saved. Next is shot ${next.index + 1}.`
}
if (queue.completedCount === 0) return `Not started · ${queue.totalCount} shots`
if (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.`
return `Shot ${queue.completedCount} of ${queue.totalCount}`
}
function jobLine(job: StudioJobRow) {
if (job.kind === 'music') {
if (job.status === 'waiting') return job.cutIn ? 'Next after the current shot · ACE-Step' : 'Waiting · ACE-Step'
if (job.status === 'held') return 'Paused'
if (job.status === 'error') return job.lastError || 'Failed'
return 'Generating track'
}
if (job.kind === 'edit') {
const passes = job.shotCount || 1
const passLine = passes > 1 ? `${passes} passes` : 'image edit'
if (job.status === 'waiting') {
return job.cutIn
? `Next after the current shot · ${passLine}`
: `Waiting · ${passLine}`
}
if (job.status === 'held') {
if (jobIsHeldPaused(job)) return 'Paused. This edit will not continue until you resume.'
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
}
if (job.status === 'running') {
if (jobIsLatched(job)) return `Editing · will pause after this · ${passLine}`
return `Editing · ${passLine}`
}
return job.status
}
if (job.status === 'waiting') {
return job.cutIn
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
: `Waiting · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
}
if (job.shots) {
const line = progressLine(job.shots)
if (jobIsLatched(job) && !job.shots.stopAfterCurrent) {
return `${line} Will pause after this shot.`
}
return line
}
if (job.status === 'held') {
if (jobIsHeldPaused(job)) return 'Paused. This batch will not continue until you resume.'
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
}
if (job.status === 'running') {
if (jobIsLatched(job)) return `Generating · will pause after this · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
}
return job.status
}
function continueLabel(queue: Queue) {
const next = nextSegment(queue)
if (!next) return 'Process next'
return `Continue shot ${next.index + 1}`
}
function segmentCaption(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return '· saved'
if (segment.status === 'running') return '· generating now'
if (segment.status === 'error') return '· failed · retry from here'
if (nextSegment(queue)?.index === segment.index) return '· next up'
return '· waiting'
}
function setSegmentStack(segment: Segment, stack: LoraStackItem[]) {
const fields = persistLoraFields(stack)
segment.loraStack = fields.loraStack
segment.loraName = fields.loraName
}
function insertSegmentTrigger(queue: Queue, segment: Segment, phrase: string) {
segment.prompt = insertActivationPhrase(segment.prompt, phrase)
saveSegment(queue, segment)
}
async function saveLoraTrigger(name: string, trigger: string) {
try {
const data = await $fetch<{ loraTriggers?: Record<string, string> }>('/api/library/lora-triggers', {
method: 'PUT',
body: { key: loraIdentityKey(name), trigger }
})
if (data.loraTriggers) loraTriggers.value = data.loraTriggers
} catch {
/* keep the last saved word */
}
}
function segmentLoraLabel(queue: Queue, segment: Segment) {
const stack = readLoraStack(segment).length ? readLoraStack(segment) : readLoraStack(queue)
return loraStackLabel(stack, loraAliases.value)
}
function segmentClass(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return 'border-emerald-400/25 bg-emerald-400/5'
if (segment.status === 'running') return 'border-amber-300/50 bg-amber-400/10'
if (segment.status === 'error') return 'border-red-400/30 bg-red-400/5'
if (nextSegment(queue)?.index === segment.index) return 'border-amber-300/30 bg-zinc-950/70'
return 'border-white/10 bg-zinc-950/50'
}
function markClass(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return 'bg-emerald-400 text-zinc-950'
if (segment.status === 'running') return 'bg-amber-400 text-zinc-950'
if (segment.status === 'error') return 'bg-red-400 text-zinc-950'
if (nextSegment(queue)?.index === segment.index) return 'border border-amber-300/70 text-amber-100'
return 'border border-white/15 text-zinc-500'
}
function markLabel(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return '✓'
if (segment.status === 'running') return '▶'
if (segment.status === 'error') return '!'
if (nextSegment(queue)?.index === segment.index) return String(segment.index + 1)
return String(segment.index + 1)
}
function mergeQueues(incoming: Queue[]) {
const previous = new Map(
[
...jobs.value.flatMap(job => job.shots ? [job.shots] : []),
...orphans.value
].map(queue => [queue.id, queue])
)
return incoming.map((next) => {
const old = previous.get(next.id)
if (!old) return next
return {
...next,
segments: next.segments.map((segment) => {
const prior = old.segments.find(item => item.index === segment.index)
if (!prior || !dirty.has(dirtyKey(next.id, segment.index))) return segment
return {
...segment,
prompt: prior.prompt,
duration: prior.duration
}
})
}
})
}
async function loadQueues() {
const [data, logData] = await Promise.all([
$fetch<{
jobs?: StudioJobRow[]
orphans?: Queue[]
waitingCount?: number
paused?: boolean
}>('/api/studio-queue?full=1'),
$fetch<{ entries?: GenerationLogEntry[] }>('/api/generation-log').catch(() => ({ entries: [] as GenerationLogEntry[] }))
])
const nextJobs = (data.jobs || []).map((job) => (
job.shots ? { ...job, shots: mergeQueues([job.shots])[0] } : job
))
jobs.value = nextJobs
orphans.value = mergeQueues(data.orphans || [])
waitingCount.value = data.waitingCount || 0
queuePaused.value = data.paused === true
logEntries.value = logData.entries || []
pendingCount.value = [
...nextJobs.flatMap(job => job.shots ? [job.shots] : []),
...orphans.value
].reduce((sum, queue) => sum + (queue.status === 'complete' ? 0 : queue.pendingCount), 0)
for (const queue of [...nextJobs.map(job => job.shots).filter(Boolean), ...orphans.value] as Queue[]) {
if (queue.status === 'running' && queue.currentJobId) listen(queue)
}
for (const job of nextJobs) {
if (job.status === 'running' && job.liveJobId && !job.shots) listenJob(job)
}
}
function listen(queue: Queue) {
if (!queue.currentJobId || sources.has(queue.id)) return
const source = new EventSource(`/api/generate/${queue.currentJobId}/stream`)
sources.set(queue.id, source)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { message?: string; type?: string; queuedRemaining?: number }
if (payload.message) liveMessage.value = { ...liveMessage.value, [queue.id]: payload.message }
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
void loadQueues()
}
if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) {
void notifyGenerationComplete('video')
}
if (payload.type === 'complete' || payload.type === 'error') {
source.close()
sources.delete(queue.id)
}
} catch { /* ignore */ }
}
}
async function cutIn(job: StudioJobRow) {
busyId.value = job.id
error.value = ''
try {
const result = await $fetch<{ started?: boolean; deferred?: boolean }>(`/api/studio-queue/${job.id}/cut-in`, { method: 'POST' })
forceClearNote.value = result.started
? 'Starting now.'
: (result.deferred
? 'This job will run after the current shot.'
: 'Queued to start when the GPU is free.')
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not move that job next'
} finally {
busyId.value = ''
}
}
async function retryJob(job: StudioJobRow) {
busyId.value = job.id
error.value = ''
try {
await $fetch(`/api/studio-queue/${job.id}/retry`, { method: 'POST' })
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not retry that job'
} finally {
busyId.value = ''
}
}
function listenJob(job: StudioJobRow) {
if (!job.liveJobId || sources.has(job.id)) return
const source = new EventSource(`/api/generate/${job.liveJobId}/stream`)
sources.set(job.id, source)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { message?: string; type?: string; queuedRemaining?: number; stillId?: string }
if (payload.message) liveMessage.value = { ...liveMessage.value, [job.id]: payload.message }
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
void loadQueues()
}
if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) {
void notifyGenerationComplete(job.kind === 'music' ? 'music' : job.kind === 'edit' || payload.stillId ? 'image' : 'video')
}
if (payload.type === 'complete' || payload.type === 'error') {
source.close()
sources.delete(job.id)
}
} catch { /* ignore */ }
}
}
function jobKindLabel(job: StudioJobRow) {
if (job.kind === 'music') return 'MUSIC'
if (job.kind === 'edit') {
if (job.imagePipeline === 'v2') return 'IMAGE V2'
return (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE'
}
return 'VIDEO'
}
function logKindLabel(entry: GenerationLogEntry) {
if (entry.kind === 'music') return 'MUSIC'
if (entry.kind === 'edit') {
if (entry.payload?.imagePipeline === 'v2') return 'IMAGE V2'
return 'IMAGE'
}
return 'VIDEO'
}
function logStatusLabel(status: string) {
if (status === 'complete') return 'done'
if (status === 'error') return 'failed'
if (status === 'cancelled') return 'cancelled'
if (status === 'running') return 'running'
return 'queued'
}
function formatLogWhen(ts: number) {
if (!ts) return ''
try {
return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
} catch {
return ''
}
}
async function requeueLog(entry: GenerationLogEntry) {
busyId.value = entry.id
error.value = ''
try {
await $fetch(`/api/generation-log/${entry.id}/requeue`, { method: 'POST' })
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not re-queue that generation'
} finally {
busyId.value = ''
}
}
async function loadLog(entry: GenerationLogEntry) {
busyId.value = entry.id
error.value = ''
try {
sessionStorage.setItem(LOAD_GENERATION_PAYLOAD_KEY, JSON.stringify({
kind: entry.kind,
payload: entry.payload
}))
if (entry.kind === 'music') {
await navigateTo('/music')
} else {
await navigateTo('/')
}
} catch (err: any) {
error.value = err?.message || 'Could not load that generation'
} finally {
busyId.value = ''
}
}
async function dismissLog(entry: GenerationLogEntry) {
busyId.value = entry.id
error.value = ''
try {
await $fetch(`/api/generation-log/${entry.id}`, { method: 'DELETE' })
logEntries.value = logEntries.value.filter(item => item.id !== entry.id)
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not dismiss that entry'
} finally {
busyId.value = ''
}
}
function jobThumb(job: StudioJobRow) {
if (!job.stillId || job.hideThumbnail || job.folderLocked) return ''
return `/api/library/stills/${job.stillId}`
}
function canRemoveJob(job: StudioJobRow) {
return job.status === 'waiting' || job.status === 'held' || job.status === 'running' || job.status === 'error'
}
function promptSnippet(text?: string) {
const value = String(text || '').replace(/\s+/g, ' ').trim()
if (!value) return ''
return value.length > 120 ? `${value.slice(0, 117)}…` : value
}
function shotProgressDetail(job: StudioJobRow) {
if (job.kind === 'music') return promptSnippet(job.prompt)
const queue = job.shots
if (!queue) return job.kind === 'edit' ? promptSnippet(job.prompt) : ''
const total = queue.totalCount || job.shotCount || 1
const next = nextSegment(queue)
const shot = next ? next.index + 1 : Math.min(total, (queue.completedCount || 0) + 1)
const snippet = promptSnippet(next?.prompt || job.prompt)
return `Shot ${shot} of ${total}${snippet ? ` · ${snippet}` : ''}`
}
function askRemoveJob(job: StudioJobRow) {
const generating = jobIsGenerating(job)
const pausedBatch = jobIsHeldPaused(job) || (job.status === 'held' && Boolean(job.shotQueueId))
pendingRemove.value = {
title: generating ? 'Cancel this job?' : 'Remove this job?',
name: job.name || 'Untitled job',
detail: shotProgressDetail(job),
consequence: generating
? (job.kind === 'music'
? 'This stops the track that’s generating on Beast and removes only this row. Other queue items stay. Saved tracks stay in the library.'
: 'This stops the job that’s generating on Beast and removes only this row. Other queue items stay. Saved clips stay in the library.')
: pausedBatch
? 'This drops the rest of this shot list only. It will not resume, and it will not stop a different job that’s generating. Saved clips stay in the library.'
: 'This removes only this job from the queue. Other items stay. Saved clips stay in the library.',
job
}
}
function askRemoveQueue(queue: Queue) {
const generating = queue.status === 'running'
const next = nextSegment(queue)
const shot = next ? next.index + 1 : Math.min(queue.totalCount, (queue.completedCount || 0) + 1)
pendingRemove.value = {
title: generating ? 'Cancel this job?' : 'Remove this job?',
name: queue.name || 'Shot batch',
detail: `Shot ${shot} of ${queue.totalCount}${next?.prompt ? ` · ${promptSnippet(next.prompt)}` : ''}`,
consequence: generating
? 'This stops the job that’s generating on Beast and removes only this batch. Other queue items stay. Saved clips stay in the library.'
: 'This drops the rest of this shot list only. It will not resume, and it will not stop a different job that’s generating. Saved clips stay in the library.',
queue
}
}
async function confirmRemove() {
const pending = pendingRemove.value
if (!pending) return
pendingRemove.value = null
busyId.value = pending.job?.id || pending.queue?.id || 'remove'
error.value = ''
try {
if (pending.job) await removeJob(pending.job)
else if (pending.queue) await remove(pending.queue)
} finally {
busyId.value = ''
}
}
async function removeJob(job: StudioJobRow) {
try {
await $fetch(`/api/studio-queue/${job.id}`, { method: 'DELETE' })
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not remove that job'
throw err
}
await loadQueues()
}
async function process(queue: Queue, count: number | 'all') {
busyId.value = queue.id
error.value = ''
try {
await $fetch(`/api/queues/${queue.id}`, {
method: 'PATCH',
body: {
segments: queue.segments
.filter(segment => segment.status !== 'complete')
.map(segment => ({ index: segment.index, prompt: segment.prompt, duration: segment.duration }))
}
}).catch(() => null)
for (const segment of queue.segments) dirty.delete(dirtyKey(queue.id, segment.index))
const started = await $fetch<{ jobId: string; count?: number }>(`/api/queues/${queue.id}/process`, {
method: 'POST',
query: { count },
body: { count }
})
queue.currentJobId = started.jobId
const n = count === 'all' ? queue.pendingCount : Number(started.count || count)
liveMessage.value = {
...liveMessage.value,
[queue.id]: n <= 1 ? 'Starting the next shot…' : `Starting ${n} shots…`
}
listen({ ...queue, currentJobId: started.jobId })
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not start the next shot'
} finally {
busyId.value = ''
}
}
function pauseToggleClass(pressed: boolean) {
return pressed
? 'bg-amber-400 text-zinc-950 shadow-[0_0_0_2px_rgba(251,191,36,0.55)]'
: 'border border-amber-300/50 text-amber-100 hover:border-amber-200 hover:bg-amber-400/10'
}
function jobIsGenerating(job: StudioJobRow) {
return job.status === 'running' || job.shots?.status === 'running'
}
function jobIsLatched(job: StudioJobRow) {
return jobIsGenerating(job) && Boolean(job.pauseAfterCurrent || job.shots?.stopAfterCurrent)
}
function jobIsHeldPaused(job: StudioJobRow) {
if (jobIsGenerating(job)) return false
return Boolean(job.pausedByUser || job.shots?.stopAfterCurrent)
}
function queueIsLatched(queue: Queue) {
return queue.status === 'running' && Boolean(queue.stopAfterCurrent)
}
function queueIsHeldPaused(queue: Queue) {
return queue.status !== 'running' && Boolean(queue.stopAfterCurrent)
}
function queueIsArmed(queue: Queue) {
return Boolean(queue.stopAfterCurrent)
}
function canProcessShots(job: StudioJobRow) {
return Boolean(job.shots) && !jobIsGenerating(job) && !jobIsHeldPaused(job)
}
function canProcessQueue(queue: Queue) {
return queue.status !== 'running' && !queueIsHeldPaused(queue)
}
function canPauseJob(job: StudioJobRow) {
return jobIsGenerating(job) || jobIsHeldPaused(job)
}
function jobIsArmed(job: StudioJobRow) {
return jobIsLatched(job) || jobIsHeldPaused(job)
}
function jobPauseLabel(job: StudioJobRow) {
if (jobIsLatched(job)) return 'Will pause after this'
if (jobIsHeldPaused(job)) return 'Paused'
return 'Pause after this'
}
function jobPauseTitle(job: StudioJobRow) {
if (jobIsLatched(job)) return 'Click to keep going after this video'
if (jobIsHeldPaused(job)) return 'Resume this batch'
return 'Finish the current video, then stop'
}
function queuePauseLabel(queue: Queue) {
if (queueIsLatched(queue)) return 'Will pause after this'
if (queueIsHeldPaused(queue)) return 'Paused'
return 'Pause after this'
}
function queuePauseTitle(queue: Queue) {
if (queueIsLatched(queue)) return 'Click to keep going after this video'
if (queueIsHeldPaused(queue)) return 'Resume this batch'
return 'Finish the current video, then stop'
}
async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) {
const armed = target?.job
? jobIsArmed(target.job)
: target?.queue
? queueIsArmed(target.queue)
: pauseArmed.value
const resume = armed
if (target?.job) {
target.job.pauseAfterCurrent = !resume
if (target.job.shots) target.job.shots.stopAfterCurrent = !resume
if (resume) target.job.pausedByUser = false
}
if (target?.queue) target.queue.stopAfterCurrent = !resume
if (!target?.job && !target?.queue) queuePaused.value = !resume
busyId.value = 'pause'
error.value = ''
try {
if (target?.job) {
await $fetch(`/api/studio-queue/${target.job.id}/pause`, { method: 'POST', body: { resume } })
} else if (target?.queue) {
await $fetch(`/api/queues/${target.queue.id}/pause`, { method: 'POST', body: { resume } })
} else {
await $fetch('/api/studio-queue/pause', { method: 'POST', body: { resume } })
}
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not update pause'
await loadQueues().catch(() => null)
} finally {
busyId.value = ''
}
}
async function remove(queue: Queue) {
await $fetch(`/api/queues/${queue.id}`, { method: 'DELETE' }).catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that batch'
})
await loadQueues()
}
async function clearAll() {
const n = clearableCount.value
if (!n) {
error.value = 'Nothing waiting to clear. Use Force clear if a running job is stuck.'
return
}
const running = jobs.value.some(job => job.status === 'running') || orphans.value.some(queue => queue.status === 'running')
const extra = running ? ' The job that’s generating will be left until you Force clear or cancel it.' : ''
if (!confirm(`Clear waiting and paused jobs from the queue? Saved clips stay in the library.${extra}`)) return
error.value = ''
try {
for (const job of jobs.value.filter(item => item.status === 'waiting' || item.status === 'held' || item.status === 'error')) {
await $fetch(`/api/studio-queue/${job.id}`, { method: 'DELETE' }).catch(() => null)
}
await $fetch('/api/queues', { method: 'DELETE' })
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not clear the queue'
}
}
async function forceClearAll() {
if (forceClearing.value) return
if (!confirm(
'Force clear cancels every running and waiting job (including stuck YuE/music), wipes shot batches, and kills Comfy if it is wedged. Saved library clips stay. Continue?'
)) return
forceClearing.value = true
error.value = ''
forceClearNote.value = ''
try {
const result = await $fetch<{
message?: string
cleared?: { cancelledStudio?: number; clearedPending?: number; cancelledLive?: number; clearedShotQueues?: number }
}>('/api/comfy/force-reset', { method: 'POST' })
const c = result.cleared || {}
await loadQueues()
forceClearNote.value = `Force cleared · studio ${c.cancelledStudio || 0}, live ${c.cancelledLive || 0}, shots ${c.clearedShotQueues || 0}. Comfy is stopped — Poke when ready.`
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not force-clear the queue'
} finally {
forceClearing.value = false
}
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function saveSegment(queue: Queue, segment: Segment) {
markDirty(queue.id, segment.index)
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void $fetch(`/api/queues/${queue.id}`, {
method: 'PATCH',
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration, loraName: segment.loraName || '', loraStack: segment.loraStack || [] }] }
}).then(() => {
dirty.delete(dirtyKey(queue.id, segment.index))
}).catch(() => null)
}, 400)
}
onMounted(async () => {
void $fetch<{ video?: string[] }>('/api/loras?fresh=1').then((listed) => {
videoLoras.value = listed.video || []
}).catch(() => null)
void $fetch<{ loraTriggers?: Record<string, string>; loraAliases?: Record<string, string> }>('/api/library?light=1').then((library) => {
if (library.loraTriggers) loraTriggers.value = library.loraTriggers
if (library.loraAliases) loraAliases.value = library.loraAliases
}).catch(() => null)
window.addEventListener('keydown', onRemoveKey)
await loadQueues().catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'
})
pollTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return
void loadQueues().catch(() => null)
}, 8000)
})
function onRemoveKey(event: KeyboardEvent) {
if (event.key === 'Escape' && pendingRemove.value) pendingRemove.value = null
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', onRemoveKey)
if (pollTimer) clearInterval(pollTimer)
if (saveTimer) clearTimeout(saveTimer)
for (const source of sources.values()) source.close()
sources.clear()
})
</script>