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:
@@ -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>
|
||||
+160
-13
@@ -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 {
|
||||
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 })
|
||||
toast('Loaded as input. It stays in Output, not the upload library.')
|
||||
}
|
||||
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 ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user