· shot {{ shot.n }} · {{ formatClipDuration(duration) }}
{{ composedShotPreview(shot.prompt, index, shot.n) }}
+
+
-
+
Video model
+
+
+
+
+
+
+
+
+
Generation settings
@@ -643,6 +688,7 @@
Seed
+
Sampler & scheduler
@@ -691,6 +737,7 @@
+
Graph default is 20 steps, CFG 1, euler. A second still switches the two-image branch on.
@@ -975,6 +1022,7 @@
label="Extension duration"
hint="Type any length, use the slider, or drag up/down in the box."
/>
+
('auto')
const imageWidth = ref(0)
const imageHeight = ref(0)
const turbo = ref(true)
+const videoLoraStack = ref([])
+const imageLoraStack = ref([])
+const videoLoras = ref([])
+const imageLoras = ref([])
+const shotLoraStacks = ref>({})
+const extendLoraStack = ref([])
+const videoGenerationPresets = ref([])
+const imageGenerationPresets = ref([])
+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([])
const clips = ref([])
@@ -1871,6 +1954,13 @@ const minimaxGraph = ref('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) {
+ videoLoraStack.value = readLoraStack(stack as { loraStack?: unknown; loraName?: unknown } | string | null)
+ const next: Record = {}
+ ;(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
- 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(`/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(`/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))
}
diff --git a/pages/queue.vue b/pages/queue.vue
index 3a2d797..86bfcdb 100644
--- a/pages/queue.vue
+++ b/pages/queue.vue
@@ -200,6 +200,19 @@
label="Duration"
@update:modelValue="saveSegment(job.shots, segment)"
/>
+ { setSegmentStack(segment, value); saveSegment(job.shots, segment) }"
+ />
+ {{ segmentLoraLabel(job.shots, segment) }}