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