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
+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',