Pause the queue after the current clip for real, and carry global locks plus labeled permanence stills through generate and shot prompts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+220
-8
@@ -167,7 +167,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
||||
Paste a block. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider.
|
||||
Paste a block. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider. Global locks are inherited; each shot should add one beat.
|
||||
<span v-if="identityPrompting"> The Comfy identity prefix is prepended to every shot, and the same Picture 1 / extra stills are sent with each shot.</span>
|
||||
</p>
|
||||
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
|
||||
@@ -274,6 +274,18 @@
|
||||
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p>
|
||||
</details>
|
||||
<div v-if="studioMode === 'video'" class="mt-3 space-y-3">
|
||||
<GlobalLocksEditor v-model="globalLocks" />
|
||||
<PermanenceRefStrip
|
||||
:refs="familyPermanenceRefs"
|
||||
label="Permanence refs"
|
||||
hint="Labels for object permanence. Last-frame I2V inherits them in the prompt; they are not extra Comfy identity pictures."
|
||||
:hidden="hideInputPreview"
|
||||
@add="openPermanencePicker()"
|
||||
@remove="removeFamilyRef"
|
||||
@update-label="updateFamilyRefLabel"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 hidden space-y-2 md:block">
|
||||
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
|
||||
<div
|
||||
@@ -285,7 +297,17 @@
|
||||
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
|
||||
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
|
||||
</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ identityPrompting ? buildIdentityPrompt(shot.prompt, extraIdentityPictures) : shot.prompt }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedShotPreview(shot.prompt, index, shot.n) }}</p>
|
||||
<PermanenceRefStrip
|
||||
class="mt-2"
|
||||
:refs="shotPermanenceRefs[shot.n] || []"
|
||||
hint="This shot"
|
||||
:hidden="hideInputPreview"
|
||||
:max="4"
|
||||
@add="openPermanencePicker(shot.n)"
|
||||
@remove="(refIndex) => removeShotRef(shot.n, refIndex)"
|
||||
@update-label="(refIndex, label) => updateShotRefLabel(shot.n, refIndex, label)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,6 +336,16 @@
|
||||
placeholder="Motion prompt for this extension…"
|
||||
/>
|
||||
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
|
||||
<PermanenceRefStrip
|
||||
class="mt-3"
|
||||
:refs="shotPermanenceRefs[index + 2] || []"
|
||||
hint="This extension"
|
||||
:hidden="hideInputPreview"
|
||||
:max="4"
|
||||
@add="openPermanencePicker(index + 2)"
|
||||
@remove="(refIndex) => removeShotRef(index + 2, refIndex)"
|
||||
@update-label="(refIndex, label) => updateShotRefLabel(index + 2, refIndex, label)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1116,6 +1148,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
|
||||
<p v-if="entry.group.head.globalLocks" class="mt-1 text-[11px] text-zinc-600">Global locks saved with this collection.</p>
|
||||
<PermanenceRefStrip
|
||||
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
|
||||
class="mt-2"
|
||||
:refs="entry.group.head.permanenceRefs || []"
|
||||
:hidden="entry.group.head.hideInput || hideInputPreview"
|
||||
@add="openPermanencePicker(undefined, entry.group.head.id)"
|
||||
@remove="(refIndex) => removeClipRef(entry.group.head, refIndex)"
|
||||
@update-label="(refIndex, label) => updateClipRefLabel(entry.group.head, refIndex, label)"
|
||||
/>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
|
||||
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }}
|
||||
@@ -1170,6 +1212,15 @@
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
|
||||
<PermanenceRefStrip
|
||||
v-if="!item.hideThumbnail"
|
||||
class="mt-2"
|
||||
:refs="item.permanenceRefs || []"
|
||||
:hidden="item.hideInput || hideInputPreview"
|
||||
@add="openPermanencePicker(undefined, item.id)"
|
||||
@remove="(refIndex) => removeClipRef(item, refIndex)"
|
||||
@update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)"
|
||||
/>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
|
||||
@@ -1360,10 +1411,12 @@
|
||||
:folder-id="folderId"
|
||||
:hide-input-preview="hideInputPreview"
|
||||
:heading="pickerHeading"
|
||||
:still-only="permanencePicker"
|
||||
@close="pickerOpen = false"
|
||||
@updated="applyLibrary"
|
||||
@error="toast"
|
||||
@select="onPickedImage"
|
||||
@pick="onPickedPermanence"
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none fixed bottom-4 right-4 z-50 space-y-2">
|
||||
@@ -1381,6 +1434,13 @@
|
||||
<script setup lang="ts">
|
||||
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
|
||||
import { parseShotScript } from '~/utils/parseShots'
|
||||
import {
|
||||
composeShotPrompt,
|
||||
extractGlobalLocks,
|
||||
isBlankGlobalLocks,
|
||||
nextPermanenceLabel,
|
||||
type PermanenceRef
|
||||
} from '~/utils/globalLocks'
|
||||
import {
|
||||
composeVideoWorkflow,
|
||||
isLtxWorkflow,
|
||||
@@ -1452,6 +1512,8 @@ interface LibraryClip {
|
||||
stillId?: string
|
||||
sound?: boolean
|
||||
hideInput?: boolean
|
||||
globalLocks?: string
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
}
|
||||
|
||||
interface RetryDraft {
|
||||
@@ -1476,8 +1538,11 @@ interface RetryDraft {
|
||||
fps?: number
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
extensions?: { prompt: string; duration: number }[]
|
||||
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
|
||||
workflow?: string
|
||||
globalLocks?: string
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
shotPermanenceRefs?: PermanenceRef[][]
|
||||
}
|
||||
|
||||
interface QueuedExtension {
|
||||
@@ -1669,7 +1734,7 @@ const studioJobs = ref<Array<{
|
||||
}>>([])
|
||||
const outputStudioJobId = ref('')
|
||||
const pickerOpen = ref(false)
|
||||
const pickerSlot = ref<'main' | 'editRef' | number>('main')
|
||||
const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` | `clip:${string}`>('main')
|
||||
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const lastSelectedKey = ref('')
|
||||
@@ -1682,11 +1747,18 @@ const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
|
||||
const useIdentityRefs = ref(false)
|
||||
const identityRefs = ref<(File | null)[]>([null, null, null, null])
|
||||
const identityPreviews = ref(['', '', '', ''])
|
||||
const globalLocks = ref('')
|
||||
const familyPermanenceRefs = ref<PermanenceRef[]>([])
|
||||
const shotPermanenceRefs = ref<Record<number, PermanenceRef[]>>({})
|
||||
const LOCKS_STORE = 'aigen-global-locks'
|
||||
const REFS_STORE = 'aigen-permanence-refs'
|
||||
const pickerHeading = computed(() => {
|
||||
if (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
|
||||
if (pickerSlot.value === 'editRef') return 'Choose image 2'
|
||||
if (typeof pickerSlot.value === 'string') return 'Choose a permanence still'
|
||||
return `Choose Picture ${Number(pickerSlot.value) + 2}`
|
||||
})
|
||||
const permanencePicker = computed(() => typeof pickerSlot.value === 'string' && pickerSlot.value !== 'main' && pickerSlot.value !== 'editRef')
|
||||
let videoTimer: ReturnType<typeof setInterval> | null = null
|
||||
let editTimer: ReturnType<typeof setInterval> | null = null
|
||||
let videoSource: EventSource | null = null
|
||||
@@ -1902,6 +1974,16 @@ const recommendedShots = computed(() => {
|
||||
})
|
||||
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
|
||||
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
|
||||
function composedShotPreview(text: string, index: number, shotN?: number) {
|
||||
const composed = composeShotPrompt({
|
||||
globalLocks: globalLocks.value,
|
||||
prompt: text,
|
||||
shotIndex: index,
|
||||
familyRefs: familyPermanenceRefs.value,
|
||||
shotRefs: shotPermanenceRefs.value[shotN ?? index + 1]
|
||||
})
|
||||
return identityPrompting.value ? buildIdentityPrompt(composed, extraIdentityPictures.value) : composed
|
||||
}
|
||||
const identityActionPrompt = computed(() => {
|
||||
if (shotScriptMode.value) return parsedShots.value[0]?.prompt || prompt.value
|
||||
return prompt.value
|
||||
@@ -2315,6 +2397,12 @@ onMounted(async () => {
|
||||
if (videoEngine.value === 'ltx') applyEngineDefaults('ltx')
|
||||
} catch { /* ignore */ }
|
||||
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
|
||||
globalLocks.value = localStorage.getItem(LOCKS_STORE) || ''
|
||||
try {
|
||||
const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '')
|
||||
familyPermanenceRefs.value = Array.isArray(savedRefs?.family) ? savedRefs.family : []
|
||||
shotPermanenceRefs.value = savedRefs?.shots && typeof savedRefs.shots === 'object' ? savedRefs.shots : {}
|
||||
} catch { /* ignore */ }
|
||||
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
||||
const savedPreset = localStorage.getItem('aigen-prompt-preset')
|
||||
if (savedPreset) recommendPresetId.value = savedPreset
|
||||
@@ -2426,6 +2514,13 @@ watch(useIdentityRefs, (value) => {
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
|
||||
try {
|
||||
localStorage.setItem(LOCKS_STORE, globalLocks.value)
|
||||
localStorage.setItem(REFS_STORE, JSON.stringify({ family: familyPermanenceRefs.value, shots: shotPermanenceRefs.value }))
|
||||
} catch { /* ignore */ }
|
||||
}, { deep: true })
|
||||
|
||||
watch(turbo, (on) => {
|
||||
if (cfgTouched.value) return
|
||||
cfg.value = ltxVideo.value ? 3.5 : (on ? CFG_TURBO : CFG_QUALITY)
|
||||
@@ -2569,8 +2664,10 @@ function applyRecommend() {
|
||||
prompt.value = text
|
||||
}
|
||||
} else {
|
||||
prompt.value = text
|
||||
if (looksLikeShotScript(text) || parseShotScript(text).length > 1) shotScriptMode.value = true
|
||||
const extracted = extractGlobalLocks(text)
|
||||
if (extracted.locks && isBlankGlobalLocks(globalLocks.value)) globalLocks.value = extracted.locks
|
||||
prompt.value = extracted.script || text
|
||||
if (looksLikeShotScript(prompt.value) || parseShotScript(prompt.value).length > 1) shotScriptMode.value = true
|
||||
}
|
||||
recommendApplied.value = true
|
||||
toast('Recommended prompt applied')
|
||||
@@ -2625,6 +2722,9 @@ function clearVideoForm() {
|
||||
clipName.value = ''
|
||||
extensionQueue.value = []
|
||||
shotScriptMode.value = false
|
||||
globalLocks.value = ''
|
||||
familyPermanenceRefs.value = []
|
||||
shotPermanenceRefs.value = {}
|
||||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||||
recommendText.value = ''
|
||||
recommendError.value = ''
|
||||
@@ -2649,6 +2749,81 @@ function openPicker(slot: 'main' | 'editRef' | number = 'main') {
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
function openPermanencePicker(shot?: number, clipId?: string) {
|
||||
if (clipId) pickerSlot.value = `clip:${clipId}`
|
||||
else if (typeof shot === 'number') pickerSlot.value = `perm:${shot}`
|
||||
else pickerSlot.value = 'perm'
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
function addPermanenceRef(stillId: string, shot?: number) {
|
||||
if (typeof shot === 'number') {
|
||||
const current = shotPermanenceRefs.value[shot] || []
|
||||
if (current.some(item => item.stillId === stillId) || current.length >= 4) return
|
||||
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: [...current, { stillId, label: nextPermanenceLabel(current) }] }
|
||||
return
|
||||
}
|
||||
if (familyPermanenceRefs.value.some(item => item.stillId === stillId) || familyPermanenceRefs.value.length >= 6) return
|
||||
familyPermanenceRefs.value = [...familyPermanenceRefs.value, { stillId, label: nextPermanenceLabel(familyPermanenceRefs.value) }]
|
||||
}
|
||||
|
||||
function removeFamilyRef(index: number) {
|
||||
familyPermanenceRefs.value = familyPermanenceRefs.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function updateFamilyRefLabel(index: number, label: string) {
|
||||
familyPermanenceRefs.value = familyPermanenceRefs.value.map((item, i) => i === index ? { ...item, label } : item)
|
||||
}
|
||||
|
||||
function removeShotRef(shot: number, index: number) {
|
||||
const current = (shotPermanenceRefs.value[shot] || []).filter((_, i) => i !== index)
|
||||
const next = { ...shotPermanenceRefs.value }
|
||||
if (current.length) next[shot] = current
|
||||
else delete next[shot]
|
||||
shotPermanenceRefs.value = next
|
||||
}
|
||||
|
||||
function updateShotRefLabel(shot: number, index: number, label: string) {
|
||||
const current = (shotPermanenceRefs.value[shot] || []).map((item, i) => i === index ? { ...item, label } : item)
|
||||
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: current }
|
||||
}
|
||||
|
||||
async function persistClipRefs(clip: LibraryClip, refs: PermanenceRef[]) {
|
||||
try {
|
||||
applyLibrary(await $fetch(`/api/library/clips/${clip.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { permanenceRefs: refs }
|
||||
}))
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not save permanence refs')
|
||||
}
|
||||
}
|
||||
|
||||
function removeClipRef(clip: LibraryClip, index: number) {
|
||||
void persistClipRefs(clip, (clip.permanenceRefs || []).filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
|
||||
void persistClipRefs(clip, (clip.permanenceRefs || []).map((item, i) => i === index ? { ...item, label } : item))
|
||||
}
|
||||
|
||||
function onPickedPermanence(still: { id: string }) {
|
||||
const slot = pickerSlot.value
|
||||
pickerOpen.value = false
|
||||
if (typeof slot === 'string' && slot.startsWith('clip:')) {
|
||||
const clipId = slot.slice(5)
|
||||
const clip = clips.value.find(item => item.id === clipId)
|
||||
if (!clip) return
|
||||
if ((clip.permanenceRefs || []).some(item => item.stillId === still.id)) return
|
||||
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || []) }])
|
||||
return
|
||||
}
|
||||
if (typeof slot === 'string' && (slot === 'perm' || slot.startsWith('perm:'))) {
|
||||
const shot = slot === 'perm' ? undefined : Number(slot.slice(5))
|
||||
addPermanenceRef(still.id, Number.isFinite(shot) ? shot : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function setEditRef(next: File) {
|
||||
if (editRefPreview.value) URL.revokeObjectURL(editRefPreview.value)
|
||||
editRefFile.value = next
|
||||
@@ -2686,6 +2861,7 @@ function clearIdentityRef(index: number) {
|
||||
function onPickedImage(next: File, hidden = false) {
|
||||
const slot = pickerSlot.value
|
||||
pickerOpen.value = false
|
||||
if (typeof slot === 'string' && slot !== 'main' && slot !== 'editRef') return
|
||||
if (slot === 'editRef') {
|
||||
setEditRef(next)
|
||||
return
|
||||
@@ -2864,8 +3040,11 @@ async function restoreStudioJob(id: string) {
|
||||
hideThumbnail?: boolean
|
||||
hideInput?: boolean
|
||||
referenceStillIds?: Array<string | null>
|
||||
extensions?: { prompt: string; duration: number }[]
|
||||
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
|
||||
queueAutoRun?: boolean
|
||||
globalLocks?: string
|
||||
permanenceRefs?: PermanenceRef[]
|
||||
shotPermanenceRefs?: PermanenceRef[][]
|
||||
}
|
||||
}>(`/api/studio-queue/${id}`)
|
||||
const payload = job.payload
|
||||
@@ -2907,6 +3086,13 @@ async function restoreStudioJob(id: string) {
|
||||
duration: clampDuration(Number(item.duration))
|
||||
}))
|
||||
queueAutoRun.value = payload.queueAutoRun === true
|
||||
globalLocks.value = payload.globalLocks || ''
|
||||
familyPermanenceRefs.value = payload.permanenceRefs || []
|
||||
const shotRefs: Record<number, PermanenceRef[]> = {}
|
||||
;(payload.shotPermanenceRefs || []).forEach((refs, index) => {
|
||||
if (refs?.length) shotRefs[index + 1] = refs
|
||||
})
|
||||
shotPermanenceRefs.value = shotRefs
|
||||
const stillId = payload.stillId
|
||||
if (stillId) {
|
||||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||||
@@ -3131,6 +3317,16 @@ async function rerun(item: LibraryClip, collection = false) {
|
||||
shotScriptMode.value = restoreAll
|
||||
useIdentityRefs.value = false
|
||||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||||
const ordered = chronologicalParts(parts)
|
||||
globalLocks.value = ordered.find(part => part.globalLocks)?.globalLocks || target.globalLocks || ''
|
||||
familyPermanenceRefs.value = ordered[0]?.permanenceRefs || target.permanenceRefs || []
|
||||
const familyIds = new Set(familyPermanenceRefs.value.map(ref => ref.stillId))
|
||||
const nextShots: Record<number, PermanenceRef[]> = {}
|
||||
ordered.forEach((part, index) => {
|
||||
const extras = (part.permanenceRefs || []).filter(ref => !familyIds.has(ref.stillId))
|
||||
if (extras.length) nextShots[index + 1] = extras
|
||||
})
|
||||
shotPermanenceRefs.value = nextShots
|
||||
extensionQueue.value = restoreAll
|
||||
? parts.slice(1).map(part => ({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -3169,6 +3365,13 @@ async function loadDraft(draft: RetryDraft) {
|
||||
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||||
duration.value = clampDuration(Number(draft.duration))
|
||||
applyVideoWorkflow(draft.workflow)
|
||||
globalLocks.value = draft.globalLocks || ''
|
||||
familyPermanenceRefs.value = draft.permanenceRefs || []
|
||||
const draftShots: Record<number, PermanenceRef[]> = {}
|
||||
;(draft.shotPermanenceRefs || []).forEach((refs, index) => {
|
||||
if (refs?.length) draftShots[index + 1] = refs
|
||||
})
|
||||
shotPermanenceRefs.value = draftShots
|
||||
extensionQueue.value = (draft.extensions || []).map(item => ({
|
||||
id: crypto.randomUUID(),
|
||||
prompt: String(item.prompt || ''),
|
||||
@@ -3857,9 +4060,18 @@ async function generate() {
|
||||
if (ref) body.append(`refImage${index}`, ref)
|
||||
})
|
||||
}
|
||||
if (globalLocks.value.trim()) body.append('globalLocks', globalLocks.value)
|
||||
body.append('permanenceRefs', JSON.stringify(familyPermanenceRefs.value))
|
||||
const shotRefList = shots
|
||||
? shots.map(shot => shotPermanenceRefs.value[shot.n] || [])
|
||||
: [shotPermanenceRefs.value[1] || [], ...queued.map((_, index) => shotPermanenceRefs.value[index + 2] || [])]
|
||||
body.append('shotPermanenceRefs', JSON.stringify(shotRefList))
|
||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||
if (queued.length) {
|
||||
body.append('extensions', JSON.stringify(queued))
|
||||
body.append('extensions', JSON.stringify(queued.map((item, index) => ({
|
||||
...item,
|
||||
permanenceRefs: shotRefList[index + 1] || []
|
||||
}))))
|
||||
body.append('queueAutoRun', String(queueAutoRun.value))
|
||||
}
|
||||
const downloadName = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
|
||||
|
||||
+140
-17
@@ -26,12 +26,23 @@
|
||||
Jobs you submitted from Studio. A long shot script is one job; its shots are the list underneath. Run next inserts a waiting job after the current shot as its own clip.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<p v-if="waitingCount" class="text-sm text-amber-100">{{ waitingCount }} job{{ waitingCount === 1 ? '' : 's' }} waiting</p>
|
||||
<p v-else-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="pauseToggleClass(pauseArmed)"
|
||||
:disabled="pauseBusy || (!anythingGenerating && !pauseArmed)"
|
||||
:aria-pressed="pauseArmed"
|
||||
:title="pauseDisabledReason || pauseToggleTitle"
|
||||
@click="togglePause()"
|
||||
>
|
||||
{{ pauseToggleLabel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-10 rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200 hover:border-red-300 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!clearableCount || Boolean(busyId)"
|
||||
@click="clearAll"
|
||||
>
|
||||
@@ -41,6 +52,12 @@
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||
<p v-else-if="pauseArmed && anythingGenerating" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||||
Will pause after this video. The current clip keeps generating; the next queued job will not start until you resume.
|
||||
</p>
|
||||
<p v-else-if="pauseArmed" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||||
Paused after current. Waiting jobs will not start until you press Resume queue.
|
||||
</p>
|
||||
<p v-else-if="!jobs.length && !orphans.length" class="glass rounded-3xl px-5 py-10 text-center text-sm text-zinc-400">
|
||||
Nothing queued. Generate from Studio — a 30-shot script takes one slot, and you can line up more jobs behind it.
|
||||
</p>
|
||||
@@ -94,14 +111,30 @@
|
||||
Run remaining
|
||||
</button>
|
||||
<button
|
||||
v-if="job.shots.status === 'running'"
|
||||
v-if="canPauseJob(job)"
|
||||
type="button"
|
||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200"
|
||||
@click="pause(job.shots)"
|
||||
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="pauseToggleClass(jobIsArmed(job))"
|
||||
:disabled="pauseBusy"
|
||||
:aria-pressed="jobIsArmed(job)"
|
||||
:title="jobIsArmed(job) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
|
||||
@click="togglePause({ job })"
|
||||
>
|
||||
Pause after this shot
|
||||
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="canPauseJob(job)"
|
||||
type="button"
|
||||
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="pauseToggleClass(jobIsArmed(job))"
|
||||
:disabled="pauseBusy"
|
||||
:aria-pressed="jobIsArmed(job)"
|
||||
:title="jobIsArmed(job) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
|
||||
@click="togglePause({ job })"
|
||||
>
|
||||
{{ jobIsArmed(job) ? 'Will pause after this' : 'Pause after this' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="job.status === 'waiting' || job.status === 'held'"
|
||||
type="button"
|
||||
@@ -217,12 +250,16 @@
|
||||
Run remaining
|
||||
</button>
|
||||
<button
|
||||
v-if="queue.status === 'running'"
|
||||
v-if="queue.status === 'running' || queue.stopAfterCurrent"
|
||||
type="button"
|
||||
class="rounded-full border border-red-400/40 px-3 py-1.5 text-xs text-red-200"
|
||||
@click="pause(queue)"
|
||||
class="min-h-10 rounded-full px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="pauseToggleClass(Boolean(queue.stopAfterCurrent) || queuePaused)"
|
||||
:disabled="pauseBusy || (queue.status !== 'running' && !queue.stopAfterCurrent && !queuePaused)"
|
||||
:aria-pressed="Boolean(queue.stopAfterCurrent) || queuePaused"
|
||||
:title="(queue.stopAfterCurrent || queuePaused) ? 'Click to keep going after this video' : 'Finish the current video, then stop'"
|
||||
@click="togglePause({ queue })"
|
||||
>
|
||||
Pause after this shot
|
||||
{{ queue.stopAfterCurrent || queuePaused ? (queue.status === 'running' ? 'Will pause after this' : 'Paused after current') : 'Pause after this' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="queue.status !== 'running'"
|
||||
@@ -312,6 +349,7 @@ type Queue = {
|
||||
completedCount: number
|
||||
totalCount: number
|
||||
currentJobId?: string
|
||||
stopAfterCurrent?: boolean
|
||||
segments: Segment[]
|
||||
}
|
||||
|
||||
@@ -324,6 +362,8 @@ type StudioJobRow = {
|
||||
shotQueueId?: string
|
||||
cutIn?: boolean
|
||||
holdForCutIn?: boolean
|
||||
pauseAfterCurrent?: boolean
|
||||
pausedByUser?: boolean
|
||||
lastError?: string
|
||||
shots?: Queue | null
|
||||
plannedShots?: { prompt: string; duration: number }[]
|
||||
@@ -336,6 +376,7 @@ const jobs = ref<StudioJobRow[]>([])
|
||||
const orphans = ref<Queue[]>([])
|
||||
const waitingCount = ref(0)
|
||||
const pendingCount = ref(0)
|
||||
const queuePaused = ref(false)
|
||||
const error = ref('')
|
||||
const busyId = ref('')
|
||||
const liveMessage = ref<Record<string, string>>({})
|
||||
@@ -344,6 +385,31 @@ const sources = new Map<string, EventSource>()
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
|
||||
const pauseBusy = computed(() => busyId.value === 'pause')
|
||||
const anythingGenerating = computed(() => (
|
||||
jobs.value.some(job => job.status === 'running' || job.shots?.status === 'running')
|
||||
|| orphans.value.some(queue => queue.status === 'running')
|
||||
))
|
||||
const pauseArmed = computed(() => (
|
||||
queuePaused.value
|
||||
|| jobs.value.some(job => job.pauseAfterCurrent || job.pausedByUser || job.shots?.stopAfterCurrent)
|
||||
|| orphans.value.some(queue => queue.stopAfterCurrent)
|
||||
))
|
||||
const pauseToggleLabel = computed(() => {
|
||||
if (pauseArmed.value && anythingGenerating.value) return 'Will pause after this'
|
||||
if (pauseArmed.value) return 'Resume queue'
|
||||
return 'Pause after current'
|
||||
})
|
||||
const pauseToggleTitle = computed(() => {
|
||||
if (pauseArmed.value && anythingGenerating.value) return 'Click to keep going after this video'
|
||||
if (pauseArmed.value) return 'Start the next waiting job'
|
||||
return 'Finish the current video, then stop. Does not cancel the one that’s generating.'
|
||||
})
|
||||
const pauseDisabledReason = computed(() => {
|
||||
if (pauseBusy.value) return 'Updating pause…'
|
||||
if (!anythingGenerating.value && !pauseArmed.value) return 'Nothing is generating, so there is nothing to pause after'
|
||||
return ''
|
||||
})
|
||||
const clearableCount = computed(() => (
|
||||
jobs.value.filter(job => job.status === 'waiting' || job.status === 'held').length
|
||||
+ orphans.value.filter(queue => queue.status !== 'running').length
|
||||
@@ -366,7 +432,8 @@ function progressLine(queue: Queue) {
|
||||
const next = nextSegment(queue)
|
||||
if (queue.status === 'complete') return `All ${queue.totalCount} shots saved.`
|
||||
if (queue.status === 'running' && next) {
|
||||
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.`
|
||||
const pauseNote = queue.stopAfterCurrent ? ' Will pause after this shot.' : ''
|
||||
return `Generating shot ${next.index + 1} of ${queue.totalCount}. ${queue.completedCount} saved.${pauseNote}`
|
||||
}
|
||||
if (queue.completedCount === 0) return `Not started · ${queue.totalCount} shots`
|
||||
if (next) return `Left off after shot ${queue.completedCount} of ${queue.totalCount}. Next is shot ${next.index + 1}.`
|
||||
@@ -379,9 +446,22 @@ function jobLine(job: StudioJobRow) {
|
||||
? `Next after the current shot · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'} · own clip, not part of the current sequence`
|
||||
: `Waiting · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||
}
|
||||
if (job.shots) return progressLine(job.shots)
|
||||
if (job.status === 'held') return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
|
||||
if (job.status === 'running') return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||
if (job.shots) {
|
||||
const line = progressLine(job.shots)
|
||||
if (job.pauseAfterCurrent && job.shots.status === 'running' && !job.shots.stopAfterCurrent) {
|
||||
return `${line} Will pause after this shot.`
|
||||
}
|
||||
if (job.pausedByUser) return `${line} Paused after current.`
|
||||
return line
|
||||
}
|
||||
if (job.status === 'held') {
|
||||
if (job.pausedByUser) return 'Paused after current. Next jobs will not start until you resume.'
|
||||
return job.holdForCutIn ? 'Paused so another job can cut in' : 'Paused'
|
||||
}
|
||||
if (job.status === 'running') {
|
||||
if (job.pauseAfterCurrent) return `Generating · will pause after this · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||
return `Generating · ${job.shotCount} shot${job.shotCount === 1 ? '' : 's'}`
|
||||
}
|
||||
return job.status
|
||||
}
|
||||
|
||||
@@ -453,6 +533,7 @@ async function loadQueues() {
|
||||
jobs?: StudioJobRow[]
|
||||
orphans?: Queue[]
|
||||
waitingCount?: number
|
||||
paused?: boolean
|
||||
}>('/api/studio-queue?full=1')
|
||||
const nextJobs = (data.jobs || []).map((job) => (
|
||||
job.shots ? { ...job, shots: mergeQueues([job.shots])[0] } : job
|
||||
@@ -460,6 +541,7 @@ async function loadQueues() {
|
||||
jobs.value = nextJobs
|
||||
orphans.value = mergeQueues(data.orphans || [])
|
||||
waitingCount.value = data.waitingCount || 0
|
||||
queuePaused.value = data.paused === true
|
||||
pendingCount.value = [
|
||||
...nextJobs.flatMap(job => job.shots ? [job.shots] : []),
|
||||
...orphans.value
|
||||
@@ -542,9 +624,50 @@ async function process(queue: Queue, count: number | 'all') {
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(queue: Queue) {
|
||||
await $fetch(`/api/queues/${queue.id}/pause`, { method: 'POST' }).catch(() => null)
|
||||
await loadQueues()
|
||||
function pauseToggleClass(pressed: boolean) {
|
||||
return pressed
|
||||
? 'bg-amber-400 text-zinc-950 shadow-[0_0_0_2px_rgba(251,191,36,0.55)]'
|
||||
: 'border border-amber-300/50 text-amber-100 hover:border-amber-200 hover:bg-amber-400/10'
|
||||
}
|
||||
|
||||
function canPauseJob(job: StudioJobRow) {
|
||||
return job.status === 'running' || job.shots?.status === 'running'
|
||||
}
|
||||
|
||||
function jobIsArmed(job: StudioJobRow) {
|
||||
return Boolean(job.pauseAfterCurrent || job.shots?.stopAfterCurrent || (queuePaused.value && job.status === 'running'))
|
||||
}
|
||||
|
||||
async function togglePause(target?: { job?: StudioJobRow; queue?: Queue }) {
|
||||
const armed = target?.job
|
||||
? jobIsArmed(target.job)
|
||||
: target?.queue
|
||||
? Boolean(target.queue.stopAfterCurrent || queuePaused.value)
|
||||
: pauseArmed.value
|
||||
const resume = armed
|
||||
if (target?.job) {
|
||||
target.job.pauseAfterCurrent = !resume
|
||||
if (target.job.shots) target.job.shots.stopAfterCurrent = !resume
|
||||
}
|
||||
if (target?.queue) target.queue.stopAfterCurrent = !resume
|
||||
if (!target?.job && !target?.queue) queuePaused.value = !resume
|
||||
busyId.value = 'pause'
|
||||
error.value = ''
|
||||
try {
|
||||
if (target?.job) {
|
||||
await $fetch(`/api/studio-queue/${target.job.id}/pause`, { method: 'POST', body: { resume } })
|
||||
} else if (target?.queue) {
|
||||
await $fetch(`/api/queues/${target.queue.id}/pause`, { method: 'POST', body: { resume } })
|
||||
} else {
|
||||
await $fetch('/api/studio-queue/pause', { method: 'POST', body: { resume } })
|
||||
}
|
||||
await loadQueues()
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not update pause'
|
||||
await loadQueues().catch(() => null)
|
||||
} finally {
|
||||
busyId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(queue: Queue) {
|
||||
|
||||
Reference in New Issue
Block a user