Name outputs from the input, engine, and LoRA keys instead of aigen_0001. Image-edit presets now store prompts, and aspect fill pads stills without stretching.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 16:24:49 -05:00
co-authored by Cursor
parent 4d411f83d4
commit 0e1222d035
23 changed files with 668 additions and 93 deletions
+79 -1
View File
@@ -5,7 +5,7 @@
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">Settings</h2>
<p class="mt-1 text-sm text-zinc-400">Library storage, folders, passwords, and prompt-helper instruction sets for this account.</p>
<p class="mt-1 text-sm text-zinc-400">Library storage, folders, passwords, LoRA filename keys, and prompt-helper instruction sets for this account.</p>
</div>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
</div>
@@ -123,6 +123,35 @@
</form>
</section>
<section class="mt-8 space-y-3">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">LoRA filename keys</h3>
<p class="mt-1 text-sm text-zinc-400">
Short names used in auto-generated output titles. Leave blank to use the file stem
(<span class="text-zinc-300">XAIgen-cline</span> becomes <span class="text-zinc-300">cline</span>).
</p>
</div>
<p v-if="loraRowsLoading" class="text-sm text-zinc-500">Loading LoRAs…</p>
<p v-else-if="!loraRows.length" class="text-sm text-zinc-500">No LoRA files found on Comfy yet.</p>
<div
v-for="row in loraRows"
:key="row.key"
class="flex flex-col gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3 sm:flex-row sm:items-center"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-zinc-200">{{ row.file }}</p>
<p class="text-[10px] uppercase tracking-wide text-zinc-600">{{ row.kind === 'both' ? 'Image · Video' : row.kind }}</p>
</div>
<input
v-model="row.draft"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm sm:w-44"
:placeholder="row.placeholder"
maxlength="24"
@change="saveLoraAlias(row)"
>
</div>
</section>
<section class="mt-8 space-y-3">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3>
@@ -190,6 +219,9 @@
</template>
<script setup lang="ts">
import { defaultLoraFilenameKey, isFilenameSystemLora } from '~/utils/outputNames'
import { loraDisplayName, loraIdentityKey } from '~/utils/loras'
export interface SettingsFolder {
id: string
name: string
@@ -216,6 +248,8 @@ const newName = ref('')
const newPresetName = ref('')
const newPresetPrompt = ref('')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([])
const loraRowsLoading = ref(true)
const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; placeholder: string }>>([])
const loginUser = ref(props.username || '')
const loginCurrent = ref('')
const loginNext = ref('')
@@ -279,6 +313,7 @@ onMounted(() => {
window.addEventListener('keydown', onKey)
void loadPresets()
void loadStorage()
void loadLoraKeys()
})
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
@@ -357,6 +392,49 @@ async function loadStorage() {
}
}
async function loadLoraKeys() {
loraRowsLoading.value = true
try {
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
const library = await $fetch<{ loraAliases?: Record<string, string> }>('/api/library?light=1').catch(() => ({ loraAliases: {} }))
const aliases = library.loraAliases || {}
const map = new Map<string, { key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; placeholder: string }>()
const add = (name: string, kind: 'image' | 'video') => {
if (isFilenameSystemLora(name)) return
const key = loraIdentityKey(name)
if (!key) return
const existing = map.get(key)
if (existing) {
if (existing.kind !== kind) existing.kind = 'both'
return
}
map.set(key, {
key,
file: loraDisplayName(name),
kind,
draft: aliases[key] || '',
placeholder: defaultLoraFilenameKey(name)
})
}
for (const name of listed.image || []) add(name, 'image')
for (const name of listed.video || []) add(name, 'video')
loraRows.value = [...map.values()].sort((a, b) => a.file.localeCompare(b.file))
} finally {
loraRowsLoading.value = false
}
}
async function saveLoraAlias(row: { key: string; draft: string }) {
try {
emit('updated', await $fetch('/api/library/lora-aliases', {
method: 'PUT',
body: { key: row.key, alias: row.draft }
}))
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not save LoRA filename key')
}
}
async function addPreset() {
const name = newPresetName.value.trim()
const systemPrompt = newPresetPrompt.value.trim()
+1 -1
View File
@@ -118,7 +118,7 @@ If `image_b` is sent on Edit/Compose and the executed graph has fewer than two `
| CFG | 4 (turbo 1) |
| Scale to MP | 1.0 lanczos |
| Strength (denoise) | 0.35 on Flux Refine. Krea Edit 0.75, Compose 0.70, Refine uses the slider. Hidden on Flux Edit/Compose/Generate. Range 0.15–0.75 |
| Size | Generate only. 1:1 1024×1024, 3:4 768×1024, 4:3 1024×768, 16:9 1280×768, 9:16 768×1280 |
| Size | Generate: Automatic 1024×1024, 16:9 1280×768, 9:16 768×1280, 1:1 1024×1024. Edit / Compose / Refine: Automatic keeps the still. Landscape / Portrait / Square pad the still and generate the new edges (no stretch). |
### When to use each Refine strength
+146 -52
View File
@@ -548,9 +548,10 @@
<input
v-model="clipName"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Shown in the library and used for the download filename"
:placeholder="autoOutputName"
maxlength="80"
>
<span class="mt-1 block text-[11px] text-zinc-600">Leave blank to use the auto name. Type here to override.</span>
</label>
<form v-if="currentFolder?.protected && !currentFolder.unlocked" class="space-y-2" @submit.prevent="unlockFolder(folderId)">
<p class="text-sm text-zinc-400">This folder is locked. You can still generate into it; unlock to view clips.</p>
@@ -733,7 +734,7 @@
/>
</div>
<div v-else-if="studioMode === 'editv2'" class="space-y-3">
<p class="text-xs text-zinc-500">Edit, Compose, Refine, and Generate are separate graphs. Shares the desktop GPU with video.</p>
<p class="text-xs text-zinc-500">Edit, Compose, Refine, and Generate are separate graphs. Shares the desktop GPU with video. Landscape / Portrait / Square fill new edges without stretching the still.</p>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Image engine</p>
<div class="grid grid-cols-2 gap-2">
<button
@@ -784,22 +785,22 @@
/>
<p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Flux LoRAs stay on the Flux engine. Switch back to restore that chain.</p>
</div>
<div v-if="v2Mode === 'generate'" class="space-y-3 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<div>
<p class="text-xs text-zinc-400">Size · near 1 MP</p>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="(pair, aspect) in IMAGE_V2_GENERATE_ASPECTS"
:key="aspect"
type="button"
class="rounded-full border px-2.5 py-1 text-xs"
:class="v2Width === pair[0] && v2Height === pair[1] ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="setGenerateSize(aspect)"
>
{{ aspect }} · {{ pair[0] }}×{{ pair[1] }}
</button>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in imageAspects"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setImageAspect(option.id)"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div>
<div v-if="v2Mode === 'refine' || (v2Engine === 'krea' && v2Mode !== 'generate')" class="space-y-2 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<label class="block text-sm">
@@ -882,6 +883,23 @@
</div>
<div v-else-if="studioMode === 'edit'" class="space-y-3">
<p class="text-xs text-zinc-500">Flux.2 Klein 9B. Shares the desktop GPU with video.</p>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in imageAspects"
:key="'v1-' + option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setImageAspect(option.id)"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div>
<div class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact />
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
@@ -1412,7 +1430,7 @@
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
<a
:href="editResultUrl"
:download="`${editDownloadName || 'aigen-edit'}.png`"
:download="`${editDownloadName || 'edited'}.png`"
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
>
Download still
@@ -2014,6 +2032,8 @@ import {
stillSettingsRows,
type StillGenerationSettings
} from '~/utils/libraryGeneration'
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames'
import {
IMAGE_V2_CFG_DEFAULT,
IMAGE_V2_CONSISTENCY_LORA,
@@ -2021,7 +2041,6 @@ import {
IMAGE_V2_DENOISE_MAX,
IMAGE_V2_DENOISE_MIN,
IMAGE_V2_DENOISE_STEP,
IMAGE_V2_GENERATE_ASPECTS,
IMAGE_V2_GENERATE_HEIGHT,
IMAGE_V2_GENERATE_WIDTH,
IMAGE_V2_KREA_CFG,
@@ -2038,7 +2057,6 @@ import {
IMAGE_V2_SNOFS_MODEL,
IMAGE_V2_STEPS_DEFAULT,
clampImageV2Denoise,
clampImageV2Size,
defaultImageV2LoraStack,
imageV2ConceptLoraName,
isImageV2ConceptLora,
@@ -2048,7 +2066,6 @@ import {
removeImageV2ConceptLora,
upsertImageV2Lora,
type ImageV2Engine,
type ImageV2GenerateAspect,
type ImageV2Mode,
type ImageV2PresetSettings,
type ImageV2Task
@@ -2205,6 +2222,27 @@ const aspects = [
{ id: '9:16' as const, label: '9:16 Portrait / Shorts', hint: '768 × 1344' },
{ id: '1:1' as const, label: '1:1 Square', hint: '960 × 960' }
]
const imageAspects = computed(() => {
const generate = studioMode.value === 'editv2' && v2Mode.value === 'generate'
return [
{ id: 'auto' as const, label: 'Automatic', hint: generate ? '1:1 · 1024 × 1024' : 'Keep the still’s shape' },
{ id: '16:9' as const, label: '16:9 Landscape', hint: generate ? '1280 × 768' : 'Fill extra edges — no stretch' },
{ id: '9:16' as const, label: '9:16 Portrait', hint: generate ? '768 × 1280' : 'Fill extra edges — no stretch' },
{ id: '1:1' as const, label: '1:1 Square', hint: generate ? '1024 × 1024' : 'Fill extra edges — no stretch' }
]
})
const imageAspectHelp = computed(() => {
if (studioMode.value === 'editv2' && v2Mode.value === 'generate') {
const size = generateSizeForAspect(aspect.value)
return `Empty canvas ${size.width} × ${size.height}. Nothing is stretched.`
}
if (aspect.value === 'auto') return 'Output keeps the still’s aspect ratio. No padding.'
return 'The still stays as-is. New edges are generated to fill the frame — no squish or stretch.'
})
const imageFillWithoutPrompt = computed(() => (
aspect.value !== 'auto'
&& (studioMode.value === 'edit' || (studioMode.value === 'editv2' && v2Mode.value !== 'generate'))
))
const player = ref<HTMLVideoElement | null>(null)
const outputPanel = ref<HTMLElement | null>(null)
@@ -2238,8 +2276,8 @@ const shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([])
const clipName = ref('')
const videoDownloadName = ref('aigen')
const editDownloadName = ref('aigen-edit')
const videoDownloadName = ref('output')
const editDownloadName = ref('edited')
const aspect = ref<AspectId>('auto')
const imageWidth = ref(0)
const imageHeight = ref(0)
@@ -2417,6 +2455,7 @@ const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const drafts = ref<RetryDraft[]>([])
const loraAliases = ref<Record<string, string>>({})
const folderId = ref('')
const browseFolderId = ref('')
const renamingId = ref('')
@@ -2487,6 +2526,29 @@ const imageV2LoraOptions = computed(() => mergeLoraOptions(
...(v2ConceptLoraEnabled.value && imageV2ConceptFile.value ? [imageV2ConceptFile.value] : [])
], v2Engine.value)
))
const autoOutputName = computed(() => {
const engine = studioMode.value === 'editv2'
? v2Engine.value
: studioMode.value === 'edit'
? 'klein'
: videoEngine.value
const stack = studioMode.value === 'editv2'
? imageV2EngineLoras()
: studioMode.value === 'edit'
? imageLoraStack.value
: videoLoraStack.value
const base = buildOutputBaseName({
inputFilename: file.value?.name || '',
engine,
loraStack: stack,
aliases: loraAliases.value
})
const existing = [
...stills.value.filter(item => item.folderId === folderId.value).map(item => item.name || ''),
...clips.value.filter(item => item.folderId === folderId.value).map(item => item.name || '')
]
return nextUniqueName(existing, base)
})
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
const globalLocks = ref('')
@@ -2755,13 +2817,14 @@ const gpuSleepHint = computed(() => {
return 'GPU is offline. Generate will try to start Comfy. Or click Wake GPU.'
})
const editBlocked = computed(() => {
if (!file.value || !prompt.value.trim() || !folderId.value) return true
if (!file.value || !folderId.value) return true
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return true
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
return false
})
const editBlockReason = computed(() => {
if (!file.value) return 'Load a still first.'
if (!prompt.value.trim()) return 'Write an edit prompt first.'
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return 'Write an edit prompt first, or pick Landscape / Portrait / Square to fill.'
if (!folderId.value) return 'Choose a library folder before editing.'
if (editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image edit is not configured. Set COMFY_HOST.'
@@ -2774,7 +2837,8 @@ const stillBHint = computed(() => {
return 'Still A is the edit image. Still B is style or background.'
})
const editV2Blocked = computed(() => {
if (!prompt.value.trim() || !folderId.value) return true
if (!folderId.value) return true
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) return true
if (v2Mode.value !== 'generate' && !file.value) return true
if (v2Mode.value === 'compose' && !editRefFile.value) return true
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return true
@@ -2784,7 +2848,11 @@ const editV2BlockReason = computed(() => {
if (v2Mode.value !== 'generate' && !file.value) return 'Load still A first.'
if (v2Mode.value === 'compose' && !editRefFile.value) return 'Compose requires still B. This will not fall back to one-image generation.'
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return 'Paint a mask on Still A first. Refine will not fall back to Edit.'
if (!prompt.value.trim()) return 'Write a prompt first.'
if (!prompt.value.trim() && !imageFillWithoutPrompt.value) {
return v2Mode.value === 'generate'
? 'Write a prompt first.'
: 'Write a prompt, or pick Landscape / Portrait / Square to fill the frame.'
}
if (!folderId.value) return 'Choose a library folder before generating.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.'
return ''
@@ -3275,12 +3343,15 @@ function applyInstancePrivacy() {
hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true)
}
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] } | unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string> } | unknown) {
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string> }
folders.value = payload.folders || []
clips.value = payload.clips || []
stills.value = payload.stills || []
drafts.value = payload.drafts || []
if (payload.loraAliases && typeof payload.loraAliases === 'object') {
loraAliases.value = payload.loraAliases
}
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || ''
}
@@ -3290,7 +3361,7 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
}
async function loadLibrary(light = false) {
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }>(light ? '/api/library?light=1' : '/api/library')
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string> }>(light ? '/api/library?light=1' : '/api/library')
applyLibrary(data)
if (light) return
const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
@@ -3832,6 +3903,7 @@ function currentPresetSnapshot() {
mode: v2Mode.value,
engine: v2Engine.value,
task: v2Mode.value === 'compose' ? v2Task.value : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene',
prompt: prompt.value,
negative: v2Negative.value,
snofsModel: imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0,
snofsClip: imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthClip ?? 0,
@@ -3842,8 +3914,9 @@ function currentPresetSnapshot() {
megapixels: v2Mode.value === 'generate' ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
turbo: v2Turbo.value === true,
strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value),
width: v2Mode.value === 'generate' ? v2Width.value : undefined,
height: v2Mode.value === 'generate' ? v2Height.value : undefined
aspect: aspect.value,
width: v2Mode.value === 'generate' ? generateSizeForAspect(aspect.value).width : undefined,
height: v2Mode.value === 'generate' ? generateSizeForAspect(aspect.value).height : undefined
}
}
}
@@ -3852,11 +3925,13 @@ function currentPresetSnapshot() {
kind: 'image' as const,
loraStack: filterLoraStackForStudio(imageLoraStack.value, ltxEnabled.value),
settings: {
prompt: prompt.value,
steps: clampImageSteps(editSteps.value),
cfg: clampImageCfg(editCfg.value),
negative: editNegative.value,
scaleToTotalPixels: editScaleToTotalPixels.value,
scaleMegapixels: clampImageScaleMegapixels(editScaleMegapixels.value)
scaleMegapixels: clampImageScaleMegapixels(editScaleMegapixels.value),
aspect: aspect.value
}
}
}
@@ -3886,6 +3961,7 @@ function applyGenerationPreset(preset: GenerationPreset) {
v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : settings.mode === 'generate' ? 'generate' : 'edit'
v2Engine.value = parseImageV2Engine(settings.engine, 'flux')
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene'
if (typeof settings.prompt === 'string') prompt.value = settings.prompt
if (typeof settings.negative === 'string') v2Negative.value = settings.negative
imageV2LoraStack.value = mergeLegacyImageV2Loras(filterLoraStackForImageEngine(stack, v2Engine.value), {
engine: v2Engine.value,
@@ -3902,8 +3978,9 @@ function applyGenerationPreset(preset: GenerationPreset) {
v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2Turbo.value = settings.turbo === true
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect)
if (v2Mode.value === 'generate') {
const size = clampImageV2Size(settings.width, settings.height)
const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width
v2Height.value = size.height
}
@@ -3913,17 +3990,21 @@ function applyGenerationPreset(preset: GenerationPreset) {
} else if (preset.kind === 'image') {
imageLoraStack.value = stack
const settings = preset.settings as {
prompt?: string
steps?: number
cfg?: number
negative?: string
scaleToTotalPixels?: boolean
scaleMegapixels?: number
aspect?: string
}
if (typeof settings.prompt === 'string') prompt.value = settings.prompt
if (typeof settings.steps === 'number') editSteps.value = clampImageSteps(settings.steps)
if (typeof settings.cfg === 'number') editCfg.value = clampImageCfg(settings.cfg)
if (typeof settings.negative === 'string') editNegative.value = settings.negative
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
aspect.value = parseImageAspect(settings.aspect)
imagePresetId.value = preset.id
imagePresetName.value = preset.name
} else {
@@ -4662,7 +4743,7 @@ async function restoreStudioJob(id: string) {
const stillId = payload.stillId
if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
readFile(new File([blob], payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
readFile(new File([blob], stillFileName(stills.value.find(item => item.id === stillId)) || payload.stillFilename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
} else {
resetImage()
}
@@ -4819,7 +4900,13 @@ function stopTimer(kind?: 'video' | 'edit') {
}
function clipFileName(name: string) {
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'aigen'
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'output'
}
function stillFileName(still?: Pick<LibraryStill, 'name' | 'filename'> | null) {
const named = (still?.name || '').trim()
if (named) return `${named}.png`
return still?.filename || 'still.png'
}
function nextClipPartName(name: string) {
@@ -4866,7 +4953,7 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (still?.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
readFile(new File([blob], stillFileName(still), { type: blob.type || 'image/png' }), { keepThumbnailPref: true, stillId })
return true
}
const blob = await $fetch<Blob>(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' })
@@ -5136,7 +5223,7 @@ function openClip(item: LibraryClip) {
awaitingReveal.value = item.hideThumbnail
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
browseFolderId.value = item.folderId
videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'output')
if (!videoBusy.value) {
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
progress.value = 100
@@ -5180,8 +5267,9 @@ function applyStillSettings(still: LibraryStill) {
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2Turbo.value = settings.turbo === true
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect)
if (mode === 'generate') {
const size = clampImageV2Size(settings.width, settings.height)
const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width
v2Height.value = size.height
}
@@ -5214,12 +5302,13 @@ function applyStillSettings(still: LibraryStill) {
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value)
}
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed))
aspect.value = parseImageAspect(settings.aspect)
}
async function loadStillAsEditRef(stillId: string) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
const still = stills.value.find(item => item.id === stillId)
setEditRef(new File([blob], still?.filename || 'still-b.png', { type: blob.type || 'image/png' }))
setEditRef(new File([blob], stillFileName(still) || 'still-b.png', { type: blob.type || 'image/png' }))
}
function stillUseAsInputMessage(still: LibraryStill) {
@@ -5245,7 +5334,7 @@ async function useStillAsInput(still: LibraryStill) {
if (!generate) {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }), { stillId: still.id })
readFile(new File([blob], stillFileName(still), { type: blob.type || 'image/png' }), { stillId: still.id })
}
if (dest === 'imagev2' && still.settings?.mode === 'compose' && still.settings.referenceStillId) {
try {
@@ -5611,7 +5700,7 @@ function startRename(item: LibraryClip, family = false) {
const source = family
? (familyBaseName(item.name || '') || item.name || '')
: (item.name || '')
renameDraft.value = source.trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'aigen')
renameDraft.value = source.trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'output')
nextTick(() => {
renameInput.value?.focus()
renameInput.value?.select()
@@ -5698,7 +5787,7 @@ function removeEditPass(id: string) {
}
async function editImage() {
if (!file.value || !prompt.value.trim()) {
if (!file.value || (!prompt.value.trim() && !imageFillWithoutPrompt.value)) {
toast(editBlockReason.value || 'Load a still first.')
return
}
@@ -5717,6 +5806,7 @@ async function editImage() {
body.append('image', file.value)
if (editRefFile.value) body.append('image2', editRefFile.value)
body.append('prompt', prompt.value.trim())
body.append('aspect', aspect.value)
body.append('negative', editNegative.value)
body.append('steps', String(clampImageSteps(editSteps.value)))
body.append('cfg', String(clampImageCfg(editCfg.value)))
@@ -5727,9 +5817,10 @@ async function editImage() {
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value)
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 downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{
jobId: string
studioJobId?: string
@@ -5828,10 +5919,11 @@ function setV2Engine(engine: ImageV2Engine) {
}
}
function setGenerateSize(aspect: ImageV2GenerateAspect) {
const [width, height] = IMAGE_V2_GENERATE_ASPECTS[aspect]
v2Width.value = width
v2Height.value = height
function setImageAspect(id: AspectId) {
aspect.value = id
const size = generateSizeForAspect(id)
v2Width.value = size.width
v2Height.value = size.height
}
function applyRefinePreset(kind: 'face' | 'hand' | 'heavy') {
@@ -5930,10 +6022,11 @@ async function editImageV2() {
body.append('strength', String(clampImageV2Denoise(v2Denoise.value)))
}
if (v2Mode.value === 'generate') {
const size = clampImageV2Size(v2Width.value, v2Height.value)
const size = generateSizeForAspect(aspect.value)
body.append('width', String(size.width))
body.append('height', String(size.height))
}
body.append('aspect', aspect.value)
body.append('engine', v2Engine.value)
body.append('prompt', prompt.value.trim())
body.append('negative', v2Negative.value)
@@ -5950,6 +6043,7 @@ async function editImageV2() {
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value)
const v2Stack = filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value
@@ -5962,7 +6056,7 @@ async function editImageV2() {
}
}
if (v2Stack.length) body.append('loraStack', JSON.stringify(v2Stack))
const downloadName = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-v2')
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{
jobId: string
studioJobId?: string
@@ -6069,7 +6163,7 @@ async function ensureVideoInputStill() {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (!blob?.size) throw new Error('Could not load that still')
const still = stills.value.find(item => item.id === stillId)
readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), {
readFile(new File([blob], stillFileName(still), { type: blob.type || 'image/png' }), {
keepThumbnailPref: true,
stillId
})
@@ -6168,7 +6262,7 @@ async function generate() {
}))))
body.append('queueAutoRun', String(queueAutoRun.value))
}
const downloadName = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const chainPlan = plannedChain.value.map(part => ({ ...part }))
console.info('[aigen generate payload]', {
keys: [...body.keys()],
+17 -3
View File
@@ -2,6 +2,7 @@ import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution'
import { parseImageAspect } from '~/utils/imageAspect'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImageScaleToTotalPixels } from '~/utils/generationPresets'
function parsePasses(raw: string | undefined) {
@@ -34,10 +35,11 @@ export default defineEventHandler(async (event) => {
const prompt = (fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes)
const aspect = parseImageAspect(fields.aspect)
if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
}
if (!prompt) {
if (!prompt && aspect === 'auto') {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
}
if (extraPasses.some(item => !item.prompt)) {
@@ -80,9 +82,21 @@ export default defineEventHandler(async (event) => {
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data)
const clipName = (fields.name || '').trim().slice(0, 80)
await listStudioLoras()
const loraStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'image')
let inputFilename = image.filename
const postedStillId = (fields.stillId || '').trim()
if (postedStillId) {
try {
const existing = getStill(ownerKey, postedStillId)
inputFilename = existing.name || existing.filename || inputFilename
} catch { /* use the uploaded filename */ }
}
const clipName = allocateOutputName(ownerKey, folderId, (fields.name || '').trim(), {
inputFilename,
engine: 'klein',
loraStack
})
const still = await rememberInputStill({
ownerKey,
@@ -114,7 +128,7 @@ export default defineEventHandler(async (event) => {
prompt,
name: clipName,
folderId,
aspect: 'auto',
aspect,
width: size?.width || 0,
height: size?.height || 0,
steps,
+9 -5
View File
@@ -3,7 +3,7 @@ import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import { clampVideoCfg } from '~/utils/generationPresets'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
function parseDuration(raw: unknown) {
@@ -115,16 +115,16 @@ export default defineEventHandler(async (event) => {
const textToVideo = isTextToVideo(workflow)
const ownerKey = libraryOwnerKey(event)
const postedStillId = (fields.stillId || '').trim()
let libraryStill: { id: string; filename: string } | null = null
let libraryStill: { id: string; filename: string; name?: string } | null = null
if (postedStillId) {
const path = stillPath(ownerKey, postedStillId)
if (existsSync(path)) {
try {
const existing = getStill(ownerKey, postedStillId)
libraryStill = { id: existing.id, filename: existing.filename }
libraryStill = { id: existing.id, filename: existing.filename, name: existing.name }
if (!image) {
image = {
filename: existing.filename || 'still.png',
filename: existing.name ? `${existing.name}.png` : (existing.filename || 'still.png'),
data: readFileSync(path),
type: 'image/png'
}
@@ -179,7 +179,6 @@ export default defineEventHandler(async (event) => {
const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true'
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
const clipName = (fields.name || '').trim().slice(0, 80)
const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === fields.folderId)
? fields.folderId
@@ -204,6 +203,11 @@ export default defineEventHandler(async (event) => {
if (!textToVideo && !still?.id) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
}
const clipName = allocateOutputName(ownerKey, folderId, (fields.name || '').trim(), {
inputFilename: libraryStill?.name || image?.filename || still?.filename || '',
engine: videoEngineOf(workflow),
loraStack: defaultStack
})
console.info('[aigen generate]', {
hasImage: Boolean(image?.data?.length),
imageBytes: image?.data?.length || 0,
+8
View File
@@ -0,0 +1,8 @@
import { libraryOwnerKey, publicLibrary, setLoraAlias } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const body = await readBody<{ key?: unknown; alias?: unknown }>(event)
const owner = libraryOwnerKey(event)
await setLoraAlias(owner, String(body?.key || ''), String(body?.alias || ''))
return publicLibrary(event)
})
+21 -5
View File
@@ -1,5 +1,6 @@
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
import { comfyConfigured } from '~/server/utils/comfy'
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
import { imageDimensions } from '~/server/utils/resolution'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps } from '~/utils/generationPresets'
import {
@@ -63,7 +64,7 @@ function fileFromStill(ownerKey: string, id: string): ImageFile {
throw createError({ statusCode: 400, statusMessage: 'That still is missing from the library' })
}
return {
filename: still.filename || `${still.id}.png`,
filename: still.name ? `${still.name}.png` : (still.filename || `${still.id}.png`),
data: readFileSync(path),
type: 'image/png'
}
@@ -123,7 +124,8 @@ export default defineEventHandler(async (event) => {
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
)
const prompt = String(fields.prompt || '').trim()
if (!prompt) {
const aspect = parseImageAspect(fields.aspect)
if (!prompt && (mode === 'generate' || aspect === 'auto')) {
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
}
if (!comfyConfigured()) {
@@ -193,11 +195,12 @@ export default defineEventHandler(async (event) => {
const seed = fields.seed && String(fields.seed) !== 'random'
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
const generateSize = clampImageV2Size(fields.width, fields.height, fields.aspect)
const generateSize = mode === 'generate'
? generateSizeForAspect(aspect)
: clampImageV2Size(fields.width, fields.height, fields.aspect)
const size = mode === 'generate'
? { width: generateSize.width, height: generateSize.height }
: imageDimensions(imageA!.data)
const clipName = String(fields.name || '').trim().slice(0, 80)
const v2Mode = mode as ImageV2Mode
const v2Task = (
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : mode === 'edit' ? 'scene' : task
@@ -223,6 +226,19 @@ export default defineEventHandler(async (event) => {
const generateSnofsClipFallback = 0
await listStudioLoras()
const loraStack = assertImageV2LoraStack(parsePostedLoraStack(fields.loraStack || fields.lora, 'image'), engine)
let inputFilename = imageA?.filename || ''
const postedStillId = String(fields.stillId || '').trim()
if (postedStillId) {
try {
const existing = getStill(ownerKey, postedStillId)
inputFilename = existing.name || existing.filename || inputFilename
} catch { /* use the uploaded filename */ }
}
const clipName = allocateOutputName(ownerKey, folderId, String(fields.name || '').trim(), {
inputFilename,
engine,
loraStack
})
const still = imageA
? await rememberInputStill({
@@ -262,7 +278,7 @@ export default defineEventHandler(async (event) => {
prompt,
name: clipName,
folderId,
aspect: mode === 'generate' ? (generateSize.aspect || '1:1') : 'auto',
aspect: mode === 'generate' ? generateSize.aspect : aspect,
width: size?.width || 0,
height: size?.height || 0,
steps,
+2 -2
View File
@@ -1,4 +1,5 @@
import { LTX_NEGATIVE } from '~/utils/videoModels'
import { comfyJobPrefix } from '~/utils/outputNames'
let comfyHostOverride = ''
@@ -103,8 +104,7 @@ export function comfyInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_${id}_${base}${ext}`
return `${comfyJobPrefix(jobId)}_${base}${ext}`
}
function comfyQueueMessage(payload: unknown, status: number) {
+11 -2
View File
@@ -9,6 +9,7 @@ import {
clampVideoCfg,
normalizePresetName,
parseGenerationPresetKind,
PRESET_PROMPT_MAX,
type GenerationPreset,
type GenerationPresetKind,
type ImagePresetSettings,
@@ -105,14 +106,20 @@ function sanitizeVideoSettings(raw: unknown): VideoPresetSettings {
}
}
function sanitizePrompt(raw: unknown) {
return typeof raw === 'string' ? raw.slice(0, PRESET_PROMPT_MAX) : undefined
}
function sanitizeImageSettings(raw: unknown): ImagePresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
return {
prompt: sanitizePrompt(rec.prompt),
steps: clampImageSteps(rec.steps),
cfg: clampImageCfg(rec.cfg),
negative: String(rec.negative || '').slice(0, 2000),
scaleToTotalPixels: rec.scaleToTotalPixels === true,
scaleMegapixels: clampImageScaleMegapixels(rec.scaleMegapixels)
scaleMegapixels: clampImageScaleMegapixels(rec.scaleMegapixels),
aspect: rec.aspect === '16:9' || rec.aspect === '9:16' || rec.aspect === '1:1' || rec.aspect === 'auto' ? rec.aspect : 'auto'
}
}
@@ -125,6 +132,7 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
mode,
engine,
task: mode === 'compose' ? parseImageV2Task(rec.task, 'scene') : mode === 'refine' ? 'refine' : mode === 'generate' ? 't2i' : 'scene',
prompt: sanitizePrompt(rec.prompt),
negative: String(rec.negative || '').slice(0, 2000),
snofsModel: isXaigenStudio() ? clampImageV2Strength(rec.snofsModel ?? rec.snofs_model, IMAGE_V2_SNOFS_MODEL) : 0,
snofsClip: isXaigenStudio() ? clampImageV2Strength(rec.snofsClip ?? rec.snofs_clip, IMAGE_V2_SNOFS_CLIP) : 0,
@@ -141,7 +149,8 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
mode === 'compose' ? IMAGE_V2_KREA_COMPOSE_DENOISE : mode === 'edit' ? IMAGE_V2_KREA_EDIT_DENOISE : IMAGE_V2_DENOISE_DEFAULT
),
width: mode === 'generate' ? clampImageV2Size(rec.width, rec.height, rec.aspect).width : undefined,
height: mode === 'generate' ? clampImageV2Size(rec.width, rec.height, rec.aspect).height : undefined
height: mode === 'generate' ? clampImageV2Size(rec.width, rec.height, rec.aspect).height : undefined,
aspect: rec.aspect === '16:9' || rec.aspect === '9:16' || rec.aspect === '1:1' || rec.aspect === 'auto' ? String(rec.aspect) : (mode === 'generate' ? '1:1' : 'auto')
}
}
+16 -3
View File
@@ -7,6 +7,7 @@ import { ensureComfyLoraNames } from '~/server/utils/loras'
import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
export type EditImageFile = { filename: string; data: Buffer; type?: string }
@@ -19,6 +20,7 @@ export type EditRunParams = {
steps: number
seed: number
cfg: number
aspect?: string
loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
scaleToTotalPixels?: boolean
@@ -46,12 +48,22 @@ export async function runEdit(job: Job, params: EditRunParams) {
job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => {
let current = params.image
const { applyImageFill } = await import('~/server/utils/imagePad')
const { composeFillPrompt } = await import('~/utils/imageAspect')
const filled = await applyImageFill({ image: params.image, aspect: params.aspect || 'auto' })
if (filled.padded) {
emitChainJob(job, {
type: 'status',
message: `Filling to ${filled.aspect} without stretching...`,
progress: 7
})
}
let current = filled.image
let parentStillId: string | undefined
for (let index = 0; index < prompts.length; index++) {
if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break
const prompt = prompts[index]
const prompt = index === 0 ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
const last = index === prompts.length - 1
if (index > 0) {
await ensureComfyReady((status) => {
@@ -104,7 +116,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
steps: params.steps,
seed,
cfg: params.cfg,
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`,
filenamePrefix: `${comfyJobPrefix(job.id)}_p${index + 1}`,
loraStack: params.loraStack || params.loraName,
scaleToTotalPixels: params.scaleToTotalPixels,
scaleMegapixels: params.scaleMegapixels
@@ -161,6 +173,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
seed,
scaleToTotalPixels: params.scaleToTotalPixels === true,
scaleMegapixels: params.scaleMegapixels,
aspect: params.aspect || 'auto',
loraName: params.loraName,
loraStack: params.loraStack,
sourceStillId: library.stillId
+34 -7
View File
@@ -7,6 +7,7 @@ import { ensureComfyLoraNames, persistLoraFields } from '~/server/utils/loras'
import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch'
import { saveStill } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2'
import type { EditImageFile } from '~/server/utils/imageChain'
@@ -31,6 +32,7 @@ export type EditV2RunParams = {
width?: number
height?: number
turbo?: boolean
aspect?: string
sourceStillId?: string
referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[]
@@ -77,7 +79,31 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
: params.mode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...',
progress: generate ? 12 : 8
})
const uploaded = generate || !params.image ? null : await uploadImage(params.image, job.id)
const { applyImageFill } = await import('~/server/utils/imagePad')
const { composeFillPrompt } = await import('~/utils/imageAspect')
let image = params.image
let mask = params.mask
let prompt = params.prompt
let padded = false
if (!generate && image) {
const filled = await applyImageFill({
image,
mask: params.mode === 'refine' ? mask : null,
aspect: params.aspect || 'auto'
})
image = filled.image
if (filled.mask) mask = filled.mask
padded = filled.padded
prompt = composeFillPrompt(prompt, padded)
if (padded) {
emitChainJob(job, {
type: 'status',
message: `Filling to ${filled.aspect} without stretching...`,
progress: 7
})
}
}
const uploaded = generate || !image ? null : await uploadImage(image, job.id)
const uploadedRef = generate || params.mode === 'refine'
? null
: params.reference
@@ -86,10 +112,10 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
filename: `ref_${params.reference.filename || 'image_b.png'}`
}, job.id)
: null
const uploadedMask = !generate && params.mode === 'refine' && params.mask
const uploadedMask = !generate && params.mode === 'refine' && mask
? await uploadImage({
...params.mask,
filename: `mask_${params.mask.filename || 'refine-mask.png'}`
...mask,
filename: `mask_${mask.filename || 'refine-mask.png'}`
}, job.id)
: null
if (job.status === 'cancelled') throw new Error('Job interrupted.')
@@ -112,7 +138,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
mode: params.mode,
engine,
task: params.task,
prompt: params.prompt,
prompt,
negative: params.negative,
imageAName: uploaded?.name,
imageBName: uploadedRef?.name,
@@ -129,7 +155,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
width: params.width,
height: params.height,
turbo: params.turbo === true,
filenamePrefix: `aigen_v2_${job.id.slice(0, 8)}`,
filenamePrefix: comfyJobPrefix(job.id),
kreaUnetName: krea?.unet,
kreaClipName: krea?.clip,
kreaVaeName: krea?.vae,
@@ -174,7 +200,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const still = await saveStill({
ownerKey: library.ownerKey,
folderId: library.folderId,
filename: output.filename,
filename: library.name ? `${library.name}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
@@ -199,6 +225,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
strength: params.mode === 'generate' ? undefined : params.strength,
width: params.mode === 'generate' ? params.width : undefined,
height: params.mode === 'generate' ? params.height : undefined,
aspect: params.aspect || 'auto',
seed: params.seed,
sourceStillId: params.sourceStillId || library.stillId,
referenceStillId: params.referenceStillId,
+2 -2
View File
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { getComfyHost } from '~/server/utils/comfy'
import { comfyJobPrefix } from '~/utils/outputNames'
const imageHostAls = new AsyncLocalStorage<string>()
let imageComfyHostOverride = ''
@@ -98,8 +99,7 @@ function imageInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_edit_${id}_${base}${ext}`
return `${comfyJobPrefix(jobId)}_${base}${ext}`
}
export async function uploadImageEdit(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
+79
View File
@@ -0,0 +1,79 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { extname, join } from 'node:path'
import { runFfmpeg } from '~/server/utils/ffmpeg'
import { imageDimensions } from '~/server/utils/resolution'
import { padExtents, parseImageAspect, type ImageAspectId } from '~/utils/imageAspect'
export type PaddedImage = { filename: string; data: Buffer; type?: string }
function sourceExt(filename: string, type?: string) {
const fromName = extname(filename || '').toLowerCase()
if (fromName === '.jpg' || fromName === '.jpeg' || fromName === '.png' || fromName === '.webp') return fromName
if (type?.includes('jpeg')) return '.jpg'
if (type?.includes('webp')) return '.webp'
return '.png'
}
export async function applyImageFill(opts: {
image: PaddedImage
mask?: PaddedImage | null
aspect: string
}): Promise<{ image: PaddedImage; mask?: PaddedImage; padded: boolean; aspect: ImageAspectId }> {
const aspect = parseImageAspect(opts.aspect)
if (aspect === 'auto') {
return { image: opts.image, mask: opts.mask || undefined, padded: false, aspect }
}
const size = imageDimensions(opts.image.data)
if (!size?.width || !size.height) {
return { image: opts.image, mask: opts.mask || undefined, padded: false, aspect }
}
const box = padExtents(size.width, size.height, aspect)
if (!box) {
return { image: opts.image, mask: opts.mask || undefined, padded: false, aspect }
}
const dir = mkdtempSync(join(tmpdir(), 'aigen-pad-'))
try {
const srcPath = join(dir, `src${sourceExt(opts.image.filename, opts.image.type)}`)
const outPath = join(dir, 'out.png')
writeFileSync(srcPath, opts.image.data)
await runFfmpeg([
'-y',
'-i', srcPath,
'-vf', `pad=${box.canvasW}:${box.canvasH}:${box.x}:${box.y}:black`,
'-frames:v', '1',
outPath
])
const padded: PaddedImage = {
filename: 'padded.png',
data: readFileSync(outPath),
type: 'image/png'
}
if (!opts.mask?.data?.length) {
return { image: padded, padded: true, aspect }
}
const userPath = join(dir, `mask${sourceExt(opts.mask.filename, opts.mask.type)}`)
const maskPath = join(dir, 'pad-mask.png')
writeFileSync(userPath, opts.mask.data)
await runFfmpeg([
'-y',
'-f', 'lavfi',
'-i', `color=c=white:s=${box.canvasW}x${box.canvasH}:d=1:r=1`,
'-i', userPath,
'-filter_complex', `[1]scale=${box.srcW}:${box.srcH}[m];[0][m]overlay=${box.x}:${box.y}`,
'-frames:v', '1',
maskPath
])
return {
image: padded,
mask: { filename: 'pad-mask.png', data: readFileSync(maskPath), type: 'image/png' },
padded: true,
aspect
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function buildEditWorkflow(params: EditParams) {
const dual = Boolean(params.referenceImageName)
const prompt = String(params.prompt || '').trim()
const negative = String(params.negative || '')
const prefix = params.filenamePrefix || 'aigen-edit'
const prefix = params.filenamePrefix || 'edit'
setImage(graph, IMAGE1, params.imageName)
setText(graph, SINGLE_PROMPT, prompt)
+3 -3
View File
@@ -400,7 +400,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
setInput(graph, '15', 'sampler_name', 'euler')
setInput(graph, '15', 'scheduler', 'simple')
setInput(graph, '15', 'denoise', denoise)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || `aigen-v2-krea-${params.mode}`)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || `v2-krea-${params.mode}`)
patchScaleMegapixels(graph, params.megapixels ?? 1)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
@@ -448,7 +448,7 @@ function buildKreaGenerateWorkflow(params: ImageV2BuildParams) {
setInput(graph, '15', 'cfg', cfg)
setInput(graph, '15', 'sampler_name', 'euler')
setInput(graph, '15', 'scheduler', 'simple')
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || 'aigen-v2-krea')
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || 'v2-krea')
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
assertImageV2Graph(graph, 'generate', undefined, 'krea')
@@ -512,7 +512,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
setInput(graph, NOISE, 'noise_seed', params.seed)
setInput(graph, SCHEDULER, 'steps', steps)
setInput(graph, CFG, 'cfg', cfg)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || (generate ? 'aigen-v2-generate' : 'aigen-v2'))
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || (generate ? 'v2-generate' : 'v2'))
if (!generate) patchScaleMegapixels(graph, params.megapixels ?? 1)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'flux'), 'flux')
+54 -4
View File
@@ -8,6 +8,8 @@ import type { H3Event } from 'h3'
import { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, remuxFaststart } from '~/server/utils/ffmpeg'
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration'
import { buildOutputBaseName, nextUniqueName, normalizeLoraAliases, sanitizeLoraAlias } from '~/utils/outputNames'
import { loraIdentityKey, type LoraStackItem } from '~/utils/loras'
const scryptAsync = promisify(scrypt)
@@ -128,6 +130,7 @@ interface Catalog {
clips: LibraryClip[]
stills: LibraryStill[]
drafts: RetryDraft[]
loraAliases: Record<string, string>
}
const writeChains = new Map<string, Promise<unknown>>()
@@ -166,7 +169,8 @@ function emptyCatalog(): Catalog {
}],
clips: [],
stills: [],
drafts: []
drafts: [],
loraAliases: {}
}
}
@@ -225,7 +229,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
folders,
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : []
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
loraAliases: normalizeLoraAliases(parsed.loraAliases)
}
}
@@ -359,7 +364,51 @@ export function stillChainName(base: string, index: number) {
export function safeDownloadName(name: string) {
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 80) || 'aigen'
return cleaned.slice(0, 80) || 'output'
}
function existingOutputNames(catalog: Catalog, folderId: string) {
const stills = catalog.stills
.filter(item => item.folderId === folderId)
.map(item => String(item.name || '').trim())
.filter(Boolean)
const clips = catalog.clips
.filter(item => item.folderId === folderId)
.map(item => String(item.name || '').trim())
.filter(Boolean)
return [...stills, ...clips]
}
export function getLoraAliases(owner: string) {
return { ...(readCatalog(owner).loraAliases || {}) }
}
export async function setLoraAlias(owner: string, identity: string, alias: string) {
const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase()
if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' })
const cleaned = sanitizeLoraAlias(alias)
return mutate(owner, (catalog) => {
if (!catalog.loraAliases) catalog.loraAliases = {}
if (!cleaned) delete catalog.loraAliases[key]
else catalog.loraAliases[key] = cleaned
return { ...catalog.loraAliases }
})
}
export function allocateOutputName(owner: string, folderId: string, requested: string, opts: {
inputFilename?: string
engine: string
loraStack?: LoraStackItem[] | unknown
}) {
const catalog = readCatalog(owner)
const base = buildOutputBaseName({
requested,
inputFilename: opts.inputFilename,
engine: opts.engine,
loraStack: opts.loraStack,
aliases: catalog.loraAliases || {}
})
return nextUniqueName(existingOutputNames(catalog, folderId), base)
}
export function passwordVersion(hash: string | null) {
@@ -425,7 +474,8 @@ export function publicLibrary(event: H3Event) {
stills: catalog.stills
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still))
.map(still => ({ ...still, role: stillRole(still) })),
drafts: catalog.drafts
drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {}
}
}
+2 -2
View File
@@ -2,6 +2,7 @@ import { getSidecarImageHost } from '~/server/utils/imageComfy'
import { ensureSidecarReady } from '~/server/utils/imageComfyLifecycle'
import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow'
import { normalizeShotScript } from '~/utils/parseRecommend'
import { comfyJobPrefix } from '~/utils/outputNames'
export type PromptJobStatus = 'queued' | 'running' | 'complete' | 'error'
@@ -46,8 +47,7 @@ function promptInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_prompt_${id}_${base}${ext}`
return `${comfyJobPrefix(jobId)}_${base}${ext}`
}
async function uploadPromptImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
+2
View File
@@ -803,6 +803,7 @@ async function startStudioEditJob(item: StudioJob) {
consistencyClip: payload.consistencyClip ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7),
megapixels: payload.scaleMegapixels ?? 1,
strength: payload.refineStrength,
aspect: payload.aspect || 'auto',
width: payload.width,
height: payload.height,
turbo: payload.turbo === true,
@@ -860,6 +861,7 @@ async function startStudioEditJob(item: StudioJob) {
steps: payload.steps,
seed: live.library?.seed || payload.seed,
cfg: payload.cfg,
aspect: payload.aspect || 'auto',
scaleToTotalPixels: payload.scaleToTotalPixels === true,
scaleMegapixels: payload.scaleMegapixels,
...persistLoraFields(payload.loraStack || payload.loraName)
+4
View File
@@ -16,12 +16,16 @@ export type VideoPresetSettings = {
sound?: boolean
}
export const PRESET_PROMPT_MAX = 8000
export type ImagePresetSettings = {
prompt?: string
steps?: number
cfg?: number
negative?: string
scaleToTotalPixels?: boolean
scaleMegapixels?: number
aspect?: string
}
export const IMAGE_STEPS_MIN = 1
+54
View File
@@ -0,0 +1,54 @@
export const IMAGE_ASPECT_IDS = ['auto', '16:9', '9:16', '1:1'] as const
export type ImageAspectId = (typeof IMAGE_ASPECT_IDS)[number]
export const IMAGE_FILL_INSTRUCTION =
'Extend the photograph to fill the empty edges of the frame. Do not stretch or squash the original image. Keep the original subject, pose, and framing. Match lighting, perspective, and texture in the filled areas.'
export function parseImageAspect(raw: unknown): ImageAspectId {
const value = String(raw || 'auto').trim()
return value === '16:9' || value === '9:16' || value === '1:1' ? value : 'auto'
}
export function imageAspectRatio(aspect: Exclude<ImageAspectId, 'auto'>) {
if (aspect === '16:9') return 16 / 9
if (aspect === '9:16') return 9 / 16
return 1
}
export function generateSizeForAspect(aspect: ImageAspectId) {
if (aspect === '16:9') return { width: 1280, height: 768, aspect: '16:9' as const }
if (aspect === '9:16') return { width: 768, height: 1280, aspect: '9:16' as const }
return { width: 1024, height: 1024, aspect: '1:1' as const }
}
export function composeFillPrompt(userPrompt: string, padded: boolean) {
const body = String(userPrompt || '').trim()
if (!padded) return body
return body ? `${IMAGE_FILL_INSTRUCTION}\n\n${body}` : IMAGE_FILL_INSTRUCTION
}
export function snapImagePad(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
export function padExtents(srcW: number, srcH: number, aspect: Exclude<ImageAspectId, 'auto'>) {
if (!srcW || !srcH) return null
const target = imageAspectRatio(aspect)
const src = srcW / srcH
if (Math.abs(src - target) < 0.03) return null
let canvasW = srcW
let canvasH = srcH
if (src > target) {
canvasW = snapImagePad(srcW)
canvasH = snapImagePad(canvasW / target)
} else {
canvasH = snapImagePad(srcH)
canvasW = snapImagePad(canvasH * target)
}
if (canvasW < srcW) canvasW = snapImagePad(srcW)
if (canvasH < srcH) canvasH = snapImagePad(srcH)
const x = Math.max(0, Math.floor((canvasW - srcW) / 2))
const y = Math.max(0, Math.floor((canvasH - srcH) / 2))
if (x === 0 && y === 0 && canvasW === srcW && canvasH === srcH) return null
return { canvasW, canvasH, x, y, srcW, srcH }
}
+2
View File
@@ -161,6 +161,7 @@ export type ImageV2PresetSettings = {
mode?: ImageV2Mode
task?: ImageV2Task
engine?: ImageV2Engine
prompt?: string
negative?: string
snofsModel?: number
snofsClip?: number
@@ -173,6 +174,7 @@ export type ImageV2PresetSettings = {
strength?: number
width?: number
height?: number
aspect?: string
}
export function imageV2ConceptLoraName(engine: ImageV2Engine, kreaConcept?: string) {
+7
View File
@@ -21,6 +21,7 @@ export type StillGenerationSettings = {
strength?: number
width?: number
height?: number
aspect?: string
seed?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
@@ -93,6 +94,7 @@ export function normalizeStillSettings(raw: unknown): StillGenerationSettings |
strength: finiteNumber(value.strength),
width: finiteNumber(value.width),
height: finiteNumber(value.height),
aspect: typeof value.aspect === 'string' ? value.aspect : undefined,
seed: finiteNumber(value.seed),
scaleToTotalPixels: typeof value.scaleToTotalPixels === 'boolean' ? value.scaleToTotalPixels : undefined,
scaleMegapixels: finiteNumber(value.scaleMegapixels),
@@ -148,6 +150,8 @@ export function stillSettingsRows(still: { width?: number; height?: number; sett
if (settings.kind === 'imagev2') {
if (settings.mode === 'generate' && settings.width && settings.height) {
rows.push({ label: 'Size', value: `${settings.width}×${settings.height}` })
} else if (settings.aspect && settings.aspect !== 'auto') {
rows.push({ label: 'Aspect', value: `${settings.aspect} fill` })
} else if (still.width && still.height) {
rows.push({ label: 'Output', value: `${still.width}×${still.height}` })
}
@@ -170,6 +174,9 @@ export function stillSettingsRows(still: { width?: number; height?: number; sett
}
} else {
if (still.width && still.height) rows.push({ label: 'Output', value: `${still.width}×${still.height}` })
if (settings.aspect && settings.aspect !== 'auto') {
rows.push({ label: 'Aspect', value: `${settings.aspect} fill` })
}
if (settings.scaleToTotalPixels) {
rows.push({ label: 'Scale to MP', value: formatNum(settings.scaleMegapixels ?? 1) })
}
+114
View File
@@ -0,0 +1,114 @@
import { loraBasename, loraDisplayName, loraIdentityKey, MINIMAX_TURBO_LORA, normalizeLoraStack, type LoraStackItem } from '~/utils/loras'
export const OUTPUT_NAME_MAX = 80
export function sanitizeFilenameKey(raw: unknown, max = 32) {
return String(raw || '')
.replace(/\.[a-z0-9]{1,8}$/i, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, max)
}
export function sanitizeLoraAlias(raw: unknown) {
return sanitizeFilenameKey(raw, 24)
}
export function normalizeLoraAliases(raw: unknown): Record<string, string> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const id = loraIdentityKey(key) || String(key || '').trim().toLowerCase()
const alias = sanitizeLoraAlias(value)
if (!id || !alias) continue
out[id] = alias
}
return out
}
export function inputStemFromFilename(filename: string) {
const base = String(filename || '').replace(/^.*[\\/]/, '').replace(/\.[a-z0-9]+$/i, '')
if (!base) return ''
if (/^(start|still|padded|last[_-]?frame|refine-canvas|refine-mask|image[12]?|mask|edited)$/i.test(base)) return ''
if (/^aigen[_-]/i.test(base)) return ''
if (/^[0-9a-f]{8,}/i.test(base) && /_\d{5}_?$/i.test(base)) return ''
return sanitizeFilenameKey(base, 40)
}
export function defaultLoraFilenameKey(name: string) {
const stem = loraDisplayName(name).replace(/^xaigen-?/i, '')
return sanitizeFilenameKey(stem, 24) || 'lora'
}
export function loraFilenameKey(name: string, aliases: Record<string, string> = {}) {
const id = loraIdentityKey(name)
const alias = String(aliases[id] || aliases[loraBasename(name).toLowerCase()] || '').trim()
if (alias) return sanitizeFilenameKey(alias, 24) || defaultLoraFilenameKey(name)
return defaultLoraFilenameKey(name)
}
export function isFilenameSystemLora(name: string) {
const id = loraIdentityKey(name)
if (id === loraIdentityKey(MINIMAX_TURBO_LORA)) return true
if (/flux2-klein-9b-consistency/i.test(id)) return true
if (/ltx-2\.3-22b-distilled/i.test(name)) return true
return false
}
export function loraKeysForFilename(stack: unknown, aliases: Record<string, string> = {}) {
const seen = new Set<string>()
const keys: string[] = []
for (const item of normalizeLoraStack(stack)) {
if (isFilenameSystemLora(item.name)) continue
const key = loraFilenameKey(item.name, aliases)
const token = key.toLowerCase()
if (!key || seen.has(token)) continue
seen.add(token)
keys.push(key)
}
return keys
}
export function engineFilenameKey(engine: string) {
const value = String(engine || '').trim().toLowerCase()
if (value === 'krea') return 'krea'
if (value === 'flux') return 'flux'
if (value === 'klein') return 'klein'
if (value === 'ltx') return 'ltx'
if (value === 'minimax') return 'minimax'
return sanitizeFilenameKey(value, 16) || 'gen'
}
export function buildOutputBaseName(opts: {
requested?: string
inputFilename?: string
engine: string
loraStack?: LoraStackItem[] | unknown
aliases?: Record<string, string>
}) {
const requested = String(opts.requested || '').trim().replace(/\s+/g, ' ').slice(0, OUTPUT_NAME_MAX)
if (requested) return requested
const stem = inputStemFromFilename(opts.inputFilename || '')
const engine = engineFilenameKey(opts.engine)
const loras = loraKeysForFilename(opts.loraStack, opts.aliases || {})
const joined = [stem, engine, ...loras].filter(Boolean).join('-') || engine || 'output'
return joined.slice(0, OUTPUT_NAME_MAX)
}
export function nextUniqueName(existing: string[], base: string) {
const trimmed = String(base || '').trim() || 'output'
const taken = new Set(existing.map(name => String(name || '').trim().toLowerCase()).filter(Boolean))
if (!taken.has(trimmed.toLowerCase())) return trimmed.slice(0, OUTPUT_NAME_MAX)
for (let n = 2; n < 10000; n++) {
const suffix = `-${n}`
const candidate = `${trimmed.slice(0, OUTPUT_NAME_MAX - suffix.length)}${suffix}`
if (!taken.has(candidate.toLowerCase())) return candidate
}
return `${trimmed.slice(0, 72)}-${Date.now().toString(16).slice(-6)}`
}
export function comfyJobPrefix(jobId?: string) {
const hex = `${String(jobId || '').replace(/-/g, '')}${crypto.randomUUID().replace(/-/g, '')}`
return hex.slice(0, 12)
}