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
+4 -2
View File
@@ -29,9 +29,11 @@ LIBRARY_DIR=/data/library
# Also removes Qwen Edit input/output files from the image sidecar after saveStill.
# PURGE_COMFY_OUTPUTS=true
# Second Comfy box (Sidecar / Qwen overflow). Beast uses COMFY_HOST first.
# Image edits run on Beast when that Comfy is idle, and roll over to Sidecar only when Beast is busy.
# Second Comfy box (Sidecar / Qwen VL prompt helper). Image edits stay on Beast (COMFY_HOST).
# Recommend sends the still + draft prompt here only — never to Beast.
IMAGE_COMFY_HOST=http://192.168.77.101:8188
# Optional override if prompt-recommend Comfy is not IMAGE_COMFY_HOST
# PROMPT_COMFY_HOST=http://192.168.77.101:8188
IMAGE_COMFY_PORT=8188
# Optional override if image Comfy on Beast is not the video instance
# IMAGE_COMFY_PRIMARY_HOST=http://192.168.77.7:8198
+126 -2
View File
@@ -5,7 +5,7 @@
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">Settings</h2>
<p class="mt-1 text-sm text-zinc-400">Library folders, passwords, and image-preview defaults for this account.</p>
<p class="mt-1 text-sm text-zinc-400">Library folders, passwords, and prompt-helper instruction sets for this account.</p>
</div>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
</div>
@@ -109,6 +109,68 @@
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Add folder</button>
</form>
</section>
<section class="mt-8 space-y-3">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3>
<p class="mt-1 text-sm text-zinc-400">
Instruction sets sent to Sidecar Qwen VL with your still and draft. Use these later for multi-shot video scripts.
</p>
</div>
<div
v-for="preset in promptPresets"
:key="preset.id"
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4 space-y-2"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-200">
{{ preset.name }}
<span v-if="preset.builtin" class="ml-2 text-[10px] uppercase tracking-wide text-zinc-500">Built-in</span>
</p>
<button
v-if="!preset.builtin"
type="button"
class="text-xs text-red-300 hover:text-red-200"
@click="removePreset(preset.id)"
>
Delete
</button>
</div>
<p v-if="preset.description" class="text-xs text-zinc-500">{{ preset.description }}</p>
<template v-if="!preset.builtin">
<input
v-model="preset.name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Name"
@change="savePreset(preset)"
>
<textarea
v-model="preset.systemPrompt"
rows="5"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Instruction set…"
@change="savePreset(preset)"
/>
</template>
</div>
<form class="space-y-2 rounded-2xl border border-dashed border-white/15 p-4" @submit.prevent="addPreset">
<p class="text-xs font-medium uppercase tracking-wide text-zinc-500">New instruction set</p>
<input
v-model="newPresetName"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Name, e.g. Multi-shot tavern script"
>
<textarea
v-model="newPresetPrompt"
rows="4"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Tell Qwen VL how to rewrite the draft. You can add shot-script rules here later."
/>
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Add instruction set</button>
</form>
</section>
</div>
</div>
</Teleport>
@@ -138,6 +200,9 @@ const emit = defineEmits<{
}>()
const newName = ref('')
const newPresetName = ref('')
const newPresetPrompt = ref('')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([])
const loginUser = ref(props.username || '')
const loginCurrent = ref('')
const loginNext = ref('')
@@ -147,7 +212,10 @@ function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close')
}
onMounted(() => window.addEventListener('keydown', onKey))
onMounted(() => {
window.addEventListener('keydown', onKey)
void loadPresets()
})
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
watch(() => props.folders, (folders) => {
@@ -199,6 +267,62 @@ async function addFolder() {
}
}
interface PromptPresetRow {
id: string
name: string
description?: string
builtin?: boolean
systemPrompt?: string
}
async function loadPresets() {
const data = await $fetch<{ presets?: PromptPresetRow[] }>('/api/prompt/presets').catch(() => ({ presets: [] }))
promptPresets.value = data.presets || []
}
async function addPreset() {
const name = newPresetName.value.trim()
const systemPrompt = newPresetPrompt.value.trim()
if (!name || !systemPrompt) {
emit('error', 'Name and instruction text are required')
return
}
try {
const data = await $fetch<{ presets?: PromptPresetRow[] }>('/api/prompt/presets', {
method: 'POST',
body: { name, systemPrompt }
})
promptPresets.value = data.presets || []
newPresetName.value = ''
newPresetPrompt.value = ''
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not save instruction set')
}
}
async function savePreset(preset: PromptPresetRow) {
if (preset.builtin) return
try {
const data = await $fetch<{ presets?: PromptPresetRow[] }>(`/api/prompt/presets/${preset.id}`, {
method: 'PATCH',
body: { name: preset.name, systemPrompt: preset.systemPrompt, description: preset.description }
})
promptPresets.value = data.presets || promptPresets.value
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not update instruction set')
}
}
async function removePreset(id: string) {
if (!confirm('Delete this instruction set?')) return
try {
const data = await $fetch<{ presets?: PromptPresetRow[] }>(`/api/prompt/presets/${id}`, { method: 'DELETE' })
promptPresets.value = data.presets || []
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not delete instruction set')
}
}
async function rename(folder: SettingsFolder) {
const name = drafts[folder.id]?.name?.trim()
if (!name || name === folder.name) return
+6
View File
@@ -36,6 +36,7 @@ export default defineNuxtConfig({
imageComfyLaunchCmd: process.env.IMAGE_COMFY_LAUNCH_CMD || '',
imageComfyControlUrl: process.env.IMAGE_COMFY_CONTROL_URL || '',
imageComfyControlToken: process.env.IMAGE_COMFY_CONTROL_TOKEN || '',
promptComfyHost: process.env.PROMPT_COMFY_HOST || '',
sessionSecret: process.env.NUXT_SESSION_SECRET || '',
oidcClientId: process.env.NUXT_OIDC_CLIENT_ID || '',
oidcClientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
@@ -63,6 +64,11 @@ export default defineNuxtConfig({
'Cache-Control': 'no-cache, no-store, no-transform',
'X-Accel-Buffering': 'no'
}
},
'/api/prompt/**': {
headers: {
'Cache-Control': 'no-cache, no-store, no-transform'
}
}
}
}
+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 })
+38 -1
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>
<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)
+15 -7
View File
@@ -1,13 +1,14 @@
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageComfyConfigured, getBeastImageHost } from '~/server/utils/imageComfy'
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost } from '~/server/utils/imageComfy'
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
export default defineEventHandler(async () => {
const videoConfigured = comfyConfigured()
const imageConfigured = imageComfyConfigured()
const beastHost = getBeastImageHost()
const [comfy, queue, beast, beastQueue] = await Promise.all([
const sidecarHost = getSidecarImageHost()
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
videoConfigured
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
: Promise.resolve({ ok: false, host: '', stats: null }),
@@ -15,10 +16,15 @@ export default defineEventHandler(async () => {
beastHost
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
: Promise.resolve({ ok: false, host: beastHost, stats: null }),
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 })
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 }),
sidecarHost
? checkImageComfyHttp(2500, sidecarHost).catch(() => ({ ok: false, host: sidecarHost, stats: null }))
: Promise.resolve({ ok: false, host: sidecarHost, stats: null }),
sidecarHost ? fetchQueueAt(sidecarHost) : Promise.resolve({ running: 0, pending: 0 })
])
const busy = Boolean(comfy.ok && queue.running > 0)
const beastBusy = Boolean(beast.ok && beastQueue.running > 0)
const sidecarBusy = Boolean(sidecar.ok && sidecarQueue.running > 0)
const imageOk = Boolean(beast.ok || comfy.ok)
const imageBusy = Boolean(imageConfigured && (beastBusy || busy))
const preferredHost = beastHost || comfy.host || ''
@@ -50,10 +56,12 @@ export default defineEventHandler(async () => {
},
sidecar: {
label: 'Sidecar',
configured: false,
ok: false,
busy: false,
host: ''
configured: Boolean(sidecarHost),
ok: Boolean(sidecar.ok),
busy: sidecarBusy,
host: sidecarHost,
queueRunning: sidecarQueue.running || 0,
queuePending: sidecarQueue.pending || 0
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import { listPromptPresets } from '~/server/utils/promptPresets'
export default defineEventHandler(async (event) => {
const owner = libraryOwnerKey(event)
return {
presets: listPromptPresets(owner).map(item => ({
id: item.id,
name: item.name,
description: item.description || '',
builtin: Boolean(item.builtin),
systemPrompt: item.systemPrompt
}))
}
})
+15
View File
@@ -0,0 +1,15 @@
import { createPromptPreset, listPromptPresets } from '~/server/utils/promptPresets'
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; systemPrompt?: string; description?: string }>(event).catch(() => ({}))
const owner = libraryOwnerKey(event)
const created = await createPromptPreset(owner, {
name: body.name || '',
systemPrompt: body.systemPrompt || '',
description: body.description
})
return {
preset: created,
presets: listPromptPresets(owner)
}
})
+12
View File
@@ -0,0 +1,12 @@
import { deletePromptPreset, listPromptPresets } from '~/server/utils/promptPresets'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing instruction set id' })
const owner = libraryOwnerKey(event)
await deletePromptPreset(owner, id)
return {
ok: true,
presets: listPromptPresets(owner)
}
})
+13
View File
@@ -0,0 +1,13 @@
import { updatePromptPreset, listPromptPresets } from '~/server/utils/promptPresets'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing instruction set id' })
const body = await readBody<{ name?: string; systemPrompt?: string; description?: string }>(event).catch(() => ({}))
const owner = libraryOwnerKey(event)
const preset = await updatePromptPreset(owner, id, body)
return {
preset,
presets: listPromptPresets(owner)
}
})
+48
View File
@@ -0,0 +1,48 @@
import { promptComfyConfigured, createPromptJob, runPromptRecommend, failPromptJob, promptJobSnapshot } from '~/server/utils/promptComfy'
import { getPromptPreset } from '~/server/utils/promptPresets'
export default defineEventHandler(async (event) => {
if (!promptComfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.'
})
}
const form = await readMultipartFormData(event).catch(() => null)
if (!form?.length) {
throw createError({ statusCode: 400, statusMessage: 'Expected an image and a draft prompt' })
}
const fields: Record<string, string> = {}
let image: { filename: string; data: Buffer; type?: string } | null = null
for (const part of form) {
if (part.name === 'image' && part.filename && part.data?.length) {
image = { filename: part.filename, data: part.data, type: part.type }
} else if (part.name && part.data) {
fields[part.name] = part.data.toString('utf8')
}
}
if (!image) {
throw createError({ statusCode: 400, statusMessage: 'Load a still before recommending a prompt' })
}
const draftPrompt = (fields.prompt || '').trim()
if (!draftPrompt) {
throw createError({ statusCode: 400, statusMessage: 'Type a draft prompt to enhance' })
}
const owner = libraryOwnerKey(event)
const preset = getPromptPreset(owner, fields.presetId || 'enhance')
const mode = ['edit', 'shot-script', 'video'].includes(fields.mode) ? fields.mode : 'video'
const job = createPromptJob()
void runPromptRecommend(job, {
image,
draftPrompt,
systemPrompt: preset.systemPrompt,
mode
}).catch(error => failPromptJob(job, error))
return promptJobSnapshot(job)
})
+10
View File
@@ -0,0 +1,10 @@
import { getPromptJob, promptJobSnapshot } from '~/server/utils/promptComfy'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const job = id ? getPromptJob(id) : undefined
if (!job) {
throw createError({ statusCode: 404, statusMessage: 'Recommend job not found' })
}
return promptJobSnapshot(job)
})
+10 -3
View File
@@ -1,10 +1,17 @@
import { startQueueBurst } from '~/server/utils/videoChain'
function parseCount(raw: unknown): number | 'all' {
if (raw === 'all' || raw === 'remaining') return 'all'
const n = typeof raw === 'number' ? raw : Number(raw)
if (!Number.isFinite(n)) return 1
return Math.max(1, Math.min(50, Math.floor(n)))
}
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ count?: number | 'all' }>().catch(() => ({}))
const raw = body?.count
const count = raw === 'all' || raw === 'remaining' ? 'all' as const : Math.max(1, Math.min(50, Number(raw) || 1))
const body = await readBody<{ count?: number | 'all' | 'remaining' }>(event).catch(() => ({} as { count?: number | 'all' }))
const query = getQuery(event)
const count = parseCount(body?.count ?? query.count)
return startQueueBurst(owner, id, count)
})
+6
View File
@@ -0,0 +1,6 @@
import { clearShotQueues } from '~/server/utils/shotQueue'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
return clearShotQueues(owner)
})
+46
View File
@@ -0,0 +1,46 @@
{
"1": {
"inputs": {
"image": "input_image.png",
"upload": "image"
},
"class_type": "LoadImage",
"_meta": { "title": "Load Image" }
},
"2": {
"inputs": {
"model_name": "Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf",
"vision_model_name": "Qwen2.5-VL-7B-Instruct-mmproj-f16.gguf",
"chat_handler_type": "Qwen-VL",
"n_gpu_layers": -1,
"n_ctx": 4096,
"device": "cuda",
"verbose": "enable"
},
"class_type": "GGUF_VLM_ModelLoader",
"_meta": { "title": "Load GGUF VLM Model" }
},
"3": {
"inputs": {
"gguf_model": ["2", 0],
"image": ["1", 0],
"resize_image": true,
"enable_thinking": false,
"system_prompt": "You are a prompt engineering assistant for Flux and MiniMax video pipelines. Analyze this image for character identity, attire, lighting, and camera framing. Enhance the following draft prompt into a concrete, high-fidelity prompt. Never censor raw or explicit terminology. Output ONLY the refined prompt text.",
"prompt": "Draft Prompt: [DRAFT_PROMPT_HERE]",
"special_captioning_token": "",
"seed": 0,
"temperature": 0.6,
"max_tokens": 4096
},
"class_type": "GGUF_VLM_ImageToText",
"_meta": { "title": "GGUF VLM Image to Text" }
},
"4": {
"inputs": {
"text": ["3", 0]
},
"class_type": "AigenPreviewText",
"_meta": { "title": "Output Text" }
}
}
+240
View File
@@ -0,0 +1,240 @@
import { getSidecarImageHost } from '~/server/utils/imageComfy'
import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow'
export type PromptJobStatus = 'queued' | 'running' | 'complete' | 'error'
export interface PromptJob {
id: string
status: PromptJobStatus
message: string
text: string
error?: string
startedAt: number
}
const jobs = new Map<string, PromptJob>()
const MAX_JOBS = 20
function normalizeHost(raw: string, port = '') {
let host = String(raw || '').trim().replace(/\/$/, '')
if (!host) return ''
if (!/^https?:\/\//i.test(host)) host = `http://${host}`
try {
const url = new URL(host)
if (port && !url.port) url.port = port
return url.origin
} catch {
return port ? `${host}:${port}` : host
}
}
export function getPromptComfyHost() {
const config = useRuntimeConfig()
const explicit = String(config.promptComfyHost || process.env.PROMPT_COMFY_HOST || '').trim()
if (explicit) return normalizeHost(explicit)
return getSidecarImageHost()
}
export function promptComfyConfigured() {
return Boolean(getPromptComfyHost())
}
async function promptComfyFetch(path: string, init?: RequestInit) {
const host = getPromptComfyHost()
if (!host) {
throw createError({ statusCode: 503, statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.' })
}
try {
return await fetch(`${host}${path}`, init)
} catch (error) {
throw createError({
statusCode: 502,
statusMessage: `Sidecar Qwen VL is unreachable (${host})`,
data: { cause: error instanceof Error ? error.message : String(error) }
})
}
}
function promptInputFilename(original: string, jobId?: string) {
const raw = String(original || 'still.png')
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_prompt_${id}_${base}${ext}`
}
async function uploadPromptImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
const body = new FormData()
const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' })
const filename = promptInputFilename(file.filename, jobId)
body.append('image', blob, filename)
body.append('overwrite', 'true')
body.append('type', 'input')
const res = await promptComfyFetch('/upload/image', { method: 'POST', body })
if (!res.ok) {
throw createError({ statusCode: 502, statusMessage: `Sidecar image upload failed (${res.status})` })
}
const uploaded = (await res.json()) as { name: string; subfolder?: string; type?: string }
return uploaded.name || filename
}
async function queuePromptGraph(graph: unknown, clientId: string) {
const res = await promptComfyFetch('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: graph, client_id: clientId })
})
const payload = await res.json().catch(() => ({}))
if (!res.ok) {
const message = (payload as { error?: { message?: string } }).error?.message
|| (payload as { node_errors?: unknown }).node_errors
|| `Sidecar queue failed (${res.status})`
throw createError({ statusCode: 502, statusMessage: String(message), data: payload })
}
return payload as { prompt_id: string }
}
async function fetchPromptHistory(promptId: string) {
const res = await promptComfyFetch(`/history/${encodeURIComponent(promptId)}`)
if (!res.ok) return null
return (await res.json()) as Record<string, unknown>
}
function collectStrings(value: unknown, depth = 0): string[] {
if (value == null || depth > 8) return []
if (typeof value === 'string') {
const text = value.trim()
return text ? [text] : []
}
if (typeof value === 'number' || typeof value === 'boolean') return []
if (Array.isArray(value)) return value.flatMap(item => collectStrings(item, depth + 1))
if (typeof value === 'object') {
const record = value as Record<string, unknown>
for (const key of ['text', 'string', 'strings']) {
if (record[key] !== undefined) {
const found = collectStrings(record[key], depth + 1)
if (found.length) return found
}
}
return Object.values(record).flatMap(item => collectStrings(item, depth + 1))
}
return []
}
function extractPromptText(history: Record<string, unknown> | null, promptId: string) {
if (!history) return ''
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
const outputs = wrapped?.outputs || (history as { outputs?: Record<string, unknown> }).outputs
if (!outputs || typeof outputs !== 'object') return ''
const node4 = outputs['4']
const candidates = [
...(node4 ? collectStrings(node4) : []),
...collectStrings(outputs)
].sort((a, b) => b.length - a.length)
return candidates[0] || ''
}
function historyError(history: Record<string, unknown> | null, promptId: string) {
const entry = history?.[promptId] as {
status?: {
status_str?: string
completed?: boolean
messages?: Array<[string, Record<string, unknown>]>
}
} | undefined
const status = entry?.status?.status_str
const err = entry?.status?.messages?.find(([type]) => type === 'execution_error')?.[1]
const message = String(err?.exception_message || err?.message || '').trim()
if (status === 'interrupted') return 'Prompt recommend was interrupted.'
if (status === 'error') return message || 'Sidecar Qwen VL reported an execution error'
if (entry?.status?.completed && !extractPromptText(history, promptId)) {
return 'Sidecar Qwen VL finished without prompt text'
}
return null
}
async function waitForPromptText(promptId: string, timeoutMs = 240_000) {
const started = Date.now()
while (Date.now() - started < timeoutMs) {
const history = await fetchPromptHistory(promptId)
const text = extractPromptText(history, promptId)
if (text) return text
const error = historyError(history, promptId)
if (error) {
throw createError({ statusCode: 502, statusMessage: error })
}
await new Promise(resolve => setTimeout(resolve, 1200))
}
throw createError({ statusCode: 504, statusMessage: 'Sidecar Qwen VL timed out while writing the prompt' })
}
export function createPromptJob(): PromptJob {
const job: PromptJob = {
id: crypto.randomUUID(),
status: 'queued',
message: 'Queued',
text: '',
startedAt: Date.now()
}
jobs.set(job.id, job)
while (jobs.size > MAX_JOBS) {
const oldest = jobs.keys().next().value
if (oldest) jobs.delete(oldest)
}
return job
}
export function getPromptJob(id: string) {
return jobs.get(id)
}
export function promptJobSnapshot(job: PromptJob) {
return {
jobId: job.id,
status: job.status,
message: job.message,
text: job.text,
error: job.error,
elapsedMs: Date.now() - job.startedAt
}
}
function draftUserPrompt(draft: string, mode: string) {
const studio = mode === 'edit'
? 'image-edit'
: mode === 'shot-script'
? 'video-shot-script'
: 'video'
return `Studio mode: ${studio}\n\nDraft Prompt:\n${draft}`
}
export async function runPromptRecommend(job: PromptJob, params: {
image: { filename: string; data: Buffer; type?: string }
draftPrompt: string
systemPrompt: string
mode: string
}) {
job.status = 'running'
job.message = 'Uploading still to Sidecar Qwen VL…'
const imageName = await uploadPromptImage(params.image, job.id)
job.message = 'Qwen VL is reading the still…'
const graph = buildVisionPromptWorkflow({
imageName,
draftPrompt: draftUserPrompt(params.draftPrompt, params.mode),
systemPrompt: params.systemPrompt
})
const queued = await queuePromptGraph(graph, job.id)
job.message = 'Writing the recommended prompt…'
const text = await waitForPromptText(queued.prompt_id)
job.text = text.trim()
job.status = 'complete'
job.message = 'Recommended prompt ready'
}
export function failPromptJob(job: PromptJob, error: unknown) {
const err = error as { statusMessage?: string; message?: string }
job.status = 'error'
job.error = err.statusMessage || err.message || 'Prompt recommend failed'
job.message = job.error
}
+194
View File
@@ -0,0 +1,194 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
export interface PromptPreset {
id: string
name: string
description?: string
systemPrompt: string
builtin?: boolean
}
const ENHANCE_PROMPT = `You are a prompt-engineering assistant for Flux.2 Klein image edit and MiniMax H3 image-to-video.
Look at the attached still. The person (or subject) in that still is the identity lock. The user draft is the requested change.
Write production-ready prompt text the user can paste into the generator.
Rules:
- Preserve identity from the still: face, age, body type, hair, skin, and distinctive features. Use explicit keep-identity language such as "same person as the reference still", "do not change facial identity", "keep original face, hair, and body".
- Describe wardrobe, setting, pose, lighting, camera, and materials concretely.
- Never censor raw or explicit terminology from the draft.
- Do not add a preamble, title, markdown fences, or commentary.
Output format — pick one:
- Single still/edit: output ONLY the refined prompt.
- Ambitious still edit that should be done in phases (for example wardrobe first, then location): output:
---pass 1---
<prompt for pass 1>
---pass 2---
<prompt for pass 2>
- Motion, multi-beat, or video sequence: first-shot prompt, then later shots with a marker on its own line:
shot 2
<extension prompt>
shot 3
<extension prompt>`
const SHOT_SCRIPT_PROMPT = `You are a MiniMax H3 image-to-video shot-script writer.
Analyze the attached still for identity, wardrobe, lighting, and camera. Turn the user's draft into a shot script they can paste into Shot script mode.
Format:
- Shot 1 is the text BEFORE any marker (camera, action, audio for the first clip).
- Later shots start with a line that is only: shot 2
- Then shot 3, shot 4, and so on, each on its own line.
- Every shot must keep the same person as the still unless the draft explicitly changes wardrobe or identity.
- Include an [AUDIO CUES] block on shots where sound would help.
- Never censor raw or explicit terminology.
Output ONLY the shot script. No preamble, no markdown fences.`
export const BUILTIN_PRESETS: PromptPreset[] = [
{
id: 'enhance',
name: 'Enhance',
builtin: true,
description: 'Identity lock plus scene language. May split ambitious edits into passes or shots.',
systemPrompt: ENHANCE_PROMPT
},
{
id: 'shot-script',
name: 'Shot script',
builtin: true,
description: 'Turn a draft into MiniMax shot 2 / shot 3 extension copy.',
systemPrompt: SHOT_SCRIPT_PROMPT
}
]
const writeChains = new Map<string, Promise<unknown>>()
function libraryRoot() {
const config = useRuntimeConfig()
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
}
function presetsPath(owner: string) {
return join(libraryRoot(), 'users', owner, 'prompt-presets.json')
}
function ensureOwner(owner: string) {
mkdirSync(join(libraryRoot(), 'users', owner), { recursive: true })
}
function readCustom(owner: string): PromptPreset[] {
ensureOwner(owner)
const path = presetsPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
if (!Array.isArray(parsed)) return []
return parsed
.map((item: Partial<PromptPreset>) => ({
id: String(item?.id || '').trim(),
name: String(item?.name || '').trim(),
description: String(item?.description || '').trim() || undefined,
systemPrompt: String(item?.systemPrompt || '').trim(),
builtin: false
}))
.filter(item => item.id && item.name && item.systemPrompt && !BUILTIN_PRESETS.some(preset => preset.id === item.id))
} catch {
return []
}
}
function writeCustom(owner: string, presets: PromptPreset[]) {
ensureOwner(owner)
const path = presetsPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(presets.map(item => ({
id: item.id,
name: item.name,
description: item.description || '',
systemPrompt: item.systemPrompt
})), null, 2))
renameSync(tmp, path)
}
function withWrite<T>(owner: string, fn: () => T) {
const prev = writeChains.get(owner) || Promise.resolve()
const next = prev.then(fn, fn)
writeChains.set(owner, next.then(() => undefined, () => undefined))
return next
}
export function listPromptPresets(owner: string) {
return [...BUILTIN_PRESETS, ...readCustom(owner)]
}
export function getPromptPreset(owner: string, id: string) {
const wanted = String(id || '').trim() || 'enhance'
return listPromptPresets(owner).find(item => item.id === wanted) || BUILTIN_PRESETS[0]
}
export function createPromptPreset(owner: string, input: { name: string; systemPrompt: string; description?: string }) {
const name = String(input.name || '').trim()
const systemPrompt = String(input.systemPrompt || '').trim()
const description = String(input.description || '').trim()
if (!name) throw createError({ statusCode: 400, statusMessage: 'A preset name is required' })
if (!systemPrompt) throw createError({ statusCode: 400, statusMessage: 'An instruction set is required' })
return withWrite(owner, () => {
const presets = readCustom(owner)
const created: PromptPreset = {
id: `custom_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`,
name,
description: description || undefined,
systemPrompt
}
presets.push(created)
writeCustom(owner, presets)
return created
})
}
export function updatePromptPreset(owner: string, id: string, input: { name?: string; systemPrompt?: string; description?: string }) {
if (BUILTIN_PRESETS.some(item => item.id === id)) {
throw createError({ statusCode: 400, statusMessage: 'Built-in instruction sets cannot be edited' })
}
return withWrite(owner, () => {
const presets = readCustom(owner)
const index = presets.findIndex(item => item.id === id)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Instruction set not found' })
const next = { ...presets[index] }
if (input.name !== undefined) {
const name = String(input.name || '').trim()
if (!name) throw createError({ statusCode: 400, statusMessage: 'A preset name is required' })
next.name = name
}
if (input.systemPrompt !== undefined) {
const systemPrompt = String(input.systemPrompt || '').trim()
if (!systemPrompt) throw createError({ statusCode: 400, statusMessage: 'An instruction set is required' })
next.systemPrompt = systemPrompt
}
if (input.description !== undefined) {
next.description = String(input.description || '').trim() || undefined
}
presets[index] = next
writeCustom(owner, presets)
return next
})
}
export function deletePromptPreset(owner: string, id: string) {
if (BUILTIN_PRESETS.some(item => item.id === id)) {
throw createError({ statusCode: 400, statusMessage: 'Built-in instruction sets cannot be deleted' })
}
return withWrite(owner, () => {
const presets = readCustom(owner)
const next = presets.filter(item => item.id !== id)
if (next.length === presets.length) {
throw createError({ statusCode: 404, statusMessage: 'Instruction set not found' })
}
writeCustom(owner, next)
return { ok: true }
})
}
+23
View File
@@ -0,0 +1,23 @@
import template from '../assets/workflow_vision_prompt.json'
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
type WorkflowGraph = Record<string, WorkflowNode>
const IMAGE_NODE = '1'
const TEXT_NODE = '3'
export function buildVisionPromptWorkflow(params: {
imageName: string
draftPrompt: string
systemPrompt: string
seed?: number
}) {
const graph = structuredClone(template) as WorkflowGraph
if (graph[IMAGE_NODE]) graph[IMAGE_NODE].inputs.image = params.imageName
if (graph[TEXT_NODE]) {
graph[TEXT_NODE].inputs.system_prompt = params.systemPrompt
graph[TEXT_NODE].inputs.prompt = params.draftPrompt
graph[TEXT_NODE].inputs.seed = Number.isFinite(params.seed) ? Number(params.seed) : Math.floor(Math.random() * 2_147_483_647)
}
return graph
}
+16
View File
@@ -370,6 +370,22 @@ export async function deleteShotQueue(owner: string, id: string) {
})
}
export async function clearShotQueues(owner: string) {
return mutate(owner, (queues) => {
const kept: ShotQueue[] = []
let removed = 0
for (const queue of queues) {
if (activeJobs.has(queue.id)) {
kept.push(queue)
continue
}
removed += 1
}
queues.splice(0, queues.length, ...kept)
return { removed, remaining: kept.length }
})
}
export function listOwnersWithQueues() {
const root = join(libraryRoot(), 'users')
if (!existsSync(root)) return []
+1 -2
View File
@@ -335,8 +335,7 @@ export async function continueQueuedExtensions(
job.library.extendPart1Path = undefined
}
if (persist && remainingAfter > 0) break
if (job.library.stopAfterCurrent) break
if (shotsLeft <= 0 || job.library.stopAfterCurrent) break
}
} finally {
if (job.library) job.library.chainContinuing = false
+7 -1
View File
@@ -229,7 +229,13 @@ function applyV2IdentityPath(
delete graph['173']
V2_REF_LOADERS.forEach((id, index) => {
if (!graph[id]) return
graph[id].inputs.image = refs[index] || params.imageName
const name = refs[index]
if (name) {
graph[id].inputs.image = name
return
}
delete graph[id]
delete graph['155'].inputs[`ref_images.ref_image_${index + 1}`]
})
if (graph['150']?.inputs) graph['150'].inputs.value = true
return
+4 -4
View File
@@ -11,13 +11,13 @@ export function identityBoilerplate(extraRefs: number | number[] = 0) {
const secondary = extras.length === 0
? ''
: extras.length === 1
? ` <Picture ${extras[0]}> is a secondary reference angle for profile details.`
: ` ${extras.map(n => `<Picture ${n}>`).join(' and ')} are secondary reference angles for profile features only.`
return `<Picture 1> is the primary identity anchor defining exact facial geometry, hairstyle, and body proportions.${secondary} Strictly maintain the structural proportions of <Picture 1> with high fidelity when moving.`
? ` <Picture ${extras[0]}> is another view of that same person for identity only, not a second character.`
: ` ${extras.map(n => `<Picture ${n}>`).join(' and ')} are extra views of that same person for identity only, not additional characters.`
return `<Picture 1> is the identity lock for a single main subject: exact face, hair, body, and clothing.${secondary} Do not spawn extra people from reference stills. If this subject leaves the frame, they must return as the same person from <Picture 1>.`
}
export function looksLikeIdentityPrompt(text: string) {
return /^<Picture 1> is the (primary identity anchor|exact facial identity)/i.test(String(text || '').trim())
return /^<Picture 1> is the (identity lock|primary identity anchor|exact facial identity)/i.test(String(text || '').trim())
}
export function buildIdentityPrompt(actionPrompt: string, extraRefs: number | number[] = 0) {
+23
View File
@@ -0,0 +1,23 @@
export function parseEditPasses(text: string): string[] | null {
const src = String(text || '').replace(/\r\n/g, '\n')
const marker = /^[ \t]*---pass\s+(\d+)---[ \t]*$/gim
const markers = [...src.matchAll(marker)]
if (!markers.length) return null
const parts: string[] = []
const preamble = src.slice(0, markers[0].index || 0).trim()
for (let i = 0; i < markers.length; i++) {
const from = (markers[i].index || 0) + markers[i][0].length
const to = i + 1 < markers.length ? (markers[i + 1].index || src.length) : src.length
const body = src.slice(from, to).trim()
if (body) parts.push(body)
}
if (!parts.length) return null
if (preamble && preamble.length > 40) {
parts[0] = `${preamble}\n\n${parts[0]}`
}
return parts
}
export function looksLikeShotScript(text: string) {
return /^[ \t]*shot\s+[2-9]\d*\b/im.test(String(text || ''))
}