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:
@@ -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)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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