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:
+4
-2
@@ -29,9 +29,11 @@ LIBRARY_DIR=/data/library
|
|||||||
# Also removes Qwen Edit input/output files from the image sidecar after saveStill.
|
# Also removes Qwen Edit input/output files from the image sidecar after saveStill.
|
||||||
# PURGE_COMFY_OUTPUTS=true
|
# PURGE_COMFY_OUTPUTS=true
|
||||||
|
|
||||||
# Second Comfy box (Sidecar / Qwen overflow). Beast uses COMFY_HOST first.
|
# Second Comfy box (Sidecar / Qwen VL prompt helper). Image edits stay on Beast (COMFY_HOST).
|
||||||
# Image edits run on Beast when that Comfy is idle, and roll over to Sidecar only when Beast is busy.
|
# Recommend sends the still + draft prompt here only — never to Beast.
|
||||||
IMAGE_COMFY_HOST=http://192.168.77.101:8188
|
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
|
IMAGE_COMFY_PORT=8188
|
||||||
# Optional override if image Comfy on Beast is not the video instance
|
# Optional override if image Comfy on Beast is not the video instance
|
||||||
# IMAGE_COMFY_PRIMARY_HOST=http://192.168.77.7:8198
|
# IMAGE_COMFY_PRIMARY_HOST=http://192.168.77.7:8198
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-display text-2xl font-bold">Settings</h2>
|
<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>
|
</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>
|
<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>
|
</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>
|
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Add folder</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -138,6 +200,9 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const newName = ref('')
|
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 loginUser = ref(props.username || '')
|
||||||
const loginCurrent = ref('')
|
const loginCurrent = ref('')
|
||||||
const loginNext = ref('')
|
const loginNext = ref('')
|
||||||
@@ -147,7 +212,10 @@ function onKey(event: KeyboardEvent) {
|
|||||||
if (event.key === 'Escape') emit('close')
|
if (event.key === 'Escape') emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => window.addEventListener('keydown', onKey))
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
void loadPresets()
|
||||||
|
})
|
||||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||||
|
|
||||||
watch(() => props.folders, (folders) => {
|
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) {
|
async function rename(folder: SettingsFolder) {
|
||||||
const name = drafts[folder.id]?.name?.trim()
|
const name = drafts[folder.id]?.name?.trim()
|
||||||
if (!name || name === folder.name) return
|
if (!name || name === folder.name) return
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default defineNuxtConfig({
|
|||||||
imageComfyLaunchCmd: process.env.IMAGE_COMFY_LAUNCH_CMD || '',
|
imageComfyLaunchCmd: process.env.IMAGE_COMFY_LAUNCH_CMD || '',
|
||||||
imageComfyControlUrl: process.env.IMAGE_COMFY_CONTROL_URL || '',
|
imageComfyControlUrl: process.env.IMAGE_COMFY_CONTROL_URL || '',
|
||||||
imageComfyControlToken: process.env.IMAGE_COMFY_CONTROL_TOKEN || '',
|
imageComfyControlToken: process.env.IMAGE_COMFY_CONTROL_TOKEN || '',
|
||||||
|
promptComfyHost: process.env.PROMPT_COMFY_HOST || '',
|
||||||
sessionSecret: process.env.NUXT_SESSION_SECRET || '',
|
sessionSecret: process.env.NUXT_SESSION_SECRET || '',
|
||||||
oidcClientId: process.env.NUXT_OIDC_CLIENT_ID || '',
|
oidcClientId: process.env.NUXT_OIDC_CLIENT_ID || '',
|
||||||
oidcClientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
|
oidcClientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
|
||||||
@@ -63,6 +64,11 @@ export default defineNuxtConfig({
|
|||||||
'Cache-Control': 'no-cache, no-store, no-transform',
|
'Cache-Control': 'no-cache, no-store, no-transform',
|
||||||
'X-Accel-Buffering': 'no'
|
'X-Accel-Buffering': 'no'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'/api/prompt/**': {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache, no-store, no-transform'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-2
@@ -138,6 +138,24 @@
|
|||||||
>
|
>
|
||||||
Insert shot template
|
Insert shot template
|
||||||
</button>
|
</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">
|
<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">
|
<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">
|
<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"
|
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"
|
: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-if="studioMode === 'edit'" class="mt-3 space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in editPassQueue"
|
v-for="(item, index) in editPassQueue"
|
||||||
@@ -349,7 +398,7 @@
|
|||||||
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
|
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
|
||||||
<span>
|
<span>
|
||||||
<span class="block font-medium text-zinc-200">Use identity references</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>
|
||||||
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
<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">
|
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch">
|
||||||
@@ -395,7 +444,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1206,6 +1255,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
|
||||||
|
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
|
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
|
||||||
@@ -1323,6 +1374,20 @@ const preview = ref('')
|
|||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
const prompt = ref('')
|
const prompt = ref('')
|
||||||
const shotScriptMode = ref(false)
|
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 extensionQueue = ref<QueuedExtension[]>([])
|
||||||
const editPassQueue = ref<QueuedEditPass[]>([])
|
const editPassQueue = ref<QueuedEditPass[]>([])
|
||||||
const clipName = ref('')
|
const clipName = ref('')
|
||||||
@@ -1608,6 +1673,21 @@ const queueReady = computed(() => {
|
|||||||
const videoGenerateDisabled = computed(() => {
|
const videoGenerateDisabled = computed(() => {
|
||||||
return videoBusy.value || !file.value || !prompt.value.trim() || !folderId.value || !queueReady.value
|
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(() => {
|
const editBlocked = computed(() => {
|
||||||
if (editBusy.value) return true
|
if (editBusy.value) return true
|
||||||
if (!file.value || !prompt.value.trim() || !folderId.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'
|
videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1'
|
||||||
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
|
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
|
||||||
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
||||||
|
const savedPreset = localStorage.getItem('aigen-prompt-preset')
|
||||||
|
if (savedPreset) recommendPresetId.value = savedPreset
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
applyInstancePrivacy()
|
applyInstancePrivacy()
|
||||||
window.addEventListener('pointerdown', onSitePointerDown, true)
|
window.addEventListener('pointerdown', onSitePointerDown, true)
|
||||||
@@ -2044,6 +2126,7 @@ onMounted(async () => {
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
await resumeActiveJob()
|
await resumeActiveJob()
|
||||||
await refreshLiveQueue()
|
await refreshLiveQueue()
|
||||||
|
await loadPromptPresets()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -2059,6 +2142,14 @@ watch(folderId, () => {
|
|||||||
applyThumbnailDefault()
|
applyThumbnailDefault()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(settingsOpen, (open) => {
|
||||||
|
if (!open) void loadPromptPresets()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(recommendPresetId, (id) => {
|
||||||
|
try { localStorage.setItem('aigen-prompt-preset', id) } catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
|
||||||
watch([browseFolderId, libraryFilter], () => {
|
watch([browseFolderId, libraryFilter], () => {
|
||||||
selectedKeys.value = []
|
selectedKeys.value = []
|
||||||
lastSelectedKey.value = ''
|
lastSelectedKey.value = ''
|
||||||
@@ -2162,6 +2253,109 @@ function insertIdentityTemplate() {
|
|||||||
: IDENTITY_ACTION_TEMPLATE
|
: 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) {
|
function toast(message: string) {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
toasts.value.push({ id, message })
|
toasts.value.push({ id, message })
|
||||||
|
|||||||
+38
-1
@@ -26,7 +26,17 @@
|
|||||||
Each shot ticks off when it saves. If a run stops, continue from the next unchecked scene.
|
Each shot ticks off when it saves. If a run stops, continue from the next unchecked scene.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
<p v-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>()
|
const sources = new Map<string, EventSource>()
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const clearableCount = computed(() => queues.value.filter(queue => queue.status !== 'running').length)
|
||||||
|
|
||||||
function dirtyKey(queueId: string, index: number) {
|
function dirtyKey(queueId: string, index: number) {
|
||||||
return `${queueId}:${index}`
|
return `${queueId}:${index}`
|
||||||
}
|
}
|
||||||
@@ -306,11 +318,17 @@ async function process(queue: Queue, count: number | 'all') {
|
|||||||
}
|
}
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
for (const segment of queue.segments) dirty.delete(dirtyKey(queue.id, segment.index))
|
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',
|
method: 'POST',
|
||||||
|
query: { count },
|
||||||
body: { count }
|
body: { count }
|
||||||
})
|
})
|
||||||
queue.currentJobId = started.jobId
|
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 })
|
listen({ ...queue, currentJobId: started.jobId })
|
||||||
await loadQueues()
|
await loadQueues()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -333,6 +351,25 @@ async function remove(queue: Queue) {
|
|||||||
await loadQueues()
|
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
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
function saveSegment(queue: Queue, segment: Segment) {
|
function saveSegment(queue: Queue, segment: Segment) {
|
||||||
markDirty(queue.id, segment.index)
|
markDirty(queue.id, segment.index)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
||||||
import { comfyConfigured } from '~/server/utils/comfy'
|
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'
|
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
|
||||||
|
|
||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
const videoConfigured = comfyConfigured()
|
const videoConfigured = comfyConfigured()
|
||||||
const imageConfigured = imageComfyConfigured()
|
const imageConfigured = imageComfyConfigured()
|
||||||
const beastHost = getBeastImageHost()
|
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
|
videoConfigured
|
||||||
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
: Promise.resolve({ ok: false, host: '', stats: null }),
|
||||||
@@ -15,10 +16,15 @@ export default defineEventHandler(async () => {
|
|||||||
beastHost
|
beastHost
|
||||||
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
|
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
|
||||||
: Promise.resolve({ 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 busy = Boolean(comfy.ok && queue.running > 0)
|
||||||
const beastBusy = Boolean(beast.ok && beastQueue.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 imageOk = Boolean(beast.ok || comfy.ok)
|
||||||
const imageBusy = Boolean(imageConfigured && (beastBusy || busy))
|
const imageBusy = Boolean(imageConfigured && (beastBusy || busy))
|
||||||
const preferredHost = beastHost || comfy.host || ''
|
const preferredHost = beastHost || comfy.host || ''
|
||||||
@@ -50,10 +56,12 @@ export default defineEventHandler(async () => {
|
|||||||
},
|
},
|
||||||
sidecar: {
|
sidecar: {
|
||||||
label: 'Sidecar',
|
label: 'Sidecar',
|
||||||
configured: false,
|
configured: Boolean(sidecarHost),
|
||||||
ok: false,
|
ok: Boolean(sidecar.ok),
|
||||||
busy: false,
|
busy: sidecarBusy,
|
||||||
host: ''
|
host: sidecarHost,
|
||||||
|
queueRunning: sidecarQueue.running || 0,
|
||||||
|
queuePending: sidecarQueue.pending || 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { startQueueBurst } from '~/server/utils/videoChain'
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const body = await readBody<{ count?: number | 'all' }>().catch(() => ({}))
|
const body = await readBody<{ count?: number | 'all' | 'remaining' }>(event).catch(() => ({} as { count?: number | 'all' }))
|
||||||
const raw = body?.count
|
const query = getQuery(event)
|
||||||
const count = raw === 'all' || raw === 'remaining' ? 'all' as const : Math.max(1, Math.min(50, Number(raw) || 1))
|
const count = parseCount(body?.count ?? query.count)
|
||||||
return startQueueBurst(owner, id, count)
|
return startQueueBurst(owner, id, count)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clearShotQueues } from '~/server/utils/shotQueue'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
return clearShotQueues(owner)
|
||||||
|
})
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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() {
|
export function listOwnersWithQueues() {
|
||||||
const root = join(libraryRoot(), 'users')
|
const root = join(libraryRoot(), 'users')
|
||||||
if (!existsSync(root)) return []
|
if (!existsSync(root)) return []
|
||||||
|
|||||||
@@ -335,8 +335,7 @@ export async function continueQueuedExtensions(
|
|||||||
job.library.extendPart1Path = undefined
|
job.library.extendPart1Path = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (persist && remainingAfter > 0) break
|
if (shotsLeft <= 0 || job.library.stopAfterCurrent) break
|
||||||
if (job.library.stopAfterCurrent) break
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (job.library) job.library.chainContinuing = false
|
if (job.library) job.library.chainContinuing = false
|
||||||
|
|||||||
@@ -229,7 +229,13 @@ function applyV2IdentityPath(
|
|||||||
delete graph['173']
|
delete graph['173']
|
||||||
V2_REF_LOADERS.forEach((id, index) => {
|
V2_REF_LOADERS.forEach((id, index) => {
|
||||||
if (!graph[id]) return
|
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
|
if (graph['150']?.inputs) graph['150'].inputs.value = true
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ export function identityBoilerplate(extraRefs: number | number[] = 0) {
|
|||||||
const secondary = extras.length === 0
|
const secondary = extras.length === 0
|
||||||
? ''
|
? ''
|
||||||
: extras.length === 1
|
: extras.length === 1
|
||||||
? ` <Picture ${extras[0]}> is a secondary reference angle for profile details.`
|
? ` <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 secondary reference angles for profile features only.`
|
: ` ${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 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.`
|
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) {
|
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) {
|
export function buildIdentityPrompt(actionPrompt: string, extraRefs: number | number[] = 0) {
|
||||||
|
|||||||
@@ -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 || ''))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user