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:
+196
-2
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user