File size: 36,034 Bytes
db59f73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af888c6
 
 
 
 
 
 
db59f73
 
af888c6
db59f73
 
af888c6
db59f73
 
 
 
 
 
 
af888c6
db59f73
 
 
 
 
 
 
 
 
af888c6
 
 
 
db59f73
 
af888c6
db59f73
 
 
 
 
 
af888c6
db59f73
 
 
 
 
 
 
 
 
 
af888c6
db59f73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af888c6
db59f73
 
af888c6
db59f73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
"""

Generate `workflow.json` for Workflow1111.



Hand-writing ~3000 lines of graph JSON is how wiring bugs get in, so the graph

is generated and then *verified* โ€” see `verify()` at the bottom. The single

most important guarantee: an `fn` operator's input ports are derived from the

bound function's own signature via `inspect`, so port order can never drift

away from the Python argument order (the executor passes `fn` args

positionally, in port order).



    python apps/05_workflow1111/build_workflow.py



Re-run after editing `nodes.py`. Node positions come from `layout.json` (the

hand-arranged, overlap-checked layout), so rebuilding preserves the canvas

arrangement instead of resetting it. To adopt a new arrangement, drag nodes in

the canvas and re-snapshot `layout.json` from the saved `workflow.json`.

"""

import inspect
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gradio.workflow import _is_injected_param  # noqa: E402

import nodes as N  # noqa: E402


def fn_params(func):
    """A bound function's real input parameters.



    Skips gradio's injected parameters (`OAuthToken`, `OAuthProfile`,

    `Request`) โ€” gradio supplies those itself, so they must not become ports.

    """
    hints = getattr(func, "__annotations__", {})
    try:
        from typing import get_type_hints
        hints = get_type_hints(func)
    except Exception:
        pass
    return [p for p in inspect.signature(func).parameters
            if not _is_injected_param(hints.get(p))]

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "workflow.json")
LAYOUT = os.path.join(HERE, "layout.json")

# Sample images ship in the Space repo and are referenced by their public Hub
# URL. That is the one default shape that satisfies everything at once: the
# canvas only renders a reference default that carries a `url` key (it strips
# `path` from graph defaults for safety), while `call_model`/`call_space` need
# something a remote provider can actually fetch โ€” which a relative
# `/gradio_api/file=` URL is not, but an absolute https one is. The same URLs
# therefore work identically when running locally and on the Space.
SPACE_ID = os.environ.get("WORKFLOW1111_SPACE", "ysharma/Workflow1111")
SAMPLE_BASE = f"https://huggingface.co/spaces/{SPACE_ID}/resolve/main/samples"


def sample(filename):
    return {"url": f"{SAMPLE_BASE}/{filename}"}

# Verified working on HF Inference Providers โ€” see the probe results recorded
# in the README. Swapping these is the main "model checkpoint" knob.
T2I_MODEL = "black-forest-labs/FLUX.1-schnell"
T2I_QUALITY_MODEL = "black-forest-labs/FLUX.1-dev"
EDIT_MODEL = "black-forest-labs/FLUX.1-Kontext-dev"
LLM_MODEL = "Qwen/Qwen3-4B-Instruct-2507"
VLM_MODEL = "Qwen/Qwen2.5-VL-72B-Instruct"
DETECT_MODEL = "facebook/detr-resnet-50"
CLASSIFY_MODEL = "google/vit-base-patch16-224"

RMBG_SPACE = "briaai/BRIA-RMBG-2.0"
UPSCALE_SPACE = "gokaygokay/AuraSR-v2"

references, operators, subjects, edges = [], [], [], []

COL = [60, 420, 800, 1180, 1560, 1940]  # x positions by pipeline stage


def _size(n_in, n_out, width, base=64, per_port=30):
    return width, base + per_port * max(n_in, n_out, 1)


def ref(node_id, label, port_type, x, y, default=None, width=250):
    """A reference node โ€” a free input. These become the API parameters."""
    references.append({
        "id": node_id, "role": "reference", "label": label, "asset_type": port_type,
        "inputs": [{"id": "in", "label": label, "type": port_type}],
        "outputs": [{"id": "out", "label": label, "type": port_type}],
        "data": {} if default is None else {"out": default},
        "x": x, "y": y, "width": width,
        "height": 200 if port_type in ("image", "audio", "video") else 96,
    })
    return node_id


def fn(node_id, fn_name, x, y, *, label=None, types=None, data=None,

       required=(), outputs=None, width=290):
    """An `fn` operator. Input ports are generated from the bound function's

    signature, so the positional call order is correct by construction."""
    func = N.BIND[fn_name]
    params = fn_params(func)
    types = types or {}
    data = data or {}

    inputs = [{
        "id": f"in_{p}",
        "label": p,
        "type": types.get(p, "text"),
        **({"required": True} if p in required else {}),
    } for p in params]

    outs = outputs or [("out_0", "output", "text")]
    out_ports = [{"id": oid, "label": olabel, "type": otype, "output_index": i}
                 for i, (oid, olabel, otype) in enumerate(outs)]

    w, h = _size(len(inputs), len(out_ports), width)
    operators.append({
        "id": node_id, "role": "operator", "kind": "fn", "fn": fn_name,
        "label": label or fn_name,
        "inputs": inputs, "outputs": out_ports,
        "data": {f"in_{k}": v for k, v in data.items()},
        "x": x, "y": y, "width": w, "height": h,
    })
    return node_id


def model(node_id, model_id, endpoint, pipeline_tag, x, y, *, label=None,

          inputs=(), outputs=None, data=None, width=290):
    """A `model` operator (HF Inference Providers).



    Input port **ids** are forwarded verbatim as keyword arguments to

    `InferenceClient.<endpoint>()`, which is what gives txt2img its real

    negative-prompt / steps / CFG / seed / size controls.

    """
    in_ports = [{"id": pid, "label": plabel, "type": ptype,
                 **({"required": True} if req else {})}
                for pid, plabel, ptype, req in inputs]
    outs = outputs or [("out_0", "Image", "image")]
    out_ports = [{"id": oid, "label": olabel, "type": otype, "output_index": i}
                 for i, (oid, olabel, otype) in enumerate(outs)]

    w, h = _size(len(in_ports), len(out_ports), width)
    operators.append({
        "id": node_id, "role": "operator", "kind": "model",
        "model_id": model_id, "pipeline_tag": pipeline_tag, "endpoint": endpoint,
        "label": label or model_id.split("/")[-1],
        "inputs": in_ports, "outputs": out_ports, "data": data or {},
        "x": x, "y": y, "width": w, "height": h,
    })
    return node_id


def space(node_id, space_id, endpoint, x, y, *, label=None, inputs=(),

          outputs=None, data=None, width=290):
    """A `space` operator. Inputs are passed **positionally**, in port order."""
    in_ports = [{"id": pid, "label": plabel, "type": ptype,
                 **({"required": True} if req else {})}
                for pid, plabel, ptype, req in inputs]
    out_ports = [{"id": oid, "label": olabel, "type": otype, "output_index": idx}
                 for oid, olabel, otype, idx in (outputs or [])]

    w, h = _size(len(in_ports), len(out_ports), width)
    operators.append({
        "id": node_id, "role": "operator", "kind": "space",
        "space_id": space_id, "endpoint": endpoint,
        "label": label or space_id.split("/")[-1],
        "inputs": in_ports, "outputs": out_ports, "data": data or {},
        "x": x, "y": y, "width": w, "height": h,
    })
    return node_id


def out(node_id, label, port_type, x, y, width=300):
    """A subject node โ€” a workflow output, and an API endpoint result."""
    subjects.append({
        "id": node_id, "role": "subject", "label": label, "asset_type": port_type,
        "inputs": [{"id": "in", "label": label, "type": port_type}],
        "outputs": [{"id": "out", "label": label, "type": port_type}],
        "data": {},
        "x": x, "y": y, "width": width,
        "height": 260 if port_type == "image" else 190,
    })
    return node_id


def link(a, b):
    """Wire "node.port" โ†’ "node.port"."""
    fnode, fport = a.split(".")
    tnode, tport = b.split(".")
    edges.append({
        "id": f"e{len(edges) + 1}",
        "from_node_id": fnode, "from_port_id": fport,
        "to_node_id": tnode, "to_port_id": tport,
        "type": None,  # filled in by verify() from the source port
    })


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 1 ยท txt2img โ€” the flagship pipeline
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 60
ref("ref_prompt", "Prompt", "text", COL[0], Y,
    "a red fox standing in a snowy pine forest, looking at the camera")
ref("ref_negative", "Negative prompt", "text", COL[0], Y + 120, "")
ref("ref_style", "Style preset", "text", COL[0], Y + 240, "Cinematic")

fn("op_style", "apply_style", COL[1], Y, label="โ‘  Prompt builder",
   required=("prompt",),
   data={"extra_tags": "", "quality_boost": True},
   outputs=[("out_0", "prompt", "text")])

fn("op_negative", "build_negative", COL[1], Y + 190, label="โ‘  Negative builder",
   types={"use_base": "boolean", "safety_filter": "boolean"},
   data={"negative": "", "use_base": True, "safety_filter": True},
   outputs=[("out_0", "negative", "text")])

fn("op_sampler", "sampler_settings", COL[1], Y + 380, label="โ‘ก Sampler",
   types={"steps": "number", "cfg_scale": "number", "seed": "number",
          "width": "number", "height": "number"},
   data={"steps": 4, "cfg_scale": 1.0, "seed": -1,
         "aspect": "1:1 Square", "width": 1024, "height": 1024},
   outputs=[("out_steps", "steps", "number"), ("out_cfg", "cfg", "number"),
            ("out_seed", "seed", "number"), ("out_width", "width", "number"),
            ("out_height", "height", "number")])

# txt2img is an `fn` node, not a `model` node, on purpose: the canvas rewrites
# a model node's ports to the endpoint's canonical schema (just `prompt` for
# text_to_image), which silently discarded the negative prompt, steps, CFG,
# seed and size. `fn` ports are left alone, so the control surface survives.
fn("op_txt2img", "txt2img", COL[2], Y, label="โ‘ข txt2img ยท FLUX.1-schnell",
   types={"steps": "number", "cfg_scale": "number", "seed": "number",
          "width": "number", "height": "number"},
   required=("prompt",),
   data={"negative_prompt": "", "steps": 4, "cfg_scale": 1.0, "seed": -1,
         "width": 1024, "height": 1024, "model_id": T2I_MODEL},
   outputs=[("out_0", "image", "image")])

fn("op_geninfo", "generation_info", COL[2], Y + 300, label="โ‘ฃ Generation params",
   types={"steps": "number", "cfg_scale": "number", "seed": "number",
          "width": "number", "height": "number"},
   data={"model_id": T2I_MODEL},
   outputs=[("out_0", "parameters", "text")])

fn("op_post", "postprocess", COL[3], Y, label="โ‘ค Post-processing",
   types={"image": "image", "upscale": "number", "sharpen": "number",
          "saturation": "number", "contrast": "number", "brightness": "number",
          "vignette": "number", "grain": "number", "border": "number"},
   required=("image",),
   data={"upscale": 1.0, "upscale_method": "Lanczos", "sharpen": 0.35,
         "saturation": 1.05, "contrast": 1.02, "brightness": 1.0,
         "vignette": 0.12, "grain": 0.04, "border": 0.0, "watermark": ""},
   outputs=[("out_0", "image", "image")])

out("sub_image", "๐Ÿ–ผ Image", "image", COL[4], Y)
out("sub_params", "๐Ÿ“‹ Generation parameters", "text", COL[4], Y + 300)

link("ref_prompt.out", "op_style.in_prompt")
link("ref_style.out", "op_style.in_style")
link("ref_style.out", "op_negative.in_style")
link("ref_negative.out", "op_negative.in_negative")

link("op_style.out_0", "op_txt2img.in_prompt")
link("op_negative.out_0", "op_txt2img.in_negative_prompt")
link("op_sampler.out_steps", "op_txt2img.in_steps")
link("op_sampler.out_cfg", "op_txt2img.in_cfg_scale")
link("op_sampler.out_seed", "op_txt2img.in_seed")
link("op_sampler.out_width", "op_txt2img.in_width")
link("op_sampler.out_height", "op_txt2img.in_height")

link("op_style.out_0", "op_geninfo.in_prompt")
link("op_negative.out_0", "op_geninfo.in_negative")
link("op_sampler.out_steps", "op_geninfo.in_steps")
link("op_sampler.out_cfg", "op_geninfo.in_cfg_scale")
link("op_sampler.out_seed", "op_geninfo.in_seed")
link("op_sampler.out_width", "op_geninfo.in_width")
link("op_sampler.out_height", "op_geninfo.in_height")

link("op_txt2img.out_0", "op_post.in_image")
link("op_geninfo.out_0", "op_post.in_embed_info")
link("op_post.out_0", "sub_image.in")
link("op_geninfo.out_0", "sub_params.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 2 ยท Hires fix โ€” upscale the txt2img result, then refine it with img2img
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 700
ref("ref_hires_instruction", "Hires refine instruction", "text", COL[2], Y,
    "enhance fine detail and micro-texture, keep the composition identical")

fn("op_hires_prep", "prep_image", COL[3], Y + 130, label="โ‘ฅ Hires prep",
   types={"image": "image", "max_side": "number", "strip_alpha": "boolean"},
   required=("image",),
   data={"max_side": 1024, "mode": "Fit", "strip_alpha": True},
   outputs=[("out_0", "image", "image")])

model("op_hires", EDIT_MODEL, "image_to_image", "image-to-image", COL[4], Y + 130,
      label="โ‘ฆ Hires fix ยท FLUX.1-Kontext",
      inputs=[("image", "image", "image", True),
              ("prompt", "prompt", "text", True)])

out("sub_hires", "โœจ Hires image", "image", COL[5], Y + 130)

link("op_post.out_0", "op_hires_prep.in_image")
link("op_hires_prep.out_0", "op_hires.image")
link("ref_hires_instruction.out", "op_hires.prompt")
link("op_hires.out_0", "sub_hires.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 3 ยท img2img โ€” edit an uploaded image by instruction
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 1080
ref("ref_init_image", "Init image", "image", COL[0], Y, sample("init_image.jpg"))
ref("ref_edit_instruction", "Edit instruction", "text", COL[0], Y + 240,
    "make it a snowy winter scene at golden hour")

fn("op_i2i_prep", "prep_image", COL[1], Y, label="โ‘  Prepare init image",
   types={"image": "image", "max_side": "number", "strip_alpha": "boolean"},
   required=("image",),
   data={"max_side": 1024, "mode": "Fit", "strip_alpha": True},
   outputs=[("out_0", "image", "image")])

model("op_i2i", EDIT_MODEL, "image_to_image", "image-to-image", COL[2], Y,
      label="โ‘ก img2img ยท FLUX.1-Kontext",
      inputs=[("image", "image", "image", True),
              ("prompt", "prompt", "text", True)])

fn("op_i2i_post", "postprocess", COL[3], Y, label="โ‘ข Post-processing",
   types={"image": "image", "upscale": "number", "sharpen": "number",
          "saturation": "number", "contrast": "number", "brightness": "number",
          "vignette": "number", "grain": "number", "border": "number"},
   required=("image",),
   data={"upscale": 1.0, "upscale_method": "Lanczos", "sharpen": 0.3,
         "saturation": 1.0, "contrast": 1.0, "brightness": 1.0,
         "vignette": 0.0, "grain": 0.0, "border": 0.0, "watermark": ""},
   outputs=[("out_0", "image", "image")])

out("sub_i2i", "๐ŸŽจ Edited image", "image", COL[4], Y)

link("ref_init_image.out", "op_i2i_prep.in_image")
link("op_i2i_prep.out_0", "op_i2i.image")
link("ref_edit_instruction.out", "op_i2i.prompt")
link("op_i2i.out_0", "op_i2i_post.in_image")
link("op_i2i_post.out_0", "sub_i2i.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 4 ยท Prompt magic โ€” an LLM writes the prompt for you
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 1450
ref("ref_idea", "Rough idea", "text", COL[0], Y, "a lighthouse in a storm")

fn("op_magic", "magic_instruction", COL[1], Y, label="โ‘  Build instruction",
   required=("idea",),
   data={"target_style": "Cinematic", "verbosity": "Detailed"},
   outputs=[("out_0", "instruction", "text")])

fn("op_llm", "chat_llm", COL[2], Y, label="โ‘ก Prompt LLM ยท Qwen3-4B",
   types={"max_tokens": "number"}, required=("prompt",),
   data={"model_id": LLM_MODEL, "max_tokens": 512},
   outputs=[("out_0", "Text", "text")])

fn("op_clean_magic", "clean_prompt", COL[3], Y, label="โ‘ข Tidy up",
   types={"max_tags": "number"}, required=("raw",),
   data={"max_tags": 40},
   outputs=[("out_0", "prompt", "text")])

out("sub_magic", "๐Ÿช„ Generated prompt", "text", COL[4], Y)

link("ref_idea.out", "op_magic.in_idea")
link("op_magic.out_0", "op_llm.in_prompt")
link("op_llm.out_0", "op_clean_magic.in_raw")
link("op_clean_magic.out_0", "sub_magic.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 5 ยท Interrogate โ€” recover a prompt (and labels) from an image
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 1780
ref("ref_interrogate_image", "Image to interrogate", "image", COL[0], Y,
    sample("interrogate.jpg"))

fn("op_vlm", "interrogate", COL[1], Y, label="โ‘  Interrogate ยท Qwen2.5-VL",
   types={"image": "image", "max_tokens": "number"}, required=("image",),
   data={"instruction": "Describe this image as a Stable Diffusion prompt: "
                        "comma-separated visual tags only, covering subject, setting, "
                        "composition, lighting, colour and medium. No sentences, "
                        "no preamble.",
         "model_id": VLM_MODEL, "max_tokens": 512},
   outputs=[("out_0", "Text", "text")])

fn("op_clean_interrogate", "clean_prompt", COL[2], Y, label="โ‘ก Tidy up",
   types={"max_tags": "number"}, required=("raw",),
   data={"max_tags": 45},
   outputs=[("out_0", "prompt", "text")])

# `fn`, not `model`: a `json` output port reaches the canvas as the literal
# string "[object Object]" (JS String(obj) instead of JSON.stringify), so the
# labels never survive the edge. Text ports carrying JSON do.
fn("op_classify", "classify_image", COL[1], Y + 260, label="โ‘ข Classify ยท ViT",
   types={"image": "image"}, required=("image",),
   data={"model_id": CLASSIFY_MODEL},
   outputs=[("out_0", "labels", "text")])

fn("op_labels", "top_labels", COL[2], Y + 260, label="โ‘ฃ Rank labels",
   types={"labels": "text", "top_k": "number", "min_score": "number"},
   required=("labels",),
   data={"top_k": 5, "min_score": 0.01},
   outputs=[("out_0", "table", "text"), ("out_1", "rows", "text")])

out("sub_interrogated", "๐Ÿ” Recovered prompt", "text", COL[3], Y)
out("sub_labels", "๐Ÿท Classification", "text", COL[3], Y + 260)

link("ref_interrogate_image.out", "op_vlm.in_image")
link("op_vlm.out_0", "op_clean_interrogate.in_raw")
link("op_clean_interrogate.out_0", "sub_interrogated.in")
link("ref_interrogate_image.out", "op_classify.in_image")
link("op_classify.out_0", "op_labels.in_labels")
link("op_labels.out_0", "sub_labels.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 6 ยท Detect & mask โ€” object detection into an inpainting mask
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 2200
ref("ref_detect_image", "Image to analyse", "image", COL[0], Y, sample("detect.jpg"))

fn("op_detect", "detect_objects", COL[1], Y, label="โ‘  Detect ยท DETR",
   types={"image": "image", "min_score": "number"}, required=("image",),
   data={"model_id": DETECT_MODEL, "min_score": 0.0},
   outputs=[("out_0", "detections", "text")])

fn("op_draw", "draw_detections", COL[2], Y, label="โ‘ก Annotate",
   types={"image": "image", "detections": "text", "min_score": "number",
          "show_labels": "boolean"},
   required=("image", "detections"),
   data={"min_score": 0.5, "show_labels": True},
   outputs=[("out_0", "image", "image"), ("out_1", "summary", "text")])

fn("op_mask", "mask_from_detections", COL[2], Y + 300, label="โ‘ข Build inpaint mask",
   types={"image": "image", "detections": "text", "min_score": "number",
          "feather": "number", "invert": "boolean", "preview": "boolean"},
   required=("image", "detections"),
   data={"label_filter": "", "min_score": 0.5, "feather": 8,
         "invert": False, "preview": False},
   outputs=[("out_0", "mask", "image")])

out("sub_detected", "๐Ÿ“ฆ Detected objects", "image", COL[3], Y)
out("sub_detect_summary", "๐Ÿ“ Detection summary", "text", COL[3], Y + 300)
out("sub_mask", "๐ŸŽญ Inpaint mask", "image", COL[4], Y + 300)

link("ref_detect_image.out", "op_detect.in_image")
link("ref_detect_image.out", "op_draw.in_image")
link("op_detect.out_0", "op_draw.in_detections")
link("ref_detect_image.out", "op_mask.in_image")
link("op_detect.out_0", "op_mask.in_detections")
link("op_draw.out_0", "sub_detected.in")
link("op_draw.out_1", "sub_detect_summary.in")
link("op_mask.out_0", "sub_mask.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 7 ยท Prompt matrix โ€” four variants rendered in parallel into an X/Y grid
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 2700
ref("ref_matrix_base", "Matrix base prompt", "text", COL[0], Y, "a lone tree on a hill")
ref("ref_matrix_variants", "Variants (| separated)", "text", COL[0], Y + 120,
    "at sunrise | in a thunderstorm | under the milky way | in autumn fog")

fn("op_matrix", "prompt_matrix", COL[1], Y, label="โ‘  Expand matrix",
   required=("base_prompt",),
   data={"shared_tags": "cinematic, highly detailed, dramatic lighting"},
   outputs=[("out_p1", "prompt 1", "text"), ("out_p2", "prompt 2", "text"),
            ("out_p3", "prompt 3", "text"), ("out_p4", "prompt 4", "text"),
            ("out_labels", "labels", "text")])

for i in range(4):
    fn(f"op_grid_{i + 1}", "txt2img", COL[2], Y + i * 250,
       label=f"โ‘ก Render {i + 1}",
       types={"steps": "number", "cfg_scale": "number", "seed": "number",
              "width": "number", "height": "number"},
       required=("prompt",),
       data={"negative_prompt": "", "steps": 4, "cfg_scale": 1.0,
             "seed": 1000 + i * 111, "width": 768, "height": 768,
             "model_id": T2I_MODEL},
       outputs=[("out_0", "image", "image")])
    link(f"op_matrix.out_p{i + 1}", f"op_grid_{i + 1}.in_prompt")
    link(f"op_grid_{i + 1}.out_0", f"op_sheet.in_image_{i + 1}")

fn("op_sheet", "contact_sheet", COL[3], Y + 320, label="โ‘ข Contact sheet",
   types={"image_1": "image", "image_2": "image", "image_3": "image",
          "image_4": "image", "columns": "number", "gap": "number"},
   data={"columns": 2, "gap": 16, "title": "Prompt matrix"},
   outputs=[("out_0", "grid", "image")])

out("sub_grid", "๐Ÿงฉ X/Y grid", "image", COL[4], Y + 320)

link("ref_matrix_base.out", "op_matrix.in_base_prompt")
link("ref_matrix_variants.out", "op_matrix.in_variations")
link("op_matrix.out_labels", "op_sheet.in_labels")
link("op_sheet.out_0", "sub_grid.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 8 ยท Extras โ€” one upload, three post-processors (two local, one remote)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 3560
ref("ref_extras_image", "Extras input image", "image", COL[0], Y, sample("extras.jpg"))

fn("op_extras", "extras_upscale", COL[1], Y, label="โ‘  Upscale (local, instant)",
   types={"image": "image", "factor": "number", "sharpen": "number",
          "denoise": "boolean", "restore_contrast": "boolean"},
   required=("image",),
   data={"factor": 2.0, "method": "Lanczos", "sharpen": 0.45,
         "denoise": False, "restore_contrast": True},
   outputs=[("out_0", "image", "image"), ("out_1", "report", "text")])

space("op_aurasr", UPSCALE_SPACE, "/process_image", COL[1], Y + 300,
      label="โ‘ก Upscale ร—4 (AuraSR GAN)",
      inputs=[("input_image", "image", "image", True)],
      outputs=[("out_0", "Upscaled", "image", 1)])

space("op_rmbg", RMBG_SPACE, "/image", COL[1], Y + 500,
      label="โ‘ข Remove background (BRIA)",
      inputs=[("image", "image", "image", True)],
      outputs=[("out_0", "Cutout", "image", 1)])

out("sub_upscaled", "๐Ÿ” Upscaled (local)", "image", COL[2], Y)
out("sub_upscale_report", "๐Ÿ“ Upscale report", "text", COL[3], Y)
out("sub_aurasr", "๐Ÿš€ Upscaled ร—4 (GAN)", "image", COL[2], Y + 300)
out("sub_cutout", "โœ‚ Background removed", "image", COL[2], Y + 620)

link("ref_extras_image.out", "op_extras.in_image")
link("op_extras.out_0", "sub_upscaled.in")
link("op_extras.out_1", "sub_upscale_report.in")
link("ref_extras_image.out", "op_aurasr.input_image")
link("op_aurasr.out_0", "sub_aurasr.in")
link("ref_extras_image.out", "op_rmbg.image")
link("op_rmbg.out_0", "sub_cutout.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 9 ยท ControlNet-style annotator previews (local)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 4340
ref("ref_control_image", "Annotator input", "image", COL[0], Y, sample("control.jpg"))

fn("op_control", "controlnet_preprocess", COL[1], Y, label="Annotator",
   types={"image": "image", "low_threshold": "number", "high_threshold": "number",
          "invert": "boolean", "blur": "number"},
   required=("image",),
   data={"mode": "Canny edges", "low_threshold": 60, "high_threshold": 160,
         "invert": False, "blur": 0.0},
   outputs=[("out_0", "map", "image")])

out("sub_control", "๐Ÿ•ธ Annotator map", "image", COL[2], Y)

link("ref_control_image.out", "op_control.in_image")
link("op_control.out_0", "sub_control.in")

# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 10 ยท PNG Info โ€” read generation parameters back out of a file
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Y = 4700
ref("ref_pnginfo_image", "PNG to inspect", "image", COL[0], Y,
    sample("with_parameters.png"))

fn("op_pnginfo", "png_info", COL[1], Y, label="Read PNG metadata",
   types={"image": "image"}, required=("image",),
   outputs=[("out_0", "report", "text"), ("out_1", "fields", "text")])

out("sub_png_report", "๐Ÿงพ PNG info", "text", COL[2], Y)
out("sub_png_fields", "๐Ÿงฎ Parsed fields", "text", COL[3], Y)

link("ref_pnginfo_image.out", "op_pnginfo.in_image")
link("op_pnginfo.out_0", "sub_png_report.in")
link("op_pnginfo.out_1", "sub_png_fields.in")


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Verification โ€” catch wiring mistakes here, not at runtime
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

from gradio.workflow import _INFERENCE_ENDPOINT_SCHEMAS  # noqa: E402


def verify():
    problems = []
    nodes = references + operators + subjects
    by_id = {}

    for n in nodes:
        if n["id"] in by_id:
            problems.append(f"duplicate node id: {n['id']}")
        by_id[n["id"]] = n

    # fn nodes: ports must mirror the Python signature exactly (positional call)
    for n in operators:
        if n["kind"] != "fn":
            continue
        func = N.BIND.get(n["fn"])
        if func is None:
            problems.append(f"{n['id']}: fn '{n['fn']}' is not in nodes.BIND")
            continue
        params = fn_params(func)
        labels = [p["label"] for p in n["inputs"]]
        if labels != params:
            problems.append(f"{n['id']}: port order {labels} != signature {params}")
        for key in n["data"]:
            if key not in {p["id"] for p in n["inputs"]}:
                problems.append(f"{n['id']}: data key '{key}' is not an input port")

    # model nodes: their ports must match the endpoint's canonical schema
    # EXACTLY. The canvas rewrites any model node whose ports differ, silently
    # dropping extra inputs (and orphaning the edges into them) the first time
    # the graph is opened in a browser. Anything needing a richer control
    # surface than the schema allows has to be an `fn` node calling
    # InferenceClient itself โ€” that is why `txt2img` is one.
    for n in operators:
        if n["kind"] != "model":
            continue
        schema = _INFERENCE_ENDPOINT_SCHEMAS.get(n["endpoint"])
        if schema is None:
            problems.append(f"{n['id']}: unknown endpoint '{n['endpoint']}'")
            continue
        expected = [p["id"] for p in schema["inputs"]]
        actual = [p["id"] for p in n["inputs"]]
        if actual != expected:
            problems.append(
                f"{n['id']}: model ports {actual} != {n['endpoint']} schema "
                f"{expected} โ€” the canvas would rewrite this node")

    # edges: endpoints must exist, and types must agree
    port_type = {}
    for n in nodes:
        for p in n.get("inputs", []):
            port_type[(n["id"], p["id"], "in")] = p["type"]
        for p in n.get("outputs", []):
            port_type[(n["id"], p["id"], "out")] = p["type"]

    fed = set()
    for e in edges:
        src = (e["from_node_id"], e["from_port_id"], "out")
        dst = (e["to_node_id"], e["to_port_id"], "in")
        if src not in port_type:
            problems.append(f"edge {e['id']}: no output port {src[0]}.{src[1]}")
            continue
        if dst not in port_type:
            problems.append(f"edge {e['id']}: no input port {dst[0]}.{dst[1]}")
            continue
        if dst in fed:
            problems.append(f"edge {e['id']}: {dst[0]}.{dst[1]} has two incoming edges")
        fed.add(dst)

        stype, dtype = port_type[src], port_type[dst]
        e["type"] = stype
        compatible = stype == dtype or "text" in (stype, dtype) and {stype, dtype} <= {
            "text", "number", "boolean", "json"}
        if not compatible:
            problems.append(
                f"edge {e['id']}: type mismatch {src[0]}.{src[1]}({stype}) "
                f"โ†’ {dst[0]}.{dst[1]}({dtype})")

    # every subject must be fed, and every required input must be satisfied
    for s in subjects:
        if (s["id"], "in", "in") not in fed:
            problems.append(f"subject {s['id']} has no incoming edge")
    for n in operators:
        for p in n["inputs"]:
            if not p.get("required"):
                continue
            if (n["id"], p["id"], "in") not in fed and p["id"] not in n["data"]:
                problems.append(
                    f"{n['id']}: required input '{p['id']}' is neither wired nor defaulted")

    # nothing may be orphaned
    touched = {e["from_node_id"] for e in edges} | {e["to_node_id"] for e in edges}
    for n in nodes:
        if n["id"] not in touched:
            problems.append(f"orphan node: {n['id']}")

    return problems


if __name__ == "__main__":
    issues = verify()
    if issues:
        print(f"REFUSING TO WRITE โ€” {len(issues)} problem(s):")
        for p in issues:
            print("  โ€ข", p)
        sys.exit(1)

    # Apply the curated layout. Positions in `layout.json` are the hand-arranged
    # ones (dragged in the canvas, then overlap-checked); the x/y computed above
    # are only a fallback for nodes the layout doesn't know about yet.
    placed = 0
    if os.path.exists(LAYOUT):
        with open(LAYOUT, encoding="utf-8") as f:
            layout = json.load(f)
        for node in references + operators + subjects:
            pos = layout.get(node["id"])
            if pos:
                node["x"], node["y"] = pos["x"], pos["y"]
                placed += 1
        missing = [n["id"] for n in references + operators + subjects
                   if n["id"] not in layout]
        if missing:
            print(f"  note: {len(missing)} node(s) not in layout.json, using "
                  f"generated positions: {', '.join(missing[:6])}")

    graph = {
        "schema_version": "2",
        "name": "Workflow1111 ยท Diffusion Studio",
        "references": references,
        "operators": operators,
        "subjects": subjects,
        "edges": edges,
    }
    with open(OUT, "w", encoding="utf-8") as f:
        json.dump(graph, f, indent=2, ensure_ascii=False)

    kinds = {}
    for o in operators:
        kinds[o["kind"]] = kinds.get(o["kind"], 0) + 1
    print(f"wrote {os.path.relpath(OUT, os.getcwd())}")
    print(f"  {len(references)} references, {len(operators)} operators "
          f"({', '.join(f'{v} {k}' for k, v in sorted(kinds.items()))}), "
          f"{len(subjects)} subjects, {len(edges)} edges")
    print(f"  {placed} node positions applied from layout.json")