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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 16:24:49 -05:00
co-authored by Cursor
parent 4d411f83d4
commit 0e1222d035
23 changed files with 668 additions and 93 deletions
+4
View File
@@ -16,12 +16,16 @@ export type VideoPresetSettings = {
sound?: boolean
}
export const PRESET_PROMPT_MAX = 8000
export type ImagePresetSettings = {
prompt?: string
steps?: number
cfg?: number
negative?: string
scaleToTotalPixels?: boolean
scaleMegapixels?: number
aspect?: string
}
export const IMAGE_STEPS_MIN = 1
+54
View File
@@ -0,0 +1,54 @@
export const IMAGE_ASPECT_IDS = ['auto', '16:9', '9:16', '1:1'] as const
export type ImageAspectId = (typeof IMAGE_ASPECT_IDS)[number]
export const IMAGE_FILL_INSTRUCTION =
'Extend the photograph to fill the empty edges of the frame. Do not stretch or squash the original image. Keep the original subject, pose, and framing. Match lighting, perspective, and texture in the filled areas.'
export function parseImageAspect(raw: unknown): ImageAspectId {
const value = String(raw || 'auto').trim()
return value === '16:9' || value === '9:16' || value === '1:1' ? value : 'auto'
}
export function imageAspectRatio(aspect: Exclude<ImageAspectId, 'auto'>) {
if (aspect === '16:9') return 16 / 9
if (aspect === '9:16') return 9 / 16
return 1
}
export function generateSizeForAspect(aspect: ImageAspectId) {
if (aspect === '16:9') return { width: 1280, height: 768, aspect: '16:9' as const }
if (aspect === '9:16') return { width: 768, height: 1280, aspect: '9:16' as const }
return { width: 1024, height: 1024, aspect: '1:1' as const }
}
export function composeFillPrompt(userPrompt: string, padded: boolean) {
const body = String(userPrompt || '').trim()
if (!padded) return body
return body ? `${IMAGE_FILL_INSTRUCTION}\n\n${body}` : IMAGE_FILL_INSTRUCTION
}
export function snapImagePad(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
export function padExtents(srcW: number, srcH: number, aspect: Exclude<ImageAspectId, 'auto'>) {
if (!srcW || !srcH) return null
const target = imageAspectRatio(aspect)
const src = srcW / srcH
if (Math.abs(src - target) < 0.03) return null
let canvasW = srcW
let canvasH = srcH
if (src > target) {
canvasW = snapImagePad(srcW)
canvasH = snapImagePad(canvasW / target)
} else {
canvasH = snapImagePad(srcH)
canvasW = snapImagePad(canvasH * target)
}
if (canvasW < srcW) canvasW = snapImagePad(srcW)
if (canvasH < srcH) canvasH = snapImagePad(srcH)
const x = Math.max(0, Math.floor((canvasW - srcW) / 2))
const y = Math.max(0, Math.floor((canvasH - srcH) / 2))
if (x === 0 && y === 0 && canvasW === srcW && canvasH === srcH) return null
return { canvasW, canvasH, x, y, srcW, srcH }
}
+2
View File
@@ -161,6 +161,7 @@ export type ImageV2PresetSettings = {
mode?: ImageV2Mode
task?: ImageV2Task
engine?: ImageV2Engine
prompt?: string
negative?: string
snofsModel?: number
snofsClip?: number
@@ -173,6 +174,7 @@ export type ImageV2PresetSettings = {
strength?: number
width?: number
height?: number
aspect?: string
}
export function imageV2ConceptLoraName(engine: ImageV2Engine, kreaConcept?: string) {
+7
View File
@@ -21,6 +21,7 @@ export type StillGenerationSettings = {
strength?: number
width?: number
height?: number
aspect?: string
seed?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
@@ -93,6 +94,7 @@ export function normalizeStillSettings(raw: unknown): StillGenerationSettings |
strength: finiteNumber(value.strength),
width: finiteNumber(value.width),
height: finiteNumber(value.height),
aspect: typeof value.aspect === 'string' ? value.aspect : undefined,
seed: finiteNumber(value.seed),
scaleToTotalPixels: typeof value.scaleToTotalPixels === 'boolean' ? value.scaleToTotalPixels : undefined,
scaleMegapixels: finiteNumber(value.scaleMegapixels),
@@ -148,6 +150,8 @@ export function stillSettingsRows(still: { width?: number; height?: number; sett
if (settings.kind === 'imagev2') {
if (settings.mode === 'generate' && settings.width && settings.height) {
rows.push({ label: 'Size', value: `${settings.width}×${settings.height}` })
} else if (settings.aspect && settings.aspect !== 'auto') {
rows.push({ label: 'Aspect', value: `${settings.aspect} fill` })
} else if (still.width && still.height) {
rows.push({ label: 'Output', value: `${still.width}×${still.height}` })
}
@@ -170,6 +174,9 @@ export function stillSettingsRows(still: { width?: number; height?: number; sett
}
} else {
if (still.width && still.height) rows.push({ label: 'Output', value: `${still.width}×${still.height}` })
if (settings.aspect && settings.aspect !== 'auto') {
rows.push({ label: 'Aspect', value: `${settings.aspect} fill` })
}
if (settings.scaleToTotalPixels) {
rows.push({ label: 'Scale to MP', value: formatNum(settings.scaleMegapixels ?? 1) })
}
+114
View File
@@ -0,0 +1,114 @@
import { loraBasename, loraDisplayName, loraIdentityKey, MINIMAX_TURBO_LORA, normalizeLoraStack, type LoraStackItem } from '~/utils/loras'
export const OUTPUT_NAME_MAX = 80
export function sanitizeFilenameKey(raw: unknown, max = 32) {
return String(raw || '')
.replace(/\.[a-z0-9]{1,8}$/i, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, max)
}
export function sanitizeLoraAlias(raw: unknown) {
return sanitizeFilenameKey(raw, 24)
}
export function normalizeLoraAliases(raw: unknown): Record<string, string> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const id = loraIdentityKey(key) || String(key || '').trim().toLowerCase()
const alias = sanitizeLoraAlias(value)
if (!id || !alias) continue
out[id] = alias
}
return out
}
export function inputStemFromFilename(filename: string) {
const base = String(filename || '').replace(/^.*[\\/]/, '').replace(/\.[a-z0-9]+$/i, '')
if (!base) return ''
if (/^(start|still|padded|last[_-]?frame|refine-canvas|refine-mask|image[12]?|mask|edited)$/i.test(base)) return ''
if (/^aigen[_-]/i.test(base)) return ''
if (/^[0-9a-f]{8,}/i.test(base) && /_\d{5}_?$/i.test(base)) return ''
return sanitizeFilenameKey(base, 40)
}
export function defaultLoraFilenameKey(name: string) {
const stem = loraDisplayName(name).replace(/^xaigen-?/i, '')
return sanitizeFilenameKey(stem, 24) || 'lora'
}
export function loraFilenameKey(name: string, aliases: Record<string, string> = {}) {
const id = loraIdentityKey(name)
const alias = String(aliases[id] || aliases[loraBasename(name).toLowerCase()] || '').trim()
if (alias) return sanitizeFilenameKey(alias, 24) || defaultLoraFilenameKey(name)
return defaultLoraFilenameKey(name)
}
export function isFilenameSystemLora(name: string) {
const id = loraIdentityKey(name)
if (id === loraIdentityKey(MINIMAX_TURBO_LORA)) return true
if (/flux2-klein-9b-consistency/i.test(id)) return true
if (/ltx-2\.3-22b-distilled/i.test(name)) return true
return false
}
export function loraKeysForFilename(stack: unknown, aliases: Record<string, string> = {}) {
const seen = new Set<string>()
const keys: string[] = []
for (const item of normalizeLoraStack(stack)) {
if (isFilenameSystemLora(item.name)) continue
const key = loraFilenameKey(item.name, aliases)
const token = key.toLowerCase()
if (!key || seen.has(token)) continue
seen.add(token)
keys.push(key)
}
return keys
}
export function engineFilenameKey(engine: string) {
const value = String(engine || '').trim().toLowerCase()
if (value === 'krea') return 'krea'
if (value === 'flux') return 'flux'
if (value === 'klein') return 'klein'
if (value === 'ltx') return 'ltx'
if (value === 'minimax') return 'minimax'
return sanitizeFilenameKey(value, 16) || 'gen'
}
export function buildOutputBaseName(opts: {
requested?: string
inputFilename?: string
engine: string
loraStack?: LoraStackItem[] | unknown
aliases?: Record<string, string>
}) {
const requested = String(opts.requested || '').trim().replace(/\s+/g, ' ').slice(0, OUTPUT_NAME_MAX)
if (requested) return requested
const stem = inputStemFromFilename(opts.inputFilename || '')
const engine = engineFilenameKey(opts.engine)
const loras = loraKeysForFilename(opts.loraStack, opts.aliases || {})
const joined = [stem, engine, ...loras].filter(Boolean).join('-') || engine || 'output'
return joined.slice(0, OUTPUT_NAME_MAX)
}
export function nextUniqueName(existing: string[], base: string) {
const trimmed = String(base || '').trim() || 'output'
const taken = new Set(existing.map(name => String(name || '').trim().toLowerCase()).filter(Boolean))
if (!taken.has(trimmed.toLowerCase())) return trimmed.slice(0, OUTPUT_NAME_MAX)
for (let n = 2; n < 10000; n++) {
const suffix = `-${n}`
const candidate = `${trimmed.slice(0, OUTPUT_NAME_MAX - suffix.length)}${suffix}`
if (!taken.has(candidate.toLowerCase())) return candidate
}
return `${trimmed.slice(0, 72)}-${Date.now().toString(16).slice(-6)}`
}
export function comfyJobPrefix(jobId?: string) {
const hex = `${String(jobId || '').replace(/-/g, '')}${crypto.randomUUID().replace(/-/g, '')}`
return hex.slice(0, 12)
}