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:
Towsty
2026-08-25 22:55:06 -05:00
co-authored by Cursor
parent a7a810a7b3
commit dbe45fd2ff
9 changed files with 388 additions and 174 deletions
+4 -87
View File
@@ -65,8 +65,8 @@
</div>
<template v-if="current && (!current.protected || current.unlocked)">
<p v-if="!visibleStills.length && !visibleClipThumbs.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
<div v-if="visibleStills.length || visibleClipThumbs.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<p v-if="!visibleStills.length" class="mt-6 text-sm text-zinc-500">No uploaded stills in this folder yet.</p>
<div v-if="visibleStills.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<article
v-for="still in visibleStills"
:key="still.id"
@@ -116,63 +116,6 @@
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
</div>
</article>
<article
v-for="clip in visibleClipThumbs"
:key="'clip-' + clip.id"
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950"
>
<div class="relative h-28 overflow-hidden bg-zinc-900">
<img
v-if="clip.hasThumbnail || clip.hideThumbnail"
:src="`/api/library/clips/${clip.id}/thumb`"
alt=""
class="h-full w-full object-cover"
:class="clip.hideThumbnail && !isRevealed('clip-' + clip.id) ? 'scale-110 blur-xl' : ''"
>
<video
v-else
:src="`/api/library/clips/${clip.id}/video#t=0.1`"
class="h-full w-full object-cover"
muted
playsinline
preload="metadata"
/>
<span v-if="clip.hideThumbnail && !isRevealed('clip-' + clip.id)" class="sensitive-glow pointer-events-none absolute inset-0 opacity-80" />
<div class="absolute inset-x-0 top-0 flex items-start justify-between p-2">
<button
v-if="clip.hideThumbnail"
type="button"
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900"
:title="isRevealed('clip-' + clip.id) ? 'Hide thumbnail' : 'Reveal thumbnail'"
@click="toggleReveal('clip-' + clip.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<template v-if="isRevealed('clip-' + clip.id)">
<path stroke-linecap="round" d="M3 3l18 18M10.6 10.6a2 2 0 102.8 2.8M9.9 5.1A9.8 9.8 0 0121 12a9.8 9.8 0 01-1.7 2.9M6.1 6.1A9.8 9.8 0 003 12a9.8 9.8 0 0012.9 6.1" />
</template>
<template v-else>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
</template>
</svg>
</button>
<span v-else />
<button
type="button"
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
title="Use as input"
@click="pickClip(clip)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="h-4 w-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
<div class="px-3 py-2">
<span class="truncate text-xs text-zinc-400">{{ clip.name || 'From a clip' }}</span>
</div>
</article>
</div>
</template>
</div>
@@ -199,19 +142,9 @@ export interface PickerStill {
hideInput?: boolean
}
export interface PickerClip {
id: string
folderId: string
hideThumbnail: boolean
hasThumbnail?: boolean
name?: string
prompt: string
}
const props = defineProps<{
folders: PickerFolder[]
stills: PickerStill[]
clips: PickerClip[]
folderId: string
hideInputPreview?: boolean
}>()
@@ -245,10 +178,7 @@ watch(() => props.folderId, (id) => {
const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0])
const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value)))
const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value)))
const hasHiddenItems = computed(() => {
return visibleStills.value.some(still => stillHidden(still)) || visibleClipThumbs.value.some(clip => clip.hideThumbnail)
})
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
function stillHidden(still: PickerStill) {
return still.hideInput === true || props.hideInputPreview === true
@@ -272,10 +202,7 @@ function toggleReveal(id: string) {
}
function visibleHiddenIds() {
return [
...visibleStills.value.filter(still => stillHidden(still)).map(still => still.id),
...visibleClipThumbs.value.filter(clip => clip.hideThumbnail).map(clip => 'clip-' + clip.id)
]
return visibleStills.value.filter(still => stillHidden(still)).map(still => still.id)
}
function toggleRevealAll() {
@@ -321,16 +248,6 @@ function onDrop(event: DragEvent) {
if (next) void ingest(next)
}
async function pickClip(clip: PickerClip) {
try {
const blob = await $fetch<Blob>(`/api/library/clips/${clip.id}/thumb`, { responseType: 'blob' })
const file = new File([blob], `${clip.id}.png`, { type: blob.type || 'image/png' })
emit('select', file, clip.hideThumbnail === true)
} catch (error: any) {
emit('error', error?.data?.statusMessage || 'Could not load that still')
}
}
async function pickStill(still: PickerStill) {
try {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
+219 -62
View File
@@ -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))
+55 -8
View File
@@ -11,9 +11,32 @@ function parseDuration(raw: string) {
return Math.min(120, Math.max(0.5, seconds))
}
function frameLength(seconds: number) {
const raw = Math.max(5, Math.round(seconds * 24))
return raw + (5 - (raw % 17) + 17) % 17
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
function frameLength(seconds: number, fps: number) {
return Math.max(5, Math.floor(seconds * fps))
}
function parseFps(raw: string | undefined) {
const fps = Number(raw)
return fps === 12 || fps === 30 || fps === 24 ? fps : 24
}
function parseCfg(raw: string | undefined, turbo: boolean) {
const fallback = turbo ? 1.5 : 4
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
const snapped = Math.round(value * 2) / 2
return Math.min(10, Math.max(1, snapped))
}
function parseSampler(raw: string | undefined) {
return SAMPLERS.has(raw || '') ? raw! : 'res_multistep'
}
function parseScheduler(raw: string | undefined) {
return SCHEDULERS.has(raw || '') ? raw! : 'simple'
}
export default defineEventHandler(async (event) => {
@@ -47,7 +70,11 @@ export default defineEventHandler(async (event) => {
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
const durationSeconds = parseDuration(fields.duration || '5')
const length = frameLength(durationSeconds)
const fps = parseFps(fields.fps)
const cfg = parseCfg(fields.cfg, turbo)
const samplerName = parseSampler(fields.sampler_name)
const scheduler = parseScheduler(fields.scheduler)
const length = frameLength(durationSeconds, fps)
const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true'
const sound = fields.sound !== 'false'
@@ -84,12 +111,16 @@ export default defineEventHandler(async (event) => {
folderLocked,
name: clipName,
prompt,
aspect: fields.aspect || 'auto',
aspect: fields.aspect || '16:9',
width,
height,
steps,
turbo,
seed,
cfg,
fps,
samplerName,
scheduler,
thumb: image.data,
stillId: still.id,
stillFilename: still.filename,
@@ -99,7 +130,7 @@ export default defineEventHandler(async (event) => {
}
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound }).catch(async (error) => {
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound, cfg, fps, samplerName, scheduler }).catch(async (error) => {
if (isComfyBusyTimeout(error) && job.library) {
try {
const draft = await saveRetryDraft({
@@ -119,7 +150,11 @@ export default defineEventHandler(async (event) => {
duration: job.library.duration || durationSeconds,
hideThumbnail: job.library.hideThumbnail,
hideInput: job.library.hideInput === true,
sound: job.library.sound !== false
sound: job.library.sound !== false,
cfg: job.library.cfg,
fps: job.library.fps,
samplerName: job.library.samplerName,
scheduler: job.library.scheduler
})
job.library.draftId = draft.id
job.status = 'deferred'
@@ -142,7 +177,11 @@ export default defineEventHandler(async (event) => {
seed,
width,
height,
aspect: fields.aspect || 'auto',
aspect: fields.aspect || '16:9',
cfg,
fps,
sampler_name: samplerName,
scheduler,
steps,
turbo,
folderId,
@@ -163,6 +202,10 @@ async function runGeneration(
turbo: boolean
length: number
sound: boolean
cfg: number
fps: number
samplerName: string
scheduler: string
}
) {
await ensureComfyReady((status) => {
@@ -196,6 +239,10 @@ async function runGeneration(
seed: params.seed,
turbo: params.turbo,
length: params.length,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
filenamePrefix: comfyFilenamePrefix(),
sound: params.sound
})
+9 -5
View File
@@ -1,14 +1,18 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ folderId?: string }>(event).catch(() => ({} as { folderId?: string }))
const body = await readBody<{ folderId?: string; name?: string }>(event).catch(() => ({} as { folderId?: string; name?: string }))
const folderId = String(body?.folderId || '')
if (!folderId) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder' })
const hasName = typeof body?.name === 'string'
if (!folderId && !hasName) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
}
const clip = getClip(owner, id)
assertFolderAccess(event, clip.folderId)
assertFolderExists(event, folderId)
await moveClip(owner, id, folderId)
if (folderId) {
assertFolderExists(event, folderId)
await moveClip(owner, id, folderId)
}
if (hasName) await renameClip(owner, id, body.name || '')
return publicLibrary(event)
})
+4
View File
@@ -49,6 +49,10 @@ export interface Job {
steps: number
turbo: boolean
seed: number
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
thumb?: Buffer
imageName?: string
imageSubfolder?: string
+37 -2
View File
@@ -30,6 +30,10 @@ export interface LibraryClip {
hideThumbnail: boolean
createdAt: number
comfyFilename?: string
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}
export interface LibraryStill {
@@ -71,6 +75,10 @@ export interface RetryDraft {
sound: boolean
hideInput?: boolean
stillFilename?: string
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}
interface Catalog {
@@ -662,6 +670,10 @@ export async function saveRetryDraft(params: {
hideThumbnail: boolean
sound: boolean
hideInput?: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}) {
return mutate(params.ownerKey, (catalog) => {
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
@@ -682,7 +694,11 @@ export async function saveRetryDraft(params: {
duration: params.duration,
hideThumbnail: params.hideThumbnail,
sound: params.sound,
hideInput: params.hideInput === true
hideInput: params.hideInput === true,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler
}
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
return draft
@@ -743,6 +759,10 @@ export async function saveClip(params: {
video: Buffer
thumb?: Buffer | null
comfyFilename?: string
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}) {
const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
@@ -760,7 +780,11 @@ export async function saveClip(params: {
seed: params.seed,
hideThumbnail: params.hideThumbnail,
createdAt: Date.now(),
comfyFilename: params.comfyFilename
comfyFilename: params.comfyFilename,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler
}
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
@@ -813,6 +837,17 @@ export function moveClip(owner: string, id: string, folderId: string) {
})
}
export function renameClip(owner: string, id: string, name: string) {
const trimmed = name.trim().slice(0, 80)
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
return mutate(owner, (catalog) => {
const clip = catalog.clips.find(item => item.id === id)
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
clip.name = trimmed
return clip
})
}
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
const subfolders = [...new Set([video.subfolder, 'video', ''])]
let lastStatus = 0
+5 -9
View File
@@ -4,7 +4,7 @@ const LONG_EDGE = 1344
export const ASPECT_PRESETS: Record<string, { 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 }
}
export function snap32(value: number) {
@@ -85,16 +85,12 @@ function jpegSize(buf: Buffer) {
return null
}
export function resolveOutputSize(aspect: string | undefined, image: Buffer, fallbackWidth?: number, fallbackHeight?: number) {
export function resolveOutputSize(aspect: string | undefined, _image: Buffer, fallbackWidth?: number, fallbackHeight?: number) {
if (aspect && ASPECT_PRESETS[aspect]) {
return ASPECT_PRESETS[aspect]
}
const fromFields = Number(fallbackWidth) && Number(fallbackHeight)
? { width: snap32(Number(fallbackWidth)), height: snap32(Number(fallbackHeight)) }
: null
const size = imageDimensions(image)
if (size?.width && size?.height) {
return autoResolution(size.width, size.height)
if (Number(fallbackWidth) && Number(fallbackHeight)) {
return { width: snap32(Number(fallbackWidth)), height: snap32(Number(fallbackHeight)) }
}
return fromFields || ASPECT_PRESETS['16:9']
return ASPECT_PRESETS['16:9']
}
+5 -1
View File
@@ -72,7 +72,11 @@ export function watchComfyJob(job: Job): Promise<void> {
hideInput: job.library.hideInput,
video: buffer,
thumb: job.library.thumb,
comfyFilename: video.filename
comfyFilename: video.filename,
cfg: job.library.cfg,
fps: job.library.fps,
samplerName: job.library.samplerName,
scheduler: job.library.scheduler
})
job.clipId = clip.id
job.hideThumbnail = clip.hideThumbnail
+50
View File
@@ -10,6 +10,10 @@ export interface GenerateParams {
seed: number
turbo: boolean
length: number
cfg: number
fps: number
samplerName: string
scheduler: string
filenamePrefix?: string
sound?: boolean
}
@@ -65,6 +69,25 @@ export function buildWorkflow(params: GenerateParams) {
node.inputs.length = params.length
}
if (node.class_type === 'CreateVideo' && 'fps' in node.inputs) {
node.inputs.fps = params.fps
}
if (node.class_type === 'KSamplerSelect' && 'sampler_name' in node.inputs) {
node.inputs.sampler_name = params.samplerName
}
if (node.class_type === 'BasicScheduler' && 'scheduler' in node.inputs) {
node.inputs.scheduler = params.scheduler
}
if ('cfg' in node.inputs && typeof node.inputs.cfg === 'number') {
node.inputs.cfg = params.cfg
}
if ('guidance' in node.inputs && typeof node.inputs.guidance === 'number') {
node.inputs.guidance = params.cfg
}
if (SEED_CLASSES.has(node.class_type)) {
if ('noise_seed' in node.inputs) node.inputs.noise_seed = params.seed
if ('seed' in node.inputs) node.inputs.seed = params.seed
@@ -91,6 +114,32 @@ export function buildWorkflow(params: GenerateParams) {
}
}
if (graph['128']) {
graph['128'] = {
class_type: 'ImageScale',
inputs: {
upscale_method: 'nearest-exact',
width,
height,
crop: 'center',
image: ['114', 0]
},
_meta: { title: 'Scaling image' }
}
}
graph['105:cfg'] = {
class_type: 'FluxGuidance',
inputs: {
guidance: params.cfg,
conditioning: ['105:104', 0]
},
_meta: { title: 'Guidance scale' }
}
if (graph['105:16']?.inputs) {
graph['105:16'].inputs.conditioning = ['105:cfg', 0]
}
for (const [, node] of entriesOf(graph, 'ComfySwitchNode')) {
if (!/step/i.test(titleOf(node))) continue
const qualityId = linkId(node.inputs.on_false)
@@ -127,6 +176,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'105:10': 'Decoding video frames',
'105:14': 'Sampling',
'105:16': 'Preparing guider',
'105:cfg': 'Applying guidance',
'105:17': 'Selecting sampler',
'105:23': 'Decoding audio',
'105:24': 'Loading audio VAE',