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
+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'