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 class="flex items-start justify-between gap-3">
<div> <div>
<h2 class="font-display text-2xl font-bold">Settings</h2> <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> </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> <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> </div>
@@ -123,6 +123,35 @@
</form> </form>
</section> </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"> <section class="mt-8 space-y-3">
<div> <div>
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3> <h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3>
@@ -190,6 +219,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defaultLoraFilenameKey, isFilenameSystemLora } from '~/utils/outputNames'
import { loraDisplayName, loraIdentityKey } from '~/utils/loras'
export interface SettingsFolder { export interface SettingsFolder {
id: string id: string
name: string name: string
@@ -216,6 +248,8 @@ const newName = ref('')
const newPresetName = ref('') const newPresetName = ref('')
const newPresetPrompt = ref('') const newPresetPrompt = ref('')
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([]) 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 loginUser = ref(props.username || '')
const loginCurrent = ref('') const loginCurrent = ref('')
const loginNext = ref('') const loginNext = ref('')
@@ -279,6 +313,7 @@ onMounted(() => {
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
void loadPresets() void loadPresets()
void loadStorage() void loadStorage()
void loadLoraKeys()
}) })
onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) 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() { async function addPreset() {
const name = newPresetName.value.trim() const name = newPresetName.value.trim()
const systemPrompt = newPresetPrompt.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) | | CFG | 4 (turbo 1) |
| Scale to MP | 1.0 lanczos | | 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 | | 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 ### When to use each Refine strength
+141 -47
View File
@@ -548,9 +548,10 @@
<input <input
v-model="clipName" v-model="clipName"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" 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" 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> </label>
<form v-if="currentFolder?.protected && !currentFolder.unlocked" class="space-y-2" @submit.prevent="unlockFolder(folderId)"> <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> <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>
<div v-else-if="studioMode === 'editv2'" class="space-y-3"> <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> <p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Image engine</p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<button <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> <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>
<div v-if="v2Mode === 'generate'" class="space-y-3 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<div> <div>
<p class="text-xs text-zinc-400">Size · near 1 MP</p> <p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p>
<div class="mt-2 flex flex-wrap gap-2"> <div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button <button
v-for="(pair, aspect) in IMAGE_V2_GENERATE_ASPECTS" v-for="option in imageAspects"
:key="aspect" :key="option.id"
type="button" type="button"
class="rounded-full border px-2.5 py-1 text-xs" class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="v2Width === pair[0] && v2Height === pair[1] ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'" :class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setGenerateSize(aspect)" @click="setImageAspect(option.id)"
> >
{{ aspect }} · {{ pair[0] }}×{{ pair[1] }} <span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button> </button>
</div> </div>
</div> <p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div> </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"> <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"> <label class="block text-sm">
@@ -882,6 +883,23 @@
</div> </div>
<div v-else-if="studioMode === 'edit'" class="space-y-3"> <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> <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"> <div class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact /> <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"> <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"> <div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
<a <a
:href="editResultUrl" :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" class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
> >
Download still Download still
@@ -2014,6 +2032,8 @@ import {
stillSettingsRows, stillSettingsRows,
type StillGenerationSettings type StillGenerationSettings
} from '~/utils/libraryGeneration' } from '~/utils/libraryGeneration'
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames'
import { import {
IMAGE_V2_CFG_DEFAULT, IMAGE_V2_CFG_DEFAULT,
IMAGE_V2_CONSISTENCY_LORA, IMAGE_V2_CONSISTENCY_LORA,
@@ -2021,7 +2041,6 @@ import {
IMAGE_V2_DENOISE_MAX, IMAGE_V2_DENOISE_MAX,
IMAGE_V2_DENOISE_MIN, IMAGE_V2_DENOISE_MIN,
IMAGE_V2_DENOISE_STEP, IMAGE_V2_DENOISE_STEP,
IMAGE_V2_GENERATE_ASPECTS,
IMAGE_V2_GENERATE_HEIGHT, IMAGE_V2_GENERATE_HEIGHT,
IMAGE_V2_GENERATE_WIDTH, IMAGE_V2_GENERATE_WIDTH,
IMAGE_V2_KREA_CFG, IMAGE_V2_KREA_CFG,
@@ -2038,7 +2057,6 @@ import {
IMAGE_V2_SNOFS_MODEL, IMAGE_V2_SNOFS_MODEL,
IMAGE_V2_STEPS_DEFAULT, IMAGE_V2_STEPS_DEFAULT,
clampImageV2Denoise, clampImageV2Denoise,
clampImageV2Size,
defaultImageV2LoraStack, defaultImageV2LoraStack,
imageV2ConceptLoraName, imageV2ConceptLoraName,
isImageV2ConceptLora, isImageV2ConceptLora,
@@ -2048,7 +2066,6 @@ import {
removeImageV2ConceptLora, removeImageV2ConceptLora,
upsertImageV2Lora, upsertImageV2Lora,
type ImageV2Engine, type ImageV2Engine,
type ImageV2GenerateAspect,
type ImageV2Mode, type ImageV2Mode,
type ImageV2PresetSettings, type ImageV2PresetSettings,
type ImageV2Task type ImageV2Task
@@ -2205,6 +2222,27 @@ const aspects = [
{ id: '9:16' as const, label: '9:16 Portrait / Shorts', hint: '768 × 1344' }, { 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' } { 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 player = ref<HTMLVideoElement | null>(null)
const outputPanel = ref<HTMLElement | null>(null) const outputPanel = ref<HTMLElement | null>(null)
@@ -2238,8 +2276,8 @@ const shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([]) const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([]) const editPassQueue = ref<QueuedEditPass[]>([])
const clipName = ref('') const clipName = ref('')
const videoDownloadName = ref('aigen') const videoDownloadName = ref('output')
const editDownloadName = ref('aigen-edit') const editDownloadName = ref('edited')
const aspect = ref<AspectId>('auto') const aspect = ref<AspectId>('auto')
const imageWidth = ref(0) const imageWidth = ref(0)
const imageHeight = ref(0) const imageHeight = ref(0)
@@ -2417,6 +2455,7 @@ const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([]) const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([]) const stills = ref<LibraryStill[]>([])
const drafts = ref<RetryDraft[]>([]) const drafts = ref<RetryDraft[]>([])
const loraAliases = ref<Record<string, string>>({})
const folderId = ref('') const folderId = ref('')
const browseFolderId = ref('') const browseFolderId = ref('')
const renamingId = ref('') const renamingId = ref('')
@@ -2487,6 +2526,29 @@ const imageV2LoraOptions = computed(() => mergeLoraOptions(
...(v2ConceptLoraEnabled.value && imageV2ConceptFile.value ? [imageV2ConceptFile.value] : []) ...(v2ConceptLoraEnabled.value && imageV2ConceptFile.value ? [imageV2ConceptFile.value] : [])
], v2Engine.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 identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', '']) const identityPreviews = ref(['', '', '', ''])
const globalLocks = 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.' return 'GPU is offline. Generate will try to start Comfy. Or click Wake GPU.'
}) })
const editBlocked = computed(() => { 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 if (editPassQueue.value.some(item => !item.prompt.trim())) return true
return false return false
}) })
const editBlockReason = computed(() => { const editBlockReason = computed(() => {
if (!file.value) return 'Load a still first.' 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 (!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 (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.' 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.' return 'Still A is the edit image. Still B is style or background.'
}) })
const editV2Blocked = computed(() => { 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 !== 'generate' && !file.value) return true
if (v2Mode.value === 'compose' && !editRefFile.value) return true if (v2Mode.value === 'compose' && !editRefFile.value) return true
if (v2Mode.value === 'refine' && !refineMaskDirty.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 !== '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 === '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 (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 (!folderId.value) return 'Choose a library folder before generating.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.' if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.'
return '' return ''
@@ -3275,12 +3343,15 @@ function applyInstancePrivacy() {
hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true) hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true)
} }
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] } | unknown) { 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[] } const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string> }
folders.value = payload.folders || [] folders.value = payload.folders || []
clips.value = payload.clips || [] clips.value = payload.clips || []
stills.value = payload.stills || [] stills.value = payload.stills || []
drafts.value = payload.drafts || [] 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)) { if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || '' folderId.value = folders.value[0]?.id || ''
} }
@@ -3290,7 +3361,7 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
} }
async function loadLibrary(light = false) { 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) applyLibrary(data)
if (light) return if (light) return
const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 })) const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
@@ -3832,6 +3903,7 @@ function currentPresetSnapshot() {
mode: v2Mode.value, mode: v2Mode.value,
engine: v2Engine.value, engine: v2Engine.value,
task: v2Mode.value === 'compose' ? v2Task.value : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene', task: v2Mode.value === 'compose' ? v2Task.value : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene',
prompt: prompt.value,
negative: v2Negative.value, negative: v2Negative.value,
snofsModel: imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0, snofsModel: imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0,
snofsClip: imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthClip ?? 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), megapixels: v2Mode.value === 'generate' ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
turbo: v2Turbo.value === true, turbo: v2Turbo.value === true,
strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value), strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value),
width: v2Mode.value === 'generate' ? v2Width.value : undefined, aspect: aspect.value,
height: v2Mode.value === 'generate' ? v2Height.value : undefined 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, kind: 'image' as const,
loraStack: filterLoraStackForStudio(imageLoraStack.value, ltxEnabled.value), loraStack: filterLoraStackForStudio(imageLoraStack.value, ltxEnabled.value),
settings: { settings: {
prompt: prompt.value,
steps: clampImageSteps(editSteps.value), steps: clampImageSteps(editSteps.value),
cfg: clampImageCfg(editCfg.value), cfg: clampImageCfg(editCfg.value),
negative: editNegative.value, negative: editNegative.value,
scaleToTotalPixels: editScaleToTotalPixels.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' v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : settings.mode === 'generate' ? 'generate' : 'edit'
v2Engine.value = parseImageV2Engine(settings.engine, 'flux') v2Engine.value = parseImageV2Engine(settings.engine, 'flux')
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene' 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 if (typeof settings.negative === 'string') v2Negative.value = settings.negative
imageV2LoraStack.value = mergeLegacyImageV2Loras(filterLoraStackForImageEngine(stack, v2Engine.value), { imageV2LoraStack.value = mergeLegacyImageV2Loras(filterLoraStackForImageEngine(stack, v2Engine.value), {
engine: v2Engine.value, engine: v2Engine.value,
@@ -3902,8 +3978,9 @@ function applyGenerationPreset(preset: GenerationPreset) {
v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2Turbo.value = settings.turbo === true v2Turbo.value = settings.turbo === true
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength) if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect)
if (v2Mode.value === 'generate') { if (v2Mode.value === 'generate') {
const size = clampImageV2Size(settings.width, settings.height) const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width v2Width.value = size.width
v2Height.value = size.height v2Height.value = size.height
} }
@@ -3913,17 +3990,21 @@ function applyGenerationPreset(preset: GenerationPreset) {
} else if (preset.kind === 'image') { } else if (preset.kind === 'image') {
imageLoraStack.value = stack imageLoraStack.value = stack
const settings = preset.settings as { const settings = preset.settings as {
prompt?: string
steps?: number steps?: number
cfg?: number cfg?: number
negative?: string negative?: string
scaleToTotalPixels?: boolean scaleToTotalPixels?: boolean
scaleMegapixels?: number 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.steps === 'number') editSteps.value = clampImageSteps(settings.steps)
if (typeof settings.cfg === 'number') editCfg.value = clampImageCfg(settings.cfg) if (typeof settings.cfg === 'number') editCfg.value = clampImageCfg(settings.cfg)
if (typeof settings.negative === 'string') editNegative.value = settings.negative if (typeof settings.negative === 'string') editNegative.value = settings.negative
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels) editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
aspect.value = parseImageAspect(settings.aspect)
imagePresetId.value = preset.id imagePresetId.value = preset.id
imagePresetName.value = preset.name imagePresetName.value = preset.name
} else { } else {
@@ -4662,7 +4743,7 @@ async function restoreStudioJob(id: string) {
const stillId = payload.stillId const stillId = payload.stillId
if (stillId) { if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' }) 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 { } else {
resetImage() resetImage()
} }
@@ -4819,7 +4900,13 @@ function stopTimer(kind?: 'video' | 'edit') {
} }
function clipFileName(name: string) { 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) { function nextClipPartName(name: string) {
@@ -4866,7 +4953,7 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
if (stillId) { if (stillId) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (still?.hideInput && showPrivacyToggles.value) hideInputPreview.value = true 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 return true
} }
const blob = await $fetch<Blob>(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' }) 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 awaitingReveal.value = item.hideThumbnail
playerSize.value = { width: item.width || 1344, height: item.height || 768 } playerSize.value = { width: item.width || 1344, height: item.height || 768 }
browseFolderId.value = item.folderId 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) { if (!videoBusy.value) {
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library' statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
progress.value = 100 progress.value = 100
@@ -5180,8 +5267,9 @@ function applyStillSettings(still: LibraryStill) {
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1) if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2Turbo.value = settings.turbo === true v2Turbo.value = settings.turbo === true
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength) if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect)
if (mode === 'generate') { if (mode === 'generate') {
const size = clampImageV2Size(settings.width, settings.height) const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width v2Width.value = size.width
v2Height.value = size.height v2Height.value = size.height
} }
@@ -5214,12 +5302,13 @@ function applyStillSettings(still: LibraryStill) {
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value) imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value)
} }
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed)) if (settings.seed != null) seedInput.value = String(Math.round(settings.seed))
aspect.value = parseImageAspect(settings.aspect)
} }
async function loadStillAsEditRef(stillId: string) { async function loadStillAsEditRef(stillId: string) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
const still = stills.value.find(item => item.id === stillId) 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) { function stillUseAsInputMessage(still: LibraryStill) {
@@ -5245,7 +5334,7 @@ async function useStillAsInput(still: LibraryStill) {
if (!generate) { if (!generate) {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true 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) { if (dest === 'imagev2' && still.settings?.mode === 'compose' && still.settings.referenceStillId) {
try { try {
@@ -5611,7 +5700,7 @@ function startRename(item: LibraryClip, family = false) {
const source = family const source = family
? (familyBaseName(item.name || '') || item.name || '') ? (familyBaseName(item.name || '') || 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(() => { nextTick(() => {
renameInput.value?.focus() renameInput.value?.focus()
renameInput.value?.select() renameInput.value?.select()
@@ -5698,7 +5787,7 @@ function removeEditPass(id: string) {
} }
async function editImage() { async function editImage() {
if (!file.value || !prompt.value.trim()) { if (!file.value || (!prompt.value.trim() && !imageFillWithoutPrompt.value)) {
toast(editBlockReason.value || 'Load a still first.') toast(editBlockReason.value || 'Load a still first.')
return return
} }
@@ -5717,6 +5806,7 @@ async function editImage() {
body.append('image', file.value) body.append('image', file.value)
if (editRefFile.value) body.append('image2', editRefFile.value) if (editRefFile.value) body.append('image2', editRefFile.value)
body.append('prompt', prompt.value.trim()) body.append('prompt', prompt.value.trim())
body.append('aspect', aspect.value)
body.append('negative', editNegative.value) body.append('negative', editNegative.value)
body.append('steps', String(clampImageSteps(editSteps.value))) body.append('steps', String(clampImageSteps(editSteps.value)))
body.append('cfg', String(clampImageCfg(editCfg.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('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value)) body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim()) body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses)) if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value)) 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<{ const started = await $fetch<{
jobId: string jobId: string
studioJobId?: string studioJobId?: string
@@ -5828,10 +5919,11 @@ function setV2Engine(engine: ImageV2Engine) {
} }
} }
function setGenerateSize(aspect: ImageV2GenerateAspect) { function setImageAspect(id: AspectId) {
const [width, height] = IMAGE_V2_GENERATE_ASPECTS[aspect] aspect.value = id
v2Width.value = width const size = generateSizeForAspect(id)
v2Height.value = height v2Width.value = size.width
v2Height.value = size.height
} }
function applyRefinePreset(kind: 'face' | 'hand' | 'heavy') { function applyRefinePreset(kind: 'face' | 'hand' | 'heavy') {
@@ -5930,10 +6022,11 @@ async function editImageV2() {
body.append('strength', String(clampImageV2Denoise(v2Denoise.value))) body.append('strength', String(clampImageV2Denoise(v2Denoise.value)))
} }
if (v2Mode.value === 'generate') { if (v2Mode.value === 'generate') {
const size = clampImageV2Size(v2Width.value, v2Height.value) const size = generateSizeForAspect(aspect.value)
body.append('width', String(size.width)) body.append('width', String(size.width))
body.append('height', String(size.height)) body.append('height', String(size.height))
} }
body.append('aspect', aspect.value)
body.append('engine', v2Engine.value) body.append('engine', v2Engine.value)
body.append('prompt', prompt.value.trim()) body.append('prompt', prompt.value.trim())
body.append('negative', v2Negative.value) body.append('negative', v2Negative.value)
@@ -5950,6 +6043,7 @@ async function editImageV2() {
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value)) body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value)) body.append('hideThumbnail', String(showPrivacyToggles.value && hideThumbnail.value))
body.append('name', clipName.value.trim()) body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value)
const v2Stack = filterLoraStackForImageEngine( const v2Stack = filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value), filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value v2Engine.value
@@ -5962,7 +6056,7 @@ async function editImageV2() {
} }
} }
if (v2Stack.length) body.append('loraStack', JSON.stringify(v2Stack)) 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<{ const started = await $fetch<{
jobId: string jobId: string
studioJobId?: string studioJobId?: string
@@ -6069,7 +6163,7 @@ async function ensureVideoInputStill() {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
if (!blob?.size) throw new Error('Could not load that still') if (!blob?.size) throw new Error('Could not load that still')
const still = stills.value.find(item => item.id === stillId) 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, keepThumbnailPref: true,
stillId stillId
}) })
@@ -6168,7 +6262,7 @@ async function generate() {
})))) }))))
body.append('queueAutoRun', String(queueAutoRun.value)) 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 })) const chainPlan = plannedChain.value.map(part => ({ ...part }))
console.info('[aigen generate payload]', { console.info('[aigen generate payload]', {
keys: [...body.keys()], 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 { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy' import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution' import { imageDimensions } from '~/server/utils/resolution'
import { parseImageAspect } from '~/utils/imageAspect'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImageScaleToTotalPixels } from '~/utils/generationPresets' import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImageScaleToTotalPixels } from '~/utils/generationPresets'
function parsePasses(raw: string | undefined) { function parsePasses(raw: string | undefined) {
@@ -34,10 +35,11 @@ export default defineEventHandler(async (event) => {
const prompt = (fields.prompt || '').trim() const prompt = (fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const aspect = parseImageAspect(fields.aspect)
if (!image) { if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' }) 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' }) throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
} }
if (extraPasses.some(item => !item.prompt)) { if (extraPasses.some(item => !item.prompt)) {
@@ -80,9 +82,21 @@ export default defineEventHandler(async (event) => {
? Number(fields.seed) ? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647) : Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data) const size = imageDimensions(image.data)
const clipName = (fields.name || '').trim().slice(0, 80)
await listStudioLoras() await listStudioLoras()
const loraStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'image') 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({ const still = await rememberInputStill({
ownerKey, ownerKey,
@@ -114,7 +128,7 @@ export default defineEventHandler(async (event) => {
prompt, prompt,
name: clipName, name: clipName,
folderId, folderId,
aspect: 'auto', aspect,
width: size?.width || 0, width: size?.width || 0,
height: size?.height || 0, height: size?.height || 0,
steps, 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 { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras' import type { LoraStackItem } from '~/utils/loras'
import { clampVideoCfg } from '~/utils/generationPresets' 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' import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
function parseDuration(raw: unknown) { function parseDuration(raw: unknown) {
@@ -115,16 +115,16 @@ export default defineEventHandler(async (event) => {
const textToVideo = isTextToVideo(workflow) const textToVideo = isTextToVideo(workflow)
const ownerKey = libraryOwnerKey(event) const ownerKey = libraryOwnerKey(event)
const postedStillId = (fields.stillId || '').trim() 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) { if (postedStillId) {
const path = stillPath(ownerKey, postedStillId) const path = stillPath(ownerKey, postedStillId)
if (existsSync(path)) { if (existsSync(path)) {
try { try {
const existing = getStill(ownerKey, postedStillId) const existing = getStill(ownerKey, postedStillId)
libraryStill = { id: existing.id, filename: existing.filename } libraryStill = { id: existing.id, filename: existing.filename, name: existing.name }
if (!image) { if (!image) {
image = { image = {
filename: existing.filename || 'still.png', filename: existing.name ? `${existing.name}.png` : (existing.filename || 'still.png'),
data: readFileSync(path), data: readFileSync(path),
type: 'image/png' type: 'image/png'
} }
@@ -179,7 +179,6 @@ export default defineEventHandler(async (event) => {
const hideThumbnail = fields.hideThumbnail === 'true' const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true' const hideInput = fields.hideInput === 'true'
const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false' const sound = !isLtxWorkflow(workflow) && fields.sound !== 'false'
const clipName = (fields.name || '').trim().slice(0, 80)
const library = publicLibrary(event) const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === fields.folderId) const folderId = library.folders.some(folder => folder.id === fields.folderId)
? fields.folderId ? fields.folderId
@@ -204,6 +203,11 @@ export default defineEventHandler(async (event) => {
if (!textToVideo && !still?.id) { if (!textToVideo && !still?.id) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' }) 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]', { console.info('[aigen generate]', {
hasImage: Boolean(image?.data?.length), hasImage: Boolean(image?.data?.length),
imageBytes: image?.data?.length || 0, 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 { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
import { comfyConfigured } from '~/server/utils/comfy' import { comfyConfigured } from '~/server/utils/comfy'
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
import { imageDimensions } from '~/server/utils/resolution' import { imageDimensions } from '~/server/utils/resolution'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps } from '~/utils/generationPresets' import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps } from '~/utils/generationPresets'
import { import {
@@ -63,7 +64,7 @@ function fileFromStill(ownerKey: string, id: string): ImageFile {
throw createError({ statusCode: 400, statusMessage: 'That still is missing from the library' }) throw createError({ statusCode: 400, statusMessage: 'That still is missing from the library' })
} }
return { return {
filename: still.filename || `${still.id}.png`, filename: still.name ? `${still.name}.png` : (still.filename || `${still.id}.png`),
data: readFileSync(path), data: readFileSync(path),
type: 'image/png' type: 'image/png'
} }
@@ -123,7 +124,8 @@ export default defineEventHandler(async (event) => {
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene' mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
) )
const prompt = String(fields.prompt || '').trim() 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' }) throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
} }
if (!comfyConfigured()) { if (!comfyConfigured()) {
@@ -193,11 +195,12 @@ export default defineEventHandler(async (event) => {
const seed = fields.seed && String(fields.seed) !== 'random' const seed = fields.seed && String(fields.seed) !== 'random'
? Number(fields.seed) ? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647) : 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' const size = mode === 'generate'
? { width: generateSize.width, height: generateSize.height } ? { width: generateSize.width, height: generateSize.height }
: imageDimensions(imageA!.data) : imageDimensions(imageA!.data)
const clipName = String(fields.name || '').trim().slice(0, 80)
const v2Mode = mode as ImageV2Mode const v2Mode = mode as ImageV2Mode
const v2Task = ( const v2Task = (
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : mode === 'edit' ? 'scene' : task mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : mode === 'edit' ? 'scene' : task
@@ -223,6 +226,19 @@ export default defineEventHandler(async (event) => {
const generateSnofsClipFallback = 0 const generateSnofsClipFallback = 0
await listStudioLoras() await listStudioLoras()
const loraStack = assertImageV2LoraStack(parsePostedLoraStack(fields.loraStack || fields.lora, 'image'), engine) 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 const still = imageA
? await rememberInputStill({ ? await rememberInputStill({
@@ -262,7 +278,7 @@ export default defineEventHandler(async (event) => {
prompt, prompt,
name: clipName, name: clipName,
folderId, folderId,
aspect: mode === 'generate' ? (generateSize.aspect || '1:1') : 'auto', aspect: mode === 'generate' ? generateSize.aspect : aspect,
width: size?.width || 0, width: size?.width || 0,
height: size?.height || 0, height: size?.height || 0,
steps, steps,
+2 -2
View File
@@ -1,4 +1,5 @@
import { LTX_NEGATIVE } from '~/utils/videoModels' import { LTX_NEGATIVE } from '~/utils/videoModels'
import { comfyJobPrefix } from '~/utils/outputNames'
let comfyHostOverride = '' let comfyHostOverride = ''
@@ -103,8 +104,7 @@ export function comfyInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.') const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png' 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 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 `${comfyJobPrefix(jobId)}_${base}${ext}`
return `aigen_${id}_${base}${ext}`
} }
function comfyQueueMessage(payload: unknown, status: number) { function comfyQueueMessage(payload: unknown, status: number) {
+11 -2
View File
@@ -9,6 +9,7 @@ import {
clampVideoCfg, clampVideoCfg,
normalizePresetName, normalizePresetName,
parseGenerationPresetKind, parseGenerationPresetKind,
PRESET_PROMPT_MAX,
type GenerationPreset, type GenerationPreset,
type GenerationPresetKind, type GenerationPresetKind,
type ImagePresetSettings, 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 { function sanitizeImageSettings(raw: unknown): ImagePresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {} const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
return { return {
prompt: sanitizePrompt(rec.prompt),
steps: clampImageSteps(rec.steps), steps: clampImageSteps(rec.steps),
cfg: clampImageCfg(rec.cfg), cfg: clampImageCfg(rec.cfg),
negative: String(rec.negative || '').slice(0, 2000), negative: String(rec.negative || '').slice(0, 2000),
scaleToTotalPixels: rec.scaleToTotalPixels === true, 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, mode,
engine, engine,
task: mode === 'compose' ? parseImageV2Task(rec.task, 'scene') : mode === 'refine' ? 'refine' : mode === 'generate' ? 't2i' : 'scene', 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), negative: String(rec.negative || '').slice(0, 2000),
snofsModel: isXaigenStudio() ? clampImageV2Strength(rec.snofsModel ?? rec.snofs_model, IMAGE_V2_SNOFS_MODEL) : 0, 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, 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 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, 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 { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch' import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library' import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
export type EditImageFile = { filename: string; data: Buffer; type?: string } export type EditImageFile = { filename: string; data: Buffer; type?: string }
@@ -19,6 +20,7 @@ export type EditRunParams = {
steps: number steps: number
seed: number seed: number
cfg: number cfg: number
aspect?: string
loraName?: string loraName?: string
loraStack?: import('~/utils/loras').LoraStackItem[] loraStack?: import('~/utils/loras').LoraStackItem[]
scaleToTotalPixels?: boolean scaleToTotalPixels?: boolean
@@ -46,12 +48,22 @@ export async function runEdit(job: Job, params: EditRunParams) {
job.imageComfyHost = getComfyHost() job.imageComfyHost = getComfyHost()
await withImageComfyHost(job.imageComfyHost, async () => { 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 let parentStillId: string | undefined
for (let index = 0; index < prompts.length; index++) { for (let index = 0; index < prompts.length; index++) {
if (job.status === 'cancelled') throw new Error('Job interrupted.') if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break 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 const last = index === prompts.length - 1
if (index > 0) { if (index > 0) {
await ensureComfyReady((status) => { await ensureComfyReady((status) => {
@@ -104,7 +116,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
steps: params.steps, steps: params.steps,
seed, seed,
cfg: params.cfg, 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, loraStack: params.loraStack || params.loraName,
scaleToTotalPixels: params.scaleToTotalPixels, scaleToTotalPixels: params.scaleToTotalPixels,
scaleMegapixels: params.scaleMegapixels scaleMegapixels: params.scaleMegapixels
@@ -161,6 +173,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
seed, seed,
scaleToTotalPixels: params.scaleToTotalPixels === true, scaleToTotalPixels: params.scaleToTotalPixels === true,
scaleMegapixels: params.scaleMegapixels, scaleMegapixels: params.scaleMegapixels,
aspect: params.aspect || 'auto',
loraName: params.loraName, loraName: params.loraName,
loraStack: params.loraStack, loraStack: params.loraStack,
sourceStillId: library.stillId 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 { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch' import { emitChainJob } from '~/server/utils/watch'
import { saveStill } from '~/server/utils/library' import { saveStill } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2' import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2'
import type { EditImageFile } from '~/server/utils/imageChain' import type { EditImageFile } from '~/server/utils/imageChain'
@@ -31,6 +32,7 @@ export type EditV2RunParams = {
width?: number width?: number
height?: number height?: number
turbo?: boolean turbo?: boolean
aspect?: string
sourceStillId?: string sourceStillId?: string
referenceStillId?: string referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[] 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...', : params.mode === 'compose' ? 'Uploading stills A and B to Beast...' : 'Uploading still A to Beast...',
progress: generate ? 12 : 8 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' const uploadedRef = generate || params.mode === 'refine'
? null ? null
: params.reference : params.reference
@@ -86,10 +112,10 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
filename: `ref_${params.reference.filename || 'image_b.png'}` filename: `ref_${params.reference.filename || 'image_b.png'}`
}, job.id) }, job.id)
: null : null
const uploadedMask = !generate && params.mode === 'refine' && params.mask const uploadedMask = !generate && params.mode === 'refine' && mask
? await uploadImage({ ? await uploadImage({
...params.mask, ...mask,
filename: `mask_${params.mask.filename || 'refine-mask.png'}` filename: `mask_${mask.filename || 'refine-mask.png'}`
}, job.id) }, job.id)
: null : null
if (job.status === 'cancelled') throw new Error('Job interrupted.') if (job.status === 'cancelled') throw new Error('Job interrupted.')
@@ -112,7 +138,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
mode: params.mode, mode: params.mode,
engine, engine,
task: params.task, task: params.task,
prompt: params.prompt, prompt,
negative: params.negative, negative: params.negative,
imageAName: uploaded?.name, imageAName: uploaded?.name,
imageBName: uploadedRef?.name, imageBName: uploadedRef?.name,
@@ -129,7 +155,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
width: params.width, width: params.width,
height: params.height, height: params.height,
turbo: params.turbo === true, turbo: params.turbo === true,
filenamePrefix: `aigen_v2_${job.id.slice(0, 8)}`, filenamePrefix: comfyJobPrefix(job.id),
kreaUnetName: krea?.unet, kreaUnetName: krea?.unet,
kreaClipName: krea?.clip, kreaClipName: krea?.clip,
kreaVaeName: krea?.vae, kreaVaeName: krea?.vae,
@@ -174,7 +200,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const still = await saveStill({ const still = await saveStill({
ownerKey: library.ownerKey, ownerKey: library.ownerKey,
folderId: library.folderId, folderId: library.folderId,
filename: output.filename, filename: library.name ? `${library.name}.png` : output.filename,
data: buffer, data: buffer,
width: size?.width || 0, width: size?.width || 0,
height: size?.height || 0, height: size?.height || 0,
@@ -199,6 +225,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
strength: params.mode === 'generate' ? undefined : params.strength, strength: params.mode === 'generate' ? undefined : params.strength,
width: params.mode === 'generate' ? params.width : undefined, width: params.mode === 'generate' ? params.width : undefined,
height: params.mode === 'generate' ? params.height : undefined, height: params.mode === 'generate' ? params.height : undefined,
aspect: params.aspect || 'auto',
seed: params.seed, seed: params.seed,
sourceStillId: params.sourceStillId || library.stillId, sourceStillId: params.sourceStillId || library.stillId,
referenceStillId: params.referenceStillId, referenceStillId: params.referenceStillId,
+2 -2
View File
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks' import { AsyncLocalStorage } from 'node:async_hooks'
import { getComfyHost } from '~/server/utils/comfy' import { getComfyHost } from '~/server/utils/comfy'
import { comfyJobPrefix } from '~/utils/outputNames'
const imageHostAls = new AsyncLocalStorage<string>() const imageHostAls = new AsyncLocalStorage<string>()
let imageComfyHostOverride = '' let imageComfyHostOverride = ''
@@ -98,8 +99,7 @@ function imageInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.') const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png' 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 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 `${comfyJobPrefix(jobId)}_${base}${ext}`
return `aigen_edit_${id}_${base}${ext}`
} }
export async function uploadImageEdit(file: { filename: string; data: Buffer; type?: string }, jobId?: string) { 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 dual = Boolean(params.referenceImageName)
const prompt = String(params.prompt || '').trim() const prompt = String(params.prompt || '').trim()
const negative = String(params.negative || '') const negative = String(params.negative || '')
const prefix = params.filenamePrefix || 'aigen-edit' const prefix = params.filenamePrefix || 'edit'
setImage(graph, IMAGE1, params.imageName) setImage(graph, IMAGE1, params.imageName)
setText(graph, SINGLE_PROMPT, prompt) 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', 'sampler_name', 'euler')
setInput(graph, '15', 'scheduler', 'simple') setInput(graph, '15', 'scheduler', 'simple')
setInput(graph, '15', 'denoise', denoise) 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) patchScaleMegapixels(graph, params.megapixels ?? 1)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea') const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
@@ -448,7 +448,7 @@ function buildKreaGenerateWorkflow(params: ImageV2BuildParams) {
setInput(graph, '15', 'cfg', cfg) setInput(graph, '15', 'cfg', cfg)
setInput(graph, '15', 'sampler_name', 'euler') setInput(graph, '15', 'sampler_name', 'euler')
setInput(graph, '15', 'scheduler', 'simple') 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') const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
assertImageV2Graph(graph, 'generate', undefined, 'krea') assertImageV2Graph(graph, 'generate', undefined, 'krea')
@@ -512,7 +512,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
setInput(graph, NOISE, 'noise_seed', params.seed) setInput(graph, NOISE, 'noise_seed', params.seed)
setInput(graph, SCHEDULER, 'steps', steps) setInput(graph, SCHEDULER, 'steps', steps)
setInput(graph, CFG, 'cfg', cfg) 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) if (!generate) patchScaleMegapixels(graph, params.megapixels ?? 1)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'flux'), 'flux') 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 { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, remuxFaststart } from '~/server/utils/ffmpeg'
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks' import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration' 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) const scryptAsync = promisify(scrypt)
@@ -128,6 +130,7 @@ interface Catalog {
clips: LibraryClip[] clips: LibraryClip[]
stills: LibraryStill[] stills: LibraryStill[]
drafts: RetryDraft[] drafts: RetryDraft[]
loraAliases: Record<string, string>
} }
const writeChains = new Map<string, Promise<unknown>>() const writeChains = new Map<string, Promise<unknown>>()
@@ -166,7 +169,8 @@ function emptyCatalog(): Catalog {
}], }],
clips: [], clips: [],
stills: [], stills: [],
drafts: [] drafts: [],
loraAliases: {}
} }
} }
@@ -225,7 +229,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
folders, folders,
clips: Array.isArray(parsed.clips) ? parsed.clips : [], clips: Array.isArray(parsed.clips) ? parsed.clips : [],
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [], 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) { export function safeDownloadName(name: string) {
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim() 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) { export function passwordVersion(hash: string | null) {
@@ -425,7 +474,8 @@ export function publicLibrary(event: H3Event) {
stills: catalog.stills stills: catalog.stills
.filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still)) .filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still))
.map(still => ({ ...still, role: stillRole(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 { ensureSidecarReady } from '~/server/utils/imageComfyLifecycle'
import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow' import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow'
import { normalizeShotScript } from '~/utils/parseRecommend' import { normalizeShotScript } from '~/utils/parseRecommend'
import { comfyJobPrefix } from '~/utils/outputNames'
export type PromptJobStatus = 'queued' | 'running' | 'complete' | 'error' export type PromptJobStatus = 'queued' | 'running' | 'complete' | 'error'
@@ -46,8 +47,7 @@ function promptInputFilename(original: string, jobId?: string) {
const dot = raw.lastIndexOf('.') const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png' 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 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 `${comfyJobPrefix(jobId)}_${base}${ext}`
return `aigen_prompt_${id}_${base}${ext}`
} }
async function uploadPromptImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) { 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), consistencyClip: payload.consistencyClip ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7),
megapixels: payload.scaleMegapixels ?? 1, megapixels: payload.scaleMegapixels ?? 1,
strength: payload.refineStrength, strength: payload.refineStrength,
aspect: payload.aspect || 'auto',
width: payload.width, width: payload.width,
height: payload.height, height: payload.height,
turbo: payload.turbo === true, turbo: payload.turbo === true,
@@ -860,6 +861,7 @@ async function startStudioEditJob(item: StudioJob) {
steps: payload.steps, steps: payload.steps,
seed: live.library?.seed || payload.seed, seed: live.library?.seed || payload.seed,
cfg: payload.cfg, cfg: payload.cfg,
aspect: payload.aspect || 'auto',
scaleToTotalPixels: payload.scaleToTotalPixels === true, scaleToTotalPixels: payload.scaleToTotalPixels === true,
scaleMegapixels: payload.scaleMegapixels, scaleMegapixels: payload.scaleMegapixels,
...persistLoraFields(payload.loraStack || payload.loraName) ...persistLoraFields(payload.loraStack || payload.loraName)
+4
View File
@@ -16,12 +16,16 @@ export type VideoPresetSettings = {
sound?: boolean sound?: boolean
} }
export const PRESET_PROMPT_MAX = 8000
export type ImagePresetSettings = { export type ImagePresetSettings = {
prompt?: string
steps?: number steps?: number
cfg?: number cfg?: number
negative?: string negative?: string
scaleToTotalPixels?: boolean scaleToTotalPixels?: boolean
scaleMegapixels?: number scaleMegapixels?: number
aspect?: string
} }
export const IMAGE_STEPS_MIN = 1 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 mode?: ImageV2Mode
task?: ImageV2Task task?: ImageV2Task
engine?: ImageV2Engine engine?: ImageV2Engine
prompt?: string
negative?: string negative?: string
snofsModel?: number snofsModel?: number
snofsClip?: number snofsClip?: number
@@ -173,6 +174,7 @@ export type ImageV2PresetSettings = {
strength?: number strength?: number
width?: number width?: number
height?: number height?: number
aspect?: string
} }
export function imageV2ConceptLoraName(engine: ImageV2Engine, kreaConcept?: string) { export function imageV2ConceptLoraName(engine: ImageV2Engine, kreaConcept?: string) {
+7
View File
@@ -21,6 +21,7 @@ export type StillGenerationSettings = {
strength?: number strength?: number
width?: number width?: number
height?: number height?: number
aspect?: string
seed?: number seed?: number
scaleToTotalPixels?: boolean scaleToTotalPixels?: boolean
scaleMegapixels?: number scaleMegapixels?: number
@@ -93,6 +94,7 @@ export function normalizeStillSettings(raw: unknown): StillGenerationSettings |
strength: finiteNumber(value.strength), strength: finiteNumber(value.strength),
width: finiteNumber(value.width), width: finiteNumber(value.width),
height: finiteNumber(value.height), height: finiteNumber(value.height),
aspect: typeof value.aspect === 'string' ? value.aspect : undefined,
seed: finiteNumber(value.seed), seed: finiteNumber(value.seed),
scaleToTotalPixels: typeof value.scaleToTotalPixels === 'boolean' ? value.scaleToTotalPixels : undefined, scaleToTotalPixels: typeof value.scaleToTotalPixels === 'boolean' ? value.scaleToTotalPixels : undefined,
scaleMegapixels: finiteNumber(value.scaleMegapixels), scaleMegapixels: finiteNumber(value.scaleMegapixels),
@@ -148,6 +150,8 @@ export function stillSettingsRows(still: { width?: number; height?: number; sett
if (settings.kind === 'imagev2') { if (settings.kind === 'imagev2') {
if (settings.mode === 'generate' && settings.width && settings.height) { if (settings.mode === 'generate' && settings.width && settings.height) {
rows.push({ label: 'Size', value: `${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) { } else if (still.width && still.height) {
rows.push({ label: 'Output', value: `${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 { } else {
if (still.width && still.height) rows.push({ label: 'Output', value: `${still.width}×${still.height}` }) 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) { if (settings.scaleToTotalPixels) {
rows.push({ label: 'Scale to MP', value: formatNum(settings.scaleMegapixels ?? 1) }) 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)
}