Add LoRA stacks with per-item strengths and named video/image generation presets so setups survive reload without mixing Klein onto MiniMax. Keep LTX Pink Cherry and xAIGen LoRAs on xAIGen, and run Recommend on Beast.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 15:24:44 -05:00
co-authored by Cursor
parent 0af3cddb14
commit f8db0d626a
36 changed files with 1767 additions and 246 deletions
+8 -13
View File
@@ -22,24 +22,19 @@ LIBRARY_DIR=/data/library
# Private clone (xaigen): password login, isolated library, Comfy prefix
# AUTH_MODE=password
# NUXT_PUBLIC_INSTANCE_NAME=xAIGen
# NUXT_PUBLIC_STUDIO=xaigen
# AUTH_USERNAME=
# AUTH_PASSWORD=
# COMFY_FILENAME_PREFIX=video/xAIGen
# Desktop Comfy files are deleted after a successful library save (set false to keep them).
# Also removes Qwen Edit input/output files from the image sidecar after saveStill.
# Also removes Qwen Edit input/output files from Beast after saveStill.
# PURGE_COMFY_OUTPUTS=true
# 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
# Recommend / Qwen VL uses Beast (same Comfy as video). Do not point this at 192.168.77.101.
IMAGE_COMFY_HOST=http://192.168.77.7:8198
# Optional override if prompt-recommend Comfy is not IMAGE_COMFY_HOST / COMFY_HOST
# PROMPT_COMFY_HOST=http://192.168.77.7:8198
IMAGE_COMFY_PORT=8198
# Optional override if image Comfy on Beast is not the video instance
# IMAGE_COMFY_PRIMARY_HOST=http://192.168.77.7:8198
# IMAGE_COMFY_FALLBACK_HOST=http://192.168.77.101:8188
# Spawned on the sidecar PC (host agent /start and start_comfyui.bat). Listen is 0.0.0.0 so Coolify can reach :8188.
IMAGE_COMFY_LAUNCH_CMD=C:\ComfyUI-QwenEdit\python\python.exe C:\ComfyUI-QwenEdit\ComfyUI\main.py --listen 0.0.0.0 --port 8188 --disable-auto-launch --lowvram --fp8_e4m3fn-unet --disable-cuda-malloc
# Optional host agent on the sidecar (scripts/comfy-host-agent.mjs)
# IMAGE_COMFY_CONTROL_URL=http://192.168.77.101:8199
# IMAGE_COMFY_CONTROL_TOKEN=
IMAGE_COMFY_LAUNCH_CMD=
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="flex min-w-0 flex-wrap items-end gap-2">
<label class="min-w-[9rem] flex-1 text-xs">
<span class="mb-1 block text-zinc-500">Preset</span>
<select
:value="selectedId"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-2 py-1 text-xs outline-none ring-amber-300/40 focus:ring-2"
:disabled="disabled"
@change="onPick"
>
<option value="">Load…</option>
<option
v-for="preset in presets"
:key="preset.id"
:value="preset.id"
>{{ preset.name }}</option>
</select>
</label>
<label class="w-32 text-xs">
<span class="mb-1 block text-zinc-500">Name</span>
<input
:value="name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-2 py-1 text-xs outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Save as…"
maxlength="80"
:disabled="disabled"
@input="emit('update:name', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="emit('save')"
>
</label>
<button
type="button"
class="rounded-xl border border-white/10 px-2 py-1 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-50"
:disabled="disabled || !name.trim()"
@click="emit('save')"
>
Save
</button>
<button
v-if="selectedId"
type="button"
class="rounded-xl border border-white/10 px-2 py-1 text-xs text-zinc-500 hover:border-red-400/40 hover:text-red-300 disabled:opacity-50"
:disabled="disabled"
@click="emit('remove')"
>
Delete
</button>
</div>
</template>
<script setup lang="ts">
import type { GenerationPreset } from '~/utils/generationPresets'
defineProps<{
presets: GenerationPreset[]
selectedId: string
name: string
disabled?: boolean
}>()
const emit = defineEmits<{
load: [id: string]
save: []
remove: []
'update:name': [value: string]
'update:selectedId': [value: string]
}>()
function onPick(event: Event) {
const id = (event.target as HTMLSelectElement).value
emit('update:selectedId', id)
if (id) emit('load', id)
}
</script>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="block min-w-0" :class="compact ? 'text-xs' : 'text-sm'">
<span v-if="label" class="mb-1 block text-zinc-400">{{ label }}</span>
<ul v-if="model.length" class="space-y-2">
<li
v-for="(item, index) in model"
:key="item.name"
class="rounded-xl border border-white/10 bg-zinc-950/80"
:class="compact ? 'px-2 py-1.5' : 'px-3 py-2'"
>
<div class="flex items-center justify-between gap-2">
<span class="min-w-0 truncate text-zinc-200">{{ loraDisplayName(item.name) }}</span>
<button
type="button"
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
:disabled="disabled"
@click="removeAt(index)"
>
Remove
</button>
</div>
<div class="mt-1.5 grid grid-cols-2 gap-2">
<label class="block min-w-0">
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500">
<span>Model</span>
<span class="tabular-nums normal-case text-zinc-400">{{ formatLoraStrength(item.strengthModel) }}</span>
</span>
<input
type="range"
class="aigen-slider w-full"
:min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX"
:step="LORA_STRENGTH_STEP"
:value="item.strengthModel"
:disabled="disabled"
@input="setStrength(index, 'strengthModel', $event)"
>
</label>
<label class="block min-w-0" :class="{ 'opacity-50': !clipStrength }">
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500">
<span>CLIP</span>
<span class="tabular-nums normal-case text-zinc-400">{{ formatLoraStrength(item.strengthClip) }}</span>
</span>
<input
type="range"
class="aigen-slider w-full"
:min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX"
:step="LORA_STRENGTH_STEP"
:value="item.strengthClip"
:disabled="disabled || !clipStrength"
@input="setStrength(index, 'strengthClip', $event)"
>
</label>
</div>
</li>
</ul>
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
<select
v-if="availableNames.length && model.length < LORA_STACK_MAX"
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
:class="[
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
model.length ? 'mt-2' : ''
]"
:disabled="disabled"
:value="''"
@change="onAdd"
>
<option value="">{{ model.length ? addLabel : emptyLabel }}</option>
<option
v-for="name in availableNames"
:key="name"
:value="name"
>{{ loraDisplayName(name) }}</option>
</select>
<p v-else-if="!model.length" class="text-[11px] text-zinc-500">{{ emptyLabel }}</p>
</div>
</template>
<script setup lang="ts">
import {
LORA_STACK_MAX,
LORA_STRENGTH_MAX,
LORA_STRENGTH_MIN,
LORA_STRENGTH_STEP,
clampLoraStrength,
formatLoraStrength,
loraDisplayName,
makeLoraStackItem,
type LoraStackItem
} from '~/utils/loras'
const props = withDefaults(defineProps<{
names: string[]
label?: string
emptyLabel?: string
addLabel?: string
compact?: boolean
disabled?: boolean
clipStrength?: boolean
}>(), {
label: 'LoRA',
emptyLabel: 'None',
addLabel: 'Add LoRA',
compact: false,
disabled: false,
clipStrength: true
})
const model = defineModel<LoraStackItem[]>({ default: () => [] })
const availableNames = computed(() => {
const taken = new Set(model.value.map(item => item.name.replace(/\\/g, '/').toLowerCase()))
return props.names.filter(name => !taken.has(name.replace(/\\/g, '/').toLowerCase()))
})
function onAdd(event: Event) {
const name = (event.target as HTMLSelectElement).value
;(event.target as HTMLSelectElement).value = ''
addLora(name)
}
function addLora(name: string) {
const item = makeLoraStackItem(name)
if (!item || model.value.length >= LORA_STACK_MAX) return
const taken = new Set(model.value.map(entry => entry.name.replace(/\\/g, '/').toLowerCase()))
if (taken.has(item.name.replace(/\\/g, '/').toLowerCase())) return
model.value = [...model.value, item]
}
function removeAt(index: number) {
model.value = model.value.filter((_, i) => i !== index)
}
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
const value = clampLoraStrength((event.target as HTMLInputElement).value)
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: value } : item)
}
</script>
+1 -1
View File
@@ -127,7 +127,7 @@
<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.
Instruction sets sent to Beast Qwen VL with your still and draft. Use these later for multi-shot video scripts.
</p>
</div>
+3 -1
View File
@@ -53,7 +53,9 @@ export default defineNuxtConfig({
public: {
authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true',
authMode: process.env.NUXT_PUBLIC_AUTH_MODE || process.env.AUTH_MODE || '',
instanceName: process.env.NUXT_PUBLIC_INSTANCE_NAME || 'AIGen'
instanceName: process.env.NUXT_PUBLIC_INSTANCE_NAME || 'AIGen',
// NUXT_PUBLIC_STUDIO=xaigen on the xAIGen Coolify app (LTX Pink Cherry + xAIGen-prefixed LoRAs)
studio: process.env.NUXT_PUBLIC_STUDIO || ''
}
},
nitro: {
+360 -22
View File
@@ -6,7 +6,7 @@
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
<div>
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
<p class="hidden text-[11px] uppercase tracking-[0.22em] text-zinc-500 sm:block">MiniMax H3 · LTX-2.3 · Flux.2 Klein</p>
<p class="hidden text-[11px] uppercase tracking-[0.22em] text-zinc-500 sm:block">{{ ltxEnabled ? 'MiniMax H3 · LTX-2.3 · Flux.2 Klein' : 'MiniMax H3 · Flux.2 Klein' }}</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
@@ -193,7 +193,7 @@
</p>
<div v-if="recommendBusy || recommendText || recommendError" class="mt-3 max-h-64 overflow-y-auto 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-if="recommendBusy" class="mt-2 text-sm text-zinc-400">{{ recommendMessage || 'Beast Qwen VL is reading the still…' }}</p>
<p v-else-if="recommendError" class="mt-2 text-sm text-red-300">{{ recommendError }}</p>
<div v-else-if="recommendedShots.length > 1" class="mt-2 space-y-2">
<div
@@ -308,6 +308,17 @@
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedShotPreview(shot.prompt, index, shot.n) }}</p>
<LoraSelect
class="mt-2"
:model-value="shotLoraStacks[shot.n] || []"
:names="videoLoraOptions"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="setShotLoraStack(shot.n, $event)"
/>
<PermanenceRefStrip
class="mt-2"
:refs="shotPermanenceRefs[shot.n] || []"
@@ -350,6 +361,17 @@
placeholder="Motion prompt for this extension…"
/>
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
<LoraSelect
class="mt-3"
:model-value="item.loraStack || []"
:names="videoLoraOptions"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="item.loraStack = $event"
/>
<PermanenceRefStrip
class="mt-3"
:refs="shotPermanenceRefs[index + 2] || []"
@@ -442,7 +464,7 @@
</div>
<div v-if="studioMode === 'video'" class="space-y-3">
<div>
<div v-if="ltxEnabled">
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Video model</p>
<div class="grid grid-cols-2 gap-2">
<button
@@ -568,6 +590,29 @@
</div>
</div>
<div v-if="studioMode === 'video'" class="max-w-md space-y-2">
<LoraSelect v-model="videoLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="None" compact :clip-strength="false" />
<GenerationPresetBar
:presets="videoGenerationPresets"
v-model:selected-id="videoPresetId"
v-model:name="videoPresetName"
@load="loadGenerationPreset"
@save="saveGenerationPreset"
@remove="deleteGenerationPreset"
/>
</div>
<div v-else class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact />
<GenerationPresetBar
:presets="imageGenerationPresets"
v-model:selected-id="imagePresetId"
v-model:name="imagePresetName"
@load="loadGenerationPreset"
@save="saveGenerationPreset"
@remove="deleteGenerationPreset"
/>
</div>
<details v-if="studioMode === 'video'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4">
@@ -643,6 +688,7 @@
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
</label>
<LoraSelect v-model="videoLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="None" :clip-strength="false" />
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Sampler & scheduler</p>
<div class="grid grid-cols-2 gap-3">
@@ -691,6 +737,7 @@
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
</label>
</div>
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" />
<p class="text-xs text-zinc-500">Graph default is 20 steps, CFG 1, euler. A second still switches the two-image branch on.</p>
</div>
</details>
@@ -975,6 +1022,7 @@
label="Extension duration"
hint="Type any length, use the slider, or drag up/down in the box."
/>
<LoraSelect v-model="extendLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="Same as clip / none" compact :clip-strength="false" />
<button
type="button"
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@@ -1550,9 +1598,20 @@ import {
type PermanenceRef
} from '~/utils/globalLocks'
import {
filterLoraStackForStudio,
loraStacksEqual,
normalizeLoraStack,
persistLoraFields,
readLoraStack,
type LoraStackItem
} from '~/utils/loras'
import type { GenerationPreset } from '~/utils/generationPresets'
import {
coerceVideoWorkflow,
composeVideoWorkflow,
isLtxWorkflow,
isTextToVideo,
ltxWorkflowEnabled,
minimaxGraphOf,
parseVideoWorkflow,
videoEngineOf,
@@ -1622,6 +1681,8 @@ interface LibraryClip {
hideInput?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
loraName?: string
loraStack?: LoraStackItem[]
}
interface RetryDraft {
@@ -1646,17 +1707,23 @@ interface RetryDraft {
fps?: number
samplerName?: string
scheduler?: string
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
workflow?: string
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
loraName?: string
loraStack?: LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: LoraStackItem[][]
}
interface QueuedExtension {
id: string
prompt: string
duration: number
loraName?: string
loraStack?: LoraStackItem[]
}
interface QueuedEditPass {
@@ -1721,6 +1788,18 @@ const aspect = ref<AspectId>('auto')
const imageWidth = ref(0)
const imageHeight = ref(0)
const turbo = ref(true)
const videoLoraStack = ref<LoraStackItem[]>([])
const imageLoraStack = ref<LoraStackItem[]>([])
const videoLoras = ref<string[]>([])
const imageLoras = ref<string[]>([])
const shotLoraStacks = ref<Record<number, LoraStackItem[]>>({})
const extendLoraStack = ref<LoraStackItem[]>([])
const videoGenerationPresets = ref<GenerationPreset[]>([])
const imageGenerationPresets = ref<GenerationPreset[]>([])
const videoPresetId = ref('')
const imagePresetId = ref('')
const videoPresetName = ref('')
const imagePresetName = ref('')
const CFG_MIN = 1
const CFG_MAX = 10
const CFG_STEP = 0.5
@@ -1814,6 +1893,10 @@ const userName = ref('')
const authEnabled = ref(false)
const authMode = ref('')
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
const ltxEnabled = computed(() => ltxWorkflowEnabled({
studio: runtimeConfig.public.studio,
instanceName: instanceName.value
}))
const showPrivacyToggles = computed(() => /xaigen/i.test(String(instanceName.value)))
const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
@@ -1871,6 +1954,13 @@ const minimaxGraph = ref<MiniMaxGraphId>('v1')
const videoWorkflow = computed(() => composeVideoWorkflow(videoEngine.value, videoStart.value, minimaxGraph.value))
const textToVideo = computed(() => isTextToVideo(videoWorkflow.value))
const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
const videoLoraOptions = computed(() => mergeLoraOptions(videoLoras.value, [
...videoLoraStack.value.map(item => item.name),
...extendLoraStack.value.map(item => item.name),
...Object.values(shotLoraStacks.value).flatMap(stack => stack.map(item => item.name)),
...extensionQueue.value.flatMap(item => (item.loraStack || []).map(entry => entry.name))
]))
const imageLoraOptions = computed(() => mergeLoraOptions(imageLoras.value, imageLoraStack.value.map(item => item.name)))
const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
@@ -2074,12 +2164,12 @@ const recommendDisabled = computed(() => {
return false
})
const recommendHint = computed(() => {
if (!imageSidecarConfigured.value) return 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.'
if (!imageSidecarConfigured.value) return 'Qwen VL is not configured. Set COMFY_HOST or IMAGE_COMFY_HOST to Beast.'
if (!file.value) return 'Load a still first.'
if (!prompt.value.trim()) return 'Type a draft prompt to enhance.'
if (!imageSidecarOk.value) return 'Sidecar is offline. Recommend will start Qwen VL on 192.168.77.101.'
if (imageSidecarBusy.value) return 'Sidecar is busy — this may wait in its queue.'
return 'Send this still and draft to Sidecar Qwen VL.'
if (!imageSidecarOk.value) return 'Beast Comfy is offline. Recommend needs Qwen VL on that instance.'
if (imageSidecarBusy.value) return 'Beast Comfy is busy — this may wait in its queue.'
return 'Send this still and draft to Beast Qwen VL.'
})
const editBlocked = computed(() => {
if (!file.value || !prompt.value.trim() || !folderId.value) return true
@@ -2548,6 +2638,12 @@ function applyComfyHealth(health: {
imageSidecarHost.value = String(health?.imageComfy?.sidecar?.host || '')
}
async function pollLoras() {
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
videoLoras.value = listed.video || []
imageLoras.value = listed.image || []
}
async function pollComfyHealth() {
const health = await $fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } }))
applyComfyHealth(health)
@@ -2570,9 +2666,16 @@ onMounted(async () => {
const start = localStorage.getItem('aigen-video-start')
if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine
if (start === 'text' || start === 'still') videoStart.value = start
if (videoEngine.value === 'ltx') applyEngineDefaults('ltx')
if (!ltxEnabled.value && videoEngine.value === 'ltx') {
videoEngine.value = 'minimax'
applyEngineDefaults('minimax')
} else if (videoEngine.value === 'ltx') {
applyEngineDefaults('ltx')
}
} catch { /* ignore */ }
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
videoLoraStack.value = readStoredLoraStack('aigen-video-lora')
imageLoraStack.value = readStoredLoraStack('aigen-image-lora')
globalLocks.value = localStorage.getItem(LOCKS_STORE) || ''
try {
const savedRefs = JSON.parse(localStorage.getItem(REFS_STORE) || '')
@@ -2591,6 +2694,8 @@ onMounted(async () => {
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [], drafts: [] }))
])
applyComfyHealth(health)
void pollLoras()
void loadGenerationPresets()
userName.value = me.user?.name || me.user?.email || ''
authEnabled.value = Boolean(me.authEnabled)
authMode.value = me.authMode || ''
@@ -2600,6 +2705,7 @@ onMounted(async () => {
healthTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return
void pollComfyHealth()
void pollLoras()
void refreshStudioQueue()
if (activeQueueId.value) void refreshLiveQueue()
}, 5000)
@@ -2680,6 +2786,13 @@ watch(autoplayEnabled, (value) => {
}
})
watch(ltxEnabled, (on) => {
if (!on && videoEngine.value === 'ltx') {
videoEngine.value = 'minimax'
applyEngineDefaults('minimax')
}
})
watch(videoWorkflow, (value) => {
try {
localStorage.setItem('aigen-video-workflow', value)
@@ -2720,6 +2833,14 @@ watch(turbo, (on) => {
cfg.value = ltxVideo.value ? 3.5 : (on ? CFG_TURBO : CFG_QUALITY)
})
watch(videoLoraStack, (value) => {
try { localStorage.setItem('aigen-video-lora', JSON.stringify(value)) } catch { /* ignore */ }
}, { deep: true })
watch(imageLoraStack, (value) => {
try { localStorage.setItem('aigen-image-lora', JSON.stringify(value)) } catch { /* ignore */ }
}, { deep: true })
function clampDuration(value: number) {
if (!Number.isFinite(value)) return 5
return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10))
@@ -2752,6 +2873,189 @@ function restoreScheduler(raw?: string) {
return raw === 'ddim_uniform' || raw === 'sgm_uniform' || raw === 'simple' ? raw : 'simple'
}
function readStoredLoraStack(key: string) {
try {
return readLoraStack(localStorage.getItem(key) || '')
} catch {
return [] as LoraStackItem[]
}
}
function mergeLoraOptions(listed: string[], extra: string[]) {
const names = [...listed]
for (const name of extra) {
const value = String(name || '').trim()
if (value && !names.includes(value)) names.push(value)
}
return names
}
function setShotLoraStack(shotN: number, stack: LoraStackItem[]) {
const next = { ...shotLoraStacks.value }
const items = normalizeLoraStack(stack)
if (items.length) next[shotN] = items
else delete next[shotN]
shotLoraStacks.value = next
}
function applyLoraSelection(stack?: unknown, shotStacks?: Array<unknown>) {
videoLoraStack.value = readLoraStack(stack as { loraStack?: unknown; loraName?: unknown } | string | null)
const next: Record<number, LoraStackItem[]> = {}
;(shotStacks || []).forEach((item, index) => {
const value = readLoraStack(item as { loraStack?: unknown; loraName?: unknown } | string | null)
if (value.length && !loraStacksEqual(value, videoLoraStack.value)) next[index + 1] = value
})
shotLoraStacks.value = next
}
const currentPresetKind = computed(() => studioMode.value === 'edit' ? 'image' : 'video')
async function loadGenerationPresets() {
const [video, image] = await Promise.all([
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'video' } }).catch(() => ({ presets: [] })),
$fetch<{ presets?: GenerationPreset[] }>('/api/generation-presets', { query: { kind: 'image' } }).catch(() => ({ presets: [] }))
])
videoGenerationPresets.value = video.presets || []
imageGenerationPresets.value = image.presets || []
}
function currentPresetSnapshot() {
if (studioMode.value === 'edit') {
return {
kind: 'image' as const,
loraStack: filterLoraStackForStudio(imageLoraStack.value, ltxEnabled.value),
settings: {
steps: editSteps.value,
cfg: editCfg.value,
negative: editNegative.value
}
}
}
return {
kind: 'video' as const,
loraStack: filterLoraStackForStudio(videoLoraStack.value, ltxEnabled.value),
settings: {
workflow: videoWorkflow.value,
turbo: turbo.value,
cfg: cfg.value,
fps: fps.value,
samplerName: samplerName.value,
scheduler: scheduler.value,
duration: duration.value,
aspect: aspect.value,
sound: withSound.value
}
}
}
function applyGenerationPreset(preset: GenerationPreset) {
const stack = filterLoraStackForStudio(preset.loraStack, ltxEnabled.value)
const skipped = preset.loraStack.length - stack.length
if (preset.kind === 'image') {
imageLoraStack.value = stack
const settings = preset.settings as { steps?: number; cfg?: number; negative?: string }
if (typeof settings.steps === 'number') editSteps.value = settings.steps
if (typeof settings.cfg === 'number') editCfg.value = settings.cfg
if (typeof settings.negative === 'string') editNegative.value = settings.negative
imagePresetId.value = preset.id
imagePresetName.value = preset.name
} else {
videoLoraStack.value = stack
const settings = preset.settings as {
workflow?: string
turbo?: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
duration?: number
aspect?: string
sound?: boolean
}
const wantedWorkflow = String(settings.workflow || '')
applyVideoWorkflow(wantedWorkflow)
if (!ltxEnabled.value && isLtxWorkflow(parseVideoWorkflow(wantedWorkflow))) {
toast('LTX Pink Cherry is not available on this instance')
}
restoreAdvanced({
turbo: settings.turbo !== false,
aspect: settings.aspect,
cfg: settings.cfg,
fps: settings.fps,
samplerName: settings.samplerName,
scheduler: settings.scheduler
})
if (typeof settings.duration === 'number') duration.value = clampDuration(settings.duration)
if (typeof settings.sound === 'boolean') withSound.value = settings.sound
videoPresetId.value = preset.id
videoPresetName.value = preset.name
}
toast(skipped
? `Loaded “${preset.name}”. Skipped ${skipped} xAIGen-only LoRA${skipped === 1 ? '' : 's'}.`
: `Loaded “${preset.name}”.`)
}
function loadGenerationPreset(id: string) {
const list = currentPresetKind.value === 'image' ? imageGenerationPresets.value : videoGenerationPresets.value
const preset = list.find(item => item.id === id)
if (!preset) return
applyGenerationPreset(preset)
}
async function saveGenerationPreset() {
const name = (currentPresetKind.value === 'image' ? imagePresetName.value : videoPresetName.value).trim()
if (!name) {
toast('Name the preset before saving.')
return
}
const list = currentPresetKind.value === 'image' ? imageGenerationPresets.value : videoGenerationPresets.value
const existing = list.find(item => item.name.toLowerCase() === name.toLowerCase())
if (existing && !window.confirm(`Overwrite preset “${existing.name}”?`)) return
try {
const body = { name, overwrite: Boolean(existing), ...currentPresetSnapshot() }
const saved = await $fetch<{ preset: GenerationPreset; presets: GenerationPreset[] }>('/api/generation-presets', {
method: 'POST',
body
})
if (saved.preset.kind === 'image') {
imageGenerationPresets.value = saved.presets
imagePresetId.value = saved.preset.id
imagePresetName.value = saved.preset.name
} else {
videoGenerationPresets.value = saved.presets
videoPresetId.value = saved.preset.id
videoPresetName.value = saved.preset.name
}
toast(existing ? `Overwrote “${saved.preset.name}”.` : `Saved “${saved.preset.name}”.`)
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not save that preset')
}
}
async function deleteGenerationPreset() {
const id = currentPresetKind.value === 'image' ? imagePresetId.value : videoPresetId.value
const list = currentPresetKind.value === 'image' ? imageGenerationPresets.value : videoGenerationPresets.value
const preset = list.find(item => item.id === id)
if (!id || !preset) return
if (!window.confirm(`Delete preset “${preset.name}”?`)) return
try {
const saved = await $fetch<{ presets: GenerationPreset[] }>(`/api/generation-presets/${id}`, {
method: 'DELETE',
query: { kind: currentPresetKind.value }
})
if (currentPresetKind.value === 'image') {
imageGenerationPresets.value = saved.presets
imagePresetId.value = ''
} else {
videoGenerationPresets.value = saved.presets
videoPresetId.value = ''
}
toast(`Deleted “${preset.name}”.`)
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not delete that preset')
}
}
function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number; height?: number; cfg?: number; fps?: number; samplerName?: string; scheduler?: string }) {
cfgTouched.value = true
turbo.value = item.turbo
@@ -2803,8 +3107,8 @@ async function recommendPrompt() {
recommendText.value = ''
recommendApplied.value = false
recommendMessage.value = imageSidecarOk.value
? 'Uploading still to Sidecar Qwen VL…'
: 'Sidecar is offline. Starting Qwen VL…'
? 'Uploading still to Beast Qwen VL…'
: 'Checking Beast Comfy…'
const body = new FormData()
body.append('image', file.value)
body.append('prompt', prompt.value)
@@ -2823,7 +3127,7 @@ async function recommendPrompt() {
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'
if (!recommendText.value) recommendError.value = 'Beast Qwen VL returned an empty prompt'
break
}
if (snap.status === 'error') {
@@ -2919,6 +3223,7 @@ function clearVideoForm() {
globalLocks.value = ''
familyPermanenceRefs.value = []
shotPermanenceRefs.value = {}
shotLoraStacks.value = {}
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
recommendText.value = ''
recommendError.value = ''
@@ -3276,11 +3581,15 @@ async function restoreStudioJob(id: string) {
hideThumbnail?: boolean
hideInput?: boolean
referenceStillIds?: Array<string | null>
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
queueAutoRun?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
loraName?: string
loraStack?: LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: LoraStackItem[][]
}
}>(`/api/studio-queue/${id}`)
const payload = job.payload
@@ -3319,7 +3628,8 @@ async function restoreStudioJob(id: string) {
extensionQueue.value = extensions.map(item => ({
id: crypto.randomUUID(),
prompt: item.prompt,
duration: clampDuration(Number(item.duration))
duration: clampDuration(Number(item.duration)),
loraStack: readLoraStack(item)
}))
queueAutoRun.value = payload.queueAutoRun === true
globalLocks.value = payload.globalLocks || ''
@@ -3329,6 +3639,10 @@ async function restoreStudioJob(id: string) {
if (refs?.length) shotRefs[index + 1] = refs
})
shotPermanenceRefs.value = shotRefs
applyLoraSelection(
payload,
payload.shotLoraStacks || payload.shotLoras || [payload, ...extensions]
)
const stillId = payload.stillId
if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
@@ -3577,11 +3891,16 @@ async function rerun(item: LibraryClip, collection = false) {
if (extras.length) nextShots[index + 1] = extras
})
shotPermanenceRefs.value = nextShots
applyLoraSelection(
restoreAll ? (ordered[0] || target) : target,
restoreAll ? ordered : undefined
)
extensionQueue.value = restoreAll
? parts.slice(1).map(part => ({
id: crypto.randomUUID(),
prompt: part.prompt,
duration: clipGenerateDuration(part)
duration: clipGenerateDuration(part),
loraStack: loraStacksEqual(readLoraStack(part), readLoraStack(ordered[0] || target)) ? [] : readLoraStack(part)
}))
: []
try {
@@ -3622,10 +3941,12 @@ async function loadDraft(draft: RetryDraft) {
if (refs?.length) draftShots[index + 1] = refs
})
shotPermanenceRefs.value = draftShots
applyLoraSelection(draft, draft.shotLoraStacks || draft.shotLoras)
extensionQueue.value = (draft.extensions || []).map(item => ({
id: crypto.randomUUID(),
prompt: String(item.prompt || ''),
duration: clampDuration(Number(item.duration))
duration: clampDuration(Number(item.duration)),
loraStack: readLoraStack(item)
}))
try {
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
@@ -3718,6 +4039,7 @@ async function openExtend(item?: LibraryClip) {
const clip = clips.value.find(entry => entry.id === id)
extendPrompt.value = clip?.prompt || prompt.value
extendDuration.value = clampDuration(duration.value)
extendLoraStack.value = readLoraStack(clip)
extendOpen.value = true
lastFrameLoading.value = true
lastFrameUrl.value = ''
@@ -3764,7 +4086,8 @@ async function generateExtension() {
body: {
clipId: id,
prompt: extendPrompt.value.trim(),
duration: extendDuration.value
duration: extendDuration.value,
loraStack: extendLoraStack.value
}
})
closeExtend()
@@ -4242,7 +4565,8 @@ function queueExtension() {
extensionQueue.value.push({
id: crypto.randomUUID(),
prompt: prompt.value.trim(),
duration: duration.value
duration: duration.value,
loraStack: []
})
}
@@ -4287,6 +4611,7 @@ async function editImage() {
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim())
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value))
const downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
const started = await $fetch<{
jobId: string
@@ -4369,6 +4694,7 @@ function applyEngineDefaults(engine: VideoEngineId) {
}
function selectVideoEngine(engine: VideoEngineId) {
if (engine === 'ltx' && !ltxEnabled.value) return
if (videoEngine.value === engine) return
cfgTouched.value = false
videoEngine.value = engine
@@ -4376,13 +4702,19 @@ function selectVideoEngine(engine: VideoEngineId) {
}
function applyVideoWorkflow(raw?: string) {
const parsed = parseVideoWorkflow(raw)
const parsed = coerceVideoWorkflow(raw, ltxEnabled.value)
videoEngine.value = videoEngineOf(parsed)
videoStart.value = videoStartOf(parsed)
minimaxGraph.value = minimaxGraphOf(parsed)
}
async function generate() {
if (!ltxEnabled.value && isLtxWorkflow(videoWorkflow.value)) {
videoEngine.value = 'minimax'
applyEngineDefaults('minimax')
toast('LTX Pink Cherry is not available on this instance')
return
}
if ((!file.value && !textToVideo.value) || !prompt.value.trim()) return
if (!queueReady.value) {
toast(shotScriptMode.value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.')
@@ -4395,8 +4727,8 @@ async function generate() {
const shots = shotScriptMode.value ? parsedShots.value : null
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
const queued = shots
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value }))
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value, ...persistLoraFields(shotLoraStacks.value[shot.n] || []) }))
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration, ...persistLoraFields(item.loraStack || []) }))
const hideOut = hideThumbnail.value
const folderLockedPref = false
try {
@@ -4432,11 +4764,17 @@ async function generate() {
? shots.map(shot => shotPermanenceRefs.value[shot.n] || [])
: [shotPermanenceRefs.value[1] || [], ...queued.map((_, index) => shotPermanenceRefs.value[index + 2] || [])]
body.append('shotPermanenceRefs', JSON.stringify(shotRefList))
const shotLoraStacksList = shots
? shots.map(shot => shotLoraStacks.value[shot.n] || [])
: [shotLoraStacks.value[1] || [], ...queued.map(item => item.loraStack || [])]
if (videoLoraStack.value.length) body.append('loraStack', JSON.stringify(videoLoraStack.value))
body.append('shotLoraStacks', JSON.stringify(shotLoraStacksList))
if (activeDraftId) body.append('draftId', activeDraftId)
if (queued.length) {
body.append('extensions', JSON.stringify(queued.map((item, index) => ({
...item,
permanenceRefs: shotRefList[index + 1] || []
permanenceRefs: shotRefList[index + 1] || [],
...persistLoraFields(shotLoraStacksList[index + 1] || [])
}))))
body.append('queueAutoRun', String(queueAutoRun.value))
}
+68 -2
View File
@@ -200,6 +200,19 @@
label="Duration"
@update:modelValue="saveSegment(job.shots, segment)"
/>
<LoraSelect
v-if="segment.status !== 'complete' && segment.status !== 'running'"
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(job.shots, segment) }"
/>
<p v-else-if="segmentLoraLabel(job.shots, segment)" class="mt-1 text-[11px] text-zinc-500">{{ segmentLoraLabel(job.shots, segment) }}</p>
<div v-if="segment.clipId" class="mt-2">
<a
class="text-xs text-amber-200 hover:text-amber-100"
@@ -221,6 +234,7 @@
>
<p class="text-sm font-medium text-zinc-100">{{ job.kind === 'edit' ? `Pass ${index + 1}` : `Shot ${index + 1}` }} <span class="font-normal text-zinc-500">· queued with this job</span></p>
<p class="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
<p v-if="loraStackLabel(readLoraStack(shot))" class="mt-1 text-[11px] text-zinc-500">{{ loraStackLabel(readLoraStack(shot)) }}</p>
</li>
</ol>
</section>
@@ -328,6 +342,19 @@
label="Duration"
@update:modelValue="saveSegment(queue, segment)"
/>
<LoraSelect
v-if="segment.status !== 'complete' && segment.status !== 'running'"
class="mt-3 max-w-md"
:model-value="readLoraStack(segment)"
:names="videoLoraOptions"
label="LoRA"
empty-label="Default"
add-label="Add LoRA"
compact
:clip-strength="false"
@update:model-value="(value) => { setSegmentStack(segment, value); saveSegment(queue, segment) }"
/>
<p v-else-if="segmentLoraLabel(queue, segment)" class="mt-1 text-[11px] text-zinc-500">{{ segmentLoraLabel(queue, segment) }}</p>
<div v-if="segment.clipId" class="mt-2">
<a
class="text-xs text-amber-200 hover:text-amber-100"
@@ -382,6 +409,8 @@
</template>
<script setup lang="ts">
import { loraStackLabel, persistLoraFields, readLoraStack, type LoraStackItem } from '~/utils/loras'
type Segment = {
index: number
prompt: string
@@ -389,6 +418,8 @@ type Segment = {
status: 'pending' | 'running' | 'complete' | 'error'
clipId?: string
error?: string
loraName?: string
loraStack?: LoraStackItem[]
}
type Queue = {
@@ -401,6 +432,8 @@ type Queue = {
totalCount: number
currentJobId?: string
stopAfterCurrent?: boolean
loraName?: string
loraStack?: LoraStackItem[]
segments: Segment[]
}
@@ -422,7 +455,7 @@ type StudioJobRow = {
pausedByUser?: boolean
lastError?: string
shots?: Queue | null
plannedShots?: { prompt: string; duration: number }[]
plannedShots?: { prompt: string; duration: number; loraName?: string; loraStack?: LoraStackItem[] }[]
completedCount?: number
}
@@ -436,6 +469,7 @@ type PendingRemove = {
}
const instanceName = ref(String(useRuntimeConfig().public.instanceName || 'AIGen'))
const videoLoras = ref<string[]>([])
const authEnabled = ref(false)
const jobs = ref<StudioJobRow[]>([])
const orphans = ref<Queue[]>([])
@@ -450,6 +484,24 @@ const sources = new Map<string, EventSource>()
const pendingRemove = ref<PendingRemove | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const videoLoraOptions = computed(() => {
const names = [...videoLoras.value]
for (const queue of [...jobs.value.map(job => job.shots), ...orphans.value]) {
if (!queue) continue
if (queue.loraName && !names.includes(queue.loraName)) names.push(queue.loraName)
for (const item of queue.loraStack || []) {
if (item.name && !names.includes(item.name)) names.push(item.name)
}
for (const segment of queue.segments) {
if (segment.loraName && !names.includes(segment.loraName)) names.push(segment.loraName)
for (const item of segment.loraStack || []) {
if (item.name && !names.includes(item.name)) names.push(item.name)
}
}
}
return names
})
const canCutIn = computed(() => jobs.value.some(job => job.status === 'running' || job.status === 'held'))
const pauseBusy = computed(() => busyId.value === 'pause')
const anythingGenerating = computed(() => (
@@ -569,6 +621,17 @@ function segmentCaption(queue: Queue, segment: Segment) {
return '· waiting'
}
function setSegmentStack(segment: Segment, stack: LoraStackItem[]) {
const fields = persistLoraFields(stack)
segment.loraStack = fields.loraStack
segment.loraName = fields.loraName
}
function segmentLoraLabel(queue: Queue, segment: Segment) {
const stack = readLoraStack(segment).length ? readLoraStack(segment) : readLoraStack(queue)
return loraStackLabel(stack)
}
function segmentClass(queue: Queue, segment: Segment) {
if (segment.status === 'complete') return 'border-emerald-400/25 bg-emerald-400/5'
if (segment.status === 'running') return 'border-amber-300/50 bg-amber-400/10'
@@ -949,7 +1012,7 @@ function saveSegment(queue: Queue, segment: Segment) {
saveTimer = setTimeout(() => {
void $fetch(`/api/queues/${queue.id}`, {
method: 'PATCH',
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration }] }
body: { segments: [{ index: segment.index, prompt: segment.prompt, duration: segment.duration, loraName: segment.loraName || '', loraStack: segment.loraStack || [] }] }
}).then(() => {
dirty.delete(dirtyKey(queue.id, segment.index))
}).catch(() => null)
@@ -960,6 +1023,9 @@ onMounted(async () => {
const me = await $fetch<{ user?: { name?: string }; authEnabled?: boolean; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
authEnabled.value = Boolean(me.authEnabled)
instanceName.value = me.instanceName || instanceName.value
void $fetch<{ video?: string[] }>('/api/loras').then((listed) => {
videoLoras.value = listed.video || []
}).catch(() => null)
window.addEventListener('keydown', onRemoveKey)
await loadQueues().catch((err: any) => {
error.value = err?.data?.statusMessage || err?.message || 'Could not load the queue'
+5 -1
View File
@@ -1,4 +1,5 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution'
@@ -81,6 +82,8 @@ export default defineEventHandler(async (event) => {
: Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data)
const clipName = (fields.name || '').trim().slice(0, 80)
await listStudioLoras()
const loraStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'image')
const still = await rememberInputStill({
ownerKey,
@@ -137,7 +140,8 @@ export default defineEventHandler(async (event) => {
negative: (fields.negative || '').trim(),
passes,
referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename
referenceStillFilename: savedRef?.filename,
...persistLoraFields(loraStack)
}
})
await kickStudioQueue()
+25 -7
View File
@@ -1,6 +1,9 @@
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { composeShotPrompt } from '~/utils/globalLocks'
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { readLoraStack } from '~/utils/loras'
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
@@ -39,7 +42,7 @@ function parseScheduler(raw: unknown) {
}
export default defineEventHandler(async (event) => {
const body = await readBody<{ clipId?: string; prompt?: string; duration?: number }>(event).catch(() => ({}))
const body = await readBody<{ clipId?: string; prompt?: string; duration?: number; lora?: unknown; loraStack?: unknown }>(event).catch(() => ({}))
const clipId = String(body?.clipId || '').trim()
const prompt = String(body?.prompt || '').trim()
if (!clipId) {
@@ -66,13 +69,21 @@ export default defineEventHandler(async (event) => {
shotIndex: (source.chainIndex || 0) + 1,
familyRefs: source.permanenceRefs
})
const workflow = parseVideoWorkflow(source.workflow)
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
}
const turbo = source.turbo !== false
const fps = parseFps(source.fps)
const cfg = parseCfg(source.cfg, turbo)
const samplerName = parseSampler(source.samplerName)
const scheduler = parseScheduler(source.scheduler)
const steps = Number(source.steps || defaultVideoSteps(turbo, parseVideoWorkflow(source.workflow)))
const steps = Number(source.steps || defaultVideoSteps(turbo, workflow))
const seed = Math.floor(Math.random() * 2_147_483_647)
await listStudioLoras()
const postedStack = parsePostedLoraStack(body?.loraStack ?? body?.lora, 'video')
const loraStack = postedStack.length ? postedStack : parsePostedLoraStack(readLoraStack(source), 'video')
const loraFields = persistLoraFields(loraStack)
const length = frameLength(durationSeconds, fps)
const width = source.width || 1344
const height = source.height || 768
@@ -109,9 +120,10 @@ export default defineEventHandler(async (event) => {
familyId: source.familyId || crypto.randomUUID(),
parentClipId: source.id,
chainIndex: (source.chainIndex || 0) + 1,
workflow: parseVideoWorkflow(source.workflow),
workflow,
globalLocks: source.globalLocks,
permanenceRefs: source.permanenceRefs
permanenceRefs: source.permanenceRefs,
...loraFields
}
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
@@ -130,7 +142,8 @@ export default defineEventHandler(async (event) => {
cfg,
fps,
samplerName,
scheduler
scheduler,
...loraFields
}).catch((error) => {
removeExtendTemp(job.library?.extendTmpDir)
const message = error instanceof Error ? error.message : String(error)
@@ -169,6 +182,8 @@ async function runExtend(
fps: number
samplerName: string
scheduler: string
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
}
) {
copyFileSync(params.sourcePath, params.part1Path)
@@ -218,7 +233,8 @@ async function runExtend(
filenamePrefix: comfyFilenamePrefix(),
sound,
workflow: job.library?.workflow,
duration: job.library?.duration
duration: job.library?.duration,
loraStack: params.loraStack || job.library?.loraStack || params.loraName || job.library?.loraName
})
const queued = await queuePrompt(graph, job.clientId)
@@ -254,7 +270,9 @@ async function runExtend(
workflow: job.library.workflow,
duration: job.library.duration,
globalLocks: job.library.globalLocks,
permanenceRefs: job.library.permanenceRefs
permanenceRefs: job.library.permanenceRefs,
loraName: job.library.loraName,
loraStack: job.library.loraStack
})
}
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
+27 -7
View File
@@ -1,5 +1,7 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
function parseDuration(raw: unknown) {
@@ -13,18 +15,23 @@ function parseExtendDuration(raw: unknown) {
}
function parseExtensions(raw: string | undefined) {
if (!raw) return [] as { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
if (!raw) return [] as { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown; duration?: unknown; permanenceRefs?: unknown }) => ({
.map((item: { prompt?: unknown; duration?: unknown; permanenceRefs?: unknown; loraName?: unknown; loraStack?: unknown }) => {
const loraStack = parsePostedLoraStack(item?.loraStack ?? item?.loraName, 'video')
return {
prompt: String(item?.prompt || '').trim(),
duration: parseExtendDuration(item?.duration),
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs)
}))
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs),
...persistLoraFields(loraStack)
}
})
.filter(item => item.prompt)
} catch {
} catch (error) {
if (error && typeof error === 'object' && 'statusCode' in error) throw error
return []
}
}
@@ -101,7 +108,11 @@ export default defineEventHandler(async (event) => {
}
}
await listStudioLoras()
const workflow = parseVideoWorkflow(fields.workflow)
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
}
const textToVideo = isTextToVideo(workflow)
if (!image && !textToVideo) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
@@ -116,10 +127,16 @@ export default defineEventHandler(async (event) => {
const extensions = parseExtensions(fields.extensions)
const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs)
const shotPermanenceRefs = parseShotPermanenceRefs(fields.shotPermanenceRefs, 1 + extensions.length)
const defaultStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'video')
const shotLoraStacks = parseShotLoraStacks(fields.shotLoraStacks || fields.shotLoras, 1 + extensions.length)
if (defaultStack.length && !shotLoraStacks[0].length) shotLoraStacks[0] = defaultStack
for (const [index, item] of extensions.entries()) {
if (!shotPermanenceRefs[index + 1]?.length && item.permanenceRefs?.length) {
shotPermanenceRefs[index + 1] = item.permanenceRefs
}
const shotStack = shotLoraStacks[index + 1]
if (!shotStack.length && item.loraStack?.length) shotLoraStacks[index + 1] = item.loraStack
else if (shotStack.length) Object.assign(item, persistLoraFields(shotStack))
}
const requestedIdentity = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const useIdentityRefs = allowIdentityRefs(requestedIdentity, permanenceRefs, globalLocks, shotPermanenceRefs)
@@ -216,7 +233,10 @@ export default defineEventHandler(async (event) => {
queueAutoRun,
globalLocks: globalLocks || undefined,
permanenceRefs: permanenceRefs.length ? permanenceRefs : undefined,
shotPermanenceRefs: shotPermanenceRefs.some(item => item.length) ? shotPermanenceRefs : undefined
shotPermanenceRefs: shotPermanenceRefs.some(item => item.length) ? shotPermanenceRefs : undefined,
...persistLoraFields(defaultStack),
shotLoras: shotLoraStacks.some(stack => stack.length) ? shotLoraStacks.map(stack => stack[0]?.name || '') : undefined,
shotLoraStacks: shotLoraStacks.some(stack => stack.length) ? shotLoraStacks : undefined
}
})
await kickStudioQueue()
+10
View File
@@ -0,0 +1,10 @@
import { listGenerationPresets } from '~/server/utils/generationPresets'
import { parseGenerationPresetKind } from '~/utils/generationPresets'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const kind = parseGenerationPresetKind(getQuery(event).kind)
return {
presets: listGenerationPresets(owner, kind)
}
})
+25
View File
@@ -0,0 +1,25 @@
import { listGenerationPresets, saveGenerationPreset } from '~/server/utils/generationPresets'
import { parseGenerationPresetKind } from '~/utils/generationPresets'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{
name?: string
kind?: string
loraStack?: unknown
settings?: unknown
overwrite?: boolean
}>(event).catch(() => ({}))
const kind = parseGenerationPresetKind(body?.kind)
const preset = await saveGenerationPreset(owner, {
name: body?.name || '',
kind,
loraStack: body?.loraStack,
settings: body?.settings,
overwrite: body?.overwrite === true
})
return {
preset,
presets: listGenerationPresets(owner, kind)
}
})
@@ -0,0 +1,13 @@
import { deleteGenerationPreset, listGenerationPresets } from '~/server/utils/generationPresets'
import { parseGenerationPresetKind } from '~/utils/generationPresets'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const kind = parseGenerationPresetKind(getQuery(event).kind)
await deleteGenerationPreset(owner, id)
return {
ok: true,
presets: listGenerationPresets(owner, kind)
}
})
+19 -15
View File
@@ -1,14 +1,15 @@
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost } from '~/server/utils/imageComfy'
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } 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 sidecarHost = getSidecarImageHost()
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
const promptHost = getSidecarImageHost()
const promptIsBeast = Boolean(promptHost && beastHost && sameImageHost(promptHost, beastHost))
const [comfy, queue, beast, beastQueue, prompt, promptQueue] = await Promise.all([
videoConfigured
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
: Promise.resolve({ ok: false, host: '', stats: null }),
@@ -17,17 +18,20 @@ export default defineEventHandler(async () => {
? 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 }),
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 })
promptHost && !promptIsBeast
? checkImageComfyHttp(2500, promptHost).catch(() => ({ ok: false, host: promptHost, stats: null }))
: Promise.resolve({ ok: false, host: promptHost, stats: null }),
promptHost && !promptIsBeast ? fetchQueueAt(promptHost) : 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 promptOk = promptIsBeast ? Boolean(beast.ok || comfy.ok) : Boolean(prompt.ok)
const promptBusy = promptIsBeast ? (beastBusy || busy) : Boolean(prompt.ok && promptQueue.running > 0)
const imageOk = Boolean(beast.ok || comfy.ok)
const imageBusy = Boolean(imageConfigured && (beastBusy || busy))
const preferredHost = beastHost || comfy.host || ''
const promptQueueRunning = promptIsBeast ? ((beastQueue.running || queue.running) || 0) : (promptQueue.running || 0)
const promptQueuePending = promptIsBeast ? ((beastQueue.pending || queue.pending) || 0) : (promptQueue.pending || 0)
return {
ok: true,
service: useRuntimeConfig().public.instanceName || 'aigen',
@@ -55,13 +59,13 @@ export default defineEventHandler(async () => {
host: preferredHost
},
sidecar: {
label: 'Sidecar',
configured: Boolean(sidecarHost),
ok: Boolean(sidecar.ok),
busy: sidecarBusy,
host: sidecarHost,
queueRunning: sidecarQueue.running || 0,
queuePending: sidecarQueue.pending || 0
label: 'Qwen VL',
configured: Boolean(promptHost),
ok: promptOk,
busy: promptBusy,
host: promptHost,
queueRunning: promptQueueRunning,
queuePending: promptQueuePending
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { listStudioLoras } from '~/server/utils/loras'
export default defineEventHandler(async () => {
const listed = await listStudioLoras()
return {
image: listed.image,
video: listed.video
}
})
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
if (!promptComfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.'
statusMessage: 'Qwen VL is not configured. Set COMFY_HOST or IMAGE_COMFY_HOST to Beast.'
})
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { getShotQueue, patchShotQueueSegments, updateShotQueue, summarizeQueue } from '~/server/utils/shotQueue'
import { listStudioLoras } from '~/server/utils/loras'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
@@ -6,10 +7,11 @@ export default defineEventHandler(async (event) => {
const body = await readBody<{
name?: string
autoRun?: boolean
segments?: Array<{ index: number; prompt?: string; duration?: number }>
segments?: Array<{ index: number; prompt?: string; duration?: number; loraName?: string; loraStack?: unknown }>
}>().catch(() => ({}))
if (Array.isArray(body?.segments) && body.segments.length) {
await listStudioLoras()
await patchShotQueueSegments(owner, id, body.segments)
}
if (typeof body?.name === 'string' || typeof body?.autoRun === 'boolean') {
+7 -2
View File
@@ -23,8 +23,13 @@ export default defineEventHandler((event) => {
]
: undefined)
: [
{ prompt: job.payload.prompt, duration: job.payload.duration },
...(job.payload.extensions || [])
{ prompt: job.payload.prompt, duration: job.payload.duration, loraName: job.payload.shotLoras?.[0] || job.payload.loraName, loraStack: job.payload.shotLoraStacks?.[0] || job.payload.loraStack },
...(job.payload.extensions || []).map((item, index) => ({
prompt: item.prompt,
duration: item.duration,
loraName: item.loraName || job.payload.shotLoras?.[index + 1],
loraStack: item.loraStack || job.payload.shotLoraStacks?.[index + 1]
}))
]
return {
...summarizeStudioJob(job),
+184
View File
@@ -0,0 +1,184 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { ownerRoot } from '~/server/utils/library'
import { filterLoraStackForStudio, normalizeLoraStack } from '~/utils/loras'
import {
normalizePresetName,
parseGenerationPresetKind,
type GenerationPreset,
type GenerationPresetKind,
type ImagePresetSettings,
type VideoPresetSettings
} from '~/utils/generationPresets'
import { coerceVideoWorkflow, isXaigenStudio, parseVideoWorkflow } from '~/utils/videoModels'
const writeChains = new Map<string, Promise<unknown>>()
function presetsPath(owner: string) {
return join(ownerRoot(owner), 'generation-presets.json')
}
function ensureOwner(owner: string) {
mkdirSync(ownerRoot(owner), { recursive: true })
}
function readPresets(owner: string): GenerationPreset[] {
ensureOwner(owner)
const path = presetsPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(parsed) ? parsed.map(normalizeStoredPreset).filter((item): item is GenerationPreset => Boolean(item)) : []
} catch {
return []
}
}
function writePresets(owner: string, presets: GenerationPreset[]) {
ensureOwner(owner)
const path = presetsPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(presets, null, 2))
renameSync(tmp, path)
}
function mutate<T>(owner: string, fn: (presets: GenerationPreset[]) => T): Promise<T> {
const prev = writeChains.get(owner) || Promise.resolve()
const run = prev.then(() => {
const presets = readPresets(owner)
const result = fn(presets)
writePresets(owner, presets)
return result
})
writeChains.set(owner, run.then(() => undefined, () => undefined))
return run
}
function clampInt(raw: unknown, fallback: number, min: number, max: number) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
return Math.min(max, Math.max(min, Math.round(value)))
}
function clampNum(raw: unknown, fallback: number, min: number, max: number, step = 0.1) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
const snapped = Math.round(value / step) * step
return Math.min(max, Math.max(min, Math.round(snapped * 100) / 100))
}
function sanitizeVideoSettings(raw: unknown): VideoPresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
const workflow = coerceVideoWorkflow(parseVideoWorkflow(rec.workflow))
const turbo = rec.turbo !== false
const sampler = String(rec.samplerName || '')
const scheduler = String(rec.scheduler || '')
const aspect = String(rec.aspect || 'auto')
return {
workflow,
turbo,
cfg: clampNum(rec.cfg, turbo ? 1.5 : 4, 1, 10, 0.5),
fps: rec.fps === 12 || rec.fps === 30 || rec.fps === 24 ? Number(rec.fps) : 24,
samplerName: sampler === 'euler' || sampler === 'dpmpp_2m' || sampler === 'res_multistep' ? sampler : 'res_multistep',
scheduler: scheduler === 'ddim_uniform' || scheduler === 'sgm_uniform' || scheduler === 'simple' ? scheduler : 'simple',
duration: clampNum(rec.duration, 5, 0.5, 120, 0.1),
aspect: aspect === '16:9' || aspect === '9:16' || aspect === '1:1' || aspect === 'auto' ? aspect : 'auto',
sound: rec.sound !== false
}
}
function sanitizeImageSettings(raw: unknown): ImagePresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
return {
steps: clampInt(rec.steps, 20, 1, 40),
cfg: clampNum(rec.cfg, 1, 0.5, 4, 0.1),
negative: String(rec.negative || '').slice(0, 2000)
}
}
function normalizeStoredPreset(raw: unknown): GenerationPreset | null {
if (!raw || typeof raw !== 'object') return null
const rec = raw as Record<string, unknown>
const name = normalizePresetName(rec.name)
if (!name) return null
const kind = parseGenerationPresetKind(rec.kind)
const id = String(rec.id || '').trim() || crypto.randomUUID()
const createdAt = Number(rec.createdAt) || Date.now()
return {
id,
name,
kind,
createdAt,
updatedAt: Number(rec.updatedAt) || createdAt,
loraStack: normalizeLoraStack(rec.loraStack),
settings: kind === 'image' ? sanitizeImageSettings(rec.settings) : sanitizeVideoSettings(rec.settings)
}
}
function publicPreset(preset: GenerationPreset): GenerationPreset {
const xaigen = isXaigenStudio()
return {
...preset,
loraStack: filterLoraStackForStudio(preset.loraStack, xaigen),
settings: preset.kind === 'video'
? { ...preset.settings, workflow: coerceVideoWorkflow((preset.settings as VideoPresetSettings).workflow) }
: preset.settings
}
}
export function listGenerationPresets(owner: string, kind?: GenerationPresetKind) {
return readPresets(owner)
.filter(item => !kind || item.kind === kind)
.map(publicPreset)
.sort((a, b) => a.name.localeCompare(b.name) || b.updatedAt - a.updatedAt)
}
export async function saveGenerationPreset(owner: string, input: {
name: string
kind: GenerationPresetKind
loraStack?: unknown
settings?: unknown
overwrite?: boolean
}) {
const name = normalizePresetName(input.name)
if (!name) {
throw createError({ statusCode: 400, statusMessage: 'A preset name is required' })
}
const kind = parseGenerationPresetKind(input.kind)
const xaigen = isXaigenStudio()
const loraStack = filterLoraStackForStudio(input.loraStack, xaigen)
const settings = kind === 'image' ? sanitizeImageSettings(input.settings) : sanitizeVideoSettings(input.settings)
const now = Date.now()
return mutate(owner, (presets) => {
const existing = presets.find(item => item.kind === kind && item.name.toLowerCase() === name.toLowerCase())
if (existing && !input.overwrite) {
throw createError({ statusCode: 409, statusMessage: `Overwrite preset “${existing.name}”?` })
}
if (existing) {
existing.updatedAt = now
existing.loraStack = loraStack
existing.settings = settings
return publicPreset(existing)
}
const created: GenerationPreset = {
id: crypto.randomUUID(),
name,
kind,
createdAt: now,
updatedAt: now,
loraStack,
settings
}
presets.unshift(created)
return publicPreset(created)
})
}
export async function deleteGenerationPreset(owner: string, id: string) {
return mutate(owner, (presets) => {
const index = presets.findIndex(item => item.id === id)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Preset not found' })
const [removed] = presets.splice(index, 1)
return removed
})
}
+4 -1
View File
@@ -18,6 +18,8 @@ export type EditRunParams = {
steps: number
seed: number
cfg: number
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
}
export async function runEdit(job: Job, params: EditRunParams) {
@@ -97,7 +99,8 @@ export async function runEdit(job: Job, params: EditRunParams) {
steps: params.steps,
seed,
cfg: params.cfg,
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`,
loraStack: params.loraStack || params.loraName
})
const queued = await queuePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
+18 -18
View File
@@ -38,13 +38,24 @@ export function getBeastImageHost() {
}
}
function isRetiredSidecarHost(raw: string) {
return /192\.168\.77\.101(?!\d)/.test(raw)
}
export function getSidecarImageHost() {
const config = useRuntimeConfig()
const fallback = String(config.imageComfyFallbackHost || process.env.IMAGE_COMFY_FALLBACK_HOST || '').trim()
if (fallback) return normalizeHost(fallback)
const legacy = String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim()
const port = String(config.imageComfyPort || process.env.IMAGE_COMFY_PORT || '').trim()
return normalizeHost(legacy, port)
const candidates: Array<[string, string]> = [
[String(config.promptComfyHost || process.env.PROMPT_COMFY_HOST || '').trim(), ''],
[String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim(), port],
[String(config.imageComfyFallbackHost || process.env.IMAGE_COMFY_FALLBACK_HOST || '').trim(), '']
]
for (const [raw, extraPort] of candidates) {
if (!raw || isRetiredSidecarHost(raw)) continue
const host = normalizeHost(raw, extraPort)
if (host && !isRetiredSidecarHost(host)) return host
}
return getBeastImageHost()
}
export function getImageComfyHost() {
@@ -352,19 +363,8 @@ async function purgeImageOnDesktop(opts: {
output?: { filename: string; subfolder: string; type: string }
}) {
const config = useRuntimeConfig()
const host = getImageComfyHost()
const sidecar = getSidecarImageHost()
const useSidecarAgent = sidecar && sameImageHost(host, sidecar)
const controlUrl = String(
useSidecarAgent
? (config.imageComfyControlUrl || process.env.IMAGE_COMFY_CONTROL_URL || '')
: (config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '')
).replace(/\/$/, '')
const token = String(
useSidecarAgent
? (config.imageComfyControlToken || process.env.IMAGE_COMFY_CONTROL_TOKEN || '')
: (config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
)
const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '')
const token = String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
if (!controlUrl) return
try {
await fetch(`${controlUrl}/purge`, {
@@ -382,7 +382,7 @@ async function purgeImageOnDesktop(opts: {
signal: AbortSignal.timeout(4000)
})
} catch {
/* Sidecar host agent is optional; Comfy HTTP delete is the primary path. */
/* Host agent purge is optional; Comfy HTTP delete is the primary path. */
}
}
+8 -93
View File
@@ -187,7 +187,7 @@ async function ensureUnlocked(onStatus: StatusFn) {
onStatus({
state: 'starting',
message: 'Image ComfyUI is offline. Starting the sidecar instance...',
message: 'Image ComfyUI is offline. Starting the Beast instance...',
host,
processRunning: Boolean(remote?.process)
})
@@ -195,7 +195,7 @@ async function ensureUnlocked(onStatus: StatusFn) {
if (!started.started && started.reason === 'no-launcher') {
throw createError({
statusCode: 503,
statusMessage: 'Image ComfyUI is offline and the sidecar host agent did not start it. Confirm the agent on 192.168.77.101:8199 is running.'
statusMessage: 'Image ComfyUI is offline and the Beast host agent did not start it. Confirm COMFY_CONTROL_URL is running.'
})
}
@@ -230,111 +230,26 @@ export function ensureImageComfyReady(onStatus: StatusFn = () => undefined) {
}
let sidecarGate: Promise<unknown> = Promise.resolve()
let sidecarLastStartAt = 0
function sidecarControlSettings() {
const config = useRuntimeConfig()
return {
host: getSidecarImageHost(),
controlUrl: String(config.imageComfyControlUrl || process.env.IMAGE_COMFY_CONTROL_URL || '').replace(/\/$/, ''),
controlToken: String(config.imageComfyControlToken || process.env.IMAGE_COMFY_CONTROL_TOKEN || ''),
healthTimeoutMs: 2500,
startTimeoutMs: Number(process.env.IMAGE_COMFY_START_TIMEOUT_MS || 180_000)
}
}
async function sidecarControlRequest(path: string, method = 'GET') {
const { controlUrl, controlToken } = sidecarControlSettings()
if (!controlUrl) return null
try {
const res = await fetch(`${controlUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(controlToken ? { Authorization: `Bearer ${controlToken}` } : {})
},
signal: AbortSignal.timeout(5000)
})
if (!res.ok) return { ok: false, status: res.status }
return await res.json() as {
ok?: boolean
http?: boolean
process?: boolean
started?: boolean
already?: boolean
}
} catch {
return null
}
}
async function startSidecarComfy() {
const now = Date.now()
if (now - sidecarLastStartAt < 15_000) {
log('sidecar-start-skipped', { reason: 'recent-start' })
return { started: false, reason: 'recent-start' as const }
}
sidecarLastStartAt = now
const remote = await sidecarControlRequest('/start', 'POST')
if (remote?.ok || remote?.started) {
log('sidecar-start-remote', {})
return { started: true, reason: 'remote' as const }
}
return { started: false, reason: 'no-launcher' as const }
}
async function ensureSidecarUnlocked(onStatus: StatusFn) {
const cfg = sidecarControlSettings()
const host = cfg.host
const host = getSidecarImageHost()
if (!host) {
throw createError({
statusCode: 503,
statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST or IMAGE_COMFY_FALLBACK_HOST.'
statusMessage: 'Qwen VL is not configured. Set COMFY_HOST or IMAGE_COMFY_HOST to Beast.'
})
}
onStatus({ state: 'offline', message: 'Checking Sidecar Qwen VL…', host, box: 'sidecar' })
const health = await checkImageComfyHttp(cfg.healthTimeoutMs, host)
onStatus({ state: 'offline', message: 'Checking Beast Qwen VL…', host, box: 'beast' })
const health = await checkImageComfyHttp(2500, host)
if (health.ok) {
onStatus({ state: 'online', message: 'Sidecar Qwen VL online', host, box: 'sidecar' })
onStatus({ state: 'online', message: 'Beast Qwen VL online', host, box: 'beast' })
return
}
onStatus({
state: 'starting',
message: 'Sidecar is offline. Starting Qwen VL on 192.168.77.101…',
host,
box: 'sidecar'
})
const started = await startSidecarComfy()
if (!started.started && started.reason === 'no-launcher') {
throw createError({
statusCode: 503,
statusMessage: 'Sidecar is offline and the host agent did not start it. Confirm the agent on 192.168.77.101:8199 is running.'
})
}
const deadline = Date.now() + cfg.startTimeoutMs
let attempt = 0
while (Date.now() < deadline) {
attempt += 1
onStatus({
state: 'starting',
message: `Starting Sidecar Qwen VL… waiting for Comfy (${attempt})`,
host,
box: 'sidecar'
})
const next = await checkImageComfyHttp(cfg.healthTimeoutMs, host)
if (next.ok) {
onStatus({ state: 'online', message: 'Sidecar Qwen VL online', host, box: 'sidecar', processRunning: true })
return
}
await sleep(2000)
}
throw createError({
statusCode: 503,
statusMessage: 'Started Sidecar Qwen VL but it never answered. The 101 box can take a couple of minutes to load after a cold boot.'
statusMessage: `Beast ComfyUI is offline (${host}). Recommend needs Qwen VL on that instance and will not start a sidecar.`
})
}
+5
View File
@@ -1,4 +1,6 @@
import template from '../assets/workflow_flux2_klein_edit.json'
import { applyUserLoraToGraph } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
const IMAGE1 = '76'
const IMAGE2 = '81'
@@ -27,6 +29,8 @@ export interface EditParams {
seed: number
cfg: number
filenamePrefix?: string
loraName?: string
loraStack?: LoraStackItem[]
}
export function buildEditWorkflow(params: EditParams) {
@@ -61,6 +65,7 @@ export function buildEditWorkflow(params: EditParams) {
if (dual ? singleNode : dualNode) delete graph[id]
}
applyUserLoraToGraph(graph, params.loraStack || params.loraName)
return graph
}
+5 -1
View File
@@ -77,7 +77,7 @@ export interface Job {
duration?: number
sound?: boolean
draftId?: string
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
chainIndex?: number
chainStep?: number
chainTotal?: number
@@ -95,6 +95,10 @@ export interface Job {
queueAutoRun?: boolean
queueBudget?: number
stopAfterCurrent?: boolean
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
}
error?: string
socketReady?: boolean
+23 -3
View File
@@ -46,6 +46,8 @@ export interface LibraryClip {
hideInput?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
}
export type StillRole = 'input' | 'output'
@@ -82,6 +84,8 @@ export interface QueuedExtension {
prompt: string
duration: number
permanenceRefs?: PermanenceRef[]
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
}
export interface RetryDraft {
@@ -111,6 +115,10 @@ export interface RetryDraft {
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
}
interface Catalog {
@@ -639,7 +647,7 @@ function writeFrameMeta(dest: string, video: string, mode: FrameSourceMeta['mode
const meta: FrameSourceMeta = { videoMtime: info.mtimeMs, videoSize: info.size, mode }
if (typeof seek === 'number') meta.seek = seek
writeFileSync(frameMetaPath(dest), JSON.stringify(meta))
} catch { /* thumbs still work without the sidecar */ }
} catch { /* thumbs still work without Comfy */ }
}
function frameCacheFresh(video: string, dest: string, mode: FrameSourceMeta['mode']) {
@@ -1209,6 +1217,10 @@ export async function saveRetryDraft(params: {
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
}) {
return mutate(params.ownerKey, (catalog) => {
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
@@ -1238,7 +1250,11 @@ export async function saveRetryDraft(params: {
workflow: params.workflow,
globalLocks: params.globalLocks,
permanenceRefs: params.permanenceRefs,
shotPermanenceRefs: params.shotPermanenceRefs
shotPermanenceRefs: params.shotPermanenceRefs,
loraName: params.loraName,
loraStack: params.loraStack,
shotLoras: params.shotLoras,
shotLoraStacks: params.shotLoraStacks
}
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
return draft
@@ -1392,6 +1408,8 @@ export async function saveClip(params: {
sound?: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
segmentFirstFrame?: Buffer
}) {
const catalog = readCatalog(params.ownerKey)
@@ -1423,7 +1441,9 @@ export async function saveClip(params: {
sound: params.sound,
hideInput: params.hideInput === true,
globalLocks: params.globalLocks,
permanenceRefs: params.permanenceRefs?.length ? normalizePermanenceRefs(params.permanenceRefs) : undefined
permanenceRefs: params.permanenceRefs?.length ? normalizePermanenceRefs(params.permanenceRefs) : undefined,
loraName: params.loraName || undefined,
loraStack: params.loraStack?.length ? params.loraStack : undefined
}
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
const videoPath = clipVideoPath(params.ownerKey, clip.id)
+305
View File
@@ -0,0 +1,305 @@
import { comfyConfigured, comfyFetch, getComfyHost } from '~/server/utils/comfy'
import { getBeastImageHost, imageComfyFetch, sameImageHost } from '~/server/utils/imageComfy'
import {
XAIGEN_LORA_MESSAGE,
filterLorasForStudio,
isXaigenOnlyLora,
normalizeLoraName,
normalizeLoraStack,
persistLoraFields,
type LoraKind,
type LoraStackItem
} from '~/utils/loras'
import { isXaigenStudio } from '~/utils/videoModels'
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
type WorkflowGraph = Record<string, WorkflowNode>
const LORA_LOADER = 'LoraLoader'
const LORA_MODEL_ONLY = 'LoraLoaderModelOnly'
const OBJECT_INFO_NODES = [
LORA_LOADER,
LORA_MODEL_ONLY,
'Power Lora Loader (rgthree)',
'Lora Loader Stack (rgthree)',
'LoraLoaderModelOnly [10]',
'WanVideoLoraSelect',
'LoraLoaderStacked'
]
const MODEL_FEED_CLASSES = new Set(['BasicGuider', 'CFGGuider', 'KSampler', 'KSamplerAdvanced'])
const CACHE_MS = 20_000
type LoraCache = {
at: number
image: string[]
video: string[]
}
let cache: LoraCache | null = null
let inflight: Promise<LoraCache> | null = null
function uniqueNames(values: unknown[]) {
const seen = new Set<string>()
const out: string[] = []
for (const value of values) {
const name = String(value || '').trim()
if (!name || seen.has(name)) continue
seen.add(name)
out.push(name)
}
return out
}
function comboFromSpec(spec: unknown): string[] {
if (!Array.isArray(spec)) return []
const first = spec[0]
if (Array.isArray(first)) return first.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
if (typeof first === 'string' && first.trim()) return spec.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
return []
}
function namesFromObjectInfoNode(info: unknown) {
if (!info || typeof info !== 'object') return [] as string[]
const input = (info as { input?: { required?: Record<string, unknown>; optional?: Record<string, unknown> } }).input
const buckets = [input?.required, input?.optional]
const names: string[] = []
for (const bucket of buckets) {
if (!bucket) continue
for (const [key, spec] of Object.entries(bucket)) {
if (!/lora/i.test(key)) continue
names.push(...comboFromSpec(spec))
}
}
return uniqueNames(names)
}
async function fetchJson(path: string, via: 'video' | 'image') {
const res = via === 'image'
? await imageComfyFetch(path, { signal: AbortSignal.timeout(8000) })
: await comfyFetch(path, { signal: AbortSignal.timeout(8000) })
if (!res.ok) return null
return res.json().catch(() => null)
}
async function fetchModelsLoras(via: 'video' | 'image') {
const payload = await fetchJson('/models/loras', via)
if (Array.isArray(payload)) return uniqueNames(payload)
if (payload && typeof payload === 'object' && Array.isArray((payload as { loras?: unknown[] }).loras)) {
return uniqueNames((payload as { loras: unknown[] }).loras)
}
return [] as string[]
}
async function fetchObjectInfoLoras(via: 'video' | 'image') {
const image: string[] = []
const video: string[] = []
let any = false
for (const node of OBJECT_INFO_NODES) {
const info = await fetchJson(`/object_info/${encodeURIComponent(node)}`, via)
if (!info || typeof info !== 'object') continue
const record = info as Record<string, unknown>
const body = record[node] || (Object.keys(record).length === 1 ? record[Object.keys(record)[0] as string] : record)
const names = namesFromObjectInfoNode(body)
if (!names.length) continue
any = true
if (node === LORA_LOADER) image.push(...names)
else video.push(...names)
if (node !== LORA_LOADER && node !== LORA_MODEL_ONLY) {
image.push(...names)
video.push(...names)
}
}
if (any) {
return { image: uniqueNames(image), video: uniqueNames(video) }
}
const all = await fetchJson('/object_info', via)
if (!all || typeof all !== 'object') return { image: [] as string[], video: [] as string[] }
const imageAll: string[] = []
const videoAll: string[] = []
for (const [classType, info] of Object.entries(all as Record<string, unknown>)) {
if (!/lora/i.test(classType)) continue
const names = namesFromObjectInfoNode(info)
if (classType === LORA_LOADER) imageAll.push(...names)
else videoAll.push(...names)
}
return { image: uniqueNames(imageAll), video: uniqueNames(videoAll) }
}
async function discoverFromHost(via: 'video' | 'image') {
const models = await fetchModelsLoras(via).catch(() => [] as string[])
const fromInfo = await fetchObjectInfoLoras(via).catch(() => ({ image: [] as string[], video: [] as string[] }))
const image = uniqueNames([...fromInfo.image, ...models])
const video = uniqueNames([...fromInfo.video, ...models])
if (!image.length && video.length) return { image: video, video }
if (!video.length && image.length) return { image, video: image }
return { image, video }
}
async function loadLoraCache(): Promise<LoraCache> {
const now = Date.now()
if (cache && now - cache.at < CACHE_MS) return cache
if (inflight) return inflight
inflight = (async () => {
const videoHost = comfyConfigured() ? getComfyHost() : ''
const imageHost = getBeastImageHost()
const same = Boolean(videoHost && imageHost && sameImageHost(videoHost, imageHost))
const video = videoHost
? await discoverFromHost('video').catch(() => ({ image: [] as string[], video: [] as string[] }))
: { image: [] as string[], video: [] as string[] }
const image = imageHost && !same
? await discoverFromHost('image').catch(() => ({ image: [] as string[], video: [] as string[] }))
: video
const xaigen = isXaigenStudio()
const next: LoraCache = {
at: Date.now(),
image: filterLorasForStudio(uniqueNames([...image.image, ...video.image]), xaigen),
video: filterLorasForStudio(uniqueNames([...video.video, ...image.video]), xaigen)
}
cache = next
return next
})().finally(() => {
inflight = null
})
return inflight
}
export async function listStudioLoras() {
try {
return await loadLoraCache()
} catch {
return { at: 0, image: [] as string[], video: [] as string[] }
}
}
export function assertLoraAllowed(raw: unknown, kind: LoraKind) {
const name = normalizeLoraName(raw)
if (!name) return ''
if (isXaigenOnlyLora(name) && !isXaigenStudio()) {
throw createError({ statusCode: 400, statusMessage: XAIGEN_LORA_MESSAGE })
}
const known = cache?.[kind]
if (known?.length && !known.includes(name) && !known.some(item => item.replace(/\\/g, '/') === name)) {
throw createError({ statusCode: 400, statusMessage: `Unknown ${kind} LoRA` })
}
return name
}
export function parsePostedLora(raw: unknown, kind: LoraKind) {
return parsePostedLoraStack(raw, kind)[0]?.name || ''
}
export function parsePostedLoraStack(raw: unknown, kind: LoraKind): LoraStackItem[] {
const out: LoraStackItem[] = []
for (const item of normalizeLoraStack(raw)) {
const name = assertLoraAllowed(item.name, kind)
if (!name) continue
out.push({ ...item, name })
}
return out
}
export function parseShotLoras(raw: unknown, shotCount: number, kind: LoraKind = 'video') {
return parseShotLoraStacks(raw, shotCount, kind).map(stack => stack[0]?.name || '')
}
export function parseShotLoraStacks(raw: unknown, shotCount: number, kind: LoraKind = 'video'): LoraStackItem[][] {
const empty = Array.from({ length: shotCount }, () => [] as LoraStackItem[])
if (!raw) return empty
let parsed: unknown = raw
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
return empty
}
}
if (!Array.isArray(parsed)) return empty
return Array.from({ length: shotCount }, (_, index) => parsePostedLoraStack(parsed[index], kind))
}
export { persistLoraFields }
function linkSource(value: unknown): string | null {
return Array.isArray(value) && typeof value[0] === 'string' ? value[0] : null
}
function alreadyHasLora(graph: WorkflowGraph, name: string) {
const wanted = name.replace(/\\/g, '/').toLowerCase()
return Object.values(graph).some((node) => {
if (node.class_type !== LORA_LOADER && node.class_type !== LORA_MODEL_ONLY) return false
return String(node.inputs.lora_name || '').replace(/\\/g, '/').toLowerCase() === wanted
})
}
function injectAfter(
graph: WorkflowGraph,
sourceId: string,
nodeId: string,
node: WorkflowNode
) {
if (!graph[sourceId] || graph[nodeId]) return
graph[nodeId] = node
for (const [id, other] of Object.entries(graph)) {
if (id === nodeId) continue
for (const [key, value] of Object.entries(other.inputs)) {
const src = linkSource(value)
if (src === sourceId && Array.isArray(value)) {
other.inputs[key] = [nodeId, value[1]]
}
}
}
}
function findModelFeed(graph: WorkflowGraph) {
for (const node of Object.values(graph)) {
if (!MODEL_FEED_CLASSES.has(node.class_type)) continue
const source = linkSource(node.inputs.model)
if (source && graph[source]) return source
}
return ''
}
export function applyUserLoraToGraph(graph: WorkflowGraph, stack?: unknown) {
const items = normalizeLoraStack(stack).filter(item => !alreadyHasLora(graph, item.name))
if (!items.length) return
const clipLoaders = Object.entries(graph).filter(([, node]) => node.class_type === LORA_LOADER)
if (clipLoaders.length) {
for (const [id] of clipLoaders) {
let sourceId = id
for (const [index, item] of items.entries()) {
const nodeId = `user:lora:${id}:${index}`
injectAfter(graph, sourceId, nodeId, {
class_type: LORA_LOADER,
inputs: {
lora_name: item.name,
strength_model: item.strengthModel,
strength_clip: item.strengthClip,
model: [sourceId, 0],
clip: [sourceId, 1]
},
_meta: { title: items.length === 1 ? 'User LoRA' : `User LoRA ${index + 1}` }
})
sourceId = nodeId
}
}
return
}
let source = findModelFeed(graph)
if (!source) return
for (const [index, item] of items.entries()) {
const nodeId = index === 0 ? 'user:lora' : `user:lora:${index}`
injectAfter(graph, source, nodeId, {
class_type: LORA_MODEL_ONLY,
inputs: {
lora_name: item.name,
strength_model: item.strengthModel,
model: [source, 0]
},
_meta: { title: items.length === 1 ? 'User LoRA' : `User LoRA ${index + 1}` }
})
source = nodeId
}
}
+17 -3
View File
@@ -47,13 +47,17 @@ export interface PendingJob {
samplerName?: string
scheduler?: string
hideInput?: boolean
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
remainingExtensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
extensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
remainingExtensions?: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
currentClipId?: string
queueId?: string
queueAutoRun?: boolean
queueBudget?: number
stopAfterCurrent?: boolean
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
}
function pendingRoot() {
@@ -139,6 +143,10 @@ export function pendingFromJob(job: Job, overrides: Partial<PendingJob> = {}): P
globalLocks: library.globalLocks,
permanenceRefs: library.permanenceRefs,
shotPermanenceRefs: library.shotPermanenceRefs,
loraName: library.loraName,
loraStack: library.loraStack,
shotLoras: library.shotLoras,
shotLoraStacks: library.shotLoraStacks,
queueId: library.queueId,
queueAutoRun: library.queueAutoRun,
queueBudget: library.queueBudget,
@@ -191,7 +199,11 @@ export function libraryFromPending(pending: PendingJob): NonNullable<Job['librar
stopAfterCurrent: pending.stopAfterCurrent,
globalLocks: pending.globalLocks,
permanenceRefs: pending.permanenceRefs,
shotPermanenceRefs: pending.shotPermanenceRefs
shotPermanenceRefs: pending.shotPermanenceRefs,
loraName: pending.loraName,
loraStack: pending.loraStack,
shotLoras: pending.shotLoras,
shotLoraStacks: pending.shotLoraStacks
}
}
@@ -318,6 +330,8 @@ export async function completePendingIfReady(pending: PendingJob) {
sound: pending.sound,
globalLocks: pending.globalLocks,
permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0]),
loraName: pending.loraName,
loraStack: pending.loraStack,
segmentFirstFrame
})
await purgeComfyArtifacts({
+45 -25
View File
@@ -17,23 +17,7 @@ export interface PromptJob {
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()
}
@@ -44,14 +28,14 @@ export function promptComfyConfigured() {
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.' })
throw createError({ statusCode: 503, statusMessage: 'Qwen VL is not configured. Set COMFY_HOST or IMAGE_COMFY_HOST to Beast.' })
}
try {
return await fetch(`${host}${path}`, init)
} catch (error) {
throw createError({
statusCode: 502,
statusMessage: `Sidecar Qwen VL is unreachable (${host})`,
statusMessage: `Beast Qwen VL is unreachable (${host})`,
data: { cause: error instanceof Error ? error.message : String(error) }
})
}
@@ -75,7 +59,7 @@ async function uploadPromptImage(file: { filename: string; data: Buffer; type?:
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})` })
throw createError({ statusCode: 502, statusMessage: `Beast image upload failed (${res.status})` })
}
const uploaded = (await res.json()) as { name: string; subfolder?: string; type?: string }
return uploaded.name || filename
@@ -89,14 +73,48 @@ async function queuePromptGraph(graph: unknown, clientId: string) {
})
const payload = await res.json().catch(() => ({}))
if (!res.ok) {
const raw = JSON.stringify(payload)
if (/does not exist|class_type|not found/i.test(raw)) {
throw createError({
statusCode: 503,
statusMessage: 'Beast ComfyUI rejected the Qwen VL graph. The GGUF VLM nodes are missing on this instance — Recommend will not use the sidecar.',
data: payload
})
}
const message = (payload as { error?: { message?: string } }).error?.message
|| (payload as { node_errors?: unknown }).node_errors
|| `Sidecar queue failed (${res.status})`
|| `Beast queue failed (${res.status})`
throw createError({ statusCode: 502, statusMessage: String(message), data: payload })
}
return payload as { prompt_id: string }
}
const QWEN_VL_NODE_TYPES = ['GGUF_VLM_ModelLoader', 'GGUF_VLM_ImageToText', 'AigenPreviewText']
async function assertQwenVlWorkflow() {
const res = await promptComfyFetch('/object_info')
if (!res.ok) {
throw createError({
statusCode: 502,
statusMessage: `Beast ComfyUI did not return node info (${res.status})`
})
}
const info = await res.json().catch(() => null)
if (!info || typeof info !== 'object') {
throw createError({
statusCode: 502,
statusMessage: 'Beast ComfyUI returned unreadable node info'
})
}
const missing = QWEN_VL_NODE_TYPES.filter(name => !(name in (info as Record<string, unknown>)))
if (missing.length) {
throw createError({
statusCode: 503,
statusMessage: `Beast ComfyUI is missing Qwen VL nodes (${missing.join(', ')}). Recommend is wired to Beast only and will not fall back to the sidecar.`
})
}
}
async function fetchPromptHistory(promptId: string) {
const res = await promptComfyFetch(`/history/${encodeURIComponent(promptId)}`)
if (!res.ok) return null
@@ -149,9 +167,9 @@ function historyError(history: Record<string, unknown> | null, promptId: string)
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 (status === 'error') return message || 'Beast Qwen VL reported an execution error'
if (entry?.status?.completed && !extractPromptText(history, promptId)) {
return 'Sidecar Qwen VL finished without prompt text'
return 'Beast Qwen VL finished without prompt text'
}
return null
}
@@ -168,7 +186,7 @@ async function waitForPromptText(promptId: string, timeoutMs = 240_000) {
}
await new Promise(resolve => setTimeout(resolve, 1200))
}
throw createError({ statusCode: 504, statusMessage: 'Sidecar Qwen VL timed out while writing the prompt' })
throw createError({ statusCode: 504, statusMessage: 'Beast Qwen VL timed out while writing the prompt' })
}
export function createPromptJob(): PromptJob {
@@ -221,11 +239,13 @@ export async function runPromptRecommend(job: PromptJob, params: {
mode: string
}) {
job.status = 'running'
job.message = 'Checking Sidecar Qwen VL…'
job.message = 'Checking Beast Qwen VL…'
await ensureSidecarReady((status) => {
job.message = status.message
})
job.message = 'Uploading still to Sidecar Qwen VL…'
job.message = 'Confirming Qwen VL nodes on Beast…'
await assertQwenVlWorkflow()
job.message = 'Uploading still to Beast Qwen VL…'
const imageName = await uploadPromptImage(params.image, job.id)
job.message = 'Qwen VL is reading the still…'
const graph = buildVisionPromptWorkflow({
+31 -8
View File
@@ -1,6 +1,8 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { getJob, listJobs, type Job } from '~/server/utils/jobs'
import { parsePostedLoraStack, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
export type ShotQueueStatus = 'idle' | 'running' | 'paused' | 'complete' | 'error'
export type ShotSegmentStatus = 'pending' | 'running' | 'complete' | 'error'
@@ -13,6 +15,8 @@ export interface ShotQueueSegment {
clipId?: string
error?: string
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
loraName?: string
loraStack?: LoraStackItem[]
}
export interface ShotQueue {
@@ -46,6 +50,8 @@ export interface ShotQueue {
referenceStillIds?: Array<string | null>
globalLocks?: string
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
loraName?: string
loraStack?: LoraStackItem[]
segments: ShotQueueSegment[]
lastError?: string
}
@@ -163,8 +169,10 @@ export async function createShotQueue(params: {
referenceStillIds?: Array<string | null>
globalLocks?: string
permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]
initial: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }
extensions: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[]
loraName?: string
loraStack?: LoraStackItem[]
initial: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }
extensions: { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
jobId?: string
}): Promise<ShotQueue> {
const now = Date.now()
@@ -198,14 +206,17 @@ export async function createShotQueue(params: {
referenceStillIds: params.referenceStillIds,
globalLocks: params.globalLocks,
permanenceRefs: params.permanenceRefs,
loraName: params.loraName,
loraStack: params.loraStack,
segments: [
{ index: 0, prompt: params.initial.prompt, duration: params.initial.duration, status: 'running', permanenceRefs: params.initial.permanenceRefs },
{ index: 0, prompt: params.initial.prompt, duration: params.initial.duration, status: 'running', permanenceRefs: params.initial.permanenceRefs, ...persistLoraFields(params.initial.loraStack || params.initial.loraName) },
...params.extensions.map((item, index) => ({
index: index + 1,
prompt: item.prompt,
duration: item.duration,
status: 'pending' as const,
permanenceRefs: item.permanenceRefs
permanenceRefs: item.permanenceRefs,
...persistLoraFields(item.loraStack || item.loraName)
}))
]
}
@@ -230,7 +241,7 @@ export async function updateShotQueue(owner: string, id: string, patch: (queue:
export async function patchShotQueueSegments(
owner: string,
id: string,
segments: Array<{ index: number; prompt?: string; duration?: number }>
segments: Array<{ index: number; prompt?: string; duration?: number; loraName?: string; loraStack?: unknown }>
) {
return updateShotQueue(owner, id, (queue) => {
if (queue.status === 'complete') {
@@ -248,6 +259,17 @@ export async function patchShotQueueSegments(
if (typeof next.duration === 'number' && Number.isFinite(next.duration)) {
segment.duration = Math.min(120, Math.max(0.5, Math.round(next.duration * 10) / 10))
}
if ('loraStack' in next || 'loraName' in next) {
const stack = parsePostedLoraStack(next.loraStack ?? next.loraName, 'video')
const fields = persistLoraFields(stack)
if (fields.loraStack) {
segment.loraStack = fields.loraStack
segment.loraName = fields.loraName
} else {
delete segment.loraStack
delete segment.loraName
}
}
}
})
}
@@ -426,10 +448,10 @@ export function listOwnersWithQueues() {
.filter(owner => existsSync(queuesPath(owner)))
}
export function remainingFromQueue(queue: ShotQueue): { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[] }[] {
export function remainingFromQueue(queue: ShotQueue): { prompt: string; duration: number; permanenceRefs?: import('~/utils/globalLocks').PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[] {
return queue.segments
.filter(segment => segment.index > 0)
.map(segment => ({ prompt: segment.prompt, duration: segment.duration, permanenceRefs: segment.permanenceRefs }))
.map(segment => ({ prompt: segment.prompt, duration: segment.duration, permanenceRefs: segment.permanenceRefs, ...persistLoraFields(segment.loraStack || segment.loraName) }))
}
export function lastCompletedIndex(queue: ShotQueue) {
@@ -443,6 +465,7 @@ export function liveSegmentPrompt(queue: ShotQueue, extensionIndex: number) {
return {
prompt: segment?.prompt || '',
duration: segment?.duration || 5,
permanenceRefs: segment?.permanenceRefs
permanenceRefs: segment?.permanenceRefs,
...persistLoraFields(segment?.loraStack || segment?.loraName)
}
}
+33 -12
View File
@@ -3,8 +3,9 @@ import { join } from 'node:path'
import { getJob, listJobs, emitJob, type Job } from '~/server/utils/jobs'
import { listPendingJobs, patchPendingJob, readPendingJob, deletePendingJob } from '~/server/utils/pending'
import { getShotQueue } from '~/server/utils/shotQueue'
import { parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
import { isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, type VideoWorkflowId } from '~/utils/videoModels'
import { persistLoraFields } from '~/server/utils/loras'
import { resolveLoraStack } from '~/utils/loras'
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
export type StudioJobKind = 'video' | 'edit'
@@ -33,11 +34,15 @@ export interface StudioJobPayload {
hideInput?: boolean
folderLocked?: boolean
referenceStillIds: Array<string | null>
extensions: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
extensions: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: import('~/utils/loras').LoraStackItem[] }[]
queueAutoRun: boolean
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
negative?: string
passes?: { prompt: string }[]
referenceStillId?: string
@@ -636,7 +641,8 @@ async function startStudioEditJob(item: StudioJob) {
chainStep: 1,
chainTotal: 1 + passes.length,
chainLabel: passes.length ? 'Pass 1' : undefined,
passes
passes,
...persistLoraFields(payload.loraStack || payload.loraName)
}
})
@@ -649,7 +655,8 @@ async function startStudioEditJob(item: StudioJob) {
negative: payload.negative || '',
steps: payload.steps,
seed: job.library?.seed || payload.seed,
cfg: payload.cfg
cfg: payload.cfg,
...persistLoraFields(payload.loraStack || payload.loraName)
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
if (job.status !== 'error' && job.status !== 'cancelled' && job.status !== 'deferred') {
@@ -680,6 +687,10 @@ export async function startStudioJob(item: StudioJob) {
const { existsSync, readFileSync } = await import('node:fs')
const payload = item.payload
const workflow = parseVideoWorkflow(payload.workflow)
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
throw new Error(LTX_DISABLED_MESSAGE)
}
const identity = allowIdentityRefs(
payload.useIdentityRefs,
payload.permanenceRefs,
@@ -720,13 +731,16 @@ export async function startStudioJob(item: StudioJob) {
chainTotal: 1 + extensions.length,
chainLabel: extensions.length ? 'Initial' : undefined,
familyId: item.familyId,
workflow: parseVideoWorkflow(payload.workflow),
workflow: workflow,
useIdentityRefs: identity,
queueAutoRun: extensions.length ? payload.queueAutoRun : false,
queueBudget: extensions.length && payload.queueAutoRun ? extensions.length : 0,
globalLocks: payload.globalLocks,
permanenceRefs: payload.permanenceRefs,
shotPermanenceRefs: payload.shotPermanenceRefs
shotPermanenceRefs: payload.shotPermanenceRefs,
...persistLoraFields(resolveLoraStack(payload.loraStack || payload.loraName, payload.shotLoraStacks?.[0] || payload.shotLoras?.[0])),
shotLoras: payload.shotLoras,
shotLoraStacks: payload.shotLoraStacks
}
if (extensions.length) {
@@ -749,14 +763,18 @@ export async function startStudioJob(item: StudioJob) {
fps: payload.fps,
samplerName: payload.samplerName,
scheduler: payload.scheduler,
workflow: parseVideoWorkflow(payload.workflow),
workflow: workflow,
sound: payload.sound,
useIdentityRefs: identity,
referenceStillIds: payload.referenceStillIds,
globalLocks: payload.globalLocks,
permanenceRefs: payload.permanenceRefs,
initial: { prompt: payload.prompt, duration: payload.duration, permanenceRefs: payload.shotPermanenceRefs?.[0] },
extensions,
...persistLoraFields(payload.loraStack || payload.loraName),
initial: { prompt: payload.prompt, duration: payload.duration, permanenceRefs: payload.shotPermanenceRefs?.[0], ...persistLoraFields(resolveLoraStack(payload.loraStack || payload.loraName, payload.shotLoraStacks?.[0] || payload.shotLoras?.[0])) },
extensions: extensions.map((item, index) => ({
...item,
...persistLoraFields(item.loraStack || item.loraName || payload.shotLoraStacks?.[index + 1] || payload.shotLoras?.[index + 1])
})),
jobId: job.id
})
job.library.queueId = queue.id
@@ -802,10 +820,13 @@ export async function startStudioJob(item: StudioJob) {
samplerName: payload.samplerName,
scheduler: payload.scheduler,
extensions,
workflow: parseVideoWorkflow(payload.workflow),
workflow: workflow,
duration: payload.duration,
useIdentityRefs: identity,
referenceImages
referenceImages,
...persistLoraFields(resolveLoraStack(payload.loraStack || payload.loraName, payload.shotLoraStacks?.[0] || payload.shotLoras?.[0])),
shotLoras: payload.shotLoras,
shotLoraStacks: payload.shotLoraStacks
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
if (job.status !== 'error' && job.status !== 'cancelled' && job.status !== 'deferred') {
+24 -5
View File
@@ -21,6 +21,9 @@ import {
updateShotQueue
} from '~/server/utils/shotQueue'
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
import { persistLoraFields } from '~/server/utils/loras'
import { readLoraStack, resolveLoraStack } from '~/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
export type ChainImage = { filename: string; data: Buffer; type?: string }
@@ -38,7 +41,7 @@ type VideoChainParams = {
fps: number
samplerName: string
scheduler: string
extensions: { prompt: string; duration: number }[]
extensions: { prompt: string; duration: number; loraName?: string; loraStack?: LoraStackItem[]; permanenceRefs?: PermanenceRef[] }[]
workflow: VideoWorkflowId
duration: number
useIdentityRefs: boolean
@@ -46,6 +49,10 @@ type VideoChainParams = {
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
loraName?: string
loraStack?: LoraStackItem[]
shotLoras?: string[]
shotLoraStacks?: LoraStackItem[][]
}
function sleep(ms: number) {
@@ -119,7 +126,10 @@ function paramsFromJob(job: Job): VideoChainParams {
referenceImages,
globalLocks: library.globalLocks,
permanenceRefs: library.permanenceRefs,
shotPermanenceRefs: library.shotPermanenceRefs
shotPermanenceRefs: library.shotPermanenceRefs,
...persistLoraFields(readLoraStack(library)),
shotLoras: library.shotLoras,
shotLoraStacks: library.shotLoraStacks
}
}
@@ -137,6 +147,7 @@ export async function queueMiniMax(
job.status = 'uploading'
const chainIndex = job.library?.chainIndex || 0
const graphId = chainIndex > 0 ? workflowForExtension(params.workflow) : params.workflow
if (job.library) Object.assign(job.library, persistLoraFields(params.loraStack || params.loraName || job.library.loraStack || job.library.loraName))
const engineName = isLtxWorkflow(graphId) ? 'LTX-2.3' : 'MiniMax H3'
const hasImage = Boolean(params.image?.data?.length)
const uploading = !hasImage
@@ -197,7 +208,8 @@ export async function queueMiniMax(
workflow: graphId,
duration: params.duration,
useIdentityRefs: params.useIdentityRefs,
referenceImageNames: params.useIdentityRefs ? referenceNames : []
referenceImageNames: params.useIdentityRefs ? referenceNames : [],
...persistLoraFields(params.loraStack || params.loraName)
})
const queued = await queuePrompt(graph, job.clientId)
@@ -305,6 +317,10 @@ export async function continueQueuedExtensions(
prompt: live.prompt || extensions[i].prompt,
duration: live.duration || extensions[i].duration
}
const shotStack = resolveLoraStack(
readLoraStack(liveQueue).length ? readLoraStack(liveQueue) : (params.loraStack || params.loraName),
live.loraStack || live.loraName || extensions[i]?.loraStack || extensions[i]?.loraName
)
if (!ext.prompt.trim()) {
throw new Error(`Shot ${i + 2} needs a prompt`)
}
@@ -328,6 +344,7 @@ export async function continueQueuedExtensions(
job.library.extendPart1Path = undefined
job.library.thumb = undefined
job.library.duration = ext.duration
Object.assign(job.library, persistLoraFields(shotStack))
if (liveQueue) {
await updateShotQueue(job.library.ownerKey, liveQueue.id, (queue) => {
const segment = queue.segments.find(item => item.index === i + 1)
@@ -393,7 +410,8 @@ export async function continueQueuedExtensions(
workflow: workflowForExtension(params.workflow),
duration: ext.duration,
useIdentityRefs: identity,
referenceImages: identity ? params.referenceImages : []
referenceImages: identity ? params.referenceImages : [],
...persistLoraFields(shotStack)
})
shotsLeft -= 1
@@ -548,7 +566,8 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
queueBudget: n,
globalLocks: queue.globalLocks,
permanenceRefs: queue.permanenceRefs,
shotPermanenceRefs: queue.segments.map(segment => segment.permanenceRefs || [])
shotPermanenceRefs: queue.segments.map(segment => segment.permanenceRefs || []),
...persistLoraFields(readLoraStack(queue))
}
setQueueJob(queue.id, job.id)
await updateShotQueue(owner, queue.id, (next) => {
+11 -2
View File
@@ -83,10 +83,17 @@ export function emitChainJob(job: Job, event: JobEvent, samplePct?: number) {
emitJob(job, next)
}
function nodeLabel(node: string) {
if (!node) return ''
if (NODE_LABELS[node]) return NODE_LABELS[node]
if (node.startsWith('user:lora')) return 'Applying LoRA'
return ''
}
function isNonSamplerProgress(node: string) {
if (!node) return false
if (isEncodingNode(node)) return true
const label = NODE_LABELS[node] || ''
const label = nodeLabel(node)
return /save|checkpoint|video combine|vhs/i.test(label) && !/sampler/i.test(label)
}
@@ -235,6 +242,8 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
sound: job.library.sound,
globalLocks: job.library.globalLocks,
permanenceRefs: clipRefsFromJob(job.library),
loraName: job.library.loraName,
loraStack: job.library.loraStack,
segmentFirstFrame
})
job.clipId = clip.id
@@ -400,7 +409,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
}
return
}
const label = NODE_LABELS[node] || `Running node ${node}`
const label = nodeLabel(node) || `Running node ${node}`
const encoding = isEncodingNode(node)
emitLocal({
type: 'executing',
+12
View File
@@ -3,10 +3,14 @@ import workflowV1 from '../assets/workflow_minimax_video.json'
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
import workflowLtx from '../assets/workflow_ltx_video.json'
import { buildIdentityPrompt } from '~/utils/identityPrompt'
import { applyUserLoraToGraph } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import {
isLtxWorkflow,
isTextToVideo,
LTX_DISABLED_MESSAGE,
LTX_NEGATIVE,
ltxWorkflowEnabled,
parseVideoWorkflow,
type VideoWorkflowId
} from '~/utils/videoModels'
@@ -33,6 +37,8 @@ export interface GenerateParams {
duration?: number
useIdentityRefs?: boolean
referenceImageNames?: string[]
loraName?: string
loraStack?: LoraStackItem[]
}
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
@@ -74,6 +80,9 @@ function templateFor(id: VideoWorkflowId) {
export function buildWorkflow(params: GenerateParams) {
const version = parseVideoWorkflow(params.workflow)
if (isLtxWorkflow(version) && !ltxWorkflowEnabled()) {
throw new Error(LTX_DISABLED_MESSAGE)
}
if (isLtxWorkflow(version)) return buildLtxWorkflow(params, version)
const graph = structuredClone(templateFor(version === 't2v' ? 'v1' : version === 'v2' ? 'v2' : 'v1'))
@@ -216,6 +225,7 @@ export function buildWorkflow(params: GenerateParams) {
if (graph['105:104']?.inputs) delete graph['105:104'].inputs.audio_vae
}
applyUserLoraToGraph(graph, params.loraStack || params.loraName)
return graph
}
@@ -288,6 +298,7 @@ function buildLtxWorkflow(params: GenerateParams, version: VideoWorkflowId) {
if (graph['12']?.inputs) graph['12'].inputs.latent = ['8', 0]
if (graph['14']?.inputs) graph['14'].inputs.latent_image = ['8', 0]
}
applyUserLoraToGraph(graph, params.loraStack || params.loraName)
return graph
}
@@ -366,6 +377,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'105:104': 'Encoding prompt & latent',
'105:107': 'Computing duration',
'105:111': 'Setting duration',
'user:lora': 'Applying LoRA',
'105:121': 'Applying turbo LoRA',
'105:122': 'Selecting model path',
'105:123': 'Selecting steps',
+40
View File
@@ -0,0 +1,40 @@
import type { LoraStackItem } from '~/utils/loras'
import type { VideoWorkflowId } from '~/utils/videoModels'
export type GenerationPresetKind = 'video' | 'image'
export type VideoPresetSettings = {
workflow?: VideoWorkflowId
turbo?: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
duration?: number
aspect?: string
sound?: boolean
}
export type ImagePresetSettings = {
steps?: number
cfg?: number
negative?: string
}
export type GenerationPreset = {
id: string
name: string
kind: GenerationPresetKind
createdAt: number
updatedAt: number
loraStack: LoraStackItem[]
settings: VideoPresetSettings | ImagePresetSettings
}
export function parseGenerationPresetKind(raw: unknown): GenerationPresetKind {
return raw === 'image' ? 'image' : 'video'
}
export function normalizePresetName(raw: unknown) {
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, 80)
}
+177
View File
@@ -0,0 +1,177 @@
export const XAIGEN_LORA_MESSAGE = 'This LoRA is only available on xAIGen'
export type LoraKind = 'image' | 'video'
export type LoraStackItem = {
name: string
strengthModel: number
strengthClip: number
}
export type LoraStack = LoraStackItem[]
export const LORA_STRENGTH_MIN = -2
export const LORA_STRENGTH_MAX = 2
export const LORA_STRENGTH_DEFAULT = 1
export const LORA_STRENGTH_STEP = 0.05
export const LORA_STACK_MAX = 8
export function loraBasename(name: string) {
const trimmed = String(name || '').trim().replace(/\\/g, '/')
const parts = trimmed.split('/').filter(Boolean)
return parts[parts.length - 1] || trimmed
}
export function isXaigenOnlyLora(name: string) {
return /^xaigen/i.test(loraBasename(name))
}
export function loraDisplayName(name: string) {
return loraBasename(name).replace(/\.(safetensors|sft|ckpt|pt|bin)$/i, '')
}
export function normalizeLoraName(raw: unknown) {
const value = String(raw || '').trim().replace(/\\/g, '/')
if (!value) return ''
if (value.length > 240) return ''
if (value.includes('..') || value.startsWith('/') || /[\0\n\r]/.test(value)) return ''
if (!/\.(safetensors|sft|ckpt|pt|bin)$/i.test(value)) return ''
return value
}
export function clampLoraStrength(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
return Math.min(LORA_STRENGTH_MAX, Math.max(LORA_STRENGTH_MIN, Math.round(snapped * 100) / 100))
}
export function formatLoraStrength(value: number) {
const n = clampLoraStrength(value)
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0$/, '')
}
function loraKey(name: string) {
return name.replace(/\\/g, '/').toLowerCase()
}
export function makeLoraStackItem(
name: string,
strengthModel: unknown = LORA_STRENGTH_DEFAULT,
strengthClip: unknown = LORA_STRENGTH_DEFAULT
): LoraStackItem | null {
const normalized = normalizeLoraName(name)
if (!normalized) return null
return {
name: normalized,
strengthModel: clampLoraStrength(strengthModel),
strengthClip: clampLoraStrength(strengthClip)
}
}
function parseStackEntry(raw: unknown): LoraStackItem | null {
if (typeof raw === 'string') return makeLoraStackItem(raw)
if (!raw || typeof raw !== 'object') return null
const rec = raw as Record<string, unknown>
const name = rec.name ?? rec.loraName ?? rec.lora_name
return makeLoraStackItem(
name,
rec.strengthModel ?? rec.strength_model ?? LORA_STRENGTH_DEFAULT,
rec.strengthClip ?? rec.strength_clip ?? LORA_STRENGTH_DEFAULT
)
}
export function normalizeLoraStack(raw: unknown): LoraStackItem[] {
if (raw == null || raw === '') return []
let parsed: unknown = raw
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return []
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
try {
parsed = JSON.parse(trimmed)
} catch {
const item = makeLoraStackItem(trimmed)
return item ? [item] : []
}
} else {
const item = makeLoraStackItem(trimmed)
return item ? [item] : []
}
}
const entries = Array.isArray(parsed) ? parsed : [parsed]
const seen = new Set<string>()
const out: LoraStackItem[] = []
for (const entry of entries) {
if (out.length >= LORA_STACK_MAX) break
const item = parseStackEntry(entry)
if (!item) continue
const key = loraKey(item.name)
if (seen.has(key)) continue
seen.add(key)
out.push(item)
}
return out
}
export function readLoraStack(value?: { loraStack?: unknown; loraName?: unknown } | string | unknown[] | null) {
if (value == null || value === '') return [] as LoraStackItem[]
if (typeof value === 'string' || Array.isArray(value)) return normalizeLoraStack(value)
const stack = normalizeLoraStack(value.loraStack)
if (stack.length) return stack
return normalizeLoraStack(value.loraName)
}
export function resolveLoraName(fallback?: string | null, override?: string | null) {
const over = normalizeLoraName(override)
if (over) return over
return normalizeLoraName(fallback)
}
export function resolveLoraStack(fallback?: unknown, override?: unknown) {
const over = normalizeLoraStack(override)
if (over.length) return over
return normalizeLoraStack(fallback)
}
export function primaryLoraName(stack?: LoraStackItem[] | null) {
return stack?.[0]?.name || ''
}
export function loraStackLabel(stack?: LoraStackItem[] | null) {
return (stack || []).map(item => loraDisplayName(item.name)).join(' · ')
}
export function persistLoraFields(stack?: unknown) {
const items = normalizeLoraStack(stack)
if (!items.length) return { loraName: undefined as string | undefined, loraStack: undefined as LoraStackItem[] | undefined }
return { loraName: items[0].name, loraStack: items }
}
export function loraStacksEqual(a?: unknown, b?: unknown) {
const left = normalizeLoraStack(a)
const right = normalizeLoraStack(b)
if (left.length !== right.length) return false
return left.every((item, index) => (
loraKey(item.name) === loraKey(right[index].name)
&& item.strengthModel === right[index].strengthModel
&& item.strengthClip === right[index].strengthClip
))
}
export function filterLorasForStudio(names: string[], xaigenStudio: boolean) {
const seen = new Set<string>()
const out: string[] = []
for (const raw of names) {
const name = String(raw || '').trim()
if (!name || seen.has(name)) continue
if (!xaigenStudio && isXaigenOnlyLora(name)) continue
seen.add(name)
out.push(name)
}
return out
}
export function filterLoraStackForStudio(stack: unknown, xaigenStudio: boolean) {
return normalizeLoraStack(stack).filter(item => xaigenStudio || !isXaigenOnlyLora(item.name))
}
+25
View File
@@ -12,6 +12,25 @@ export function parseVideoWorkflow(raw: unknown): VideoWorkflowId {
return 'v1'
}
export const LTX_DISABLED_MESSAGE = 'LTX Pink Cherry is not available on this instance'
export function isXaigenStudio(publicConfig?: { studio?: unknown; instanceName?: unknown }) {
let pub = publicConfig
if (!pub) {
try {
pub = useRuntimeConfig().public as { studio?: unknown; instanceName?: unknown }
} catch {
pub = {}
}
}
return /^xaigen$/i.test(String(pub.studio || '').trim())
|| /xaigen/i.test(String(pub.instanceName || '').trim())
}
export function ltxWorkflowEnabled(publicConfig?: { studio?: unknown; instanceName?: unknown }) {
return isXaigenStudio(publicConfig)
}
export function videoEngineOf(id: VideoWorkflowId): VideoEngineId {
return id.startsWith('ltx') ? 'ltx' : 'minimax'
}
@@ -44,6 +63,12 @@ export function composeVideoWorkflow(engine: VideoEngineId, start: VideoStartId,
return graph === 'v2' ? 'v2' : 'v1'
}
export function coerceVideoWorkflow(raw: unknown, allowLtx?: boolean): VideoWorkflowId {
const id = parseVideoWorkflow(raw)
if ((allowLtx ?? ltxWorkflowEnabled()) || !isLtxWorkflow(id)) return id
return composeVideoWorkflow('minimax', videoStartOf(id), 'v1')
}
export function defaultVideoSteps(turbo: boolean, workflow: string = 'v1') {
const id = parseVideoWorkflow(workflow)
if (!turbo) return isLtxWorkflow(id) ? 12 : 20