Add generation quality controls and bind them to the MiniMax workflow.
Expose aspect, CFG, FPS, sampler, and scheduler in Generation settings, persist them on clips and held jobs, and inject those values into the Comfy graph. Keep the image picker stills-only and allow renaming library clips. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+219
-62
@@ -67,24 +67,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-zinc-300">Aspect ratio / quality</p>
|
||||
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<button
|
||||
v-for="option in aspects"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="rounded-2xl border px-3 py-3 text-left text-sm transition"
|
||||
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||||
@click="aspect = option.id"
|
||||
>
|
||||
<span class="block font-semibold">{{ option.label }}</span>
|
||||
<span class="text-xs text-zinc-400">{{ option.id === 'auto' ? autoHint : option.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? ' · matching the still' : ' · override' }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
|
||||
<div class="space-y-3">
|
||||
@@ -143,6 +125,23 @@
|
||||
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
|
||||
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio / resolution</p>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="option in aspects"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="rounded-xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
|
||||
@click="aspect = option.id"
|
||||
>
|
||||
<span class="block font-semibold">{{ option.label }}</span>
|
||||
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@@ -150,7 +149,42 @@
|
||||
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">20 High-Fidelity · LoRA bypass</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
class="aigen-slider min-w-0 flex-1"
|
||||
type="range"
|
||||
:min="CFG_MIN"
|
||||
:max="CFG_MAX"
|
||||
:step="CFG_STEP"
|
||||
:value="cfg"
|
||||
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }"
|
||||
aria-label="CFG guidance scale"
|
||||
@input="onCfgSlider"
|
||||
>
|
||||
<input
|
||||
v-model.number="cfg"
|
||||
type="number"
|
||||
:min="CFG_MIN"
|
||||
:max="CFG_MAX"
|
||||
:step="CFG_STEP"
|
||||
class="w-20 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
@change="onCfgNumber"
|
||||
>
|
||||
</div>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">{{ turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity' }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Frame rate</span>
|
||||
<select v-model.number="fps" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||||
<option :value="24">24 FPS</option>
|
||||
<option :value="12">12 FPS</option>
|
||||
<option :value="30">30 FPS</option>
|
||||
</select>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
|
||||
</label>
|
||||
<div class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Duration</span>
|
||||
<div class="relative">
|
||||
@@ -182,10 +216,31 @@
|
||||
>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Type any length, use the slider, or drag up/down in the box.</p>
|
||||
</div>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Seed</span>
|
||||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
|
||||
</label>
|
||||
</div>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Seed</span>
|
||||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
|
||||
</label>
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Sampler & scheduler</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Sampler</span>
|
||||
<select v-model="samplerName" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||||
<option value="res_multistep">res_multistep</option>
|
||||
<option value="euler">euler</option>
|
||||
<option value="dpmpp_2m">dpmpp_2m</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Scheduler</span>
|
||||
<select v-model="scheduler" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
|
||||
<option value="simple">simple</option>
|
||||
<option value="ddim_uniform">ddim_uniform</option>
|
||||
<option value="sgm_uniform">sgm_uniform</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -342,7 +397,17 @@
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
||||
<input
|
||||
v-if="renamingId === item.id"
|
||||
ref="renameInput"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRename(item)"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRename(item)"
|
||||
>
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
||||
<div class="group relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -362,6 +427,7 @@
|
||||
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</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>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
@@ -402,7 +468,6 @@
|
||||
v-if="pickerOpen"
|
||||
:folders="folders"
|
||||
:stills="stills"
|
||||
:clips="clips"
|
||||
:folder-id="folderId"
|
||||
:hide-input-preview="hideInputPreview"
|
||||
@close="pickerOpen = false"
|
||||
@@ -424,7 +489,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
|
||||
type AspectId = '16:9' | '9:16' | '1:1'
|
||||
|
||||
interface LibraryFolder {
|
||||
id: string
|
||||
@@ -460,6 +525,10 @@ interface LibraryClip {
|
||||
hideThumbnail: boolean
|
||||
hasThumbnail?: boolean
|
||||
createdAt: number
|
||||
cfg?: number
|
||||
fps?: number
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
}
|
||||
|
||||
interface RetryDraft {
|
||||
@@ -480,19 +549,22 @@ interface RetryDraft {
|
||||
hideThumbnail: boolean
|
||||
sound: boolean
|
||||
hideInput?: boolean
|
||||
cfg?: number
|
||||
fps?: number
|
||||
samplerName?: string
|
||||
scheduler?: string
|
||||
}
|
||||
|
||||
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
||||
const PRESETS: Record<AspectId, { width: number; height: number }> = {
|
||||
'16:9': { width: 1344, height: 768 },
|
||||
'9:16': { width: 768, height: 1344 },
|
||||
'1:1': { width: 1024, height: 1024 }
|
||||
'1:1': { width: 960, height: 960 }
|
||||
}
|
||||
|
||||
const aspects = [
|
||||
{ id: 'auto' as const, label: 'Automatic', hint: 'Match the still' },
|
||||
{ id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' },
|
||||
{ id: '9:16' as const, label: '9:16 Portrait', hint: '768 × 1344 · 0.98 MP' },
|
||||
{ id: '1:1' as const, label: '1:1 Square', hint: '1024 × 1024' }
|
||||
{ id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768' },
|
||||
{ id: '9:16' as const, label: '9:16 Portrait / Shorts', hint: '768 × 1344' },
|
||||
{ id: '1:1' as const, label: '1:1 Square', hint: '960 × 960' }
|
||||
]
|
||||
|
||||
const player = ref<HTMLVideoElement | null>(null)
|
||||
@@ -503,10 +575,20 @@ const dragging = ref(false)
|
||||
const prompt = ref('')
|
||||
const clipName = ref('')
|
||||
const outputDownloadName = ref('aigen')
|
||||
const aspect = ref<AspectId>('auto')
|
||||
const aspect = ref<AspectId>('16:9')
|
||||
const imageWidth = ref(0)
|
||||
const imageHeight = ref(0)
|
||||
const turbo = ref(true)
|
||||
const CFG_MIN = 1
|
||||
const CFG_MAX = 10
|
||||
const CFG_STEP = 0.5
|
||||
const CFG_TURBO = 1.5
|
||||
const CFG_QUALITY = 4
|
||||
const cfg = ref(CFG_TURBO)
|
||||
const cfgTouched = ref(false)
|
||||
const fps = ref(24)
|
||||
const samplerName = ref('res_multistep')
|
||||
const scheduler = ref('simple')
|
||||
const DURATION_MIN = 0.5
|
||||
const DURATION_MAX = 120
|
||||
const DURATION_SLIDER_MIN = 1
|
||||
@@ -549,6 +631,9 @@ const stills = ref<LibraryStill[]>([])
|
||||
const drafts = ref<RetryDraft[]>([])
|
||||
const folderId = ref('')
|
||||
const browseFolderId = ref('')
|
||||
const renamingId = ref('')
|
||||
const renameDraft = ref('')
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const unlockPassword = ref('')
|
||||
const settingsOpen = ref(false)
|
||||
const pickerOpen = ref(false)
|
||||
@@ -589,35 +674,18 @@ const comfyBadge = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function snap32(value: number) {
|
||||
return Math.max(32, Math.round(value / 32) * 32)
|
||||
}
|
||||
|
||||
function autoResolution(srcWidth: number, srcHeight: number) {
|
||||
const ratio = srcWidth / Math.max(srcHeight, 1)
|
||||
let width: number
|
||||
let height: number
|
||||
if (ratio >= 1) {
|
||||
height = 768
|
||||
width = Math.min(1344, height * ratio)
|
||||
} else {
|
||||
width = 768
|
||||
height = Math.min(1344, width / ratio)
|
||||
function normalizeAspect(raw?: string, width?: number, height?: number): AspectId {
|
||||
if (raw === '16:9' || raw === '9:16' || raw === '1:1') return raw
|
||||
if (width && height) {
|
||||
if (width === height) return '1:1'
|
||||
if (height > width) return '9:16'
|
||||
}
|
||||
return { width: snap32(width), height: snap32(height) }
|
||||
return '16:9'
|
||||
}
|
||||
|
||||
const outputSize = computed(() => {
|
||||
if (aspect.value !== 'auto') return PRESETS[aspect.value]
|
||||
if (imageWidth.value && imageHeight.value) return autoResolution(imageWidth.value, imageHeight.value)
|
||||
return { width: 1344, height: 768 }
|
||||
})
|
||||
|
||||
const autoHint = computed(() => {
|
||||
if (!imageWidth.value || !imageHeight.value) return 'Match the still'
|
||||
const size = autoResolution(imageWidth.value, imageHeight.value)
|
||||
return `${size.width} × ${size.height} · from still`
|
||||
})
|
||||
const outputSize = computed(() => PRESETS[aspect.value])
|
||||
const frameCount = computed(() => Math.max(5, Math.floor(duration.value * fps.value)))
|
||||
const cfgPct = computed(() => ((cfg.value - CFG_MIN) / (CFG_MAX - CFG_MIN)) * 100)
|
||||
|
||||
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
|
||||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||||
@@ -710,6 +778,10 @@ watch(duration, (value) => {
|
||||
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
|
||||
})
|
||||
|
||||
watch(turbo, (on) => {
|
||||
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
|
||||
})
|
||||
|
||||
function clampDuration(value: number) {
|
||||
if (!Number.isFinite(value)) return 5
|
||||
return Math.min(DURATION_MAX, Math.max(DURATION_MIN, Math.round(value * 10) / 10))
|
||||
@@ -730,6 +802,48 @@ function onDurationSlider(event: Event) {
|
||||
durationDraft.value = formatDuration(duration.value)
|
||||
}
|
||||
|
||||
function clampCfg(value: number) {
|
||||
if (!Number.isFinite(value)) return turbo.value ? CFG_TURBO : CFG_QUALITY
|
||||
return Math.min(CFG_MAX, Math.max(CFG_MIN, Math.round(value / CFG_STEP) * CFG_STEP))
|
||||
}
|
||||
|
||||
function onCfgSlider(event: Event) {
|
||||
cfgTouched.value = true
|
||||
cfg.value = clampCfg(Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
function onCfgNumber() {
|
||||
cfgTouched.value = true
|
||||
cfg.value = clampCfg(Number(cfg.value))
|
||||
}
|
||||
|
||||
function restoreFps(raw?: number) {
|
||||
return raw === 12 || raw === 30 || raw === 24 ? raw : 24
|
||||
}
|
||||
|
||||
function restoreSampler(raw?: string) {
|
||||
return raw === 'euler' || raw === 'dpmpp_2m' || raw === 'res_multistep' ? raw : 'res_multistep'
|
||||
}
|
||||
|
||||
function restoreScheduler(raw?: string) {
|
||||
return raw === 'ddim_uniform' || raw === 'sgm_uniform' || raw === 'simple' ? raw : 'simple'
|
||||
}
|
||||
|
||||
function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number; height?: number; cfg?: number; fps?: number; samplerName?: string; scheduler?: string }) {
|
||||
cfgTouched.value = true
|
||||
turbo.value = item.turbo
|
||||
aspect.value = normalizeAspect(item.aspect, item.width, item.height)
|
||||
if (typeof item.cfg === 'number') {
|
||||
cfg.value = clampCfg(item.cfg)
|
||||
} else {
|
||||
cfg.value = item.turbo ? CFG_TURBO : CFG_QUALITY
|
||||
cfgTouched.value = false
|
||||
}
|
||||
fps.value = restoreFps(item.fps)
|
||||
samplerName.value = restoreSampler(item.samplerName)
|
||||
scheduler.value = restoreScheduler(item.scheduler)
|
||||
}
|
||||
|
||||
function onDurationPointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0) return
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
@@ -903,9 +1017,8 @@ function clipFileName(name: string) {
|
||||
function rerun(item: LibraryClip) {
|
||||
prompt.value = item.prompt
|
||||
clipName.value = item.name || ''
|
||||
turbo.value = item.turbo
|
||||
seedInput.value = String(item.seed)
|
||||
aspect.value = (item.aspect as AspectId) || 'auto'
|
||||
restoreAdvanced(item)
|
||||
hideThumbnail.value = item.hideThumbnail
|
||||
folderId.value = item.folderId
|
||||
toast('Settings restored. Choose the same still if it is not already loaded.')
|
||||
@@ -915,9 +1028,8 @@ async function loadDraft(draft: RetryDraft) {
|
||||
activeDraftId = draft.id
|
||||
prompt.value = draft.prompt
|
||||
clipName.value = draft.name || ''
|
||||
turbo.value = draft.turbo
|
||||
seedInput.value = String(draft.seed)
|
||||
aspect.value = (draft.aspect as AspectId) || 'auto'
|
||||
restoreAdvanced(draft)
|
||||
hideThumbnail.value = draft.hideThumbnail
|
||||
folderId.value = draft.folderId
|
||||
withSound.value = draft.sound !== false
|
||||
@@ -1031,6 +1143,47 @@ async function onMoveClip(item: LibraryClip, event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(item: LibraryClip) {
|
||||
renamingId.value = item.id
|
||||
renameDraft.value = (item.name || '').trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
||||
nextTick(() => {
|
||||
renameInput.value?.focus()
|
||||
renameInput.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
renamingId.value = ''
|
||||
renameDraft.value = ''
|
||||
}
|
||||
|
||||
async function saveRename(item: LibraryClip) {
|
||||
if (renamingId.value !== item.id) return
|
||||
const name = renameDraft.value.trim().slice(0, 80)
|
||||
if (!name) {
|
||||
toast('A name is required')
|
||||
nextTick(() => renameInput.value?.focus())
|
||||
return
|
||||
}
|
||||
if (name === item.name) {
|
||||
cancelRename()
|
||||
return
|
||||
}
|
||||
try {
|
||||
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name }
|
||||
}))
|
||||
cancelRename()
|
||||
if (videoUrl.value.includes(item.id)) {
|
||||
outputDownloadName.value = clipFileName(name)
|
||||
}
|
||||
statusMessage.value = `Renamed to ${name}`
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not rename clip')
|
||||
}
|
||||
}
|
||||
|
||||
function onPlayed() {
|
||||
awaitingReveal.value = false
|
||||
}
|
||||
@@ -1071,6 +1224,10 @@ async function generate() {
|
||||
body.append('turbo', String(turbo.value))
|
||||
body.append('steps', String(turbo.value ? 8 : 20))
|
||||
body.append('duration', String(duration.value))
|
||||
body.append('cfg', String(cfg.value))
|
||||
body.append('fps', String(fps.value))
|
||||
body.append('sampler_name', samplerName.value)
|
||||
body.append('scheduler', scheduler.value)
|
||||
body.append('seed', seedInput.value || 'random')
|
||||
body.append('folderId', folderId.value)
|
||||
body.append('hideThumbnail', String(hideThumbnail.value))
|
||||
|
||||
Reference in New Issue
Block a user