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