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:
+360
-22
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user