Add sidecar Qwen VL prompt recommend, fix queued shot bursts, and leave unused MiniMax identity slots empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 17:04:43 -05:00
co-authored by Cursor
parent 2bb30395d8
commit df5ac24622
23 changed files with 1068 additions and 25 deletions
+196 -2
View File
@@ -138,6 +138,24 @@
>
Insert shot template
</button>
<select
v-model="recommendPresetId"
class="max-w-[10rem] rounded-lg border border-white/10 bg-zinc-950 px-2 py-1 text-xs text-zinc-300 outline-none ring-amber-300/40 focus:ring-2"
:disabled="recommendBusy"
:title="recommendHint"
>
<option v-for="item in promptPresets" :key="item.id" :value="item.id">{{ item.name }}</option>
</select>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
:class="recommendBusy ? 'border-amber-300/40 text-amber-100' : 'border-amber-300/40 text-amber-100 hover:bg-amber-400/10'"
:disabled="recommendDisabled"
:title="recommendHint"
@click="recommendPrompt"
>
{{ recommendBusy ? 'Recommending…' : 'Recommend' }}
</button>
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
@@ -161,6 +179,37 @@
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
:placeholder="promptPlaceholder"
/>
<div v-if="recommendBusy || recommendText || recommendError" class="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
<p class="text-xs font-medium uppercase tracking-wide text-amber-200/80">Recommended prompt</p>
<p v-if="recommendBusy" class="mt-2 text-sm text-zinc-400">{{ recommendMessage || 'Sidecar Qwen VL is reading the still…' }}</p>
<p v-else-if="recommendError" class="mt-2 text-sm text-red-300">{{ recommendError }}</p>
<p v-else class="mt-2 whitespace-pre-wrap text-sm text-zinc-200">{{ recommendText }}</p>
<div v-if="recommendText && !recommendBusy" class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-xl bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:opacity-50"
:disabled="recommendApplied"
@click="applyRecommend"
>
{{ recommendApplied ? 'Applied' : 'Apply to prompt' }}
</button>
<button
v-if="recommendApplied"
type="button"
class="rounded-xl border border-white/15 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/40"
@click="revertRecommend"
>
Revert
</button>
<button
type="button"
class="rounded-xl border border-white/15 px-3 py-1.5 text-xs text-zinc-400 hover:text-white"
@click="dismissRecommend"
>
Keep mine
</button>
</div>
</div>
<div v-if="studioMode === 'edit'" class="mt-3 space-y-3">
<div
v-for="(item, index) in editPassQueue"
@@ -349,7 +398,7 @@
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Use identity references</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses the start still as first-frame only. On keeps that still as Picture 1 and adds extra stills as Picture 2–5.</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses the start still as first-frame only. On keeps that still as Picture 1. Extra stills are optional identity views of the same person, not extra characters.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch">
@@ -395,7 +444,7 @@
</button>
</div>
</div>
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5. Empty extra slots reuse the start still.</p>
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5 only if you add them. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
</div>
</div>
@@ -1206,6 +1255,8 @@
</template>
<script setup lang="ts">
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
const runtimeConfig = useRuntimeConfig()
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
@@ -1323,6 +1374,20 @@ const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const shotScriptMode = ref(false)
const recommendPresetId = ref('enhance')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean }>>([
{ id: 'enhance', name: 'Enhance', builtin: true },
{ id: 'shot-script', name: 'Shot script', builtin: true }
])
const recommendBusy = ref(false)
const recommendText = ref('')
const recommendError = ref('')
const recommendMessage = ref('')
const recommendJobId = ref('')
const recommendApplied = ref(false)
const promptBeforeRecommend = ref('')
const editPassesBeforeRecommend = ref<QueuedEditPass[]>([])
const shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([])
const clipName = ref('')
@@ -1608,6 +1673,21 @@ const queueReady = computed(() => {
const videoGenerateDisabled = computed(() => {
return videoBusy.value || !file.value || !prompt.value.trim() || !folderId.value || !queueReady.value
})
const recommendDisabled = computed(() => {
if (recommendBusy.value) return true
if (!file.value || !prompt.value.trim()) return true
if (!imageSidecarConfigured.value) return true
if (!imageSidecarOk.value) return true
return false
})
const recommendHint = computed(() => {
if (!imageSidecarConfigured.value) return 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.'
if (!imageSidecarOk.value) return 'Sidecar Qwen VL is offline.'
if (!file.value) return 'Load a still first.'
if (!prompt.value.trim()) return 'Type a draft prompt to enhance.'
if (imageSidecarBusy.value) return 'Sidecar is busy — this may wait in its queue.'
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
@@ -2024,6 +2104,8 @@ onMounted(async () => {
videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1'
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
const savedPreset = localStorage.getItem('aigen-prompt-preset')
if (savedPreset) recommendPresetId.value = savedPreset
} catch { /* ignore */ }
applyInstancePrivacy()
window.addEventListener('pointerdown', onSitePointerDown, true)
@@ -2044,6 +2126,7 @@ onMounted(async () => {
}, 5000)
await resumeActiveJob()
await refreshLiveQueue()
await loadPromptPresets()
})
onBeforeUnmount(() => {
@@ -2059,6 +2142,14 @@ watch(folderId, () => {
applyThumbnailDefault()
})
watch(settingsOpen, (open) => {
if (!open) void loadPromptPresets()
})
watch(recommendPresetId, (id) => {
try { localStorage.setItem('aigen-prompt-preset', id) } catch { /* ignore */ }
})
watch([browseFolderId, libraryFilter], () => {
selectedKeys.value = []
lastSelectedKey.value = ''
@@ -2162,6 +2253,109 @@ function insertIdentityTemplate() {
: IDENTITY_ACTION_TEMPLATE
}
interface PromptRecommendSnap {
jobId?: string
status?: string
message?: string
text?: string
error?: string
}
async function loadPromptPresets() {
const data = await $fetch<{ presets?: Array<{ id: string; name: string; description?: string; builtin?: boolean }> }>('/api/prompt/presets').catch(() => ({ presets: [] }))
const presets = data.presets || []
if (presets.length) promptPresets.value = presets
const saved = (() => {
try { return localStorage.getItem('aigen-prompt-preset') || '' }
catch { return '' }
})()
if (saved && promptPresets.value.some(item => item.id === saved)) {
recommendPresetId.value = saved
} else if (!promptPresets.value.some(item => item.id === recommendPresetId.value)) {
recommendPresetId.value = promptPresets.value[0]?.id || 'enhance'
}
}
async function recommendPrompt() {
if (recommendDisabled.value || !file.value) return
recommendBusy.value = true
recommendError.value = ''
recommendText.value = ''
recommendApplied.value = false
recommendMessage.value = 'Uploading still to Sidecar Qwen VL…'
const body = new FormData()
body.append('image', file.value)
body.append('prompt', prompt.value)
body.append('presetId', recommendPresetId.value)
body.append(
'mode',
studioMode.value === 'edit' ? 'edit' : (shotScriptMode.value ? 'shot-script' : 'video')
)
try {
const started = await $fetch<PromptRecommendSnap>('/api/prompt/recommend', { method: 'POST', body })
const jobId = String(started.jobId || '')
recommendJobId.value = jobId
if (!jobId) throw new Error('Recommend job did not start')
while (recommendJobId.value === jobId) {
const snap = await $fetch<PromptRecommendSnap>(`/api/prompt/recommend/${jobId}`)
recommendMessage.value = snap.message || recommendMessage.value
if (snap.status === 'complete') {
recommendText.value = String(snap.text || '').trim()
if (!recommendText.value) recommendError.value = 'Sidecar returned an empty prompt'
break
}
if (snap.status === 'error') {
recommendError.value = snap.error || snap.message || 'Prompt recommend failed'
toast(recommendError.value)
break
}
await new Promise(resolve => setTimeout(resolve, 1500))
}
} catch (error: any) {
recommendError.value = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Prompt recommend failed'
toast(recommendError.value)
} finally {
recommendBusy.value = false
if (recommendJobId.value) recommendJobId.value = ''
}
}
function applyRecommend() {
const text = recommendText.value.trim()
if (!text) return
promptBeforeRecommend.value = prompt.value
editPassesBeforeRecommend.value = editPassQueue.value.map(item => ({ ...item }))
shotScriptBeforeRecommend.value = shotScriptMode.value
if (studioMode.value === 'edit') {
const passes = parseEditPasses(text)
if (passes && passes.length > 1) {
prompt.value = passes[0]
editPassQueue.value = passes.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item }))
} else {
prompt.value = text
}
} else {
prompt.value = text
if (looksLikeShotScript(text)) shotScriptMode.value = true
}
recommendApplied.value = true
toast('Recommended prompt applied')
}
function revertRecommend() {
prompt.value = promptBeforeRecommend.value
editPassQueue.value = editPassesBeforeRecommend.value.map(item => ({ ...item }))
shotScriptMode.value = shotScriptBeforeRecommend.value
recommendApplied.value = false
}
function dismissRecommend() {
recommendText.value = ''
recommendError.value = ''
recommendMessage.value = ''
recommendApplied.value = false
}
function toast(message: string) {
const id = crypto.randomUUID()
toasts.value.push({ id, message })
+39 -2
View File
@@ -26,7 +26,17 @@
Each shot ticks off when it saves. If a run stops, continue from the next unchecked scene.
</p>
</div>
<p v-if="pendingCount" class="text-sm text-amber-100">{{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting</p>
<div class="flex flex-wrap items-center gap-3">
<p v-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"
:disabled="!clearableCount || Boolean(busyId)"
@click="clearAll"
>
Clear queue
</button>
</div>
</div>
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
@@ -183,6 +193,8 @@ const dirty = new Set<string>()
const sources = new Map<string, EventSource>()
let pollTimer: ReturnType<typeof setInterval> | null = null
const clearableCount = computed(() => queues.value.filter(queue => queue.status !== 'running').length)
function dirtyKey(queueId: string, index: number) {
return `${queueId}:${index}`
}
@@ -306,11 +318,17 @@ async function process(queue: Queue, count: number | 'all') {
}
}).catch(() => null)
for (const segment of queue.segments) dirty.delete(dirtyKey(queue.id, segment.index))
const started = await $fetch<{ jobId: string }>(`/api/queues/${queue.id}/process`, {
const started = await $fetch<{ jobId: string; count?: number }>(`/api/queues/${queue.id}/process`, {
method: 'POST',
query: { count },
body: { count }
})
queue.currentJobId = started.jobId
const n = count === 'all' ? queue.pendingCount : Number(started.count || count)
liveMessage.value = {
...liveMessage.value,
[queue.id]: n <= 1 ? 'Starting the next shot…' : `Starting ${n} shots…`
}
listen({ ...queue, currentJobId: started.jobId })
await loadQueues()
} catch (err: any) {
@@ -333,6 +351,25 @@ async function remove(queue: Queue) {
await loadQueues()
}
async function clearAll() {
const n = clearableCount.value
if (!n) {
error.value = 'Pause the batch that’s generating before clearing the queue.'
return
}
const running = queues.value.some(queue => queue.status === 'running')
const extra = running ? ' The batch that’s generating will be left until you pause it.' : ''
const label = n === 1 ? 'this batch' : `all ${n} batches`
if (!confirm(`Remove ${label} from the queue? Saved clips stay in the library.${extra}`)) return
error.value = ''
try {
await $fetch('/api/queues', { method: 'DELETE' })
await loadQueues()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not clear the queue'
}
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function saveSegment(queue: Queue, segment: Segment) {
markDirty(queue.id, segment.index)