Show generator settings in the library and restore them as input.

Collapse tiles so prompts stay behind the info button. Use as input opens the matching Video or Image v2 mode with the saved sliders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 23:38:23 -05:00
co-authored by Cursor
parent b35f9af10d
commit be5211f488
7 changed files with 458 additions and 22 deletions
+18
View File
@@ -0,0 +1,18 @@
<template>
<dl v-if="rows.length" class="mt-2 grid grid-cols-[7.25rem_minmax(0,1fr)] gap-x-3 gap-y-1 text-[11px] leading-snug">
<template v-for="row in rows" :key="row.label">
<dt class="text-zinc-500">{{ row.label }}</dt>
<dd class="min-w-0 break-words text-zinc-300">{{ row.value }}</dd>
</template>
</dl>
<p v-else class="mt-2 text-[11px] text-zinc-600">{{ empty }}</p>
</template>
<script setup lang="ts">
import type { SettingsRow } from '~/utils/libraryGeneration'
defineProps<{
rows: SettingsRow[]
empty?: string
}>()
</script>
+163 -16
View File
@@ -35,7 +35,7 @@
</header>
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div>
<div class="mb-3 flex flex-wrap gap-2">
<button
@@ -1514,12 +1514,13 @@
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(entry.group) }}</p>
<PromptPreviewTip
v-if="familyScript(entry.group.parts)"
:text="familyScript(entry.group.parts)"
:aria-label="'Copy prompt for ' + collectionTitle(entry.group)"
@copy="copyPrompt"
/>
</div>
<p class="text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
<p class="truncate text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
<p v-if="entry.group.head.globalLocks" class="mt-1 text-[11px] text-zinc-600">Global locks saved with this collection.</p>
<PermanenceRefStrip
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
@@ -1545,9 +1546,12 @@
{{ familyFramesRevealed(entry.group.parts) ? 'Hide frames' : 'Click to reveal' }}
</button>
<button type="button" class="text-xs text-amber-300" @click="openClip(entry.group.head)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useClipAsInput(entry.group.head, true)">Use as input</button>
<button type="button" class="text-xs text-amber-300" @click="openExtend(entry.group.head)">Extend</button>
<button type="button" class="text-xs text-amber-300" @click="startRename(entry.group.head, true)">Rename</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(entry.group.head, true)">Rerun</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
</button>
<select
v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
@@ -1565,6 +1569,11 @@
</select>
<button type="button" class="text-xs text-red-300" @click="askRemoveClip(entry.group.head, true)">Delete</button>
</div>
<LibrarySettingsDetails
v-if="expandedDetails[entry.key]"
:rows="clipSettingsRows(entry.group.head)"
empty="No generator settings were saved with this clip."
/>
</div>
</div>
<ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
@@ -1595,7 +1604,7 @@
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
</div>
<p class="text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
<p class="truncate text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
<PermanenceRefStrip
v-if="!item.hideThumbnail"
class="mt-2"
@@ -1617,9 +1626,12 @@
{{ stitchReveal[item.id] ? 'Hide frames' : 'Click to reveal' }}
</button>
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useClipAsInput(item)">Use as input</button>
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
</button>
<select
v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
@@ -1637,6 +1649,11 @@
</select>
<button type="button" class="text-xs text-red-300" @click="askRemoveClip(item)">Delete</button>
</div>
<LibrarySettingsDetails
v-if="expandedDetails[item.id]"
:rows="clipSettingsRows(item)"
empty="No generator settings were saved with this clip."
/>
</div>
</li>
</ul>
@@ -1675,18 +1692,22 @@
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillCollectionTitle(entry.stillGroup) }}</p>
<PromptPreviewTip
v-if="stillFamilyScript(entry.stillGroup.parts)"
:text="stillFamilyScript(entry.stillGroup.parts)"
:aria-label="'Copy prompt for ' + stillCollectionTitle(entry.stillGroup)"
@copy="copyPrompt"
/>
</div>
<p class="text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p>
<p class="truncate text-xs text-zinc-500">{{ stillMetaLine(entry.stillGroup.head) }}</p>
<div class="mt-2 flex flex-wrap items-center gap-3">
<button v-if="entry.stillGroup.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.stillGroup.key)">
{{ expandedFamilies[entry.stillGroup.key] ? 'Collapse' : `Expand ${entry.stillGroup.parts.length} passes` }}
</button>
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
</button>
<a
:href="`/api/library/stills/${entry.stillGroup.head.id}`"
:download="stillTitle(entry.stillGroup.head)"
@@ -1710,6 +1731,11 @@
</select>
<button type="button" class="text-xs text-red-300" @click="askRemoveStill(entry.stillGroup.head, true)">Delete</button>
</div>
<LibrarySettingsDetails
v-if="expandedDetails[entry.key]"
:rows="stillSettingsRows(entry.stillGroup.head)"
empty="Settings were not saved with this still. New generations will keep them."
/>
</div>
</div>
<ul v-if="expandedFamilies[entry.stillGroup.key] && entry.stillGroup.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
@@ -1735,10 +1761,13 @@
>
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ stillTitle(item) }}</p>
</div>
<p class="text-xs text-zinc-500">{{ stillMetaLine(item) }}</p>
<p class="truncate text-xs text-zinc-500">{{ stillMetaLine(item) }}</p>
<div class="mt-1.5 flex flex-wrap items-center gap-3">
<button type="button" class="text-xs text-amber-300" @click="openStill(item)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(item)">Use as input</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
</button>
<a :href="`/api/library/stills/${item.id}`" :download="stillTitle(item)" class="text-xs text-amber-300">Download</a>
<button type="button" class="text-xs text-amber-300" @click="startRenameStill(item)">Rename</button>
<select
@@ -1758,6 +1787,11 @@
</select>
<button type="button" class="text-xs text-red-300" @click="askRemoveStill(item)">Delete</button>
</div>
<LibrarySettingsDetails
v-if="expandedDetails[item.id]"
:rows="stillSettingsRows(item)"
empty="Settings were not saved with this still. New generations will keep them."
/>
</div>
</li>
</ul>
@@ -1908,6 +1942,13 @@ import {
VIDEO_CFG_STEP,
type GenerationPreset
} from '~/utils/generationPresets'
import {
clipSettingsRows,
inferStillPipeline,
stillModeLabel,
stillSettingsRows,
type StillGenerationSettings
} from '~/utils/libraryGeneration'
import {
IMAGE_V2_CFG_DEFAULT,
IMAGE_V2_CONSISTENCY_CLIP,
@@ -1981,6 +2022,7 @@ interface LibraryStill {
familyId?: string
parentStillId?: string
chainIndex?: number
settings?: StillGenerationSettings
}
interface LibraryClip {
@@ -2181,6 +2223,7 @@ const overallProgress = ref(0)
const completedChainStep = ref(0)
const activeChainPlan = ref<{ label: string; prompt: string; duration: number }[]>([])
const expandedFamilies = ref<Record<string, boolean>>({})
const expandedDetails = ref<Record<string, boolean>>({})
const stitchReveal = reactive<Record<string, boolean>>({})
const elapsedMs = ref(0)
const videoUrl = ref('')
@@ -2898,7 +2941,16 @@ function clipMetaLine(item: LibraryClip) {
const duration = formatClipDuration(item.duration)
const steps = `${item.steps || '—'} steps`
const seed = `seed ${item.seed || '—'}`
return duration ? `${res} · ${duration} · ${steps} · ${seed}` : `${res} · ${steps} · ${seed}`
const engine = item.workflow === 'ltx' || item.workflow === 'ltx-t2v'
? 'LTX'
: item.workflow === 't2v'
? 'T2V'
: item.workflow === 'v2'
? 'MiniMax v2'
: item.workflow === 'v1'
? 'MiniMax'
: ''
return [res, duration, engine, steps, seed].filter(Boolean).join(' · ')
}
function stillTitle(still: LibraryStill) {
@@ -2920,10 +2972,17 @@ function stillFamilyScript(parts: LibraryStill[]) {
function stillMetaLine(still: LibraryStill) {
const res = still.width && still.height ? `${still.width}×${still.height}` : 'image'
const mode = stillModeLabel(still)
const pass = typeof still.chainIndex === 'number' ? `pass ${still.chainIndex + 1}` : ''
if (pass && still.prompt) return `${res} · ${pass} · ${still.prompt}`
if (still.prompt) return `${res} · ${still.prompt}`
return pass ? `${res} · ${pass}` : res
return [res, mode, pass].filter(Boolean).join(' · ')
}
function toggleDetails(key: string) {
expandedDetails.value = { ...expandedDetails.value, [key]: !expandedDetails.value[key] }
}
function scrollToStudioInput() {
document.getElementById('studio-input')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function toggleFamily(key: string) {
@@ -4717,7 +4776,7 @@ function openClip(item: LibraryClip) {
}
function openStill(still: LibraryStill) {
studioMode.value = 'edit'
studioMode.value = inferStillPipeline(still) === 'imagev2' ? 'editv2' : 'edit'
outputFocus.value = 'edit'
browseFolderId.value = still.folderId
editResultUrl.value = `/api/library/stills/${still.id}`
@@ -4731,17 +4790,105 @@ function openStill(still: LibraryStill) {
}
}
function applyStillSettings(still: LibraryStill) {
const dest = inferStillPipeline(still)
const settings = still.settings
folderId.value = still.folderId
browseFolderId.value = still.folderId
if (still.prompt) prompt.value = still.prompt
if (dest === 'imagev2') {
studioMode.value = 'editv2'
outputFocus.value = 'edit'
const mode = settings?.mode === 'compose' || settings?.mode === 'refine' || settings?.mode === 'generate'
? settings.mode
: 'edit'
setV2Mode(mode)
if (!settings) return
if (typeof settings.negative === 'string') v2Negative.value = settings.negative
if (settings.snofsModel != null) v2SnofsModel.value = clampImageV2Strength(settings.snofsModel, IMAGE_V2_SNOFS_MODEL)
if (settings.snofsClip != null) v2SnofsClip.value = clampImageV2Strength(settings.snofsClip, IMAGE_V2_SNOFS_CLIP)
if (mode !== 'generate') {
if (settings.consistencyModel != null) v2ConsistencyModel.value = clampImageV2Strength(settings.consistencyModel, IMAGE_V2_CONSISTENCY_MODEL)
if (settings.consistencyClip != null) v2ConsistencyClip.value = clampImageV2Strength(settings.consistencyClip, IMAGE_V2_CONSISTENCY_CLIP)
}
if (settings.steps != null) v2Steps.value = clampImageSteps(settings.steps, IMAGE_V2_STEPS_DEFAULT)
if (settings.cfg != null) v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT)
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2Turbo.value = settings.turbo === true
if (mode === 'refine' && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
if (mode === 'generate') {
const size = clampImageV2Size(settings.width, settings.height)
v2Width.value = size.width
v2Height.value = size.height
}
if (mode === 'compose' && settings.task) v2Task.value = settings.task
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed))
return
}
studioMode.value = 'edit'
outputFocus.value = 'edit'
if (!settings) return
if (settings.steps != null) editSteps.value = clampImageSteps(settings.steps)
if (settings.cfg != null) editCfg.value = clampImageCfg(settings.cfg)
if (typeof settings.negative === 'string') editNegative.value = settings.negative
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
if (settings.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
if (settings.loraStack?.length || settings.loraName) {
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value)
}
if (settings.seed != null) seedInput.value = String(Math.round(settings.seed))
}
async function loadStillAsEditRef(stillId: string) {
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
const still = stills.value.find(item => item.id === stillId)
setEditRef(new File([blob], still?.filename || 'still-b.png', { type: blob.type || 'image/png' }))
}
function stillUseAsInputMessage(still: LibraryStill) {
const dest = inferStillPipeline(still)
const mode = still.settings?.mode
if (dest === 'imagev2' && mode === 'generate') return 'Generate settings loaded. Tweak and generate.'
if (dest === 'imagev2' && mode === 'compose') {
return still.settings?.referenceStillId
? 'Compose settings loaded. Tweak and generate.'
: 'Compose settings loaded. Re-add still B, then generate.'
}
if (dest === 'imagev2' && mode === 'refine') return 'Refine settings loaded. Paint a mask, then generate.'
if (dest === 'imagev2') return 'Image v2 settings loaded. Tweak and generate.'
if (dest === 'image' && still.settings) return 'Image edit settings loaded. Tweak and generate.'
return 'Loaded as input. It stays in Output, not the upload library.'
}
async function useStillAsInput(still: LibraryStill) {
try {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }), { stillId: still.id })
toast('Loaded as input. It stays in Output, not the upload library.')
applyStillSettings(still)
const dest = inferStillPipeline(still)
const generate = dest === 'imagev2' && still.settings?.mode === 'generate'
if (!generate) {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
if (still.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
readFile(new File([blob], still.filename, { type: blob.type || 'image/png' }), { stillId: still.id })
}
if (dest === 'imagev2' && still.settings?.mode === 'compose' && still.settings.referenceStillId) {
try {
await loadStillAsEditRef(still.settings.referenceStillId)
} catch {
toast('Compose settings loaded, but still B is missing. Re-add it.')
}
}
scrollToStudioInput()
toast(stillUseAsInputMessage(still))
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not load that still')
}
}
async function useClipAsInput(item: LibraryClip, collection = false) {
await rerun(item, collection)
scrollToStudioInput()
}
function libraryPromptSnippet(text?: string) {
const value = String(text || '').replace(/\s+/g, ' ').trim()
if (!value) return ''
+13 -1
View File
@@ -152,7 +152,19 @@ export async function runEdit(job: Job, params: EditRunParams) {
prompt,
familyId: library.familyId,
parentStillId,
chainIndex: index
chainIndex: index,
settings: {
kind: 'image',
steps: params.steps,
cfg: params.cfg,
negative: params.negative,
seed,
scaleToTotalPixels: params.scaleToTotalPixels === true,
scaleMegapixels: params.scaleMegapixels,
loraName: params.loraName,
loraStack: params.loraStack,
sourceStillId: library.stillId
}
})
job.stillId = still?.id
parentStillId = still?.id
+23 -1
View File
@@ -30,6 +30,8 @@ export type EditV2RunParams = {
width?: number
height?: number
turbo?: boolean
sourceStillId?: string
referenceStillId?: string
}
export async function runEditV2(job: Job, params: EditV2RunParams) {
@@ -163,7 +165,27 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
hideInput: job.hideThumbnail === true,
role: 'output',
name: library.name || undefined,
prompt: built.prompt
prompt: params.prompt,
settings: {
kind: 'imagev2',
mode: params.mode,
task: params.task,
negative: params.negative,
snofsModel: params.snofsModel,
snofsClip: params.snofsClip,
consistencyModel: params.consistencyModel,
consistencyClip: params.consistencyClip,
steps: params.steps,
cfg: params.cfg,
megapixels: params.mode === 'generate' ? undefined : params.megapixels,
turbo: params.turbo === true,
strength: params.mode === 'refine' ? params.strength : undefined,
width: params.mode === 'generate' ? params.width : undefined,
height: params.mode === 'generate' ? params.height : undefined,
seed: params.seed,
sourceStillId: params.sourceStillId || library.stillId,
referenceStillId: params.referenceStillId
}
})
job.stillId = still?.id
await purgeComfyArtifacts({
+11 -3
View File
@@ -7,6 +7,7 @@ import { promisify } from 'node:util'
import type { H3Event } from 'h3'
import { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, remuxFaststart } from '~/server/utils/ffmpeg'
import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration'
const scryptAsync = promisify(scrypt)
@@ -67,6 +68,7 @@ export interface LibraryStill {
familyId?: string
parentStillId?: string
chainIndex?: number
settings?: StillGenerationSettings
}
export interface PublicFolder {
@@ -205,7 +207,8 @@ function normalizeStill(still: LibraryStill): LibraryStill {
prompt: still.prompt,
familyId: still.familyId,
parentStillId: still.parentStillId,
chainIndex: still.chainIndex
chainIndex: still.chainIndex,
settings: normalizeStillSettings(still.settings)
}
}
@@ -1108,6 +1111,7 @@ export async function saveStill(params: {
familyId?: string
parentStillId?: string
chainIndex?: number
settings?: StillGenerationSettings
}): Promise<LibraryStill | null> {
if (lastFrameStillName(params.filename)) return null
const role = params.role === 'output' ? 'output' : 'input'
@@ -1123,7 +1127,8 @@ export async function saveStill(params: {
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt) {
const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || nextSettings) {
await mutate(params.ownerKey, (next) => {
const item = next.stills.find(still => still.id === existing.id)
if (!item) return
@@ -1134,11 +1139,13 @@ export async function saveStill(params: {
if (params.familyId) item.familyId = params.familyId
if (params.parentStillId) item.parentStillId = params.parentStillId
if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex
if (nextSettings) item.settings = nextSettings
})
existing.hideInput = hideInput
existing.role = role
if (name) existing.name = name
if (prompt) existing.prompt = prompt
if (nextSettings) existing.settings = nextSettings
}
return existing
}
@@ -1159,7 +1166,8 @@ export async function saveStill(params: {
prompt: params.prompt || undefined,
familyId: params.familyId,
parentStillId: params.parentStillId,
chainIndex: params.chainIndex
chainIndex: params.chainIndex,
settings: normalizeStillSettings(params.settings)
}
mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
writeFileSync(stillPath(params.ownerKey, still.id), params.data)
+3 -1
View File
@@ -770,7 +770,9 @@ async function startStudioEditJob(item: StudioJob) {
strength: payload.refineStrength,
width: payload.width,
height: payload.height,
turbo: payload.turbo === true
turbo: payload.turbo === true,
sourceStillId: payload.stillId,
referenceStillId: payload.referenceStillId
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
if (live && live.status !== 'error' && live.status !== 'cancelled' && live.status !== 'deferred') {
+227
View File
@@ -0,0 +1,227 @@
import { loraDisplayName, normalizeLoraStack, type LoraStackItem } from '~/utils/loras'
import type { ImageV2Mode, ImageV2Task } from '~/utils/imageV2'
import type { VideoWorkflowId } from '~/utils/videoModels'
export type StillGenerationKind = 'image' | 'imagev2'
export type StillGenerationSettings = {
kind: StillGenerationKind
mode?: ImageV2Mode
task?: ImageV2Task
negative?: string
snofsModel?: number
snofsClip?: number
consistencyModel?: number
consistencyClip?: number
steps?: number
cfg?: number
megapixels?: number
turbo?: boolean
strength?: number
width?: number
height?: number
seed?: number
scaleToTotalPixels?: boolean
scaleMegapixels?: number
loraName?: string
loraStack?: LoraStackItem[]
sourceStillId?: string
referenceStillId?: string
}
export type SettingsRow = { label: string; value: string }
function finiteNumber(raw: unknown): number | undefined {
const value = Number(raw)
return Number.isFinite(value) ? value : undefined
}
function formatNum(raw: unknown, digits = 2) {
const value = Number(raw)
if (!Number.isFinite(value)) return ''
const rounded = Number(value.toFixed(digits))
return Number.isInteger(rounded) ? String(rounded) : String(rounded)
}
function formatPair(model?: number, clip?: number) {
if (model == null && clip == null) return ''
return `${formatNum(model ?? 0)} / ${formatNum(clip ?? 0)}`
}
function formatLoras(stack?: LoraStackItem[], name?: string) {
const items = normalizeLoraStack(stack?.length ? stack : name)
if (!items.length) return ''
return items.map((item) => {
const label = loraDisplayName(item.name)
if (item.strengthModel === item.strengthClip) return `${label} ${formatNum(item.strengthModel)}`
return `${label} ${formatNum(item.strengthModel)} / ${formatNum(item.strengthClip)}`
}).join(', ')
}
export function normalizeStillSettings(raw: unknown): StillGenerationSettings | undefined {
if (!raw || typeof raw !== 'object') return undefined
const value = raw as Record<string, unknown>
const mode = value.mode === 'compose' || value.mode === 'refine' || value.mode === 'generate' || value.mode === 'edit'
? value.mode
: undefined
const kind: StillGenerationKind | undefined = value.kind === 'imagev2' || mode
? 'imagev2'
: value.kind === 'image'
? 'image'
: undefined
if (!kind) return undefined
const task = value.task === 'identity' || value.task === 'outfit' || value.task === 'face_lock' || value.task === 'refine' || value.task === 't2i' || value.task === 'scene'
? value.task
: undefined
const stack = normalizeLoraStack(value.loraStack || value.loraName)
return {
kind,
mode,
task,
negative: typeof value.negative === 'string' ? value.negative : undefined,
snofsModel: finiteNumber(value.snofsModel),
snofsClip: finiteNumber(value.snofsClip),
consistencyModel: finiteNumber(value.consistencyModel),
consistencyClip: finiteNumber(value.consistencyClip),
steps: finiteNumber(value.steps),
cfg: finiteNumber(value.cfg),
megapixels: finiteNumber(value.megapixels),
turbo: typeof value.turbo === 'boolean' ? value.turbo : undefined,
strength: finiteNumber(value.strength),
width: finiteNumber(value.width),
height: finiteNumber(value.height),
seed: finiteNumber(value.seed),
scaleToTotalPixels: typeof value.scaleToTotalPixels === 'boolean' ? value.scaleToTotalPixels : undefined,
scaleMegapixels: finiteNumber(value.scaleMegapixels),
loraName: typeof value.loraName === 'string' ? value.loraName : undefined,
loraStack: stack.length ? stack : undefined,
sourceStillId: typeof value.sourceStillId === 'string' ? value.sourceStillId : undefined,
referenceStillId: typeof value.referenceStillId === 'string' ? value.referenceStillId : undefined
}
}
export function inferStillPipeline(still: { filename?: string; settings?: StillGenerationSettings | null }) {
if (still.settings?.kind === 'imagev2' || still.settings?.mode) return 'imagev2' as const
if (still.settings?.kind === 'image') return 'image' as const
if (/aigen_v2_/i.test(String(still.filename || ''))) return 'imagev2' as const
if (/aigen[_-]?edit/i.test(String(still.filename || ''))) return 'image' as const
return null
}
function composeTaskLabel(task?: ImageV2Task) {
if (task === 'outfit') return 'Outfit'
if (task === 'identity') return 'Identity'
if (task === 'face_lock') return 'Face lock'
if (task === 'scene') return 'Scene'
return ''
}
export function stillModeLabel(still: { filename?: string; settings?: StillGenerationSettings | null }) {
const settings = still.settings
if (settings?.kind === 'imagev2' || settings?.mode) {
const mode = settings.mode === 'generate'
? 'Generate'
: settings.mode === 'compose'
? 'Compose'
: settings.mode === 'refine'
? 'Refine'
: 'Edit'
const task = settings.mode === 'compose' ? composeTaskLabel(settings.task) : ''
return task ? `Image v2 · ${mode} · ${task}` : `Image v2 · ${mode}`
}
if (settings?.kind === 'image') return 'Image edit'
if (/aigen_v2_/i.test(String(still.filename || ''))) return 'Image v2'
if (/aigen[_-]?edit/i.test(String(still.filename || ''))) return 'Image edit'
return ''
}
export function stillSettingsRows(still: { width?: number; height?: number; settings?: StillGenerationSettings | null }) {
const settings = still.settings
const rows: SettingsRow[] = []
const mode = stillModeLabel(still)
if (mode) rows.push({ label: 'Mode', value: mode })
if (!settings) return rows
if (settings.kind === 'imagev2') {
if (settings.mode === 'generate' && settings.width && settings.height) {
rows.push({ label: 'Size', value: `${settings.width}×${settings.height}` })
} else if (still.width && still.height) {
rows.push({ label: 'Output', value: `${still.width}×${still.height}` })
}
if (settings.snofsModel != null || settings.snofsClip != null) {
rows.push({ label: 'SNOFS', value: formatPair(settings.snofsModel, settings.snofsClip) })
}
if (settings.mode !== 'generate' && (settings.consistencyModel != null || settings.consistencyClip != null)) {
rows.push({ label: 'Consistency', value: formatPair(settings.consistencyModel, settings.consistencyClip) })
}
if (settings.mode === 'refine' && settings.strength != null) {
rows.push({ label: 'Strength', value: formatNum(settings.strength) })
}
if (settings.mode !== 'generate' && settings.megapixels != null) {
rows.push({ label: 'Scale to MP', value: formatNum(settings.megapixels) })
}
} else {
if (still.width && still.height) rows.push({ label: 'Output', value: `${still.width}×${still.height}` })
if (settings.scaleToTotalPixels) {
rows.push({ label: 'Scale to MP', value: formatNum(settings.scaleMegapixels ?? 1) })
}
const loras = formatLoras(settings.loraStack, settings.loraName)
if (loras) rows.push({ label: 'LoRA', value: loras })
}
if (settings.steps != null) rows.push({ label: 'Steps', value: String(Math.round(settings.steps)) })
if (settings.cfg != null) rows.push({ label: 'CFG', value: formatNum(settings.cfg) })
if (settings.seed != null) rows.push({ label: 'Seed', value: String(Math.round(settings.seed)) })
if (settings.turbo === true) rows.push({ label: 'Turbo', value: 'On' })
if (settings.negative?.trim()) rows.push({ label: 'Negative', value: settings.negative.trim() })
return rows
}
function videoWorkflowLabel(workflow?: string) {
if (workflow === 'v2') return 'MiniMax I2V v2'
if (workflow === 't2v') return 'MiniMax T2V'
if (workflow === 'ltx') return 'LTX Pink Cherry'
if (workflow === 'ltx-t2v') return 'LTX T2V'
if (workflow === 'v1') return 'MiniMax I2V'
return workflow || ''
}
export function clipSettingsRows(clip: {
width?: number
height?: number
duration?: number
steps?: number
turbo?: boolean
seed?: number
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
aspect?: string
sound?: boolean
workflow?: VideoWorkflowId | string
loraName?: string
loraStack?: LoraStackItem[]
}) {
const rows: SettingsRow[] = []
const workflow = videoWorkflowLabel(clip.workflow)
if (workflow) rows.push({ label: 'Workflow', value: workflow })
if (clip.width && clip.height) rows.push({ label: 'Size', value: `${clip.width}×${clip.height}` })
if (clip.aspect && clip.aspect !== 'auto') rows.push({ label: 'Aspect', value: clip.aspect })
if (typeof clip.duration === 'number' && clip.duration > 0) {
const rounded = Math.round(clip.duration * 10) / 10
rows.push({ label: 'Duration', value: Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` })
}
if (clip.steps != null) {
rows.push({ label: 'Steps', value: clip.turbo ? `${clip.steps} · Turbo` : String(clip.steps) })
} else if (clip.turbo) {
rows.push({ label: 'Turbo', value: 'On' })
}
if (clip.cfg != null) rows.push({ label: 'CFG', value: formatNum(clip.cfg) })
if (clip.fps != null) rows.push({ label: 'FPS', value: String(clip.fps) })
if (clip.samplerName) rows.push({ label: 'Sampler', value: clip.samplerName })
if (clip.scheduler) rows.push({ label: 'Scheduler', value: clip.scheduler })
if (typeof clip.sound === 'boolean') rows.push({ label: 'Sound', value: clip.sound ? 'On' : 'Off' })
if (clip.seed != null) rows.push({ label: 'Seed', value: String(clip.seed) })
const loras = formatLoras(clip.loraStack, clip.loraName)
if (loras) rows.push({ label: 'LoRA', value: loras })
return rows
}