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:
@@ -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' })
|
||||
|
||||
+215
-58
@@ -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>
|
||||
</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))
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
if (folderId) {
|
||||
assertFolderExists(event, folderId)
|
||||
await moveClip(owner, id, folderId)
|
||||
}
|
||||
if (hasName) await renameClip(owner, id, body.name || '')
|
||||
return publicLibrary(event)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user