Ship MiniMax v2 identity refs and Qwen sidecar edits.
Keep v1 as the default video graph, relay image edits to the Qwen Lightning box, and keep /api/health up if one Comfy host is unset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+130
-26
@@ -1,5 +1,8 @@
|
||||
// Nitro bundles this JSON into the production server output.
|
||||
import workflowTemplate from '../assets/workflow_minimax_video.json'
|
||||
// Nitro bundles these JSON graphs into the production server output.
|
||||
import workflowV1 from '../assets/workflow_minimax_video.json'
|
||||
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
|
||||
|
||||
export type VideoWorkflowId = 'v1' | 'v2'
|
||||
|
||||
export interface GenerateParams {
|
||||
prompt: string
|
||||
@@ -16,6 +19,10 @@ export interface GenerateParams {
|
||||
scheduler: string
|
||||
filenamePrefix?: string
|
||||
sound?: boolean
|
||||
workflow?: VideoWorkflowId
|
||||
duration?: number
|
||||
useIdentityRefs?: boolean
|
||||
referenceImageNames?: string[]
|
||||
}
|
||||
|
||||
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
|
||||
@@ -23,6 +30,7 @@ type WorkflowGraph = Record<string, WorkflowNode>
|
||||
|
||||
const PROMPT_CLASSES = new Set([
|
||||
'MiniMaxH3ImageToVideo',
|
||||
'MiniMaxH3ReferenceToVideo',
|
||||
'CLIPTextEncode',
|
||||
'CLIPTextEncodeQwen3VL',
|
||||
'TextEncodeQwen3VL'
|
||||
@@ -30,6 +38,7 @@ const PROMPT_CLASSES = new Set([
|
||||
const SEED_CLASSES = new Set(['RandomNoise', 'KSampler', 'SamplerMiniMax', 'KSamplerAdvanced'])
|
||||
const IMAGE_CLASSES = new Set(['LoadImage'])
|
||||
const LORA_CLASSES = new Set(['LoraLoaderModelOnly', 'LoraLoader'])
|
||||
const V2_REF_LOADERS = ['140', '141', '142', '143'] as const
|
||||
|
||||
function entriesOf(graph: WorkflowGraph, classType: string) {
|
||||
return Object.entries(graph).filter(([, node]) => node.class_type === classType)
|
||||
@@ -47,13 +56,24 @@ function titleOf(node: WorkflowNode) {
|
||||
return node._meta?.title || ''
|
||||
}
|
||||
|
||||
function templateFor(id: VideoWorkflowId) {
|
||||
return (id === 'v2' ? workflowV2 : workflowV1) as WorkflowGraph
|
||||
}
|
||||
|
||||
export function parseVideoWorkflow(raw: unknown): VideoWorkflowId {
|
||||
return String(raw || '').trim() === 'v2' ? 'v2' : 'v1'
|
||||
}
|
||||
|
||||
export function buildWorkflow(params: GenerateParams) {
|
||||
const graph = structuredClone(workflowTemplate) as WorkflowGraph
|
||||
const version = params.workflow === 'v2' ? 'v2' : 'v1'
|
||||
const graph = structuredClone(templateFor(version))
|
||||
const width = snap32(params.width)
|
||||
const height = snap32(params.height)
|
||||
const refs = (params.referenceImageNames || []).map(name => String(name || '').trim()).filter(Boolean).slice(0, 4)
|
||||
const useRefs = version === 'v2' && params.useIdentityRefs === true && refs.length > 0
|
||||
|
||||
for (const [, node] of Object.entries(graph)) {
|
||||
if (IMAGE_CLASSES.has(node.class_type) && 'image' in node.inputs) {
|
||||
for (const [id, node] of Object.entries(graph)) {
|
||||
if (IMAGE_CLASSES.has(node.class_type) && 'image' in node.inputs && id === '114') {
|
||||
node.inputs.image = params.imageName
|
||||
}
|
||||
|
||||
@@ -64,9 +84,14 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
|
||||
if (node.class_type === 'MiniMaxH3ImageToVideo') {
|
||||
node.inputs.prompt = params.prompt
|
||||
node.inputs.width = width
|
||||
node.inputs.height = height
|
||||
node.inputs.length = params.length
|
||||
if (typeof node.inputs.width === 'number') node.inputs.width = width
|
||||
if (typeof node.inputs.height === 'number') node.inputs.height = height
|
||||
if (typeof node.inputs.length === 'number') node.inputs.length = params.length
|
||||
}
|
||||
|
||||
if (node.class_type === 'MiniMaxH3ReferenceToVideo') {
|
||||
node.inputs.prompt = params.prompt
|
||||
if (!Array.isArray(node.inputs.length)) node.inputs.length = ['174', 1]
|
||||
}
|
||||
|
||||
if (node.class_type === 'CreateVideo' && 'fps' in node.inputs) {
|
||||
@@ -93,7 +118,7 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
if ('seed' in node.inputs) node.inputs.seed = params.seed
|
||||
}
|
||||
|
||||
if (LORA_CLASSES.has(node.class_type)) {
|
||||
if (LORA_CLASSES.has(node.class_type) && !/identity|ref 1/i.test(titleOf(node))) {
|
||||
node.inputs.strength_model = params.turbo ? 1 : 0
|
||||
}
|
||||
|
||||
@@ -101,11 +126,22 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
node.inputs.filename_prefix = params.filenamePrefix || node.inputs.filename_prefix || 'video/MiniMax_H3'
|
||||
}
|
||||
|
||||
if (node.class_type === 'PrimitiveBoolean') {
|
||||
if (node.class_type === 'PrimitiveBoolean' && /lightning|turbo/i.test(titleOf(node))) {
|
||||
node.inputs.value = params.turbo
|
||||
}
|
||||
|
||||
if (node.class_type === 'ComfySwitchNode' && typeof node.inputs.switch === 'boolean') {
|
||||
if (node.class_type === 'PrimitiveBoolean' && /identity|ref2va|reference/i.test(titleOf(node))) {
|
||||
node.inputs.value = useRefs
|
||||
}
|
||||
|
||||
if (node.class_type === 'PrimitiveFloat' && /duration/i.test(titleOf(node))) {
|
||||
const seconds = typeof params.duration === 'number' && params.duration > 0
|
||||
? params.duration
|
||||
: params.length / Math.max(params.fps, 1)
|
||||
node.inputs.value = seconds
|
||||
}
|
||||
|
||||
if (node.class_type === 'ComfySwitchNode' && typeof node.inputs.switch === 'boolean' && /lightning|turbo|lora/i.test(titleOf(node))) {
|
||||
node.inputs.switch = params.turbo
|
||||
}
|
||||
|
||||
@@ -114,7 +150,7 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
}
|
||||
}
|
||||
|
||||
if (graph['128']) {
|
||||
if (version === 'v1' && graph['128']?.class_type === 'ImageScaleToTotalPixels') {
|
||||
graph['128'] = {
|
||||
class_type: 'ImageScale',
|
||||
inputs: {
|
||||
@@ -128,16 +164,18 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
if (version === 'v1') {
|
||||
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')) {
|
||||
@@ -152,15 +190,69 @@ export function buildWorkflow(params: GenerateParams) {
|
||||
}
|
||||
}
|
||||
|
||||
if (version === 'v2') applyV2IdentityPath(graph, params, useRefs, refs)
|
||||
|
||||
if (params.sound === false) {
|
||||
delete graph['105:23']
|
||||
delete graph['105:24']
|
||||
if (graph['105:91']?.inputs) delete graph['105:91'].inputs.audio
|
||||
delete graph['163']
|
||||
if (graph['172']?.inputs) delete graph['172'].inputs.audio
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
function applyV2IdentityPath(
|
||||
graph: WorkflowGraph,
|
||||
params: GenerateParams,
|
||||
useRefs: boolean,
|
||||
refs: string[]
|
||||
) {
|
||||
if (useRefs && graph['155'] && graph['167'] && graph['168']) {
|
||||
graph['167'].inputs.latent_image = ['155', 1]
|
||||
graph['168'].inputs.conditioning = ['155', 0]
|
||||
graph['155'].inputs.prompt = params.prompt
|
||||
graph['155'].inputs.length = ['174', 1]
|
||||
delete graph['173']
|
||||
V2_REF_LOADERS.forEach((id, index) => {
|
||||
const name = refs[index]
|
||||
const inputKey = `ref_images.ref_image_${index + 1}`
|
||||
if (name && graph[id]) {
|
||||
graph[id].inputs.image = name
|
||||
} else {
|
||||
delete graph[id]
|
||||
if (graph['155']?.inputs) delete graph['155'].inputs[inputKey]
|
||||
}
|
||||
})
|
||||
graph['v2:cfg'] = {
|
||||
class_type: 'FluxGuidance',
|
||||
inputs: {
|
||||
guidance: params.cfg,
|
||||
conditioning: ['155', 0]
|
||||
},
|
||||
_meta: { title: 'Guidance scale' }
|
||||
}
|
||||
graph['168'].inputs.conditioning = ['v2:cfg', 0]
|
||||
return
|
||||
}
|
||||
|
||||
delete graph['155']
|
||||
delete graph['150']
|
||||
for (const id of V2_REF_LOADERS) delete graph[id]
|
||||
if (graph['173'] && graph['168']) {
|
||||
graph['v2:cfg'] = {
|
||||
class_type: 'FluxGuidance',
|
||||
inputs: {
|
||||
guidance: params.cfg,
|
||||
conditioning: ['173', 0]
|
||||
},
|
||||
_meta: { title: 'Guidance scale' }
|
||||
}
|
||||
graph['168'].inputs.conditioning = ['v2:cfg', 0]
|
||||
}
|
||||
}
|
||||
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'114': 'Loading image',
|
||||
'128': 'Scaling image',
|
||||
@@ -168,6 +260,14 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'119': 'Scaling image',
|
||||
'120': 'Reading image size',
|
||||
'92': 'Saving MP4',
|
||||
'140': 'Loading identity ref 1',
|
||||
'141': 'Loading identity ref 2',
|
||||
'142': 'Loading identity ref 3',
|
||||
'143': 'Loading identity ref 4',
|
||||
'150': 'Identity references',
|
||||
'155': 'Reference to video',
|
||||
'172': 'Encoding video',
|
||||
'173': 'Image to video',
|
||||
'105:6': 'Loading MiniMax H3 model',
|
||||
'105:11': 'Loading video VAE',
|
||||
'105:13': 'Loading Qwen3-VL encoder',
|
||||
@@ -177,6 +277,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'105:14': 'Sampling',
|
||||
'105:16': 'Preparing guider',
|
||||
'105:cfg': 'Applying guidance',
|
||||
'v2:cfg': 'Applying guidance',
|
||||
'105:17': 'Selecting sampler',
|
||||
'105:23': 'Decoding audio',
|
||||
'105:24': 'Loading audio VAE',
|
||||
@@ -192,13 +293,16 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'105:126': 'Selecting turbo LoRA'
|
||||
}
|
||||
|
||||
function labelsFrom(template: WorkflowGraph) {
|
||||
return Object.entries(template).map(([id, node]) => [id, node._meta?.title || `Node ${id}`] as const)
|
||||
}
|
||||
|
||||
export const NODE_LABELS: Record<string, string> = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(workflowTemplate as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`])
|
||||
),
|
||||
...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)),
|
||||
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
|
||||
...LABEL_OVERRIDES
|
||||
}
|
||||
|
||||
export function isEncodingNode(node: string) {
|
||||
return node === '92' || node === '105:91' || /encoding|saving mp4/i.test(NODE_LABELS[node] || '')
|
||||
return node === '92' || node === '105:91' || node === '172' || /encoding|saving mp4|create video/i.test(NODE_LABELS[node] || '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user