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 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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 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)
|
||||||
|
|||||||
@@ -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
@@ -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,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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
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) {
|
||||||
|
|||||||
@@ -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) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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