, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
@@ -1580,6 +1663,8 @@ const aspects = [
const player = ref
(null)
const outputPanel = ref(null)
+const editLightbox = ref(null)
+const editLightboxUrl = ref('')
const file = ref(null)
const preview = ref('')
const dragging = ref(false)
@@ -1728,6 +1813,7 @@ const liveQueue = ref<{
const studioJobs = ref('all')
const selectedKeys = ref([])
const lastSelectedKey = ref('')
+const pendingLibraryDelete = ref(null)
+const libraryDeleteBusy = ref(false)
const videoEngine = ref('minimax')
const videoStart = ref('still')
const minimaxGraph = ref('v1')
@@ -1946,7 +2034,6 @@ const recommendHint = computed(() => {
return 'Send this still and draft to Sidecar Qwen VL.'
})
const editBlocked = computed(() => {
- if (editBusy.value) return true
if (!file.value || !prompt.value.trim() || !folderId.value) return true
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
if (!comfyOk.value && !imageComfyOk.value) return true
@@ -1980,6 +2067,12 @@ const editLabel = computed(() => {
if (!n) return 'Edit image'
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 recommendedShots = computed(() => {
if (!recommendText.value || recommendBusy.value) return []
@@ -2448,6 +2541,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onSitePointerDown, true)
+ window.removeEventListener('keydown', onEditLightboxKey)
if (healthTimer) clearInterval(healthTimer)
healthTimer = null
if (libraryRefreshTimer) clearTimeout(libraryRefreshTimer)
@@ -2482,6 +2576,15 @@ watch(videoBusy, (busy) => {
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) => {
try {
localStorage.setItem('aigen-hide-input-preview', String(value))
@@ -2967,7 +3070,11 @@ function studioJobStatusLabel(job: { status: string; cutIn?: boolean; holdForCut
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 done = job.completedCount || 0
if (total <= 1) return '1 clip'
@@ -2980,6 +3087,7 @@ async function refreshStudioQueue() {
jobs?: Array<{
id: string
status: string
+ kind?: string
name: string
prompt: string
shotCount: number
@@ -2997,7 +3105,19 @@ async function refreshStudioQueue() {
}>('/api/studio-queue').catch(() => ({ jobs: [] }))
studioJobs.value = data.jobs || []
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
videoJobId.value = running.liveJobId
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) {
- const root = outputPanel.value
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 (concealOutput.value && !awaitingReveal.value && !lockedSave.value && videoUrl.value) {
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) {
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 {
await $fetch(`/api/library/stills/${still.id}`, {
method: 'DELETE',
@@ -3773,8 +3998,6 @@ async function onMoveSelected(event: Event) {
async function removeSelected() {
const items = selectedLibraryEntries()
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()
const stillIds = new Set()
const results = await Promise.allSettled(items.map(entry => {
@@ -3952,27 +4175,7 @@ async function editImage() {
return
}
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
- editStatusBusy.value = false
- 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')
+ const hideOut = hideThumbnail.value
try {
const body = new FormData()
body.append('image', file.value)
@@ -3987,16 +4190,54 @@ async function editImage() {
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim())
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
- editDownloadName.value = 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 downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
+ const started = await $fetch<{
+ jobId: string
+ studioJobId?: string
+ queued?: boolean
+ steps: number
+ hideThumbnail?: boolean
+ folderLocked?: boolean
+ }>('/api/edit', {
method: 'POST',
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
- persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
- listen('edit', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
+ 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')
+ 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 refreshStudioQueue()
} catch (error: any) {
editSettledUi = true
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
diff --git a/pages/queue.vue b/pages/queue.vue
index 45bd8ee..3a2d797 100644
--- a/pages/queue.vue
+++ b/pages/queue.vue
@@ -69,11 +69,22 @@
>
-
Job
+
+
![]()
+
+
{{ jobKindLabel(job) }}
{{ job.name }}
{{ jobLine(job) }}
{{ liveMessage[job.shots.id] }}
+
{{ liveMessage[job.id] }}
{{ job.lastError }}
+
+
@@ -207,7 +219,7 @@
:key="index"
class="rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3"
>
- Shot {{ index + 1 }} · queued with this job
+ {{ job.kind === 'edit' ? `Pass ${index + 1}` : `Shot ${index + 1}` }} · queued with this job
{{ shot.prompt }}
@@ -266,12 +278,12 @@
{{ queuePauseLabel(queue) }}
- Dismiss
+ {{ queue.status === 'running' ? 'Cancel job' : 'Remove job' }}