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> </div>
<template v-if="current && (!current.protected || current.unlocked)"> <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> <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 || visibleClipThumbs.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4"> <div v-if="visibleStills.length" class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<article <article
v-for="still in visibleStills" v-for="still in visibleStills"
:key="still.id" :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> <button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click="removeStill(still)">Delete</button>
</div> </div>
</article> </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> </div>
</template> </template>
</div> </div>
@@ -199,19 +142,9 @@ export interface PickerStill {
hideInput?: boolean hideInput?: boolean
} }
export interface PickerClip {
id: string
folderId: string
hideThumbnail: boolean
hasThumbnail?: boolean
name?: string
prompt: string
}
const props = defineProps<{ const props = defineProps<{
folders: PickerFolder[] folders: PickerFolder[]
stills: PickerStill[] stills: PickerStill[]
clips: PickerClip[]
folderId: string folderId: string
hideInputPreview?: boolean 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 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 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(() => visibleStills.value.some(still => stillHidden(still)))
const hasHiddenItems = computed(() => {
return visibleStills.value.some(still => stillHidden(still)) || visibleClipThumbs.value.some(clip => clip.hideThumbnail)
})
function stillHidden(still: PickerStill) { function stillHidden(still: PickerStill) {
return still.hideInput === true || props.hideInputPreview === true return still.hideInput === true || props.hideInputPreview === true
@@ -272,10 +202,7 @@ function toggleReveal(id: string) {
} }
function visibleHiddenIds() { function visibleHiddenIds() {
return [ return visibleStills.value.filter(still => stillHidden(still)).map(still => still.id)
...visibleStills.value.filter(still => stillHidden(still)).map(still => still.id),
...visibleClipThumbs.value.filter(clip => clip.hideThumbnail).map(clip => 'clip-' + clip.id)
]
} }
function toggleRevealAll() { function toggleRevealAll() {
@@ -321,16 +248,6 @@ function onDrop(event: DragEvent) {
if (next) void ingest(next) 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) { async function pickStill(still: PickerStill) {
try { try {
const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' }) const blob = await $fetch<Blob>(`/api/library/stills/${still.id}`, { responseType: 'blob' })
+216 -59
View File
@@ -67,24 +67,6 @@
/> />
</div> </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> <div>
<p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p> <p class="mb-2 text-sm font-medium text-zinc-300">Save to library</p>
<div class="space-y-3"> <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"> <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> <summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4"> <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> <div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p> <p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
<div class="grid grid-cols-2 gap-2"> <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> <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> </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"> <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"> <div class="text-sm">
<span class="mb-1 block text-zinc-400">Duration</span> <span class="mb-1 block text-zinc-400">Duration</span>
<div class="relative"> <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> <p class="mt-1 text-[11px] text-zinc-500">Type any length, use the slider, or drag up/down in the box.</p>
</div> </div>
</div>
<label class="text-sm"> <label class="text-sm">
<span class="mb-1 block text-zinc-400">Seed</span> <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"> <input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
</label> </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>
</div> </div>
</details> </details>
@@ -342,7 +397,17 @@
</button> </button>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-start gap-2"> <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"> <div class="group relative shrink-0">
<button <button
type="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> <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"> <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="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> <button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
<select <select
v-if="folders.length > 1" v-if="folders.length > 1"
@@ -402,7 +468,6 @@
v-if="pickerOpen" v-if="pickerOpen"
:folders="folders" :folders="folders"
:stills="stills" :stills="stills"
:clips="clips"
:folder-id="folderId" :folder-id="folderId"
:hide-input-preview="hideInputPreview" :hide-input-preview="hideInputPreview"
@close="pickerOpen = false" @close="pickerOpen = false"
@@ -424,7 +489,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
type AspectId = 'auto' | '16:9' | '9:16' | '1:1' type AspectId = '16:9' | '9:16' | '1:1'
interface LibraryFolder { interface LibraryFolder {
id: string id: string
@@ -460,6 +525,10 @@ interface LibraryClip {
hideThumbnail: boolean hideThumbnail: boolean
hasThumbnail?: boolean hasThumbnail?: boolean
createdAt: number createdAt: number
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
} }
interface RetryDraft { interface RetryDraft {
@@ -480,19 +549,22 @@ interface RetryDraft {
hideThumbnail: boolean hideThumbnail: boolean
sound: boolean sound: boolean
hideInput?: 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 }, '16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 }, '9:16': { width: 768, height: 1344 },
'1:1': { width: 1024, height: 1024 } '1:1': { width: 960, height: 960 }
} }
const aspects = [ const aspects = [
{ id: 'auto' as const, label: 'Automatic', hint: 'Match the still' }, { id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768' },
{ id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' }, { id: '9:16' as const, label: '9:16 Portrait / Shorts', hint: '768 × 1344' },
{ 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: '960 × 960' }
{ id: '1:1' as const, label: '1:1 Square', hint: '1024 × 1024' }
] ]
const player = ref<HTMLVideoElement | null>(null) const player = ref<HTMLVideoElement | null>(null)
@@ -503,10 +575,20 @@ const dragging = ref(false)
const prompt = ref('') const prompt = ref('')
const clipName = ref('') const clipName = ref('')
const outputDownloadName = ref('aigen') const outputDownloadName = ref('aigen')
const aspect = ref<AspectId>('auto') const aspect = ref<AspectId>('16:9')
const imageWidth = ref(0) const imageWidth = ref(0)
const imageHeight = ref(0) const imageHeight = ref(0)
const turbo = ref(true) 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_MIN = 0.5
const DURATION_MAX = 120 const DURATION_MAX = 120
const DURATION_SLIDER_MIN = 1 const DURATION_SLIDER_MIN = 1
@@ -549,6 +631,9 @@ const stills = ref<LibraryStill[]>([])
const drafts = ref<RetryDraft[]>([]) const drafts = ref<RetryDraft[]>([])
const folderId = ref('') const folderId = ref('')
const browseFolderId = ref('') const browseFolderId = ref('')
const renamingId = ref('')
const renameDraft = ref('')
const renameInput = ref<HTMLInputElement | null>(null)
const unlockPassword = ref('') const unlockPassword = ref('')
const settingsOpen = ref(false) const settingsOpen = ref(false)
const pickerOpen = ref(false) const pickerOpen = ref(false)
@@ -589,35 +674,18 @@ const comfyBadge = computed(() => {
} }
}) })
function snap32(value: number) { function normalizeAspect(raw?: string, width?: number, height?: number): AspectId {
return Math.max(32, Math.round(value / 32) * 32) 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 '16:9'
} }
function autoResolution(srcWidth: number, srcHeight: number) { const outputSize = computed(() => PRESETS[aspect.value])
const ratio = srcWidth / Math.max(srcHeight, 1) const frameCount = computed(() => Math.max(5, Math.floor(duration.value * fps.value)))
let width: number const cfgPct = computed(() => ((cfg.value - CFG_MIN) / (CFG_MAX - CFG_MIN)) * 100)
let height: number
if (ratio >= 1) {
height = 768
width = Math.min(1344, height * ratio)
} else {
width = 768
height = Math.min(1344, width / ratio)
}
return { width: snap32(width), height: snap32(height) }
}
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 visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value)) const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.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) if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
}) })
watch(turbo, (on) => {
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
})
function clampDuration(value: number) { function clampDuration(value: number) {
if (!Number.isFinite(value)) return 5 if (!Number.isFinite(value)) return 5
return Math.min(DURATION_MAX, Math.max(DURATION_MIN, Math.round(value * 10) / 10)) 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) 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) { function onDurationPointerDown(event: PointerEvent) {
if (event.button !== 0) return if (event.button !== 0) return
const input = event.currentTarget as HTMLInputElement const input = event.currentTarget as HTMLInputElement
@@ -903,9 +1017,8 @@ function clipFileName(name: string) {
function rerun(item: LibraryClip) { function rerun(item: LibraryClip) {
prompt.value = item.prompt prompt.value = item.prompt
clipName.value = item.name || '' clipName.value = item.name || ''
turbo.value = item.turbo
seedInput.value = String(item.seed) seedInput.value = String(item.seed)
aspect.value = (item.aspect as AspectId) || 'auto' restoreAdvanced(item)
hideThumbnail.value = item.hideThumbnail hideThumbnail.value = item.hideThumbnail
folderId.value = item.folderId folderId.value = item.folderId
toast('Settings restored. Choose the same still if it is not already loaded.') 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 activeDraftId = draft.id
prompt.value = draft.prompt prompt.value = draft.prompt
clipName.value = draft.name || '' clipName.value = draft.name || ''
turbo.value = draft.turbo
seedInput.value = String(draft.seed) seedInput.value = String(draft.seed)
aspect.value = (draft.aspect as AspectId) || 'auto' restoreAdvanced(draft)
hideThumbnail.value = draft.hideThumbnail hideThumbnail.value = draft.hideThumbnail
folderId.value = draft.folderId folderId.value = draft.folderId
withSound.value = draft.sound !== false 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() { function onPlayed() {
awaitingReveal.value = false awaitingReveal.value = false
} }
@@ -1071,6 +1224,10 @@ async function generate() {
body.append('turbo', String(turbo.value)) body.append('turbo', String(turbo.value))
body.append('steps', String(turbo.value ? 8 : 20)) body.append('steps', String(turbo.value ? 8 : 20))
body.append('duration', String(duration.value)) 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('seed', seedInput.value || 'random')
body.append('folderId', folderId.value) body.append('folderId', folderId.value)
body.append('hideThumbnail', String(hideThumbnail.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)) return Math.min(120, Math.max(0.5, seconds))
} }
function frameLength(seconds: number) { const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const raw = Math.max(5, Math.round(seconds * 24)) const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
return raw + (5 - (raw % 17) + 17) % 17
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) => { export default defineEventHandler(async (event) => {
@@ -47,7 +70,11 @@ export default defineEventHandler(async (event) => {
? Number(fields.seed) ? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647) : Math.floor(Math.random() * 2_147_483_647)
const durationSeconds = parseDuration(fields.duration || '5') 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 hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true' const hideInput = fields.hideInput === 'true'
const sound = fields.sound !== 'false' const sound = fields.sound !== 'false'
@@ -84,12 +111,16 @@ export default defineEventHandler(async (event) => {
folderLocked, folderLocked,
name: clipName, name: clipName,
prompt, prompt,
aspect: fields.aspect || 'auto', aspect: fields.aspect || '16:9',
width, width,
height, height,
steps, steps,
turbo, turbo,
seed, seed,
cfg,
fps,
samplerName,
scheduler,
thumb: image.data, thumb: image.data,
stillId: still.id, stillId: still.id,
stillFilename: still.filename, stillFilename: still.filename,
@@ -99,7 +130,7 @@ export default defineEventHandler(async (event) => {
} }
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 }) 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) { if (isComfyBusyTimeout(error) && job.library) {
try { try {
const draft = await saveRetryDraft({ const draft = await saveRetryDraft({
@@ -119,7 +150,11 @@ export default defineEventHandler(async (event) => {
duration: job.library.duration || durationSeconds, duration: job.library.duration || durationSeconds,
hideThumbnail: job.library.hideThumbnail, hideThumbnail: job.library.hideThumbnail,
hideInput: job.library.hideInput === true, 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.library.draftId = draft.id
job.status = 'deferred' job.status = 'deferred'
@@ -142,7 +177,11 @@ export default defineEventHandler(async (event) => {
seed, seed,
width, width,
height, height,
aspect: fields.aspect || 'auto', aspect: fields.aspect || '16:9',
cfg,
fps,
sampler_name: samplerName,
scheduler,
steps, steps,
turbo, turbo,
folderId, folderId,
@@ -163,6 +202,10 @@ async function runGeneration(
turbo: boolean turbo: boolean
length: number length: number
sound: boolean sound: boolean
cfg: number
fps: number
samplerName: string
scheduler: string
} }
) { ) {
await ensureComfyReady((status) => { await ensureComfyReady((status) => {
@@ -196,6 +239,10 @@ async function runGeneration(
seed: params.seed, seed: params.seed,
turbo: params.turbo, turbo: params.turbo,
length: params.length, length: params.length,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
filenamePrefix: comfyFilenamePrefix(), filenamePrefix: comfyFilenamePrefix(),
sound: params.sound sound: params.sound
}) })
+7 -3
View File
@@ -1,14 +1,18 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '') 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 || '') const folderId = String(body?.folderId || '')
if (!folderId) { const hasName = typeof body?.name === 'string'
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder' }) if (!folderId && !hasName) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
} }
const clip = getClip(owner, id) const clip = getClip(owner, id)
assertFolderAccess(event, clip.folderId) assertFolderAccess(event, clip.folderId)
if (folderId) {
assertFolderExists(event, folderId) assertFolderExists(event, folderId)
await moveClip(owner, id, folderId) await moveClip(owner, id, folderId)
}
if (hasName) await renameClip(owner, id, body.name || '')
return publicLibrary(event) return publicLibrary(event)
}) })
+4
View File
@@ -49,6 +49,10 @@ export interface Job {
steps: number steps: number
turbo: boolean turbo: boolean
seed: number seed: number
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
thumb?: Buffer thumb?: Buffer
imageName?: string imageName?: string
imageSubfolder?: string imageSubfolder?: string
+37 -2
View File
@@ -30,6 +30,10 @@ export interface LibraryClip {
hideThumbnail: boolean hideThumbnail: boolean
createdAt: number createdAt: number
comfyFilename?: string comfyFilename?: string
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
} }
export interface LibraryStill { export interface LibraryStill {
@@ -71,6 +75,10 @@ export interface RetryDraft {
sound: boolean sound: boolean
hideInput?: boolean hideInput?: boolean
stillFilename?: string stillFilename?: string
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
} }
interface Catalog { interface Catalog {
@@ -662,6 +670,10 @@ export async function saveRetryDraft(params: {
hideThumbnail: boolean hideThumbnail: boolean
sound: boolean sound: boolean
hideInput?: boolean hideInput?: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}) { }) {
return mutate(params.ownerKey, (catalog) => { return mutate(params.ownerKey, (catalog) => {
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined 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, duration: params.duration,
hideThumbnail: params.hideThumbnail, hideThumbnail: params.hideThumbnail,
sound: params.sound, 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) catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
return draft return draft
@@ -743,6 +759,10 @@ export async function saveClip(params: {
video: Buffer video: Buffer
thumb?: Buffer | null thumb?: Buffer | null
comfyFilename?: string comfyFilename?: string
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
}) { }) {
const catalog = readCatalog(params.ownerKey) const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] 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, seed: params.seed,
hideThumbnail: params.hideThumbnail, hideThumbnail: params.hideThumbnail,
createdAt: Date.now(), 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 }) mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video) 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 }) { export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
const subfolders = [...new Set([video.subfolder, 'video', ''])] const subfolders = [...new Set([video.subfolder, 'video', ''])]
let lastStatus = 0 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 }> = { export const ASPECT_PRESETS: Record<string, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 }, '16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 }, '9:16': { width: 768, height: 1344 },
'1:1': { width: 1024, height: 1024 } '1:1': { width: 960, height: 960 }
} }
export function snap32(value: number) { export function snap32(value: number) {
@@ -85,16 +85,12 @@ function jpegSize(buf: Buffer) {
return null 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]) { if (aspect && ASPECT_PRESETS[aspect]) {
return ASPECT_PRESETS[aspect] return ASPECT_PRESETS[aspect]
} }
const fromFields = Number(fallbackWidth) && Number(fallbackHeight) if (Number(fallbackWidth) && Number(fallbackHeight)) {
? { width: snap32(Number(fallbackWidth)), height: snap32(Number(fallbackHeight)) } return { width: snap32(Number(fallbackWidth)), height: snap32(Number(fallbackHeight)) }
: null
const size = imageDimensions(image)
if (size?.width && size?.height) {
return autoResolution(size.width, size.height)
} }
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, hideInput: job.library.hideInput,
video: buffer, video: buffer,
thumb: job.library.thumb, 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.clipId = clip.id
job.hideThumbnail = clip.hideThumbnail job.hideThumbnail = clip.hideThumbnail
+50
View File
@@ -10,6 +10,10 @@ export interface GenerateParams {
seed: number seed: number
turbo: boolean turbo: boolean
length: number length: number
cfg: number
fps: number
samplerName: string
scheduler: string
filenamePrefix?: string filenamePrefix?: string
sound?: boolean sound?: boolean
} }
@@ -65,6 +69,25 @@ export function buildWorkflow(params: GenerateParams) {
node.inputs.length = params.length 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 (SEED_CLASSES.has(node.class_type)) {
if ('noise_seed' in node.inputs) node.inputs.noise_seed = params.seed if ('noise_seed' in node.inputs) node.inputs.noise_seed = params.seed
if ('seed' in node.inputs) node.inputs.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')) { for (const [, node] of entriesOf(graph, 'ComfySwitchNode')) {
if (!/step/i.test(titleOf(node))) continue if (!/step/i.test(titleOf(node))) continue
const qualityId = linkId(node.inputs.on_false) const qualityId = linkId(node.inputs.on_false)
@@ -127,6 +176,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'105:10': 'Decoding video frames', '105:10': 'Decoding video frames',
'105:14': 'Sampling', '105:14': 'Sampling',
'105:16': 'Preparing guider', '105:16': 'Preparing guider',
'105:cfg': 'Applying guidance',
'105:17': 'Selecting sampler', '105:17': 'Selecting sampler',
'105:23': 'Decoding audio', '105:23': 'Decoding audio',
'105:24': 'Loading audio VAE', '105:24': 'Loading audio VAE',