File size: 14,614 Bytes
8fb6433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
#!/usr/bin/env python3
"""Compile fresh 0.6 workflows from named recipes and this branch's schemas.

No archived workflow, widget array, position, or frontend metadata is an input.
Settings and wires use names. --check detects stale generated documents.
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import uuid
from pathlib import Path
from workflow_schema import DATA, ROOT, fields, is_widget, widget_fields, validate_value, load_schemas, options

EXAMPLES = ROOT / 'example_workflows'
NAMESPACE = uuid.UUID('aedf0e28-4e77-4ec8-98d8-7662561e4cf4')
TITLE_HEIGHT, GAP = 30, 70
AUTHOR = {'MiniMaxH3ChainPlanModern', 'MiniMaxH3GenerationProfile', 'MiniMaxH3ChainPlanStudio',
          'MiniMaxH3ProjectAssetManager', 'MiniMaxH3ChainScenePromptEditor',
          'MiniMaxH3ChainRichScenePromptEditor', 'MiniMaxH3ChainCheckpointManager'}
SIZES = {
    'MiniMaxH3ChainPlanModern': (1000,900), 'MiniMaxH3ChainPlanStudio': (1080,940),
    'MiniMaxH3ProjectAssetManager': (1080,880), 'MiniMaxH3ChainCheckpointManager': (1200,900),
    'MiniMaxH3ChainScenePromptEditor': (1000,840), 'MiniMaxH3ChainRichScenePromptEditor': (1200,940),
    'MiniMaxH3ChainReview': (760,880), 'MiniMaxH3ChainCurrent': (460,400),
    'MiniMaxH3ChainUpscaleCurrent': (480,520), 'MiniMaxH3TaggedReferenceToVideo': (520,540),
    'MiniMaxH3ReferenceToVideo': (520,420), 'MiniMaxH3ImageToVideo': (500,380),
    'MiniMaxH3ChainSegmentSave': (460,220), 'MiniMaxH3ChainAssemble': (440,360),
    'MiniMaxH3ChainContext': (420,260), 'MiniMaxH3LoopTrim': (420,250),
    'MiniMaxH3ChainLoopEnd': (420,220), 'MiniMaxH3ChainManifestLoad': (440,200),
    'LoadImage': (400,420), 'LoadImageMask': (400,420), 'LoadVideo': (400,380),
    'VHS_LoadVideo': (400,580), 'PreviewImage': (420,380), 'PreviewAny': (420,180), 'SaveVideo': (480,440),
    'SeedVR2VideoPathUpscaler': (520,580), 'Note': (1000,330),
    'MiniMaxH3ChainUpscalePixelConditioning': (600,520),
    'MiniMaxH3ChainUpscalePixelCurrent': (500,300),
    'UltimateSDUpscaleNoUpscaleGuider': (500,720),
}
LABELS = {
    'MiniMaxH3ChainPlanModern':'Production Plan', 'MiniMaxH3ChainPlanStudio':'Plan Studio',
    'MiniMaxH3ProjectAssetManager':'Project Asset Carousel', 'MiniMaxH3ChainCheckpointManager':'Checkpoint Manager',
    'MiniMaxH3ChainScenePromptEditor':'Scene Prompts', 'MiniMaxH3ChainRichScenePromptEditor':'Scene Prompts + References',
    'MiniMaxH3GenerationProfile':'Generation Profile', 'MiniMaxH3ChainLoopStart':'Loop Start',
    'MiniMaxH3ChainCurrent':'Current Scene', 'MiniMaxH3ChainContext':'Apply Scene Context',
    'MiniMaxH3TaggedReferenceToVideo':'Tagged Ref2VA Conditioning', 'MiniMaxH3LoopTrim':'Trim Carried Overlap',
    'MiniMaxH3ChainSegmentSave':'Save Segment + Checkpoint', 'MiniMaxH3ChainReview':'Review Candidates',
    'MiniMaxH3ChainLoopEnd':'Loop End', 'MiniMaxH3ChainAssemble':'Assemble Final Video',
    'MiniMaxH3ChainManifestLoad':'Load Saved Clips', 'MiniMaxH3ImageToVideo':'Image / Keyframe Conditioning',
    'MiniMaxH3ReferenceToVideo':'Reference Conditioning', 'UNETLoader':'H3 Diffusion Model',
    'CLIPLoader':'H3 Text Encoder', 'RandomNoise':'Scene Seed', 'SamplerCustomAdvanced':'Sample Video + Audio',
    'BasicScheduler':'Sampling Schedule', 'KSamplerSelect':'Sampler',
    'MinimaxH3LatentUpscaler3D':'LBH 3D Latent Upscaler', 'SeedVR2VideoPathUpscaler':'SeedVR2 Video Path Upscaler',
    'MiniMaxH3ChainUpscalePixelCurrent':'Pixel Current Scene • Experimental',
    'MiniMaxH3ChainUpscalePixelConditioning':'Pixel Conditioning • Actual Image Size',
    'UltimateSDUpscaleNoUpscaleGuider':'USDU H3 • Refine Upscaled Images',
}


def socket_type(spec):
    return 'COMBO' if isinstance(spec[0], (list,tuple)) else spec[0]


def make_node(recipe, schema, number):
    typ, settings = recipe['type'], recipe['settings']
    controls = list(widget_fields(schema, settings))
    assert not (set(settings)-{key for key,_ in controls}), (typ,'unknown settings')
    values=[]
    for key,spec in controls:
        assert key in settings, (typ,key,'recipe must explicitly name every widget')
        validate_value(settings[key],spec,(typ,key))
        values.append(copy.deepcopy(settings[key]))
    inputs=[]
    for name,spec in fields(schema).items():
        if spec[0]=='COMFY_AUTOGROW_V3':
            template=options(spec)['template']
            item_spec=next(iter(template['input']['required'].values()))
            names=[key for key in recipe['inputs'] if key.startswith(name+'.')]
            names.append(name+'.'+template['prefix']+str(len(names)))
            inputs.extend({'name':key,'type':socket_type(item_spec),'link':None} for key in names)
        elif not is_widget(spec) or name in recipe['inputs']:
            value={'name':name,'type':socket_type(spec),'link':None}
            if is_widget(spec): value['widget']={'name':name}
            inputs.append(value)
    label=LABELS.get(typ) or re.sub(r'(?<=[a-z])(?=[A-Z])',' ',typ.replace('MiniMaxH3','').replace('Contex','Context'))
    if typ=='VAELoader': label='Audio VAE' if 'audio' in settings['vae_name'] else 'Video VAE'
    if typ in ('LoadImage','LoadVideo','LoadImageMask'):
        label={'LoadImage':'Reference Image','LoadVideo':'Source Video','LoadImageMask':'Edit Mask'}[typ]
    if recipe.get('mode')==2:label='RECOVERY • '+label
    elif recipe.get('mode')==4:label='OPTIONAL • '+label
    outputs=[dict(name=name,type=kind,links=None) for name,kind in zip(schema['output_name'],schema['output'])]
    input_width=max((len(i['name']) for i in inputs),default=0)*7
    output_width=max((len(o['name']) for o in outputs),default=0)*7
    width=max(360,len(label)*8+48,input_width+output_width+80)
    height=64+max(len(inputs),len(outputs))*20+len(controls)*24
    if any(options(spec).get('multiline') for _,spec in controls):height+=180
    explicit=SIZES.get(typ,(width,height))
    width,height=max(width,explicit[0]),max(height,explicit[1])
    if typ=='VHS_LoadVideo':values=copy.deepcopy(settings)  # VHS uses a named serializer.
    return dict(id=number,type=typ,pos=[0,0],size=[width,height],flags={},order=number-1,
                mode=recipe.get('mode',0),inputs=inputs,outputs=outputs,title=label,
                properties={'Node name for S&R':typ},widgets_values=values)


def group(title,members,color):
    left=min(n['pos'][0] for n in members)-40
    top=min(n['pos'][1] for n in members)-90
    right=max(n['pos'][0]+n['size'][0] for n in members)+40
    bottom=max(n['pos'][1]+n['size'][1] for n in members)+40
    return dict(title=title,bounding=[left,top,right-left,bottom-top],color=color,font_size=24,flags={})


def stack(nodes,x,y):
    for node in nodes:
        node['pos']=[x,y]
        y+=node['size'][1]+TITLE_HEIGHT+GAP
    return y


def layout(nodes,links):
    groups=[]
    author=[n for n in nodes if n['type'] in AUTHOR or n['type']=='Note']
    studio=any(n['type']=='MiniMaxH3ChainPlanStudio' for n in author)
    columns=[[],[],[]] if studio else [[],[]]
    priority={'Note':0,'MiniMaxH3GenerationProfile':1,'MiniMaxH3ChainPlanModern':2,
              'MiniMaxH3ChainPlanStudio':3,'MiniMaxH3ChainScenePromptEditor':3,
              'MiniMaxH3ChainRichScenePromptEditor':3,'MiniMaxH3ProjectAssetManager':4,
              'MiniMaxH3ChainCheckpointManager':5}
    for n in sorted(author,key=lambda n:priority.get(n['type'],9)):
        t=n['type']; col=0
        if studio:
            if t in ('MiniMaxH3ChainPlanStudio','MiniMaxH3ProjectAssetManager'):col=1
            elif t in ('MiniMaxH3ChainRichScenePromptEditor','MiniMaxH3ChainCheckpointManager'):col=2
        elif t in ('MiniMaxH3ChainScenePromptEditor','MiniMaxH3ChainRichScenePromptEditor'):col=1
        columns[col].append(n)
    x=80
    for col in columns:
        if not col:continue
        stack(col,x,180); x+=max(n['size'][0] for n in col)+120
    if author:groups.append(group('01 • PROJECT & SCENES',author,'#315566'))
    x+=100
    runtime=[n for n in nodes if n not in author and n['mode']!=2]
    recovery=sorted([n for n in nodes if n not in author and n['mode']==2],
                    key=lambda n:n['type']!='MiniMaxH3ChainManifestLoad')
    runtime_ids={n['id'] for n in runtime}
    predecessors={n['id']:set() for n in runtime}
    for _,source,_,target,_,_ in links:
        if source in runtime_ids and target in runtime_ids:predecessors[target].add(source)
    depths={}
    while len(depths)<len(runtime):
        ready=[n for n in runtime if n['id'] not in depths and predecessors[n['id']]<=depths.keys()]
        assert ready,'Cycle in generation graph'
        for n in ready:depths[n['id']]=max((depths[p]+1 for p in predecessors[n['id']]),default=0)
    # Adjacent dependency depths share a column, stacked in topological order.
    column_numbers=sorted({depths[n['id']]//2 for n in runtime})
    runtime_left=x
    for index,layer in enumerate(column_numbers):
        col=sorted([n for n in runtime if depths[n['id']]//2==layer],key=lambda n:(depths[n['id']],n['id']))
        stack(col,x,180)
        types={n['type'] for n in col}; heading='PREPARE SCENE'
        if index==0:heading='MODELS & INPUTS'
        if 'SamplerCustomAdvanced' in types:heading='SAMPLE & DECODE'
        if types & {'MiniMaxH3ChainSegmentSave','MiniMaxH3ChainUpscaleSegmentSave'}:heading='SAVE & REVIEW'
        if 'MiniMaxH3ChainReview' in types:heading='REVIEW & CONTINUE'
        if types & {'MiniMaxH3ChainAssemble','SaveVideo'}:heading='DELIVER'
        groups.append(group(f'{index+2:02d} • {heading}',col,'#393f58' if index%2==0 else '#394c49'))
        x+=max(n['size'][0] for n in col)+150
    if recovery:
        y=max((n['pos'][1]+n['size'][1] for n in runtime),default=180)+180
        rx=runtime_left
        for n in recovery:
            n['pos']=[rx,y];rx+=n['size'][0]+130
        groups.append(group('RECOVERY • DISABLED / NO SAMPLING',recovery,'#665135'))
    return groups


def build(recipe,filename,schemas):
    recipe=copy.deepcopy(recipe)
    guides=[n['settings']['text'] for n in recipe['nodes'] if n['type']=='Note']
    records=[n for n in recipe['nodes'] if n['type']!='Note']
    plans=[n for n in records if n['type']=='MiniMaxH3ChainPlanModern']
    for n in records:
        if n['type']=='MiniMaxH3ChainPlanStudio' and plans:
            n['settings'].update({k:v for k,v in plans[0]['settings'].items() if k in n['settings']})
    nodes=[make_node(n,schemas[n['type']],i+1) for i,n in enumerate(records)]
    by_key={r['key']:n for r,n in zip(records,nodes)}
    links=[]
    for record,node in zip(records,nodes):
        for input_name,(source_key,output_name) in record['inputs'].items():
            source=by_key[source_key]
            source_slots=[i for i,s in enumerate(source['outputs']) if s['name'].lower()==output_name.lower()]
            assert len(source_slots)==1,(filename,source_key,output_name,'missing/ambiguous output')
            origin_slot=source_slots[0]
            target_slot=next(i for i,s in enumerate(node['inputs']) if s['name']==input_name)
            inp=node['inputs'][target_slot];out=source['outputs'][origin_slot]
            assert (set(out['type'].split(',')) & set(inp['type'].split(','))
                    or '*' in (out['type'],inp['type'])), (filename,source['type'],out,node['type'],inp)
            link_id=len(links)+1
            links.append([link_id,source['id'],origin_slot,node['id'],target_slot,out['type']])
            inp['link']=link_id;out['links']=(out['links'] or [])+[link_id]
    project_step='Set a unique project run name, then edit scenes and the Generation Profile.'
    if filename.startswith('Deferred'):
        project_step='Select a saved run and lineage in Checkpoint Manager, then set the upscale profile / target size.'
    elif filename.startswith('Masked AV Bridge'):
        project_step='Check both source intervals and the bridge target length; this standalone graph does not use a Plan.'
    recovery_note=('Recovery is disabled by default. Enable it only to assemble saved clips without sampling.\n'
                   if any(n['mode']==2 for n in nodes) else '')
    setup_steps=recipe.get('setup_steps',
        '1. Install models and extra packs listed in example_workflows/README.md.\n'
        '2. Copy the supplied assets to ComfyUI/input; select source media where requested.\n'
        '3. '+project_step+'\n'
        '4. Follow the numbered columns left → right and inspect the saved results.')
    note_text=('0.6 RELEASE • '+filename.removesuffix(' - MiniMax H3 0.6.json')+'\n\n'
        +setup_steps+'\n\n'
        +recovery_note+
        'Workflow-specific setup and detailed wiring notes:\nexample_workflows/guides/'+filename.removesuffix('.json')+'.md')
    note_size=[SIZES['Note'][0],max(SIZES['Note'][1],70+20*len(note_text.splitlines()))]
    nodes.append(dict(id=len(nodes)+1,type='Note',pos=[0,0],size=note_size,flags={},order=len(nodes),mode=0,
        inputs=[],outputs=[],title='START HERE • 0.6',properties={'Node name for S&R':'Note'},widgets_values=[note_text]))
    identity=str(uuid.uuid5(NAMESPACE,filename))
    workflow=dict(id=identity,revision=0,last_node_id=len(nodes),last_link_id=len(links),nodes=nodes,links=links,
        groups=layout(nodes,links),config={},extra={'ds':{'scale':0.65,'offset':[30,0]},
        'comfyui_mcp':{'workflow_uuid':identity}},version=0.4)
    compatibility_note=recipe.get('compatibility_note','Built for the **0.6 branch**, not nightly.')
    guide='# '+filename.removesuffix('.json')+'\n\n'+compatibility_note+'\n\n'
    guide+=('Setup controls come first, followed by numbered generation columns. '+recovery_note).rstrip()+'\n\n'
    guide+='\n\n---\n\n'.join(guides)+'\n'
    return workflow,guide


def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--check',action='store_true')
    parser.add_argument('--output-dir',type=Path,default=EXAMPLES)
    args=parser.parse_args();schemas=load_schemas()
    for path in sorted((DATA/'recipes').glob('*.json')):
        workflow,guide=build(json.loads(path.read_text()),path.name,schemas)
        outputs={args.output_dir/path.name:json.dumps(workflow,ensure_ascii=False,indent=2)+'\n',
                 args.output_dir/'guides'/path.with_suffix('.md').name:guide}
        for target,content in outputs.items():
            if args.check:assert target.read_text()==content, f'Stale generated file: {target}'
            else:
                target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content,encoding='utf-8')
        print(path.name)


if __name__=='__main__':main()