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
+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 ''