Keep image jobs on the studio queue and confirm each job or library delete. Label stitch-frame slots when thumbs are hidden, and open output stills in a lightbox.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,15 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<span v-if="hidden" class="sensitive-glow block h-12 w-12 overflow-hidden rounded-lg" />
|
<div class="flex items-start gap-1.5">
|
||||||
<div v-else class="flex items-start gap-1.5">
|
<div v-if="stillId && (hidden || !failed.still)" class="flex w-12 flex-col items-center gap-0.5">
|
||||||
|
<span v-if="hidden" class="relative h-12 w-12 overflow-hidden rounded-lg">
|
||||||
|
<span class="sensitive-glow block h-full w-full" />
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="stillId && !failed.still"
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-12 flex-col items-center gap-0.5"
|
class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900"
|
||||||
aria-label="Start still"
|
aria-label="Start still"
|
||||||
@click.stop="openPreview(stillFull, 'Start')"
|
@click.stop="openPreview(stillFull, 'Start')"
|
||||||
>
|
>
|
||||||
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
|
|
||||||
<img
|
<img
|
||||||
:src="stillThumb"
|
:src="stillThumb"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -18,17 +20,20 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
@error="failed.still = true"
|
@error="failed.still = true"
|
||||||
>
|
>
|
||||||
</span>
|
|
||||||
<span class="text-[10px] leading-none text-zinc-500">Start</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-[10px] leading-none text-zinc-500">Start</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="fromClipId && (hidden || !failed.from)" class="flex w-12 flex-col items-center gap-0.5">
|
||||||
|
<span v-if="hidden" class="relative h-12 w-12 overflow-hidden rounded-lg">
|
||||||
|
<span class="sensitive-glow block h-full w-full" />
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="fromClipId && !failed.from"
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-12 flex-col items-center gap-0.5"
|
class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900"
|
||||||
aria-label="Last frame of previous clip"
|
aria-label="Last frame of previous clip"
|
||||||
@click.stop="openPreview(fromFull, 'From prev')"
|
@click.stop="openPreview(fromFull, 'From prev')"
|
||||||
>
|
>
|
||||||
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
|
|
||||||
<img
|
<img
|
||||||
:src="fromThumb"
|
:src="fromThumb"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -37,17 +42,20 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
@error="failed.from = true"
|
@error="failed.from = true"
|
||||||
>
|
>
|
||||||
</span>
|
|
||||||
<span class="text-[10px] leading-none text-zinc-500">From prev</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-[10px] leading-none text-zinc-500">From prev</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showFirstFrame && clipId && (hidden || !failed.first)" class="flex w-12 flex-col items-center gap-0.5">
|
||||||
|
<span v-if="hidden" class="relative h-12 w-12 overflow-hidden rounded-lg">
|
||||||
|
<span class="sensitive-glow block h-full w-full" />
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="showFirstFrame && clipId && !failed.first"
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-12 flex-col items-center gap-0.5"
|
class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900"
|
||||||
aria-label="First frame of this clip"
|
aria-label="First frame of this clip"
|
||||||
@click.stop="openPreview(firstFull, 'This clip')"
|
@click.stop="openPreview(firstFull, 'This clip')"
|
||||||
>
|
>
|
||||||
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
|
|
||||||
<img
|
<img
|
||||||
:src="firstThumb"
|
:src="firstThumb"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -56,9 +64,9 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
@error="failed.first = true"
|
@error="failed.first = true"
|
||||||
>
|
>
|
||||||
</span>
|
|
||||||
<span class="text-[10px] leading-none text-zinc-500">This clip</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-[10px] leading-none text-zinc-500">This clip</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -90,12 +98,12 @@ const props = defineProps<{
|
|||||||
const failed = reactive({ still: false, from: false, first: false })
|
const failed = reactive({ still: false, from: false, first: false })
|
||||||
const preview = ref<{ src: string; label: string } | null>(null)
|
const preview = ref<{ src: string; label: string } | null>(null)
|
||||||
|
|
||||||
const stillThumb = computed(() => props.stillId ? `/api/library/stills/${props.stillId}?thumb=1` : '')
|
const stillThumb = computed(() => !props.hidden && props.stillId ? `/api/library/stills/${props.stillId}?thumb=1` : '')
|
||||||
const stillFull = computed(() => props.stillId ? `/api/library/stills/${props.stillId}` : '')
|
const stillFull = computed(() => !props.hidden && props.stillId ? `/api/library/stills/${props.stillId}` : '')
|
||||||
const fromThumb = computed(() => props.fromClipId ? `/api/library/clips/${props.fromClipId}/last-frame?thumb=1` : '')
|
const fromThumb = computed(() => !props.hidden && props.fromClipId ? `/api/library/clips/${props.fromClipId}/last-frame?thumb=1` : '')
|
||||||
const fromFull = computed(() => props.fromClipId ? `/api/library/clips/${props.fromClipId}/last-frame` : '')
|
const fromFull = computed(() => !props.hidden && props.fromClipId ? `/api/library/clips/${props.fromClipId}/last-frame` : '')
|
||||||
const firstThumb = computed(() => props.clipId ? `/api/library/clips/${props.clipId}/first-frame?thumb=1` : '')
|
const firstThumb = computed(() => !props.hidden && props.clipId ? `/api/library/clips/${props.clipId}/first-frame?thumb=1` : '')
|
||||||
const firstFull = computed(() => props.clipId ? `/api/library/clips/${props.clipId}/first-frame` : '')
|
const firstFull = computed(() => !props.hidden && props.clipId ? `/api/library/clips/${props.clipId}/first-frame` : '')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.hidden, props.stillId, props.fromClipId, props.clipId, props.showFirstFrame],
|
() => [props.hidden, props.stillId, props.fromClipId, props.clipId, props.showFirstFrame],
|
||||||
@@ -103,11 +111,12 @@ watch(
|
|||||||
failed.still = false
|
failed.still = false
|
||||||
failed.from = false
|
failed.from = false
|
||||||
failed.first = false
|
failed.first = false
|
||||||
|
if (props.hidden) closePreview()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function openPreview(src: string, label: string) {
|
function openPreview(src: string, label: string) {
|
||||||
if (!src) return
|
if (props.hidden || !src) return
|
||||||
preview.value = { src, label }
|
preview.value = { src, label }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+292
-51
@@ -693,7 +693,7 @@
|
|||||||
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
|
:disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
|
||||||
@click="studioMode === 'edit' ? editImage() : generate()"
|
@click="studioMode === 'edit' ? editImage() : generate()"
|
||||||
>
|
>
|
||||||
{{ studioMode === 'edit' ? (editBusy ? 'Editing…' : editLabel) : generateLabel }}
|
{{ studioMode === 'edit' ? editSubmitLabel : generateLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="videoBusy || editBusy"
|
v-if="videoBusy || editBusy"
|
||||||
@@ -705,9 +705,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="studioMode === 'video' && videoBusy" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
|
<p v-if="studioMode === 'video' && videoBusy" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
|
||||||
|
<p v-else-if="studioMode === 'edit' && (videoBusy || editBusy)" class="text-xs text-zinc-400">The current job keeps running in Output. This submit waits in the job queue.</p>
|
||||||
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
|
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
|
||||||
|
|
||||||
<div v-if="studioMode === 'video' && studioJobs.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
|
<div v-if="studioJobs.length" class="rounded-2xl border border-white/10 bg-zinc-950/40 p-4">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
|
<h3 class="text-sm font-semibold text-zinc-100">Job queue</h3>
|
||||||
@@ -729,6 +730,7 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
|
<p class="truncate text-sm font-medium text-zinc-100">{{ job.name }}</p>
|
||||||
<p class="mt-0.5 text-xs text-zinc-500">
|
<p class="mt-0.5 text-xs text-zinc-500">
|
||||||
|
<span v-if="job.kind === 'edit'" class="mr-1 text-amber-200/80">{{ (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE' }} ·</span>
|
||||||
{{ studioJobStatusLabel(job) }}
|
{{ studioJobStatusLabel(job) }}
|
||||||
· {{ studioJobShotLine(job) }}
|
· {{ studioJobShotLine(job) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -755,7 +757,7 @@
|
|||||||
|
|
||||||
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
|
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
|
||||||
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
|
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
|
||||||
<p class="mt-1 text-xs text-zinc-400">Comfy was still busy after 3 minutes. Click one to restore the still, prompt, and settings.</p>
|
<p class="mt-1 text-xs text-zinc-400">Jobs saved to retry later. Click one to restore the still, prompt, and settings.</p>
|
||||||
<ul class="mt-3 space-y-2">
|
<ul class="mt-3 space-y-2">
|
||||||
<li
|
<li
|
||||||
v-for="draft in drafts"
|
v-for="draft in drafts"
|
||||||
@@ -971,13 +973,29 @@
|
|||||||
:class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }"
|
:class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }"
|
||||||
style="min-height: 12rem"
|
style="min-height: 12rem"
|
||||||
>
|
>
|
||||||
<img
|
<button
|
||||||
v-if="editResultUrl && !editLockedSave"
|
v-if="editResultUrl && !editLockedSave"
|
||||||
|
type="button"
|
||||||
|
class="group relative mx-auto block w-full cursor-zoom-in bg-black"
|
||||||
|
:class="{ 'pointer-events-none opacity-0': editAwaitingReveal }"
|
||||||
|
aria-label="View still fullscreen"
|
||||||
|
@click="openEditLightbox"
|
||||||
|
>
|
||||||
|
<img
|
||||||
:src="editResultUrl"
|
:src="editResultUrl"
|
||||||
alt="Edited still"
|
alt="Edited still"
|
||||||
class="mx-auto max-h-[min(70dvh,24rem)] w-full bg-black object-contain"
|
class="mx-auto max-h-[min(70dvh,24rem)] w-full object-contain"
|
||||||
:class="{ 'opacity-0': editAwaitingReveal }"
|
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="!editAwaitingReveal"
|
||||||
|
class="pointer-events-none absolute right-3 top-3 rounded-full border border-white/15 bg-zinc-950/75 p-1.5 text-amber-100 opacity-0 shadow-lg transition group-hover:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" class="h-4 w-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="editAwaitingReveal && !editLockedSave"
|
v-if="editAwaitingReveal && !editLockedSave"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1100,7 +1118,7 @@
|
|||||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="text-red-300 hover:text-red-200" @click="removeSelected">Delete</button>
|
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
|
||||||
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="mt-4 space-y-3">
|
<ul class="mt-4 space-y-3">
|
||||||
@@ -1190,7 +1208,7 @@
|
|||||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="text-xs text-red-300" @click="removeClip(entry.group.head, true)">Delete</button>
|
<button type="button" class="text-xs text-red-300" @click="askRemoveClip(entry.group.head, true)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1198,14 +1216,14 @@
|
|||||||
<li
|
<li
|
||||||
v-for="(item, partIndex) in chronologicalParts(entry.group.parts)"
|
v-for="(item, partIndex) in chronologicalParts(entry.group.parts)"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
class="flex flex-wrap items-start gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
||||||
>
|
>
|
||||||
<ClipFrameThumbs
|
<ClipFrameThumbs
|
||||||
:hidden="item.hideThumbnail"
|
:hidden="item.hideThumbnail"
|
||||||
:still-id="item.hideThumbnail || partIndex !== 0 ? undefined : item.stillId"
|
:still-id="partIndex === 0 ? item.stillId : undefined"
|
||||||
:from-clip-id="item.hideThumbnail ? undefined : previousChronoClipId(entry.group.parts, partIndex)"
|
:from-clip-id="previousChronoClipId(entry.group.parts, partIndex)"
|
||||||
:clip-id="item.id"
|
:clip-id="item.id"
|
||||||
:show-first-frame="!item.hideThumbnail && partIndex > 0"
|
:show-first-frame="partIndex > 0 || !item.stillId"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
@@ -1250,7 +1268,7 @@
|
|||||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="text-xs text-red-300" @click="removeClip(item)">Delete</button>
|
<button type="button" class="text-xs text-red-300" @click="askRemoveClip(item)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1335,7 +1353,7 @@
|
|||||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="text-xs text-red-300" @click="removeStill(entry.stillGroup.head, true)">Delete</button>
|
<button type="button" class="text-xs text-red-300" @click="askRemoveStill(entry.stillGroup.head, true)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1383,7 +1401,7 @@
|
|||||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="text-xs text-red-300" @click="removeStill(item)">Delete</button>
|
<button type="button" class="text-xs text-red-300" @click="askRemoveStill(item)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1428,6 +1446,60 @@
|
|||||||
@pick="onPickedPermanence"
|
@pick="onPickedPermanence"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="editLightboxUrl"
|
||||||
|
ref="editLightbox"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Edited still fullscreen"
|
||||||
|
@click.self="closeEditLightbox"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="editLightboxUrl"
|
||||||
|
alt="Edited still"
|
||||||
|
class="max-h-[90vh] max-w-[min(96vw,80rem)] object-contain"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="pendingLibraryDelete"
|
||||||
|
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="library-delete-title"
|
||||||
|
@click.self="pendingLibraryDelete = null"
|
||||||
|
>
|
||||||
|
<div class="glass w-full max-w-md rounded-3xl p-5 sm:p-6">
|
||||||
|
<h2 id="library-delete-title" class="font-display text-xl font-bold">{{ pendingLibraryDelete.title }}</h2>
|
||||||
|
<p class="mt-2 text-sm font-medium text-zinc-100">{{ pendingLibraryDelete.name }}</p>
|
||||||
|
<p v-if="pendingLibraryDelete.detail" class="mt-1 text-xs text-zinc-500">{{ pendingLibraryDelete.detail }}</p>
|
||||||
|
<p class="mt-3 text-sm text-zinc-400">{{ pendingLibraryDelete.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="pendingLibraryDelete = 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="libraryDeleteBusy"
|
||||||
|
@click="confirmLibraryDelete"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="toast in toasts"
|
v-for="toast in toasts"
|
||||||
@@ -1565,6 +1637,17 @@ interface QueuedEditPass {
|
|||||||
prompt: string
|
prompt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingLibraryDelete = {
|
||||||
|
title: string
|
||||||
|
name: string
|
||||||
|
detail: string
|
||||||
|
consequence: string
|
||||||
|
mode: 'clip' | 'still' | 'selected'
|
||||||
|
clip?: LibraryClip
|
||||||
|
still?: LibraryStill
|
||||||
|
family?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
||||||
'16:9': { width: 1344, height: 768 },
|
'16:9': { width: 1344, height: 768 },
|
||||||
'9:16': { width: 768, height: 1344 },
|
'9:16': { width: 768, height: 1344 },
|
||||||
@@ -1580,6 +1663,8 @@ const aspects = [
|
|||||||
|
|
||||||
const player = ref<HTMLVideoElement | null>(null)
|
const player = ref<HTMLVideoElement | null>(null)
|
||||||
const outputPanel = ref<HTMLElement | null>(null)
|
const outputPanel = ref<HTMLElement | null>(null)
|
||||||
|
const editLightbox = ref<HTMLElement | null>(null)
|
||||||
|
const editLightboxUrl = ref('')
|
||||||
const file = ref<File | null>(null)
|
const file = ref<File | null>(null)
|
||||||
const preview = ref('')
|
const preview = ref('')
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
@@ -1728,6 +1813,7 @@ const liveQueue = ref<{
|
|||||||
const studioJobs = ref<Array<{
|
const studioJobs = ref<Array<{
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
|
kind?: string
|
||||||
name: string
|
name: string
|
||||||
prompt: string
|
prompt: string
|
||||||
shotCount: number
|
shotCount: number
|
||||||
@@ -1748,6 +1834,8 @@ const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` |
|
|||||||
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
||||||
const selectedKeys = ref<string[]>([])
|
const selectedKeys = ref<string[]>([])
|
||||||
const lastSelectedKey = ref('')
|
const lastSelectedKey = ref('')
|
||||||
|
const pendingLibraryDelete = ref<PendingLibraryDelete | null>(null)
|
||||||
|
const libraryDeleteBusy = ref(false)
|
||||||
const videoEngine = ref<VideoEngineId>('minimax')
|
const videoEngine = ref<VideoEngineId>('minimax')
|
||||||
const videoStart = ref<VideoStartId>('still')
|
const videoStart = ref<VideoStartId>('still')
|
||||||
const minimaxGraph = ref<MiniMaxGraphId>('v1')
|
const minimaxGraph = ref<MiniMaxGraphId>('v1')
|
||||||
@@ -1946,7 +2034,6 @@ const recommendHint = computed(() => {
|
|||||||
return 'Send this still and draft to Sidecar Qwen VL.'
|
return 'Send this still and draft to Sidecar Qwen VL.'
|
||||||
})
|
})
|
||||||
const editBlocked = computed(() => {
|
const editBlocked = computed(() => {
|
||||||
if (editBusy.value) return true
|
|
||||||
if (!file.value || !prompt.value.trim() || !folderId.value) return true
|
if (!file.value || !prompt.value.trim() || !folderId.value) return true
|
||||||
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
|
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
|
||||||
if (!comfyOk.value && !imageComfyOk.value) return true
|
if (!comfyOk.value && !imageComfyOk.value) return true
|
||||||
@@ -1980,6 +2067,12 @@ const editLabel = computed(() => {
|
|||||||
if (!n) return 'Edit image'
|
if (!n) return 'Edit image'
|
||||||
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
|
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
|
||||||
})
|
})
|
||||||
|
const editSubmitLabel = computed(() => {
|
||||||
|
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
|
||||||
|
if (!occupied) return editLabel.value
|
||||||
|
const n = editPassQueue.value.length
|
||||||
|
return n ? `Queue ${n + 1} passes` : 'Queue edit'
|
||||||
|
})
|
||||||
const parsedShots = computed(() => parseShotScript(prompt.value))
|
const parsedShots = computed(() => parseShotScript(prompt.value))
|
||||||
const recommendedShots = computed(() => {
|
const recommendedShots = computed(() => {
|
||||||
if (!recommendText.value || recommendBusy.value) return []
|
if (!recommendText.value || recommendBusy.value) return []
|
||||||
@@ -2448,6 +2541,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('pointerdown', onSitePointerDown, true)
|
window.removeEventListener('pointerdown', onSitePointerDown, true)
|
||||||
|
window.removeEventListener('keydown', onEditLightboxKey)
|
||||||
if (healthTimer) clearInterval(healthTimer)
|
if (healthTimer) clearInterval(healthTimer)
|
||||||
healthTimer = null
|
healthTimer = null
|
||||||
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
|
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
|
||||||
@@ -2482,6 +2576,15 @@ watch(videoBusy, (busy) => {
|
|||||||
if (busy) outputFocus.value = 'video'
|
if (busy) outputFocus.value = 'video'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(editLightboxUrl, (value) => {
|
||||||
|
if (value) window.addEventListener('keydown', onEditLightboxKey)
|
||||||
|
else window.removeEventListener('keydown', onEditLightboxKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([editResultUrl, editAwaitingReveal, editLockedSave], () => {
|
||||||
|
if (!editResultUrl.value || editAwaitingReveal.value || editLockedSave.value) closeEditLightbox()
|
||||||
|
})
|
||||||
|
|
||||||
watch(hideInputPreview, (value) => {
|
watch(hideInputPreview, (value) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('aigen-hide-input-preview', String(value))
|
localStorage.setItem('aigen-hide-input-preview', String(value))
|
||||||
@@ -2967,7 +3070,11 @@ function studioJobStatusLabel(job: { status: string; cutIn?: boolean; holdForCut
|
|||||||
return job.status
|
return job.status
|
||||||
}
|
}
|
||||||
|
|
||||||
function studioJobShotLine(job: { shotCount: number; completedCount?: number; status: string }) {
|
function studioJobShotLine(job: { kind?: string; shotCount: number; completedCount?: number; status: string }) {
|
||||||
|
if (job.kind === 'edit') {
|
||||||
|
const total = job.shotCount || 1
|
||||||
|
return total <= 1 ? 'image edit' : `${total} passes`
|
||||||
|
}
|
||||||
const total = job.shotCount || 1
|
const total = job.shotCount || 1
|
||||||
const done = job.completedCount || 0
|
const done = job.completedCount || 0
|
||||||
if (total <= 1) return '1 clip'
|
if (total <= 1) return '1 clip'
|
||||||
@@ -2980,6 +3087,7 @@ async function refreshStudioQueue() {
|
|||||||
jobs?: Array<{
|
jobs?: Array<{
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
|
kind?: string
|
||||||
name: string
|
name: string
|
||||||
prompt: string
|
prompt: string
|
||||||
shotCount: number
|
shotCount: number
|
||||||
@@ -2997,7 +3105,19 @@ async function refreshStudioQueue() {
|
|||||||
}>('/api/studio-queue').catch(() => ({ jobs: [] }))
|
}>('/api/studio-queue').catch(() => ({ jobs: [] }))
|
||||||
studioJobs.value = data.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)
|
||||||
if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value) {
|
if (running?.kind === 'edit') {
|
||||||
|
if (running.liveJobId && running.liveJobId !== editJobId.value && !editBusy.value) {
|
||||||
|
outputStudioJobId.value = running.id
|
||||||
|
editJobId.value = running.liveJobId
|
||||||
|
persistActiveJob('edit', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
||||||
|
listen('edit', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
||||||
|
editBusy.value = true
|
||||||
|
editStatusMessage.value = 'Starting queued edit…'
|
||||||
|
startTimer('edit')
|
||||||
|
} else if (running.id && running.liveJobId === editJobId.value) {
|
||||||
|
outputStudioJobId.value = running.id
|
||||||
|
}
|
||||||
|
} else if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value) {
|
||||||
outputStudioJobId.value = running.id
|
outputStudioJobId.value = running.id
|
||||||
videoJobId.value = running.liveJobId
|
videoJobId.value = running.liveJobId
|
||||||
persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
|
||||||
@@ -3223,9 +3343,24 @@ async function resumeActiveJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditLightbox() {
|
||||||
|
if (!editResultUrl.value || editAwaitingReveal.value || editLockedSave.value) return
|
||||||
|
editLightboxUrl.value = editResultUrl.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditLightbox() {
|
||||||
|
editLightboxUrl.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditLightboxKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') closeEditLightbox()
|
||||||
|
}
|
||||||
|
|
||||||
function onSitePointerDown(event: PointerEvent) {
|
function onSitePointerDown(event: PointerEvent) {
|
||||||
const root = outputPanel.value
|
|
||||||
const target = event.target as Node | null
|
const target = event.target as Node | null
|
||||||
|
if (editLightbox.value && target && editLightbox.value.contains(target)) return
|
||||||
|
if (editLightboxUrl.value) closeEditLightbox()
|
||||||
|
const root = outputPanel.value
|
||||||
if (root && target && root.contains(target)) return
|
if (root && target && root.contains(target)) return
|
||||||
if (concealOutput.value && !awaitingReveal.value && !lockedSave.value && videoUrl.value) {
|
if (concealOutput.value && !awaitingReveal.value && !lockedSave.value && videoUrl.value) {
|
||||||
concealAndPause()
|
concealAndPause()
|
||||||
@@ -3599,12 +3734,102 @@ async function useStillAsInput(still: LibraryStill) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function libraryPromptSnippet(text?: string) {
|
||||||
|
const value = String(text || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (!value) return ''
|
||||||
|
return value.length > 120 ? `${value.slice(0, 117)}…` : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipDeleteName(item: LibraryClip) {
|
||||||
|
return item.name || libraryPromptSnippet(item.prompt) || 'Untitled clip'
|
||||||
|
}
|
||||||
|
|
||||||
|
function stillDeleteName(still: LibraryStill) {
|
||||||
|
return still.name || libraryPromptSnippet(still.prompt) || stillTitle(still)
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveClip(item: LibraryClip, family = false) {
|
||||||
|
const parts = family ? familyPartsFor(item) : [item]
|
||||||
|
const collection = Boolean(family)
|
||||||
|
pendingLibraryDelete.value = {
|
||||||
|
title: collection ? 'Delete this collection?' : 'Delete this clip?',
|
||||||
|
name: collection
|
||||||
|
? (collectionTitle({ head: item, parts }) || clipDeleteName(item))
|
||||||
|
: clipDeleteName(item),
|
||||||
|
detail: collection
|
||||||
|
? `${parts.length} ${parts.length === 1 ? 'part' : 'parts'}`
|
||||||
|
: (item.name ? libraryPromptSnippet(item.prompt) : ''),
|
||||||
|
consequence: collection
|
||||||
|
? 'This removes the whole collection from the library.'
|
||||||
|
: 'This removes only this clip from the library.',
|
||||||
|
mode: 'clip',
|
||||||
|
clip: item,
|
||||||
|
family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveStill(still: LibraryStill, family = false) {
|
||||||
|
const parts = family ? stillFamilyPartsFor(still) : [still]
|
||||||
|
const collection = Boolean(family)
|
||||||
|
pendingLibraryDelete.value = {
|
||||||
|
title: collection ? 'Delete this collection?' : 'Delete this still?',
|
||||||
|
name: collection
|
||||||
|
? stillCollectionTitle({ head: still, parts })
|
||||||
|
: stillDeleteName(still),
|
||||||
|
detail: collection
|
||||||
|
? `${parts.length} ${parts.length === 1 ? 'part' : 'parts'}`
|
||||||
|
: (still.name ? libraryPromptSnippet(still.prompt) : ''),
|
||||||
|
consequence: collection
|
||||||
|
? 'This removes the whole collection from the library.'
|
||||||
|
: 'This removes only this still from the library.',
|
||||||
|
mode: 'still',
|
||||||
|
still,
|
||||||
|
family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveSelected() {
|
||||||
|
const items = selectedLibraryEntries()
|
||||||
|
if (!items.length) return
|
||||||
|
if (items.length === 1) {
|
||||||
|
const entry = items[0]
|
||||||
|
if (entry.kind === 'clip') askRemoveClip(entry.group.head, true)
|
||||||
|
else askRemoveStill(entry.stillGroup!.head, true)
|
||||||
|
const pending = pendingLibraryDelete.value
|
||||||
|
if (pending) pendingLibraryDelete.value = { ...pending, mode: 'selected' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const partCount = items.reduce((count, entry) => (
|
||||||
|
count + (entry.kind === 'clip' ? entry.group.parts.length : entry.stillGroup!.parts.length)
|
||||||
|
), 0)
|
||||||
|
const names = items.slice(0, 3).map(entry => (
|
||||||
|
entry.kind === 'clip' ? collectionTitle(entry.group) : stillCollectionTitle(entry.stillGroup!)
|
||||||
|
)).filter(Boolean)
|
||||||
|
pendingLibraryDelete.value = {
|
||||||
|
title: 'Delete these items?',
|
||||||
|
name: names.join(', ') + (items.length > 3 ? ` and ${items.length - 3} more` : ''),
|
||||||
|
detail: `${items.length} collections · ${partCount} ${partCount === 1 ? 'part' : 'parts'}`,
|
||||||
|
consequence: 'This removes the selected collections from the library.',
|
||||||
|
mode: 'selected'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmLibraryDelete() {
|
||||||
|
const pending = pendingLibraryDelete.value
|
||||||
|
if (!pending || libraryDeleteBusy.value) return
|
||||||
|
pendingLibraryDelete.value = null
|
||||||
|
libraryDeleteBusy.value = true
|
||||||
|
try {
|
||||||
|
if (pending.mode === 'selected') await removeSelected()
|
||||||
|
else if (pending.mode === 'clip' && pending.clip) await removeClip(pending.clip, Boolean(pending.family))
|
||||||
|
else if (pending.mode === 'still' && pending.still) await removeStill(pending.still, Boolean(pending.family))
|
||||||
|
} finally {
|
||||||
|
libraryDeleteBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeStill(still: LibraryStill, family = false) {
|
async function removeStill(still: LibraryStill, family = false) {
|
||||||
const watched = family ? stillFamilyPartsFor(still) : [still]
|
const watched = family ? stillFamilyPartsFor(still) : [still]
|
||||||
const label = family && watched.length > 1
|
|
||||||
? `this ${watched.length}-pass edit`
|
|
||||||
: `“${stillTitle(still)}”`
|
|
||||||
if (!confirm(`Remove ${label} from this folder?`)) return
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/library/stills/${still.id}`, {
|
await $fetch(`/api/library/stills/${still.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -3773,8 +3998,6 @@ async function onMoveSelected(event: Event) {
|
|||||||
async function removeSelected() {
|
async function removeSelected() {
|
||||||
const items = selectedLibraryEntries()
|
const items = selectedLibraryEntries()
|
||||||
if (!items.length) return
|
if (!items.length) return
|
||||||
const label = items.length === 1 ? 'this item' : `these ${items.length} items`
|
|
||||||
if (!confirm(`Remove ${label} from this folder?`)) return
|
|
||||||
const clipIds = new Set<string>()
|
const clipIds = new Set<string>()
|
||||||
const stillIds = new Set<string>()
|
const stillIds = new Set<string>()
|
||||||
const results = await Promise.allSettled(items.map(entry => {
|
const results = await Promise.allSettled(items.map(entry => {
|
||||||
@@ -3952,27 +4175,7 @@ async function editImage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
|
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
|
||||||
editStatusBusy.value = false
|
const hideOut = hideThumbnail.value
|
||||||
editBusy.value = true
|
|
||||||
stopListen('edit')
|
|
||||||
editSettledUi = false
|
|
||||||
editProgress.value = 2
|
|
||||||
editResultUrl.value = ''
|
|
||||||
editAwaitingReveal.value = false
|
|
||||||
concealEditOutput.value = hideThumbnail.value
|
|
||||||
editLockedSave.value = false
|
|
||||||
editStatusMessage.value = 'Checking Beast ComfyUI...'
|
|
||||||
editMaxStep.value = editSteps.value
|
|
||||||
editChainStep.value = 1
|
|
||||||
editChainTotal.value = 1 + extraPasses.length
|
|
||||||
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
|
|
||||||
editOverallProgress.value = 0
|
|
||||||
editCompletedChainStep.value = 0
|
|
||||||
editActiveChainPlan.value = [
|
|
||||||
{ label: 'Pass 1', prompt: prompt.value.trim() },
|
|
||||||
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
|
|
||||||
]
|
|
||||||
startTimer('edit')
|
|
||||||
try {
|
try {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', file.value)
|
body.append('image', file.value)
|
||||||
@@ -3987,16 +4190,54 @@ async function editImage() {
|
|||||||
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
|
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
|
||||||
body.append('name', clipName.value.trim())
|
body.append('name', clipName.value.trim())
|
||||||
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
||||||
editDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
|
const downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
|
||||||
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
|
const started = await $fetch<{
|
||||||
|
jobId: string
|
||||||
|
studioJobId?: string
|
||||||
|
queued?: boolean
|
||||||
|
steps: number
|
||||||
|
hideThumbnail?: boolean
|
||||||
|
folderLocked?: boolean
|
||||||
|
}>('/api/edit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body
|
body
|
||||||
})
|
})
|
||||||
editJobId.value = started.jobId
|
if (started.queued) {
|
||||||
|
const n = 1 + extraPasses.length
|
||||||
|
toast(n > 1
|
||||||
|
? `Queued ${n} edit passes behind the current job.`
|
||||||
|
: 'Queued behind the current job.')
|
||||||
|
await refreshStudioQueue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editStatusBusy.value = false
|
||||||
|
editBusy.value = true
|
||||||
|
stopListen('edit')
|
||||||
|
editSettledUi = false
|
||||||
|
editProgress.value = 2
|
||||||
|
editResultUrl.value = ''
|
||||||
|
editAwaitingReveal.value = false
|
||||||
|
concealEditOutput.value = hideOut
|
||||||
|
editLockedSave.value = false
|
||||||
|
editStatusMessage.value = 'Checking Beast ComfyUI...'
|
||||||
editMaxStep.value = started.steps
|
editMaxStep.value = started.steps
|
||||||
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
editChainStep.value = 1
|
||||||
listen('edit', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
|
editChainTotal.value = 1 + extraPasses.length
|
||||||
|
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
|
||||||
|
editOverallProgress.value = 0
|
||||||
|
editCompletedChainStep.value = 0
|
||||||
|
editActiveChainPlan.value = [
|
||||||
|
{ label: 'Pass 1', prompt: prompt.value.trim() },
|
||||||
|
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
|
||||||
|
]
|
||||||
|
startTimer('edit')
|
||||||
|
editDownloadName.value = downloadName
|
||||||
|
editJobId.value = started.jobId
|
||||||
|
outputStudioJobId.value = started.studioJobId || ''
|
||||||
|
persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
|
||||||
|
listen('edit', started.jobId, started.hideThumbnail === true || hideOut, started.folderLocked === true)
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
|
void refreshStudioQueue()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
editSettledUi = true
|
editSettledUi = true
|
||||||
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
|
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
|
||||||
|
|||||||
+191
-10
@@ -69,12 +69,23 @@
|
|||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-[11px] uppercase tracking-[0.18em] text-zinc-500">Job</p>
|
<div class="flex items-start gap-3">
|
||||||
|
<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">{{ job.name }}</h3>
|
<h3 class="truncate font-display text-lg font-semibold text-zinc-100">{{ job.name }}</h3>
|
||||||
<p class="mt-1 text-sm text-zinc-300">{{ jobLine(job) }}</p>
|
<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-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.lastError" class="mt-1 text-xs text-red-300">{{ job.lastError }}</p>
|
<p v-if="job.lastError" class="mt-1 text-xs text-red-300">{{ job.lastError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="job.status === 'waiting' && canCutIn"
|
v-if="job.status === 'waiting' && canCutIn"
|
||||||
@@ -138,12 +149,13 @@
|
|||||||
{{ jobPauseLabel(job) }}
|
{{ jobPauseLabel(job) }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="job.status === 'waiting' || job.status === 'held'"
|
v-if="canRemoveJob(job)"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
|
||||||
@click="removeJob(job)"
|
:disabled="Boolean(busyId)"
|
||||||
|
@click="askRemoveJob(job)"
|
||||||
>
|
>
|
||||||
Remove job
|
{{ job.status === 'running' ? 'Cancel job' : 'Remove job' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +219,7 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
class="rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"
|
class="rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"
|
||||||
>
|
>
|
||||||
<p class="text-sm font-medium text-zinc-100">Shot {{ index + 1 }} <span class="font-normal text-zinc-500">· queued with this job</span></p>
|
<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 class="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -266,12 +278,12 @@
|
|||||||
{{ queuePauseLabel(queue) }}
|
{{ queuePauseLabel(queue) }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="queue.status !== 'running'"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-red-200"
|
||||||
@click="remove(queue)"
|
:disabled="Boolean(busyId)"
|
||||||
|
@click="askRemoveQueue(queue)"
|
||||||
>
|
>
|
||||||
Dismiss
|
{{ queue.status === 'running' ? 'Cancel job' : 'Remove job' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,6 +343,41 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -360,10 +407,15 @@ type Queue = {
|
|||||||
type StudioJobRow = {
|
type StudioJobRow = {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
|
kind?: string
|
||||||
name: string
|
name: string
|
||||||
|
prompt?: string
|
||||||
shotCount: number
|
shotCount: number
|
||||||
liveJobId?: string
|
liveJobId?: string
|
||||||
shotQueueId?: string
|
shotQueueId?: string
|
||||||
|
stillId?: string
|
||||||
|
hideThumbnail?: boolean
|
||||||
|
folderLocked?: boolean
|
||||||
cutIn?: boolean
|
cutIn?: boolean
|
||||||
holdForCutIn?: boolean
|
holdForCutIn?: boolean
|
||||||
pauseAfterCurrent?: boolean
|
pauseAfterCurrent?: boolean
|
||||||
@@ -374,6 +426,15 @@ type StudioJobRow = {
|
|||||||
completedCount?: number
|
completedCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingRemove = {
|
||||||
|
title: string
|
||||||
|
name: string
|
||||||
|
detail: string
|
||||||
|
consequence: string
|
||||||
|
job?: StudioJobRow
|
||||||
|
queue?: Queue
|
||||||
|
}
|
||||||
|
|
||||||
const instanceName = ref(String(useRuntimeConfig().public.instanceName || 'AIGen'))
|
const instanceName = ref(String(useRuntimeConfig().public.instanceName || 'AIGen'))
|
||||||
const authEnabled = ref(false)
|
const authEnabled = ref(false)
|
||||||
const jobs = ref<StudioJobRow[]>([])
|
const jobs = ref<StudioJobRow[]>([])
|
||||||
@@ -386,6 +447,7 @@ const busyId = ref('')
|
|||||||
const liveMessage = ref<Record<string, string>>({})
|
const liveMessage = ref<Record<string, string>>({})
|
||||||
const dirty = new Set<string>()
|
const dirty = new Set<string>()
|
||||||
const sources = new Map<string, EventSource>()
|
const sources = new Map<string, EventSource>()
|
||||||
|
const pendingRemove = ref<PendingRemove | null>(null)
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
|
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
|
||||||
@@ -452,6 +514,24 @@ function progressLine(queue: Queue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function jobLine(job: StudioJobRow) {
|
function jobLine(job: StudioJobRow) {
|
||||||
|
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') {
|
if (job.status === 'waiting') {
|
||||||
return job.cutIn
|
return job.cutIn
|
||||||
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
|
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
|
||||||
@@ -559,6 +639,9 @@ async function loadQueues() {
|
|||||||
for (const queue of [...nextJobs.map(job => job.shots).filter(Boolean), ...orphans.value] as Queue[]) {
|
for (const queue of [...nextJobs.map(job => job.shots).filter(Boolean), ...orphans.value] as Queue[]) {
|
||||||
if (queue.status === 'running' && queue.currentJobId) listen(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) {
|
function listen(queue: Queue) {
|
||||||
@@ -593,8 +676,101 @@ async function cutIn(job: StudioJobRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
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' || payload.type === 'error') {
|
||||||
|
source.close()
|
||||||
|
sources.delete(job.id)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobKindLabel(job: StudioJobRow) {
|
||||||
|
if (job.kind === 'edit') return (job.shotCount || 1) > 1 ? 'EDIT' : 'IMAGE'
|
||||||
|
return 'Job'
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
? '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) {
|
async function removeJob(job: StudioJobRow) {
|
||||||
if (!confirm('Remove this job from the queue? Saved clips stay in the library.')) return
|
|
||||||
await $fetch(`/api/studio-queue/${job.id}`, { method: 'DELETE' }).catch((err: any) => {
|
await $fetch(`/api/studio-queue/${job.id}`, { method: 'DELETE' }).catch((err: any) => {
|
||||||
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that job'
|
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that job'
|
||||||
})
|
})
|
||||||
@@ -739,7 +915,6 @@ async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(queue: Queue) {
|
async function remove(queue: Queue) {
|
||||||
if (!confirm('Remove this batch from the queue? Saved clips stay in the library.')) return
|
|
||||||
await $fetch(`/api/queues/${queue.id}`, { method: 'DELETE' }).catch((err: any) => {
|
await $fetch(`/api/queues/${queue.id}`, { method: 'DELETE' }).catch((err: any) => {
|
||||||
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that batch'
|
error.value = err?.data?.statusMessage || err?.message || 'Could not remove that batch'
|
||||||
})
|
})
|
||||||
@@ -785,6 +960,7 @@ onMounted(async () => {
|
|||||||
const me = await $fetch<{ user?: { name?: string }; authEnabled?: boolean; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
|
const me = await $fetch<{ user?: { name?: string }; authEnabled?: boolean; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
|
||||||
authEnabled.value = Boolean(me.authEnabled)
|
authEnabled.value = Boolean(me.authEnabled)
|
||||||
instanceName.value = me.instanceName || instanceName.value
|
instanceName.value = me.instanceName || instanceName.value
|
||||||
|
window.addEventListener('keydown', onRemoveKey)
|
||||||
await loadQueues().catch((err: any) => {
|
await loadQueues().catch((err: any) => {
|
||||||
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'
|
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'
|
||||||
})
|
})
|
||||||
@@ -794,7 +970,12 @@ onMounted(async () => {
|
|||||||
}, 4000)
|
}, 4000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function onRemoveKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && pendingRemove.value) pendingRemove.value = null
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onRemoveKey)
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
for (const source of sources.values()) source.close()
|
for (const source of sources.values()) source.close()
|
||||||
|
|||||||
+37
-226
@@ -1,7 +1,5 @@
|
|||||||
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
|
||||||
import { getComfyHost, comfyConfigured, uploadImage, queuePrompt, purgeComfyArtifacts } from '~/server/utils/comfy'
|
import { comfyConfigured } from '~/server/utils/comfy'
|
||||||
import { withImageComfyHost, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
|
|
||||||
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
|
|
||||||
import { imageDimensions } from '~/server/utils/resolution'
|
import { imageDimensions } from '~/server/utils/resolution'
|
||||||
|
|
||||||
function parseSteps(raw: unknown) {
|
function parseSteps(raw: unknown) {
|
||||||
@@ -82,6 +80,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
? Number(fields.seed)
|
? Number(fields.seed)
|
||||||
: Math.floor(Math.random() * 2_147_483_647)
|
: Math.floor(Math.random() * 2_147_483_647)
|
||||||
const size = imageDimensions(image.data)
|
const size = imageDimensions(image.data)
|
||||||
|
const clipName = (fields.name || '').trim().slice(0, 80)
|
||||||
|
|
||||||
const still = await rememberInputStill({
|
const still = await rememberInputStill({
|
||||||
ownerKey,
|
ownerKey,
|
||||||
@@ -92,31 +91,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
height: size?.height || 0,
|
height: size?.height || 0,
|
||||||
hideInput
|
hideInput
|
||||||
})
|
})
|
||||||
if (reference) {
|
const savedRef = reference
|
||||||
await rememberInputStill({
|
? await rememberInputStill({
|
||||||
ownerKey,
|
ownerKey,
|
||||||
folderId,
|
folderId,
|
||||||
filename: reference.filename,
|
filename: reference.filename,
|
||||||
data: reference.data,
|
data: reference.data,
|
||||||
hideInput
|
hideInput
|
||||||
})
|
})
|
||||||
}
|
: null
|
||||||
|
|
||||||
const passes = extraPasses
|
const passes = extraPasses
|
||||||
const chainTotal = 1 + passes.length
|
const chainTotal = 1 + passes.length
|
||||||
const familyId = chainTotal > 1 ? crypto.randomUUID() : undefined
|
const familyId = crypto.randomUUID()
|
||||||
const job = createJob()
|
const studio = await addStudioJob({
|
||||||
job.kind = 'edit'
|
|
||||||
job.maxStep = steps
|
|
||||||
job.hideThumbnail = hideThumbnail
|
|
||||||
job.library = {
|
|
||||||
ownerKey,
|
ownerKey,
|
||||||
folderId,
|
familyId,
|
||||||
hideThumbnail,
|
kind: 'edit',
|
||||||
hideInput,
|
payload: {
|
||||||
folderLocked,
|
|
||||||
name: (fields.name || '').trim().slice(0, 80),
|
|
||||||
prompt,
|
prompt,
|
||||||
|
name: clipName,
|
||||||
|
folderId,
|
||||||
aspect: 'auto',
|
aspect: 'auto',
|
||||||
width: size?.width || 0,
|
width: size?.width || 0,
|
||||||
height: size?.height || 0,
|
height: size?.height || 0,
|
||||||
@@ -124,42 +119,35 @@ export default defineEventHandler(async (event) => {
|
|||||||
turbo: true,
|
turbo: true,
|
||||||
seed,
|
seed,
|
||||||
cfg,
|
cfg,
|
||||||
|
fps: 24,
|
||||||
|
samplerName: 'res_multistep',
|
||||||
|
scheduler: 'simple',
|
||||||
|
duration: 0,
|
||||||
|
sound: false,
|
||||||
|
workflow: 'v1',
|
||||||
|
useIdentityRefs: false,
|
||||||
stillId: still?.id,
|
stillId: still?.id,
|
||||||
familyId,
|
stillFilename: still?.filename,
|
||||||
chainIndex: 0,
|
hideThumbnail,
|
||||||
chainStep: 1,
|
hideInput,
|
||||||
chainTotal,
|
folderLocked,
|
||||||
chainLabel: chainTotal > 1 ? 'Pass 1' : undefined,
|
referenceStillIds: [null, null, null, null],
|
||||||
passes
|
extensions: [],
|
||||||
}
|
queueAutoRun: false,
|
||||||
emitJob(job, {
|
|
||||||
type: 'status',
|
|
||||||
message: 'Checking Beast ComfyUI...',
|
|
||||||
progress: 2,
|
|
||||||
chainStep: 1,
|
|
||||||
chainTotal,
|
|
||||||
chainLabel: job.library.chainLabel
|
|
||||||
})
|
|
||||||
|
|
||||||
void runEdit(job, {
|
|
||||||
image,
|
|
||||||
reference,
|
|
||||||
prompt,
|
|
||||||
passes,
|
|
||||||
negative: (fields.negative || '').trim(),
|
negative: (fields.negative || '').trim(),
|
||||||
steps,
|
passes,
|
||||||
seed,
|
referenceStillId: savedRef?.id,
|
||||||
cfg
|
referenceStillFilename: savedRef?.filename
|
||||||
}).catch(async (error) => {
|
}
|
||||||
if (job.status === 'error' || job.status === 'cancelled') return
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
job.status = 'error'
|
|
||||||
job.error = message
|
|
||||||
emitJob(job, { type: 'error', error: message, message })
|
|
||||||
})
|
})
|
||||||
|
await kickStudioQueue()
|
||||||
|
const latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
|
||||||
|
const liveJobId = latest?.liveJobId || ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: job.id,
|
jobId: liveJobId || studio.id,
|
||||||
|
studioJobId: studio.id,
|
||||||
|
queued: !liveJobId,
|
||||||
seed,
|
seed,
|
||||||
steps,
|
steps,
|
||||||
hideThumbnail,
|
hideThumbnail,
|
||||||
@@ -168,180 +156,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
chainTotal
|
chainTotal
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function runEdit(
|
|
||||||
job: ReturnType<typeof createJob>,
|
|
||||||
params: {
|
|
||||||
image: { filename: string; data: Buffer; type?: string }
|
|
||||||
reference: { filename: string; data: Buffer; type?: string } | null
|
|
||||||
prompt: string
|
|
||||||
passes: { prompt: string }[]
|
|
||||||
negative: string
|
|
||||||
steps: number
|
|
||||||
seed: number
|
|
||||||
cfg: number
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const library = job.library
|
|
||||||
if (!library) throw new Error('Edit job is missing library metadata')
|
|
||||||
const prompts = [params.prompt, ...params.passes.map(item => item.prompt)]
|
|
||||||
const chainTotal = prompts.length
|
|
||||||
library.chainTotal = chainTotal
|
|
||||||
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
|
|
||||||
|
|
||||||
await ensureComfyReady((status) => {
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: status.state === 'busy' ? 'busy' : 'status',
|
|
||||||
message: status.message,
|
|
||||||
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
|
|
||||||
busy: status.state === 'busy'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
|
||||||
job.imageComfyHost = getComfyHost()
|
|
||||||
|
|
||||||
await withImageComfyHost(job.imageComfyHost, async () => {
|
|
||||||
let current = params.image
|
|
||||||
let parentStillId: string | undefined
|
|
||||||
for (let index = 0; index < prompts.length; index++) {
|
|
||||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
|
||||||
const prompt = prompts[index]
|
|
||||||
const last = index === prompts.length - 1
|
|
||||||
if (index > 0) {
|
|
||||||
await ensureComfyReady((status) => {
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: status.state === 'busy' ? 'busy' : 'status',
|
|
||||||
message: status.message,
|
|
||||||
progress: status.state === 'online' ? 4 : 2,
|
|
||||||
busy: status.state === 'busy'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
|
|
||||||
library.prompt = prompt
|
|
||||||
library.seed = seed
|
|
||||||
library.chainIndex = index
|
|
||||||
library.chainStep = index + 1
|
|
||||||
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined
|
|
||||||
const reference = index === 0 ? params.reference : null
|
|
||||||
const dual = Boolean(reference)
|
|
||||||
const passName = stillChainName(library.name || '', index)
|
|
||||||
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: 'status',
|
|
||||||
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
|
|
||||||
progress: 8
|
|
||||||
})
|
|
||||||
const uploaded = await uploadImage(current, job.id)
|
|
||||||
const uploadedRef = reference
|
|
||||||
? await uploadImage({
|
|
||||||
...reference,
|
|
||||||
filename: `ref_${reference.filename || 'image2.png'}`
|
|
||||||
}, job.id)
|
|
||||||
: null
|
|
||||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
|
||||||
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: 'status',
|
|
||||||
message: dual
|
|
||||||
? 'Queueing Flux.2 Klein two-image edit on Beast...'
|
|
||||||
: 'Queueing Flux.2 Klein edit on Beast...',
|
|
||||||
progress: 12
|
|
||||||
})
|
|
||||||
const graph = buildEditWorkflow({
|
|
||||||
prompt,
|
|
||||||
negative: params.negative,
|
|
||||||
imageName: uploaded.name,
|
|
||||||
referenceImageName: uploadedRef?.name,
|
|
||||||
steps: params.steps,
|
|
||||||
seed,
|
|
||||||
cfg: params.cfg,
|
|
||||||
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`
|
|
||||||
})
|
|
||||||
const queued = await queuePrompt(graph, job.clientId)
|
|
||||||
job.promptId = queued.prompt_id
|
|
||||||
job.status = 'running'
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: 'status',
|
|
||||||
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
|
|
||||||
progress: 18,
|
|
||||||
maxStep: params.steps
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = await waitForImageEdit({
|
|
||||||
promptId: queued.prompt_id,
|
|
||||||
clientId: job.clientId,
|
|
||||||
timeoutMs: 10 * 60 * 1000,
|
|
||||||
onProgress: (event) => {
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: 'status',
|
|
||||||
message: event.message,
|
|
||||||
progress: event.progress,
|
|
||||||
step: event.step,
|
|
||||||
maxStep: event.maxStep || params.steps,
|
|
||||||
node: event.node
|
|
||||||
})
|
|
||||||
},
|
|
||||||
isCancelled: () => job.status === 'cancelled'
|
|
||||||
})
|
|
||||||
|
|
||||||
emitChainJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
|
||||||
const buffer = await downloadEditedImage(output)
|
|
||||||
const size = imageDimensions(buffer)
|
|
||||||
const still = await saveStill({
|
|
||||||
ownerKey: library.ownerKey,
|
|
||||||
folderId: library.folderId,
|
|
||||||
filename: passName ? `${passName}.png` : output.filename,
|
|
||||||
data: buffer,
|
|
||||||
width: size?.width || 0,
|
|
||||||
height: size?.height || 0,
|
|
||||||
hideInput: job.hideThumbnail === true,
|
|
||||||
role: 'output',
|
|
||||||
name: passName || undefined,
|
|
||||||
prompt,
|
|
||||||
familyId: library.familyId,
|
|
||||||
parentStillId,
|
|
||||||
chainIndex: index
|
|
||||||
})
|
|
||||||
job.stillId = still?.id
|
|
||||||
parentStillId = still?.id
|
|
||||||
await purgeComfyArtifacts({
|
|
||||||
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
|
||||||
imageName: uploaded.name,
|
|
||||||
imageSubfolder: uploaded.subfolder,
|
|
||||||
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
|
|
||||||
promptId: job.promptId
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!last) {
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: 'checkpoint',
|
|
||||||
message: `Pass ${index + 1} saved`,
|
|
||||||
progress: 100,
|
|
||||||
stillId: still?.id,
|
|
||||||
hideThumbnail: job.hideThumbnail,
|
|
||||||
folderLocked: library.folderLocked
|
|
||||||
})
|
|
||||||
current = {
|
|
||||||
filename: still?.filename || `pass_${index + 1}.png`,
|
|
||||||
data: buffer,
|
|
||||||
type: 'image/png'
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
job.status = 'complete'
|
|
||||||
emitChainJob(job, {
|
|
||||||
type: 'complete',
|
|
||||||
message: chainTotal > 1 ? 'Edit chain finished on Beast' : 'Edit finished on Beast',
|
|
||||||
progress: 100,
|
|
||||||
stillId: still?.id,
|
|
||||||
filename: output.filename,
|
|
||||||
subfolder: output.subfolder,
|
|
||||||
mediaType: 'image',
|
|
||||||
hideThumbnail: job.hideThumbnail,
|
|
||||||
folderLocked: library.folderLocked
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { deleteShotQueue } from '~/server/utils/shotQueue'
|
import { cancelOrphanQueue } from '~/server/utils/studioQueue'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
await deleteShotQueue(owner, id)
|
await cancelOrphanQueue(owner, id)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,9 +11,17 @@ export default defineEventHandler((event) => {
|
|||||||
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
||||||
const jobs = active.map((job) => {
|
const jobs = active.map((job) => {
|
||||||
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
|
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
|
||||||
|
const kind = job.kind === 'edit' ? 'edit' : 'video'
|
||||||
const shotSummary = shots ? summarizeQueue(shots) : null
|
const shotSummary = shots ? summarizeQueue(shots) : null
|
||||||
const plannedShots = shotSummary
|
const plannedShots = shotSummary
|
||||||
? undefined
|
? undefined
|
||||||
|
: kind === 'edit'
|
||||||
|
? (job.payload.passes?.length
|
||||||
|
? [
|
||||||
|
{ prompt: job.payload.prompt, duration: 0 },
|
||||||
|
...job.payload.passes.map(item => ({ prompt: item.prompt, duration: 0 }))
|
||||||
|
]
|
||||||
|
: undefined)
|
||||||
: [
|
: [
|
||||||
{ prompt: job.payload.prompt, duration: job.payload.duration },
|
{ prompt: job.payload.prompt, duration: job.payload.duration },
|
||||||
...(job.payload.extensions || [])
|
...(job.payload.extensions || [])
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ function hostWithPort(host: string, port: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureUnlocked(onStatus: StatusFn) {
|
async function ensureUnlocked(onStatus: StatusFn, skipBusyWait = false) {
|
||||||
const cfg = settings()
|
const cfg = settings()
|
||||||
const host = cfg.host
|
const host = cfg.host
|
||||||
onStatus({ state: 'offline', message: 'Checking ComfyUI...', host })
|
onStatus({ state: 'offline', message: 'Checking ComfyUI...', host })
|
||||||
@@ -228,7 +228,7 @@ async function ensureUnlocked(onStatus: StatusFn) {
|
|||||||
const health = await checkComfyHttp(cfg.healthTimeoutMs)
|
const health = await checkComfyHttp(cfg.healthTimeoutMs)
|
||||||
if (health.ok) {
|
if (health.ok) {
|
||||||
log('http-ok', { host })
|
log('http-ok', { host })
|
||||||
await waitWhileBusy(onStatus)
|
if (!skipBusyWait) await waitWhileBusy(onStatus)
|
||||||
onStatus({ state: 'online', message: 'ComfyUI online', host })
|
onStatus({ state: 'online', message: 'ComfyUI online', host })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ async function ensureUnlocked(onStatus: StatusFn) {
|
|||||||
if (viaProxy.ok) {
|
if (viaProxy.ok) {
|
||||||
setComfyHostOverride(proxyUrl)
|
setComfyHostOverride(proxyUrl)
|
||||||
log('http-ok-proxy', { host: proxyUrl, localPort: remote.port || null })
|
log('http-ok-proxy', { host: proxyUrl, localPort: remote.port || null })
|
||||||
await waitWhileBusy(onStatus)
|
if (!skipBusyWait) await waitWhileBusy(onStatus)
|
||||||
onStatus({ state: 'online', message: 'ComfyUI online', host: proxyUrl, processRunning: true })
|
onStatus({ state: 'online', message: 'ComfyUI online', host: proxyUrl, processRunning: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ async function ensureUnlocked(onStatus: StatusFn) {
|
|||||||
})
|
})
|
||||||
const ready = await pollUntilHealthy(cfg.bootPollAttempts, cfg.bootPollDelayMs, onStatus, 'booting', 'Waiting for ComfyUI to finish booting')
|
const ready = await pollUntilHealthy(cfg.bootPollAttempts, cfg.bootPollDelayMs, onStatus, 'booting', 'Waiting for ComfyUI to finish booting')
|
||||||
if (ready) {
|
if (ready) {
|
||||||
await waitWhileBusy(onStatus)
|
if (!skipBusyWait) await waitWhileBusy(onStatus)
|
||||||
onStatus({ state: 'online', message: 'ComfyUI online', host, processRunning: true })
|
onStatus({ state: 'online', message: 'ComfyUI online', host, processRunning: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ async function ensureUnlocked(onStatus: StatusFn) {
|
|||||||
const next = await checkComfyHttp()
|
const next = await checkComfyHttp()
|
||||||
if (next.ok) {
|
if (next.ok) {
|
||||||
log('started', { attempt, reason: started.reason })
|
log('started', { attempt, reason: started.reason })
|
||||||
await waitWhileBusy(onStatus)
|
if (!skipBusyWait) await waitWhileBusy(onStatus)
|
||||||
onStatus({ state: 'online', message: 'ComfyUI online', host, processRunning: true })
|
onStatus({ state: 'online', message: 'ComfyUI online', host, processRunning: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -322,8 +322,9 @@ async function ensureUnlocked(onStatus: StatusFn) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureComfyReady(onStatus: StatusFn = () => undefined) {
|
export function ensureComfyReady(onStatus: StatusFn = () => undefined, options?: { skipBusyWait?: boolean }) {
|
||||||
const run = gate.then(() => ensureUnlocked(onStatus))
|
const skipBusyWait = options?.skipBusyWait === true
|
||||||
|
const run = gate.then(() => ensureUnlocked(onStatus, skipBusyWait))
|
||||||
gate = run.then(() => undefined, () => undefined)
|
gate = run.then(() => undefined, () => undefined)
|
||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
|
||||||
|
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
||||||
|
import { getComfyHost, uploadImage, queuePrompt, purgeComfyArtifacts } from '~/server/utils/comfy'
|
||||||
|
import { withImageComfyHost, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
|
||||||
|
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
|
||||||
|
import { imageDimensions } from '~/server/utils/resolution'
|
||||||
|
import { emitChainJob } from '~/server/utils/watch'
|
||||||
|
import { saveStill, stillChainName } from '~/server/utils/library'
|
||||||
|
|
||||||
|
export type EditImageFile = { filename: string; data: Buffer; type?: string }
|
||||||
|
|
||||||
|
export type EditRunParams = {
|
||||||
|
image: EditImageFile
|
||||||
|
reference: EditImageFile | null
|
||||||
|
prompt: string
|
||||||
|
passes: { prompt: string }[]
|
||||||
|
negative: string
|
||||||
|
steps: number
|
||||||
|
seed: number
|
||||||
|
cfg: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runEdit(job: Job, params: EditRunParams) {
|
||||||
|
const library = job.library
|
||||||
|
if (!library) throw new Error('Edit job is missing library metadata')
|
||||||
|
const prompts = [params.prompt, ...params.passes.map(item => item.prompt)]
|
||||||
|
const chainTotal = prompts.length
|
||||||
|
library.chainTotal = chainTotal
|
||||||
|
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureComfyReady((status) => {
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: status.state === 'busy' ? 'busy' : 'status',
|
||||||
|
message: status.message,
|
||||||
|
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
|
||||||
|
busy: status.state === 'busy'
|
||||||
|
})
|
||||||
|
}, { skipBusyWait: true })
|
||||||
|
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||||
|
job.imageComfyHost = getComfyHost()
|
||||||
|
|
||||||
|
await withImageComfyHost(job.imageComfyHost, async () => {
|
||||||
|
let current = params.image
|
||||||
|
let parentStillId: string | undefined
|
||||||
|
for (let index = 0; index < prompts.length; index++) {
|
||||||
|
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||||
|
if (index > 0 && library.stopAfterCurrent === true) break
|
||||||
|
const prompt = prompts[index]
|
||||||
|
const last = index === prompts.length - 1
|
||||||
|
if (index > 0) {
|
||||||
|
await ensureComfyReady((status) => {
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: status.state === 'busy' ? 'busy' : 'status',
|
||||||
|
message: status.message,
|
||||||
|
progress: status.state === 'online' ? 4 : 2,
|
||||||
|
busy: status.state === 'busy'
|
||||||
|
})
|
||||||
|
}, { skipBusyWait: true })
|
||||||
|
}
|
||||||
|
const seed = index === 0 ? params.seed : Math.floor(Math.random() * 2_147_483_647)
|
||||||
|
library.prompt = prompt
|
||||||
|
library.seed = seed
|
||||||
|
library.chainIndex = index
|
||||||
|
library.chainStep = index + 1
|
||||||
|
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined
|
||||||
|
const reference = index === 0 ? params.reference : null
|
||||||
|
const dual = Boolean(reference)
|
||||||
|
const passName = stillChainName(library.name || '', index)
|
||||||
|
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: 'status',
|
||||||
|
message: dual ? 'Uploading both stills to Beast...' : 'Uploading still to Beast...',
|
||||||
|
progress: 8
|
||||||
|
})
|
||||||
|
const uploaded = await uploadImage(current, job.id)
|
||||||
|
const uploadedRef = reference
|
||||||
|
? await uploadImage({
|
||||||
|
...reference,
|
||||||
|
filename: `ref_${reference.filename || 'image2.png'}`
|
||||||
|
}, job.id)
|
||||||
|
: null
|
||||||
|
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||||
|
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: 'status',
|
||||||
|
message: dual
|
||||||
|
? 'Queueing Flux.2 Klein two-image edit on Beast...'
|
||||||
|
: 'Queueing Flux.2 Klein edit on Beast...',
|
||||||
|
progress: 12
|
||||||
|
})
|
||||||
|
const graph = buildEditWorkflow({
|
||||||
|
prompt,
|
||||||
|
negative: params.negative,
|
||||||
|
imageName: uploaded.name,
|
||||||
|
referenceImageName: uploadedRef?.name,
|
||||||
|
steps: params.steps,
|
||||||
|
seed,
|
||||||
|
cfg: params.cfg,
|
||||||
|
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`
|
||||||
|
})
|
||||||
|
const queued = await queuePrompt(graph, job.clientId)
|
||||||
|
job.promptId = queued.prompt_id
|
||||||
|
job.status = 'running'
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: 'status',
|
||||||
|
message: dual ? 'Applying image 2 onto image 1...' : 'Editing still on Beast...',
|
||||||
|
progress: 18,
|
||||||
|
maxStep: params.steps
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = await waitForImageEdit({
|
||||||
|
promptId: queued.prompt_id,
|
||||||
|
clientId: job.clientId,
|
||||||
|
timeoutMs: 10 * 60 * 1000,
|
||||||
|
onProgress: (event) => {
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: 'status',
|
||||||
|
message: event.message,
|
||||||
|
progress: event.progress,
|
||||||
|
step: event.step,
|
||||||
|
maxStep: event.maxStep || params.steps,
|
||||||
|
node: event.node
|
||||||
|
})
|
||||||
|
},
|
||||||
|
isCancelled: () => job.status === 'cancelled'
|
||||||
|
})
|
||||||
|
|
||||||
|
emitChainJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
|
||||||
|
const buffer = await downloadEditedImage(output)
|
||||||
|
const size = imageDimensions(buffer)
|
||||||
|
const still = await saveStill({
|
||||||
|
ownerKey: library.ownerKey,
|
||||||
|
folderId: library.folderId,
|
||||||
|
filename: passName ? `${passName}.png` : output.filename,
|
||||||
|
data: buffer,
|
||||||
|
width: size?.width || 0,
|
||||||
|
height: size?.height || 0,
|
||||||
|
hideInput: job.hideThumbnail === true,
|
||||||
|
role: 'output',
|
||||||
|
name: passName || undefined,
|
||||||
|
prompt,
|
||||||
|
familyId: library.familyId,
|
||||||
|
parentStillId,
|
||||||
|
chainIndex: index
|
||||||
|
})
|
||||||
|
job.stillId = still?.id
|
||||||
|
parentStillId = still?.id
|
||||||
|
await purgeComfyArtifacts({
|
||||||
|
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
||||||
|
imageName: uploaded.name,
|
||||||
|
imageSubfolder: uploaded.subfolder,
|
||||||
|
extraImageNames: uploadedRef?.name ? [uploadedRef.name] : [],
|
||||||
|
promptId: job.promptId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!last) {
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: 'checkpoint',
|
||||||
|
message: `Pass ${index + 1} saved`,
|
||||||
|
progress: 100,
|
||||||
|
stillId: still?.id,
|
||||||
|
hideThumbnail: job.hideThumbnail,
|
||||||
|
folderLocked: library.folderLocked
|
||||||
|
})
|
||||||
|
current = {
|
||||||
|
filename: still?.filename || `pass_${index + 1}.png`,
|
||||||
|
data: buffer,
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
if (library.stopAfterCurrent === true) break
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
job.status = 'complete'
|
||||||
|
emitChainJob(job, {
|
||||||
|
type: 'complete',
|
||||||
|
message: chainTotal > 1 ? 'Edit chain finished on Beast' : 'Edit finished on Beast',
|
||||||
|
progress: 100,
|
||||||
|
stillId: still?.id,
|
||||||
|
filename: output.filename,
|
||||||
|
subfolder: output.subfolder,
|
||||||
|
mediaType: 'image',
|
||||||
|
hideThumbnail: job.hideThumbnail,
|
||||||
|
folderLocked: library.folderLocked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (job.status !== 'error' && job.status !== 'cancelled') {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = message
|
||||||
|
emitJob(job, { type: 'error', error: message, message })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
|
||||||
|
await onLiveVideoSettled(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEditLiveJob(params: {
|
||||||
|
steps: number
|
||||||
|
hideThumbnail: boolean
|
||||||
|
library: NonNullable<Job['library']>
|
||||||
|
}) {
|
||||||
|
const job = createJob()
|
||||||
|
job.kind = 'edit'
|
||||||
|
job.maxStep = params.steps
|
||||||
|
job.hideThumbnail = params.hideThumbnail
|
||||||
|
job.library = params.library
|
||||||
|
return job
|
||||||
|
}
|
||||||
@@ -388,10 +388,11 @@ export async function pauseShotQueue(owner: string, id: string) {
|
|||||||
return setShotQueuePause(owner, id, true)
|
return setShotQueuePause(owner, id, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteShotQueue(owner: string, id: string) {
|
export async function deleteShotQueue(owner: string, id: string, options: { force?: boolean } = {}) {
|
||||||
if (activeJobs.has(id)) {
|
if (!options.force && activeJobs.has(id)) {
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Stop this batch before deleting it' })
|
throw createError({ statusCode: 409, statusMessage: 'Stop this batch before deleting it' })
|
||||||
}
|
}
|
||||||
|
activeJobs.delete(id)
|
||||||
return mutate(owner, (queues) => {
|
return mutate(owner, (queues) => {
|
||||||
const index = queues.findIndex(item => item.id === id)
|
const index = queues.findIndex(item => item.id === id)
|
||||||
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
|||||||
+179
-17
@@ -1,12 +1,13 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
|
import { getJob, listJobs, emitJob, type Job } from '~/server/utils/jobs'
|
||||||
import { listPendingJobs, patchPendingJob, readPendingJob } from '~/server/utils/pending'
|
import { listPendingJobs, patchPendingJob, readPendingJob, deletePendingJob } from '~/server/utils/pending'
|
||||||
import { getShotQueue } from '~/server/utils/shotQueue'
|
import { getShotQueue } from '~/server/utils/shotQueue'
|
||||||
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
|
||||||
import type { PermanenceRef } from '~/utils/globalLocks'
|
import type { PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
||||||
|
export type StudioJobKind = 'video' | 'edit'
|
||||||
|
|
||||||
export interface StudioJobPayload {
|
export interface StudioJobPayload {
|
||||||
prompt: string
|
prompt: string
|
||||||
@@ -37,6 +38,10 @@ export interface StudioJobPayload {
|
|||||||
globalLocks?: string
|
globalLocks?: string
|
||||||
permanenceRefs?: PermanenceRef[]
|
permanenceRefs?: PermanenceRef[]
|
||||||
shotPermanenceRefs?: PermanenceRef[][]
|
shotPermanenceRefs?: PermanenceRef[][]
|
||||||
|
negative?: string
|
||||||
|
passes?: { prompt: string }[]
|
||||||
|
referenceStillId?: string
|
||||||
|
referenceStillFilename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudioJob {
|
export interface StudioJob {
|
||||||
@@ -45,6 +50,7 @@ export interface StudioJob {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
status: StudioJobStatus
|
status: StudioJobStatus
|
||||||
|
kind: StudioJobKind
|
||||||
name: string
|
name: string
|
||||||
prompt: string
|
prompt: string
|
||||||
shotCount: number
|
shotCount: number
|
||||||
@@ -176,12 +182,17 @@ export function listStudioJobs(owner: string) {
|
|||||||
return readJobs(owner)
|
return readJobs(owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
||||||
|
return job.kind === 'edit' ? 'edit' : 'video'
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizeStudioJob(job: StudioJob) {
|
export function summarizeStudioJob(job: StudioJob) {
|
||||||
return {
|
return {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
createdAt: job.createdAt,
|
createdAt: job.createdAt,
|
||||||
updatedAt: job.updatedAt,
|
updatedAt: job.updatedAt,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
|
kind: studioJobKind(job),
|
||||||
name: job.name,
|
name: job.name,
|
||||||
prompt: job.prompt,
|
prompt: job.prompt,
|
||||||
shotCount: job.shotCount,
|
shotCount: job.shotCount,
|
||||||
@@ -203,7 +214,7 @@ export function summarizeStudioJob(job: StudioJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function videoJobsBusy() {
|
export function videoJobsBusy() {
|
||||||
if (listJobs().some(job => job.kind !== 'edit' && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))) {
|
if (listJobs().some(job => job.status === 'queued' || job.status === 'uploading' || job.status === 'running')) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return listPendingJobs().some(job => Boolean(job.promptId))
|
return listPendingJobs().some(job => Boolean(job.promptId))
|
||||||
@@ -230,15 +241,20 @@ export async function addStudioJob(params: {
|
|||||||
ownerKey: string
|
ownerKey: string
|
||||||
payload: StudioJobPayload
|
payload: StudioJobPayload
|
||||||
familyId: string
|
familyId: string
|
||||||
|
kind?: StudioJobKind
|
||||||
}) {
|
}) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const shotCount = 1 + (params.payload.extensions?.length || 0)
|
const kind = params.kind === 'edit' ? 'edit' : 'video'
|
||||||
|
const shotCount = kind === 'edit'
|
||||||
|
? 1 + (params.payload.passes?.length || 0)
|
||||||
|
: 1 + (params.payload.extensions?.length || 0)
|
||||||
const job: StudioJob = {
|
const job: StudioJob = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
ownerKey: params.ownerKey,
|
ownerKey: params.ownerKey,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
|
kind,
|
||||||
name: (params.payload.name || '').trim() || params.payload.prompt.slice(0, 80),
|
name: (params.payload.name || '').trim() || params.payload.prompt.slice(0, 80),
|
||||||
prompt: params.payload.prompt,
|
prompt: params.payload.prompt,
|
||||||
shotCount,
|
shotCount,
|
||||||
@@ -264,32 +280,46 @@ export async function patchStudioJob(owner: string, id: string, patch: (job: Stu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelStudioJob(owner: string, id: string) {
|
export async function cancelStudioJob(owner: string, id: string) {
|
||||||
const result = await mutate(owner, (jobs) => {
|
const before = readJobs(owner).find(item => item.id === id)
|
||||||
const job = jobs.find(item => item.id === id)
|
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||||
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
if (before.status !== 'waiting' && before.status !== 'held' && before.status !== 'running') {
|
||||||
if (job.status === 'running') {
|
throw createError({ statusCode: 409, statusMessage: 'That job is no longer in the queue' })
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Stop the live job from Output if you want to cancel the one that is generating' })
|
|
||||||
}
|
}
|
||||||
if (job.status !== 'waiting' && job.status !== 'held') {
|
const stopLive = before.status === 'running'
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Only a waiting or paused job can be removed from the queue' })
|
const liveJobId = before.liveJobId
|
||||||
|
const shotQueueId = before.shotQueueId
|
||||||
|
|
||||||
|
const result = await mutateStore(owner, (store) => {
|
||||||
|
const job = store.jobs.find(item => item.id === id)
|
||||||
|
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||||
|
if (job.status !== 'waiting' && job.status !== 'held' && job.status !== 'running') {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'That job is no longer in the queue' })
|
||||||
}
|
}
|
||||||
job.status = 'cancelled'
|
job.status = 'cancelled'
|
||||||
job.cutIn = false
|
job.cutIn = false
|
||||||
job.holdForCutIn = false
|
job.holdForCutIn = false
|
||||||
|
job.pauseAfterCurrent = false
|
||||||
|
job.pausedByUser = false
|
||||||
|
job.liveJobId = undefined
|
||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
const stillCutIn = jobs.some(item => item.status === 'waiting' && item.cutIn)
|
const stillCutIn = store.jobs.some(item => item.status === 'waiting' && item.cutIn)
|
||||||
if (!stillCutIn) {
|
if (!stillCutIn) {
|
||||||
for (const item of jobs) {
|
for (const item of store.jobs) {
|
||||||
if (item.status === 'running' || item.status === 'held') {
|
if (item.status === 'running' || item.status === 'held') {
|
||||||
item.holdForCutIn = false
|
item.holdForCutIn = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncPausedFlag(store)
|
||||||
return structuredClone(job)
|
return structuredClone(job)
|
||||||
})
|
})
|
||||||
|
if (stopLive) {
|
||||||
|
await stopLiveGeneration(liveJobId, shotQueueId)
|
||||||
|
}
|
||||||
if (result.shotQueueId) {
|
if (result.shotQueueId) {
|
||||||
const { deleteShotQueue } = await import('~/server/utils/shotQueue')
|
const { deleteShotQueue, setQueueJob } = await import('~/server/utils/shotQueue')
|
||||||
await deleteShotQueue(owner, result.shotQueueId).catch(() => null)
|
setQueueJob(result.shotQueueId, null)
|
||||||
|
await deleteShotQueue(owner, result.shotQueueId, { force: true }).catch(() => null)
|
||||||
}
|
}
|
||||||
const jobs = readJobs(owner)
|
const jobs = readJobs(owner)
|
||||||
if (!jobs.some(item => item.status === 'waiting' && item.cutIn)) {
|
if (!jobs.some(item => item.status === 'waiting' && item.cutIn)) {
|
||||||
@@ -303,6 +333,44 @@ export async function cancelStudioJob(owner: string, id: string) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
||||||
|
if (shotQueueId) {
|
||||||
|
const { setQueueJob, pauseShotQueue } = await import('~/server/utils/shotQueue')
|
||||||
|
setQueueJob(shotQueueId, null)
|
||||||
|
const live = liveJobId ? getJob(liveJobId) : undefined
|
||||||
|
if (live?.library?.ownerKey) {
|
||||||
|
await pauseShotQueue(live.library.ownerKey, shotQueueId).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!liveJobId) return
|
||||||
|
const live = getJob(liveJobId)
|
||||||
|
if (live) {
|
||||||
|
live.status = 'cancelled'
|
||||||
|
if (live.library) {
|
||||||
|
live.library.stopAfterCurrent = true
|
||||||
|
live.library.queueAutoRun = false
|
||||||
|
}
|
||||||
|
emitJob(live, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||||
|
}
|
||||||
|
deletePendingJob(liveJobId)
|
||||||
|
const { interruptComfy } = await import('~/server/utils/comfy')
|
||||||
|
await interruptComfy().catch(() => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOrphanQueue(owner: string, queueId: string) {
|
||||||
|
const { getShotQueue, getQueueJobId, queueIsProcessing, deleteShotQueue } = await import('~/server/utils/shotQueue')
|
||||||
|
const queue = getShotQueue(owner, queueId)
|
||||||
|
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
|
||||||
|
const generating = queue.status === 'running' || queueIsProcessing(queueId)
|
||||||
|
const liveJobId = getQueueJobId(queueId) || queue.currentJobId
|
||||||
|
if (generating) {
|
||||||
|
await stopLiveGeneration(liveJobId, queueId)
|
||||||
|
}
|
||||||
|
await deleteShotQueue(owner, queueId, { force: true }).catch(() => null)
|
||||||
|
kickStudioQueue()
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
export async function requestCutIn(owner: string, id: string) {
|
export async function requestCutIn(owner: string, id: string) {
|
||||||
const jobsNow = readJobs(owner)
|
const jobsNow = readJobs(owner)
|
||||||
const running = jobsNow.find(job => job.status === 'running')
|
const running = jobsNow.find(job => job.status === 'running')
|
||||||
@@ -518,7 +586,92 @@ async function resumeHeldStudioJob(item: StudioJob) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startStudioEditJob(item: StudioJob) {
|
||||||
|
try {
|
||||||
|
const { stillPath } = await import('~/server/utils/library')
|
||||||
|
const { existsSync, readFileSync } = await import('node:fs')
|
||||||
|
const { createEditLiveJob, runEdit } = await import('~/server/utils/imageChain')
|
||||||
|
|
||||||
|
const payload = item.payload
|
||||||
|
if (!payload.stillId || !existsSync(stillPath(item.ownerKey, payload.stillId))) {
|
||||||
|
throw new Error('The input still is missing from the library')
|
||||||
|
}
|
||||||
|
const image = {
|
||||||
|
filename: payload.stillFilename || 'still.png',
|
||||||
|
data: readFileSync(stillPath(item.ownerKey, payload.stillId)),
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
const refId = payload.referenceStillId
|
||||||
|
const reference = refId && existsSync(stillPath(item.ownerKey, refId))
|
||||||
|
? {
|
||||||
|
filename: payload.referenceStillFilename || 'image2.png',
|
||||||
|
data: readFileSync(stillPath(item.ownerKey, refId)),
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const passes = payload.passes || []
|
||||||
|
const job = createEditLiveJob({
|
||||||
|
steps: payload.steps,
|
||||||
|
hideThumbnail: payload.hideThumbnail,
|
||||||
|
library: {
|
||||||
|
ownerKey: item.ownerKey,
|
||||||
|
folderId: payload.folderId,
|
||||||
|
hideThumbnail: payload.hideThumbnail,
|
||||||
|
hideInput: payload.hideInput,
|
||||||
|
folderLocked: payload.folderLocked,
|
||||||
|
name: payload.name,
|
||||||
|
prompt: payload.prompt,
|
||||||
|
aspect: payload.aspect || 'auto',
|
||||||
|
width: payload.width,
|
||||||
|
height: payload.height,
|
||||||
|
steps: payload.steps,
|
||||||
|
turbo: true,
|
||||||
|
seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
|
||||||
|
cfg: payload.cfg,
|
||||||
|
stillId: payload.stillId,
|
||||||
|
stillFilename: payload.stillFilename,
|
||||||
|
familyId: item.familyId,
|
||||||
|
chainIndex: 0,
|
||||||
|
chainStep: 1,
|
||||||
|
chainTotal: 1 + passes.length,
|
||||||
|
chainLabel: passes.length ? 'Pass 1' : undefined,
|
||||||
|
passes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await markStudioLive(item.ownerKey, item.id, job.id)
|
||||||
|
void runEdit(job, {
|
||||||
|
image,
|
||||||
|
reference,
|
||||||
|
prompt: payload.prompt,
|
||||||
|
passes,
|
||||||
|
negative: payload.negative || '',
|
||||||
|
steps: payload.steps,
|
||||||
|
seed: job.library?.seed || payload.seed,
|
||||||
|
cfg: payload.cfg
|
||||||
|
}).catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (job.status !== 'error' && job.status !== 'cancelled' && job.status !== 'deferred') {
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
await patchStudioJob(item.ownerKey, item.id, (job) => {
|
||||||
|
job.status = 'error'
|
||||||
|
job.lastError = message
|
||||||
|
}).catch(() => null)
|
||||||
|
kickStudioQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function startStudioJob(item: StudioJob) {
|
export async function startStudioJob(item: StudioJob) {
|
||||||
|
if (studioJobKind(item) === 'edit') {
|
||||||
|
await startStudioEditJob(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
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')
|
||||||
@@ -676,8 +829,17 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
const failed = job.status === 'error' || job.status === 'cancelled'
|
const failed = job.status === 'error' || job.status === 'cancelled'
|
||||||
const snapshot = readStore(owner)
|
const snapshot = readStore(owner)
|
||||||
const rowNow = snapshot.jobs.find(item => item.liveJobId === job.id)
|
const rowNow = snapshot.jobs.find(item => item.liveJobId === job.id)
|
||||||
|| snapshot.jobs.find(item => item.status === 'running' && item.shotQueueId && item.shotQueueId === job.library?.queueId)
|
|| snapshot.jobs.find(item => (
|
||||||
|| snapshot.jobs.find(item => item.status === 'running' && !job.library?.queueId)
|
item.status === 'running'
|
||||||
|
&& Boolean(item.shotQueueId)
|
||||||
|
&& item.shotQueueId === job.library?.queueId
|
||||||
|
))
|
||||||
|
|| snapshot.jobs.find(item => (
|
||||||
|
item.status === 'running'
|
||||||
|
&& !item.shotQueueId
|
||||||
|
&& !job.library?.queueId
|
||||||
|
&& (!item.liveJobId || item.liveJobId === job.id)
|
||||||
|
))
|
||||||
const userPause = !failed && rowNow?.holdForCutIn !== true && (
|
const userPause = !failed && rowNow?.holdForCutIn !== true && (
|
||||||
rowNow?.pauseAfterCurrent === true
|
rowNow?.pauseAfterCurrent === true
|
||||||
|| rowNow?.pausedByUser === true
|
|| rowNow?.pausedByUser === true
|
||||||
|
|||||||
Reference in New Issue
Block a user