From 5c6995246d6687ae670438c0281a88fced4a4e82 Mon Sep 17 00:00:00 2001 From: bymyself Date: Wed, 2 Sep 2026 12:56:08 +0000 Subject: [PATCH 1/2] generate: emit --emit-workflow output via workflow_ops primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit comfy generate --emit-workflow / comfy workflow compose produced raw API-format dicts that comfy workflow slot-editing commands (list_slots/set_widget/connect) rejected as workflow_not_frontend_format (BE-11131 — 48 staging refusals). build_workflow now constructs the partner node, LoadImage loaders, ImageBatch chains, and the SaveImage/ SaveVideo sink via workflow_ops.add_node/connect/set_widget instead of hand-writing dict entries with a local sequential-id counter. Output is now frontend-format (nodes[]/links[]) plus the replayable op list those primitives emit, so the CRDT doc-host applier can replay it directly once a --emit-ops flag lands (tracked separately). --emit-workflow has no server/API-key dependency, so the node classes it addresses (the 5 partner nodes in MODEL_NODE_MAP plus LoadImage/ ImageBatch/SaveImage/SaveVideo) are resolved against a bundled, offline object_info snapshot (comfy_cli/command/generate/data/emit_object_info.json) rather than a live catalog fetch — same recorded-snapshot approach test_emit.py already used for its completeness-contract test, now the single source both the module and the test read. Tests: fixture extended with LoadImage/ImageBatch/SaveImage/SaveVideo; test_emit.py assertions rewritten from API-format literals to frontend-format node/link/op-list assertions, plus a new apply_op-replay-matches-build_workflow test (P1 fidelity). Plan: research/architecture/ecw-52-emit-ops-implementation-plan-2026-09-01.md in christian-byrne/in-app-agent-program (PR A of the split). Trace: BE-11131, ecw-52, ecw-57. ## Evidence - uv run pytest tests/comfy_cli/command/generate/test_emit.py -q → 31 passed - uv run ruff check + ruff format --check on changed files → clean - pre-existing hang on origin/main confirmed for the rest of tests/comfy_cli/command/generate/* (test_app.py, test_list_schema_envelope.py timeout under this env regardless of these changes) — not a regression --- comfy_cli/command/generate/data/__init__.py | 0 .../generate/data/emit_object_info.json | 1017 +++++++++++++++++ comfy_cli/command/generate/emit.py | 137 ++- pyproject.toml | 1 + .../fixtures/partner_nodes_object_info.json | 213 +++- tests/comfy_cli/command/generate/test_emit.py | 227 ++-- 6 files changed, 1474 insertions(+), 121 deletions(-) create mode 100644 comfy_cli/command/generate/data/__init__.py create mode 100644 comfy_cli/command/generate/data/emit_object_info.json diff --git a/comfy_cli/command/generate/data/__init__.py b/comfy_cli/command/generate/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/comfy_cli/command/generate/data/emit_object_info.json b/comfy_cli/command/generate/data/emit_object_info.json new file mode 100644 index 000000000..7b8eb94a2 --- /dev/null +++ b/comfy_cli/command/generate/data/emit_object_info.json @@ -0,0 +1,1017 @@ +{ + "ByteDanceImageToVideoNode": { + "api_node": true, + "category": "partner/video/ByteDance", + "deprecated": false, + "description": "Generate video using ByteDance models via api based on image and prompt", + "dev_only": false, + "display_name": "ByteDance Image to Video", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "camera_fixed": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "Specifies whether to fix the camera. The platform appends an instruction to fix the camera to your prompt, but does not guarantee the actual effect." + } + ], + "generate_audio": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 0, + "display": "number", + "max": 2147483647, + "min": 0, + "step": 1, + "tooltip": "Seed to use for generation." + } + ], + "watermark": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "Whether to add an \"AI generated\" watermark to the video." + } + ] + }, + "required": { + "aspect_ratio": [ + "COMBO", + { + "multiselect": false, + "options": [ + "adaptive", + "16:9", + "4:3", + "1:1", + "3:4", + "9:16", + "21:9" + ], + "tooltip": "The aspect ratio of the output video." + } + ], + "duration": [ + "INT", + { + "default": 5, + "display": "slider", + "max": 12, + "min": 3, + "step": 1, + "tooltip": "The duration of the output video in seconds." + } + ], + "image": [ + "IMAGE", + { + "tooltip": "First frame to be used for the video." + } + ], + "model": [ + "COMBO", + { + "default": "seedance-1-0-pro-fast-251015", + "multiselect": false, + "options": [ + "seedance-1-5-pro-251215", + "seedance-1-0-pro-250528", + "seedance-1-0-lite-i2v-250428", + "seedance-1-0-pro-fast-251015" + ] + } + ], + "prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "The text prompt used to generate the video." + } + ], + "resolution": [ + "COMBO", + { + "multiselect": false, + "options": [ + "480p", + "720p", + "1080p" + ], + "tooltip": "The resolution of the output video." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "seed", + "camera_fixed", + "watermark", + "generate_audio" + ], + "required": [ + "model", + "prompt", + "image", + "resolution", + "aspect_ratio", + "duration" + ] + }, + "is_input_list": false, + "name": "ByteDanceImageToVideoNode", + "output": [ + "VIDEO" + ], + "output_is_list": [ + false + ], + "output_matchtypes": null, + "output_name": [ + "VIDEO" + ], + "output_node": false, + "output_tooltips": [ + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [ + { + "name": "model", + "type": "COMBO" + }, + { + "name": "duration", + "type": "INT" + }, + { + "name": "resolution", + "type": "COMBO" + }, + { + "name": "generate_audio", + "type": "BOOLEAN" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $priceByModel := {\n \"seedance-1-5-pro\": {\n \"480p\":[0.12,0.12],\n \"720p\":[0.26,0.26],\n \"1080p\":[0.58,0.59]\n },\n \"seedance-1-0-pro\": {\n \"480p\":[0.23,0.24],\n \"720p\":[0.51,0.56],\n \"1080p\":[1.18,1.22]\n },\n \"seedance-1-0-pro-fast\": {\n \"480p\":[0.09,0.1],\n \"720p\":[0.21,0.23],\n \"1080p\":[0.47,0.49]\n },\n \"seedance-1-0-lite\": {\n \"480p\":[0.17,0.18],\n \"720p\":[0.37,0.41],\n \"1080p\":[0.85,0.88]\n }\n };\n $model := widgets.model;\n $modelKey :=\n $contains($model, \"seedance-1-5-pro\") ? \"seedance-1-5-pro\" :\n $contains($model, \"seedance-1-0-pro-fast\") ? \"seedance-1-0-pro-fast\" :\n $contains($model, \"seedance-1-0-pro\") ? \"seedance-1-0-pro\" :\n \"seedance-1-0-lite\";\n $resolution := widgets.resolution;\n $resKey :=\n $contains($resolution, \"1080\") ? \"1080p\" :\n $contains($resolution, \"720\") ? \"720p\" :\n \"480p\";\n $modelPrices := $lookup($priceByModel, $modelKey);\n $baseRange := $lookup($modelPrices, $resKey);\n $min10s := $baseRange[0];\n $max10s := $baseRange[1];\n $scale := widgets.duration / 10;\n $audioMultiplier := ($modelKey = \"seedance-1-5-pro\" and widgets.generate_audio) ? 2 : 1;\n $minCost := $min10s * $scale * $audioMultiplier;\n $maxCost := $max10s * $scale * $audioMultiplier;\n ($minCost = $maxCost)\n ? {\"type\":\"usd\",\"usd\": $minCost, \"format\": { \"approximate\": true }}\n : {\"type\":\"range_usd\",\"min_usd\": $minCost, \"max_usd\": $maxCost, \"format\": { \"approximate\": true }}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_bytedance", + "search_aliases": null + }, + "Flux2ProImageNode": { + "api_node": true, + "category": "partner/image/BFL", + "deprecated": true, + "description": "Generates images synchronously based on prompt and resolution.", + "dev_only": false, + "display_name": "Flux.2 [pro] Image", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "images": [ + "IMAGE", + { + "tooltip": "Up to 9 images to be used as references." + } + ] + }, + "required": { + "height": [ + "INT", + { + "default": 768, + "max": 2048, + "min": 256, + "step": 32 + } + ], + "prompt": [ + "STRING", + { + "default": "", + "multiline": true, + "tooltip": "Prompt for the image generation or edit" + } + ], + "prompt_upsampling": [ + "BOOLEAN", + { + "advanced": true, + "default": true, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation." + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 0, + "max": 18446744073709551615, + "min": 0, + "tooltip": "The random seed used for creating the noise." + } + ], + "width": [ + "INT", + { + "default": 1024, + "max": 2048, + "min": 256, + "step": 32 + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "images" + ], + "required": [ + "prompt", + "width", + "height", + "seed", + "prompt_upsampling" + ] + }, + "is_input_list": false, + "name": "Flux2ProImageNode", + "output": [ + "IMAGE" + ], + "output_is_list": [ + false + ], + "output_matchtypes": null, + "output_name": [ + "IMAGE" + ], + "output_node": false, + "output_tooltips": [ + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [ + "images" + ], + "widgets": [ + { + "name": "width", + "type": "INT" + }, + { + "name": "height", + "type": "INT" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $MP := 1024 * 1024;\n $outMP := $max([1, $floor(((widgets.width * widgets.height) + $MP - 1) / $MP)]);\n $outputCost := 0.03 + 0.015 * ($outMP - 1);\n inputs.images.connected\n ? {\n \"type\":\"range_usd\",\n \"min_usd\": $outputCost + 0.015,\n \"max_usd\": $outputCost + 0.12,\n \"format\": { \"approximate\": true }\n }\n : {\"type\":\"usd\",\"usd\": $outputCost}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_bfl", + "search_aliases": null + }, + "FluxProUltraImageNode": { + "api_node": true, + "category": "partner/image/BFL", + "deprecated": false, + "description": "Generates images using Flux Pro 1.1 Ultra via api based on prompt and resolution.", + "dev_only": false, + "display_name": "Flux 1.1 [pro] Ultra Image", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "image_prompt": [ + "IMAGE", + {} + ], + "image_prompt_strength": [ + "FLOAT", + { + "default": 0.1, + "max": 1.0, + "min": 0.0, + "step": 0.01, + "tooltip": "Blend between the prompt and the image prompt." + } + ] + }, + "required": { + "aspect_ratio": [ + "STRING", + { + "default": "16:9", + "multiline": false, + "tooltip": "Aspect ratio of image; must be between 1:4 and 4:1." + } + ], + "prompt": [ + "STRING", + { + "default": "", + "multiline": true, + "tooltip": "Prompt for the image generation" + } + ], + "prompt_upsampling": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result)." + } + ], + "raw": [ + "BOOLEAN", + { + "default": false, + "tooltip": "When True, generate less processed, more natural-looking images." + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 0, + "max": 18446744073709551615, + "min": 0, + "tooltip": "The random seed used for creating the noise." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "image_prompt", + "image_prompt_strength" + ], + "required": [ + "prompt", + "prompt_upsampling", + "seed", + "aspect_ratio", + "raw" + ] + }, + "is_input_list": false, + "name": "FluxProUltraImageNode", + "output": [ + "IMAGE" + ], + "output_is_list": [ + false + ], + "output_matchtypes": null, + "output_name": [ + "IMAGE" + ], + "output_node": false, + "output_tooltips": [ + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [] + }, + "engine": "jsonata", + "expr": "{\"type\":\"usd\",\"usd\":0.06}" + }, + "python_module": "comfy_api_nodes.nodes_bfl", + "search_aliases": null + }, + "GeminiImageNode": { + "api_node": true, + "category": "partner/image/Gemini", + "deprecated": false, + "description": "Edit images synchronously via Google API.", + "dev_only": false, + "display_name": "Nano Banana (Google Gemini Image)", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "aspect_ratio": [ + "COMBO", + { + "default": "auto", + "multiselect": false, + "options": [ + "auto", + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9" + ], + "tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares." + } + ], + "files": [ + "GEMINI_INPUT_FILES", + { + "tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node." + } + ], + "images": [ + "IMAGE", + { + "tooltip": "Optional image(s) to use as context for the model. To include multiple images, you can use the Batch Images node." + } + ], + "response_modalities": [ + "COMBO", + { + "advanced": true, + "multiselect": false, + "options": [ + "IMAGE+TEXT", + "IMAGE" + ], + "tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response." + } + ], + "system_prompt": [ + "STRING", + { + "advanced": true, + "default": "You are an expert image-generation engine. You must ALWAYS produce an image.\nInterpret all user input\u2014regardless of format, intent, or abstraction\u2014as literal visual directives for image composition.\nIf a prompt is conversational or lacks specific visual details, you must creatively invent a concrete visual scenario that depicts the concept.\nPrioritize generating the visual representation above any text, formatting, or conversational requests.", + "multiline": true, + "tooltip": "Foundational instructions that dictate an AI's behavior." + } + ] + }, + "required": { + "model": [ + "COMBO", + { + "multiselect": false, + "options": [ + "gemini-2.5-flash-image" + ], + "tooltip": "The Gemini model to use for generating responses." + } + ], + "prompt": [ + "STRING", + { + "default": "", + "multiline": true, + "tooltip": "Text prompt for generation" + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 42, + "max": 18446744073709551615, + "min": 0, + "tooltip": "When seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "images", + "files", + "aspect_ratio", + "response_modalities", + "system_prompt" + ], + "required": [ + "prompt", + "model", + "seed" + ] + }, + "is_input_list": false, + "name": "GeminiImageNode", + "output": [ + "IMAGE", + "STRING" + ], + "output_is_list": [ + false, + false + ], + "output_matchtypes": null, + "output_name": [ + "IMAGE", + "STRING" + ], + "output_node": false, + "output_tooltips": [ + null, + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [] + }, + "engine": "jsonata", + "expr": "{\"type\":\"usd\",\"usd\":0.039,\"format\":{\"suffix\":\"/Image (1K)\",\"approximate\":true}}" + }, + "python_module": "comfy_api_nodes.nodes_gemini", + "search_aliases": null + }, + "ImageBatch": { + "input": { + "required": { + "image1": [ + "IMAGE", + {} + ], + "image2": [ + "IMAGE", + {} + ] + } + }, + "input_order": { + "required": [ + "image1", + "image2" + ] + }, + "output": [ + "IMAGE" + ], + "output_is_list": [ + false + ], + "output_name": [ + "IMAGE" + ], + "name": "ImageBatch", + "display_name": "Batch Images", + "description": "Batches two images together.", + "category": "image", + "output_node": false, + "api_node": false, + "deprecated": true, + "python_module": "nodes", + "search_aliases": [ + "combine images", + "merge images", + "stack images" + ] + }, + "KlingImage2VideoNode": { + "api_node": true, + "category": "partner/video/Kling", + "deprecated": false, + "description": "", + "dev_only": false, + "display_name": "Kling Image(First Frame) to Video", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "required": { + "aspect_ratio": [ + "COMBO", + { + "default": "16:9", + "multiselect": false, + "options": [ + "16:9", + "9:16", + "1:1" + ] + } + ], + "cfg_scale": [ + "FLOAT", + { + "default": 0.8, + "max": 1.0, + "min": 0.0 + } + ], + "duration": [ + "COMBO", + { + "default": "5", + "multiselect": false, + "options": [ + "5", + "10" + ] + } + ], + "mode": [ + "COMBO", + { + "default": "std", + "multiselect": false, + "options": [ + "std", + "pro" + ] + } + ], + "model_name": [ + "COMBO", + { + "default": "kling-v2-master", + "multiselect": false, + "options": [ + "kling-v1", + "kling-v1-5", + "kling-v1-6", + "kling-v2-master", + "kling-v2-1", + "kling-v2-1-master", + "kling-v2-5-turbo" + ] + } + ], + "negative_prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "Negative text prompt" + } + ], + "prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "Positive text prompt" + } + ], + "start_frame": [ + "IMAGE", + { + "tooltip": "The reference image used to generate the video." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "required": [ + "start_frame", + "prompt", + "negative_prompt", + "model_name", + "cfg_scale", + "mode", + "aspect_ratio", + "duration" + ] + }, + "is_input_list": false, + "name": "KlingImage2VideoNode", + "output": [ + "VIDEO", + "STRING", + "STRING" + ], + "output_is_list": [ + false, + false, + false + ], + "output_matchtypes": null, + "output_name": [ + "VIDEO", + "video_id", + "duration" + ], + "output_node": false, + "output_tooltips": [ + null, + null, + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [ + { + "name": "mode", + "type": "COMBO" + }, + { + "name": "model_name", + "type": "COMBO" + }, + { + "name": "duration", + "type": "COMBO" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $mode := widgets.mode;\n $model := widgets.model_name;\n $dur := widgets.duration;\n $contains($model,\"v2-5-turbo\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.7} : {\"type\":\"usd\",\"usd\":0.35})\n : ($contains($model,\"v2-1-master\") or $contains($model,\"v2-master\"))\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":2.8} : {\"type\":\"usd\",\"usd\":1.4})\n : ($contains($model,\"v2-1\") or $contains($model,\"v1-6\") or $contains($model,\"v1-5\"))\n ? (\n $contains($mode,\"pro\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.98} : {\"type\":\"usd\",\"usd\":0.49})\n : ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.56} : {\"type\":\"usd\",\"usd\":0.28})\n )\n : $contains($model,\"v1\")\n ? (\n $contains($mode,\"pro\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.98} : {\"type\":\"usd\",\"usd\":0.49})\n : ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.28} : {\"type\":\"usd\",\"usd\":0.14})\n )\n : {\"type\":\"usd\",\"usd\":0.14}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_kling", + "search_aliases": null + }, + "LoadImage": { + "input": { + "required": { + "image": [ + [], + { + "image_upload": true + } + ] + } + }, + "input_order": { + "required": [ + "image" + ] + }, + "output": [ + "IMAGE", + "MASK" + ], + "output_is_list": [ + false, + false + ], + "output_name": [ + "IMAGE", + "MASK" + ], + "name": "LoadImage", + "display_name": "Load Image", + "description": "Load an image from disk.", + "category": "image", + "output_node": false, + "api_node": false, + "deprecated": false, + "python_module": "nodes", + "search_aliases": [ + "load image", + "open image", + "import image", + "image input", + "upload image", + "read image", + "image loader" + ] + }, + "SaveImage": { + "input": { + "required": { + "images": [ + "IMAGE", + { + "tooltip": "The images to save." + } + ], + "filename_prefix": [ + "STRING", + { + "default": "ComfyUI", + "tooltip": "The prefix for the file to save." + } + ] + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO" + } + }, + "input_order": { + "required": [ + "images", + "filename_prefix" + ], + "hidden": [ + "prompt", + "extra_pnginfo" + ] + }, + "output": [], + "output_is_list": [], + "output_name": [], + "name": "SaveImage", + "display_name": "Save Image", + "description": "Saves the input images to your ComfyUI output directory.", + "category": "image", + "output_node": true, + "api_node": false, + "deprecated": false, + "python_module": "nodes", + "search_aliases": [ + "save", + "save image", + "export image", + "output image", + "write image", + "download" + ] + }, + "SaveVideo": { + "input": { + "required": { + "video": [ + "VIDEO", + { + "tooltip": "The video to save." + } + ], + "filename_prefix": [ + "STRING", + { + "default": "video/ComfyUI", + "tooltip": "The prefix for the file to save." + } + ], + "format": [ + [ + "auto", + "mp4", + "webm" + ], + { + "default": "auto", + "tooltip": "The format to save the video as." + } + ], + "codec": [ + [ + "auto", + "h264", + "vp9" + ], + { + "default": "auto", + "tooltip": "The codec to use for the video." + } + ] + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO" + } + }, + "input_order": { + "required": [ + "video", + "filename_prefix", + "format", + "codec" + ], + "hidden": [ + "prompt", + "extra_pnginfo" + ] + }, + "output": [], + "output_is_list": [], + "output_name": [], + "name": "SaveVideo", + "display_name": "Save Video", + "description": "Saves the input images to your ComfyUI output directory.", + "category": "image/video", + "output_node": true, + "api_node": false, + "deprecated": false, + "python_module": "comfy_extras.nodes_video", + "search_aliases": [ + "export video" + ] + } +} diff --git a/comfy_cli/command/generate/emit.py b/comfy_cli/command/generate/emit.py index 9030aea49..a5f6dee4f 100644 --- a/comfy_cli/command/generate/emit.py +++ b/comfy_cli/command/generate/emit.py @@ -18,10 +18,13 @@ import json from dataclasses import dataclass, field +from importlib import resources from pathlib import Path from typing import Any +from comfy_cli import workflow_ops from comfy_cli.command.generate import spec +from comfy_cli.cql.engine import Graph class EmitError(RuntimeError): @@ -230,6 +233,34 @@ class NodeSpec: } +# Bundled, offline object_info snapshot covering exactly the classes this +# module ever mints: every ``node_class`` in MODEL_NODE_MAP, plus the fixed +# support cast (LoadImage/ImageBatch/SaveImage/SaveVideo). ``--emit-workflow`` +# has no server/API-key dependency (see module docstring), so this cannot be a +# live ``object_info`` fetch — it is the same recorded-snapshot approach +# ``tests/comfy_cli/command/generate/test_emit.py`` already used for the +# completeness-contract test, promoted from test-only to the module that needs +# it so the two never drift. Refresh both together from `comfy nodes show +# --json` against a current cloud catalog when a mapped node's +# schema changes. +_SUPPORT_NODE_CLASSES = ("LoadImage", "ImageBatch", "SaveImage", "SaveVideo") + + +def _load_catalog() -> Graph: + data = resources.files("comfy_cli.command.generate.data").joinpath("emit_object_info.json").read_bytes() + return Graph.from_object_info(json.loads(data)) + + +_CATALOG: Graph | None = None + + +def _catalog() -> Graph: + global _CATALOG + if _CATALOG is None: + _CATALOG = _load_catalog() + return _CATALOG + + def supported_models() -> list[str]: """Aliases that ``--emit-workflow`` knows how to render as a node.""" return sorted(MODEL_NODE_MAP) @@ -260,52 +291,78 @@ def _resolve_model(model: str) -> tuple[str, NodeSpec]: return found -def build_workflow(model: str, values: dict[str, Any], *, output_prefix: str = "generate") -> dict[str, Any]: - """Build an API-format workflow that drives the partner node for ``model``. +def build_workflow( + model: str, values: dict[str, Any], *, output_prefix: str = "generate" +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Build a frontend-format workflow that drives the partner node for ``model``. ``values`` are the parsed ``--param`` values (same dict the proxy client receives). Local-file image params are materialized as ``LoadImage`` nodes and wired into the partner node; scalar params override the node's fixed defaults. A ``SaveImage``/``SaveVideo`` is appended so ``comfy run`` writes the result to disk. + + Returns ``(workflow, ops)``: ``workflow`` is frontend-format (``nodes``/ + ``links``, minted via ``workflow_ops.add_node``/``connect``/``set_widget`` + — the same CRDT-ready primitives ``comfy workflow`` slot-editing commands + require), and ``ops`` is the replayable op stream those primitives emitted, + in apply order (BE-11131: this is what makes the result editable downstream + instead of a raw API-format dict `list_slots`/`set_widget`/`connect` reject + as ``workflow_not_frontend_format``). + + The partner node classes in ``MODEL_NODE_MAP`` are addressed by name + against the bundled snapshot regardless of that snapshot's own + ``deprecated`` flag (some partner nodes are marked deprecated upstream in + favor of a newer node this emitter does not yet target) — ``--emit-workflow`` + always emits the class ``NodeSpec`` names, so every ``add_node`` call in + this module passes ``allow_deprecated=True``. """ _alias, ns = _resolve_model(model) + graph = _catalog() - node_inputs: dict[str, Any] = dict(ns.fixed) + workflow: dict[str, Any] = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + ops: list[dict[str, Any]] = [] + + def _add(class_type: str) -> Any: + nonlocal workflow + workflow, op = workflow_ops.add_node(workflow, graph, class_type, allow_deprecated=True) + ops.append(op) + return op["node_id"] + + def _set(node_id: Any, widget: str, value: Any) -> None: + nonlocal workflow + workflow, op = workflow_ops.set_widget(workflow, graph, node_id, widget, value) + ops.append(op) - next_id = 2 # the partner node is "1"; loaders/save get 2, 3, … + def _link(from_node: Any, from_slot: int, to_node: Any, to_slot: int) -> None: + nonlocal workflow + workflow, op = workflow_ops.connect(workflow, graph, from_node, from_slot, to_node, to_slot) + ops.append(op) + + node_inputs: dict[str, Any] = dict(ns.fixed) + image_links: dict[str, tuple[Any, int]] = {} # Image-path params → LoadImage nodes wired into the partner node. - workflow: dict[str, Any] = {} for flag, node_key in ns.image_params.items(): raw = values.get(flag) if raw is None: continue paths = [str(Path(p).expanduser()) for p in (raw if isinstance(raw, list | tuple) else [raw])] - loader_ids: list[str] = [] + loader_ids: list[Any] = [] for path in paths: - loader_id = str(next_id) - next_id += 1 - workflow[loader_id] = { - "class_type": "LoadImage", - "_meta": {"title": f"load {Path(path).name}"}, - "inputs": {"image": path}, - } + loader_id = _add("LoadImage") + _set(loader_id, "image", path) loader_ids.append(loader_id) # One file wires straight in; several fold through chained core # ImageBatch nodes (2-input, always present) so the partner still # receives a single IMAGE stream. upstream, upstream_out = loader_ids[0], 0 for lid in loader_ids[1:]: - batch_id = str(next_id) - next_id += 1 - workflow[batch_id] = { - "class_type": "ImageBatch", - "_meta": {"title": "batch reference images"}, - "inputs": {"image1": [upstream, upstream_out], "image2": [lid, 0]}, - } + batch_id = _add("ImageBatch") + _link(upstream, upstream_out, batch_id, "image1") + _link(lid, 0, batch_id, "image2") upstream, upstream_out = batch_id, 0 - node_inputs[node_key] = [upstream, upstream_out] + image_links[node_key] = (upstream, upstream_out) # Scalar params → node inputs, honoring the explicit param_map. for flag, node_key in ns.param_map.items(): @@ -328,32 +385,22 @@ def build_workflow(model: str, values: dict[str, Any], *, output_prefix: str = " if width_given and height_given: node_inputs[ns.aspect_from_wh] = f"{values['width']}:{values['height']}" - partner = { - "class_type": ns.node_class, - "_meta": {"title": f"{ns.node_class} ({model})"}, - "inputs": node_inputs, - } - workflow["1"] = partner + partner_id = _add(ns.node_class) + for node_key, value in node_inputs.items(): + _set(partner_id, node_key, value) + for node_key, (src_node, src_slot) in image_links.items(): + _link(src_node, src_slot, partner_id, node_key) - save_id = str(next_id) if ns.output == "VIDEO": - workflow[save_id] = { - "class_type": "SaveVideo", - "_meta": {"title": "save generated video"}, - "inputs": { - "video": ["1", ns.media_port], - "filename_prefix": output_prefix, - "format": "mp4", - "codec": "h264", - }, - } + save_id = _add("SaveVideo") + _set(save_id, "filename_prefix", output_prefix) + _link(partner_id, ns.media_port, save_id, "video") else: - workflow[save_id] = { - "class_type": "SaveImage", - "_meta": {"title": "save generated image"}, - "inputs": {"images": ["1", ns.media_port], "filename_prefix": output_prefix}, - } - return workflow + save_id = _add("SaveImage") + _set(save_id, "filename_prefix", output_prefix) + _link(partner_id, ns.media_port, save_id, "images") + + return workflow, ops def write_workflow( @@ -361,7 +408,7 @@ def write_workflow( ) -> dict[str, Any]: """Build the workflow for ``model`` and write it to ``path`` as JSON. Returns the workflow dict. Raises ``EmitError`` on an unsupported model.""" - workflow = build_workflow(model, values, output_prefix=output_prefix) + workflow, _ops = build_workflow(model, values, output_prefix=output_prefix) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(workflow, indent=2) + "\n", encoding="utf-8") return workflow diff --git a/pyproject.toml b/pyproject.toml index d51de35e7..577304e08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ include = [ "comfy_cli*" ] "comfy_cli.cql.data" = [ "*.yaml", "*.json" ] "comfy_cli.command.generate" = [ "spec/openapi.yml" ] +"comfy_cli.command.generate.data" = [ "*.json" ] [tool.ruff] target-version = "py310" diff --git a/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json b/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json index f0cd26a34..7b8eb94a2 100644 --- a/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json +++ b/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json @@ -541,7 +541,7 @@ "STRING", { "advanced": true, - "default": "You are an expert image-generation engine. You must ALWAYS produce an image.\nInterpret all user input—regardless of format, intent, or abstraction—as literal visual directives for image composition.\nIf a prompt is conversational or lacks specific visual details, you must creatively invent a concrete visual scenario that depicts the concept.\nPrioritize generating the visual representation above any text, formatting, or conversational requests.", + "default": "You are an expert image-generation engine. You must ALWAYS produce an image.\nInterpret all user input\u2014regardless of format, intent, or abstraction\u2014as literal visual directives for image composition.\nIf a prompt is conversational or lacks specific visual details, you must creatively invent a concrete visual scenario that depicts the concept.\nPrioritize generating the visual representation above any text, formatting, or conversational requests.", "multiline": true, "tooltip": "Foundational instructions that dictate an AI's behavior." } @@ -630,6 +630,48 @@ "python_module": "comfy_api_nodes.nodes_gemini", "search_aliases": null }, + "ImageBatch": { + "input": { + "required": { + "image1": [ + "IMAGE", + {} + ], + "image2": [ + "IMAGE", + {} + ] + } + }, + "input_order": { + "required": [ + "image1", + "image2" + ] + }, + "output": [ + "IMAGE" + ], + "output_is_list": [ + false + ], + "output_name": [ + "IMAGE" + ], + "name": "ImageBatch", + "display_name": "Batch Images", + "description": "Batches two images together.", + "category": "image", + "output_node": false, + "api_node": false, + "deprecated": true, + "python_module": "nodes", + "search_aliases": [ + "combine images", + "merge images", + "stack images" + ] + }, "KlingImage2VideoNode": { "api_node": true, "category": "partner/video/Kling", @@ -802,5 +844,174 @@ }, "python_module": "comfy_api_nodes.nodes_kling", "search_aliases": null + }, + "LoadImage": { + "input": { + "required": { + "image": [ + [], + { + "image_upload": true + } + ] + } + }, + "input_order": { + "required": [ + "image" + ] + }, + "output": [ + "IMAGE", + "MASK" + ], + "output_is_list": [ + false, + false + ], + "output_name": [ + "IMAGE", + "MASK" + ], + "name": "LoadImage", + "display_name": "Load Image", + "description": "Load an image from disk.", + "category": "image", + "output_node": false, + "api_node": false, + "deprecated": false, + "python_module": "nodes", + "search_aliases": [ + "load image", + "open image", + "import image", + "image input", + "upload image", + "read image", + "image loader" + ] + }, + "SaveImage": { + "input": { + "required": { + "images": [ + "IMAGE", + { + "tooltip": "The images to save." + } + ], + "filename_prefix": [ + "STRING", + { + "default": "ComfyUI", + "tooltip": "The prefix for the file to save." + } + ] + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO" + } + }, + "input_order": { + "required": [ + "images", + "filename_prefix" + ], + "hidden": [ + "prompt", + "extra_pnginfo" + ] + }, + "output": [], + "output_is_list": [], + "output_name": [], + "name": "SaveImage", + "display_name": "Save Image", + "description": "Saves the input images to your ComfyUI output directory.", + "category": "image", + "output_node": true, + "api_node": false, + "deprecated": false, + "python_module": "nodes", + "search_aliases": [ + "save", + "save image", + "export image", + "output image", + "write image", + "download" + ] + }, + "SaveVideo": { + "input": { + "required": { + "video": [ + "VIDEO", + { + "tooltip": "The video to save." + } + ], + "filename_prefix": [ + "STRING", + { + "default": "video/ComfyUI", + "tooltip": "The prefix for the file to save." + } + ], + "format": [ + [ + "auto", + "mp4", + "webm" + ], + { + "default": "auto", + "tooltip": "The format to save the video as." + } + ], + "codec": [ + [ + "auto", + "h264", + "vp9" + ], + { + "default": "auto", + "tooltip": "The codec to use for the video." + } + ] + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO" + } + }, + "input_order": { + "required": [ + "video", + "filename_prefix", + "format", + "codec" + ], + "hidden": [ + "prompt", + "extra_pnginfo" + ] + }, + "output": [], + "output_is_list": [], + "output_name": [], + "name": "SaveVideo", + "display_name": "Save Video", + "description": "Saves the input images to your ComfyUI output directory.", + "category": "image/video", + "output_node": true, + "api_node": false, + "deprecated": false, + "python_module": "comfy_extras.nodes_video", + "search_aliases": [ + "export video" + ] } } diff --git a/tests/comfy_cli/command/generate/test_emit.py b/tests/comfy_cli/command/generate/test_emit.py index ea4c1b56a..7fe6cf524 100644 --- a/tests/comfy_cli/command/generate/test_emit.py +++ b/tests/comfy_cli/command/generate/test_emit.py @@ -1,6 +1,15 @@ """Tests for ``comfy generate --emit-workflow`` and the underlying ``emit`` module: model→node-class mapping, param translation, and the emitted -API-format workflow shape. +frontend-format workflow + op-stream shape. + +BE-11131 / ecw-57: ``build_workflow`` used to hand-construct a raw API-format +dict (``workflow["1"] = {"class_type": ..., "inputs": ...}``). It now routes +through ``workflow_ops.py``'s CRDT-ready primitives (``add_node``/``connect``/ +``set_widget``), so the output is frontend-format (``nodes``/``links``) plus a +replayable op list, and downstream ``comfy workflow`` slot-editing commands +(gated on ``_is_frontend_format``) accept it instead of rejecting it as +``workflow_not_frontend_format``. Assertions below read the emitted graph by +node ``type``/``widgets_values``/``links`` instead of API-format literals. """ import json @@ -17,6 +26,10 @@ # from the cloud catalog. Used to enforce NodeSpec's completeness contract: the # emitted node must carry EVERY widget input, optional section included (a # schema-`optional` input may still be positionally required by execute()). +# +# Same file `emit.py` bundles as `comfy_cli/command/generate/data/emit_object_info.json` +# (single source of truth — see that module's `_load_catalog` docstring for why +# this cannot be a live `object_info` fetch). PARTNER_OBJECT_INFO = json.loads( (Path(__file__).parent / "fixtures" / "partner_nodes_object_info.json").read_text(encoding="utf-8") ) @@ -33,48 +46,78 @@ def runner(): return CliRunner() +def _by_type(wf: dict, class_type: str) -> list[dict]: + return [n for n in wf["nodes"] if n["type"] == class_type] + + +def _one(wf: dict, class_type: str) -> dict: + nodes = _by_type(wf, class_type) + assert len(nodes) == 1, f"expected exactly one {class_type!r} node, found {len(nodes)}" + return nodes[0] + + +def _widget(node: dict, graph: Graph, name: str): + """The value ``node``'s widget ``name`` carries, resolved by the schema's + positional widget order (frontend-format stores ``widgets_values`` + positionally, never by name).""" + order = graph.widget_order_default(node["type"]) + idx = order.index(name) + return node["widgets_values"][idx] + + +def _linked_from(wf: dict, node: dict, input_name: str) -> dict: + """The upstream node feeding ``node``'s link input ``input_name``.""" + port = next(i for i in node["inputs"] if i["name"] == input_name) + link = next(link_ for link_ in wf["links"] if link_[0] == port["link"]) + _link_id, from_node, _from_slot, _to_node, _to_slot, _type = link + return next(n for n in wf["nodes"] if n["id"] == from_node) + + +def _catalog() -> Graph: + return emit._catalog() + + # ─── unit: build_workflow ───────────────────────────────────────────────── def test_build_flux_text_to_image_class_type_and_params(): - wf = emit.build_workflow("flux-2", {"prompt": "a fox", "width": 512}) - # partner node is "1" - assert wf["1"]["class_type"] == "Flux2ProImageNode" - assert wf["1"]["inputs"]["prompt"] == "a fox" + wf, ops = emit.build_workflow("flux-2", {"prompt": "a fox", "width": 512}) + graph = _catalog() + partner = _one(wf, "Flux2ProImageNode") + assert _widget(partner, graph, "prompt") == "a fox" # user override applied, fixed default preserved for unset params - assert wf["1"]["inputs"]["width"] == 512 - assert wf["1"]["inputs"]["height"] == 768 + assert _widget(partner, graph, "width") == 512 + assert _widget(partner, graph, "height") == 768 # save node references the partner output - save = [n for n in wf.values() if n["class_type"] == "SaveImage"] - assert len(save) == 1 - assert save[0]["inputs"]["images"] == ["1", 0] + save = _one(wf, "SaveImage") + assert _linked_from(wf, save, "images") is partner + assert ops and all("op_id" in op for op in ops) def test_build_nano_banana_wires_load_image(): - wf = emit.build_workflow("nano-banana", {"prompt": "add sunglasses", "image": "cat.png"}) - assert wf["1"]["class_type"] == "GeminiImageNode" + wf, _ops = emit.build_workflow("nano-banana", {"prompt": "add sunglasses", "image": "cat.png"}) + graph = _catalog() + partner = _one(wf, "GeminiImageNode") # an image param becomes a LoadImage node wired into `images` - loaders = [(k, v) for k, v in wf.items() if v["class_type"] == "LoadImage"] - assert len(loaders) == 1 - loader_id, loader = loaders[0] - assert loader["inputs"]["image"] == "cat.png" - assert wf["1"]["inputs"]["images"] == [loader_id, 0] + loader = _one(wf, "LoadImage") + assert _widget(loader, graph, "image") == "cat.png" + assert _linked_from(wf, partner, "images") is loader def test_build_seedance_emits_save_video(): - wf = emit.build_workflow("seedance", {"prompt": "drift", "image": "frame.png", "duration": 8}) - assert wf["1"]["class_type"] == "ByteDanceImageToVideoNode" - assert wf["1"]["inputs"]["duration"] == 8 - save = [n for n in wf.values() if n["class_type"] == "SaveVideo"] - assert len(save) == 1 - assert save[0]["inputs"]["video"] == ["1", 0] + wf, _ops = emit.build_workflow("seedance", {"prompt": "drift", "image": "frame.png", "duration": 8}) + graph = _catalog() + partner = _one(wf, "ByteDanceImageToVideoNode") + assert _widget(partner, graph, "duration") == 8 + save = _one(wf, "SaveVideo") + assert _linked_from(wf, save, "video") is partner def test_build_kling_i2v_class_and_start_frame(): - wf = emit.build_workflow("kling-i2v", {"prompt": "zoom in", "image": "start.png"}) - assert wf["1"]["class_type"] == "KlingImage2VideoNode" - loader_id = next(k for k, v in wf.items() if v["class_type"] == "LoadImage") - assert wf["1"]["inputs"]["start_frame"] == [loader_id, 0] + wf, _ops = emit.build_workflow("kling-i2v", {"prompt": "zoom in", "image": "start.png"}) + partner = _one(wf, "KlingImage2VideoNode") + loader = _one(wf, "LoadImage") + assert _linked_from(wf, partner, "start_frame") is loader def test_build_seedance_fills_execute_required_defaults(): @@ -83,28 +126,30 @@ def test_build_seedance_fills_execute_required_defaults(): defaults — omitting them validates cleanly and then fails the run with "missing 3 required positional arguments" (observed live on cloud). The emitter must always write them.""" - wf = emit.build_workflow( + wf, _ops = emit.build_workflow( "seedance", {"prompt": "drift", "image": "frame.png", "model": "seedance-1-0-lite-i2v-250428"}, ) - inputs = wf["1"]["inputs"] - assert inputs["seed"] == 0 - assert inputs["camera_fixed"] is False - assert inputs["watermark"] is False - assert inputs["generate_audio"] is False + graph = _catalog() + partner = _one(wf, "ByteDanceImageToVideoNode") + assert _widget(partner, graph, "seed") == 0 + assert _widget(partner, graph, "camera_fixed") is False + assert _widget(partner, graph, "watermark") is False + assert _widget(partner, graph, "generate_audio") is False def test_build_seedance_proxy_flag_spellings_reach_node_inputs(): """The generate proxy flags are --ratio/--camerafixed; the node inputs are aspect_ratio/camera_fixed. User-passed values must not be dropped.""" - wf = emit.build_workflow( + wf, _ops = emit.build_workflow( "seedance", {"prompt": "drift", "image": "frame.png", "ratio": "9:16", "camerafixed": True, "watermark": True}, ) - inputs = wf["1"]["inputs"] - assert inputs["aspect_ratio"] == "9:16" - assert inputs["camera_fixed"] is True - assert inputs["watermark"] is True + graph = _catalog() + partner = _one(wf, "ByteDanceImageToVideoNode") + assert _widget(partner, graph, "aspect_ratio") == "9:16" + assert _widget(partner, graph, "camera_fixed") is True + assert _widget(partner, graph, "watermark") is True @pytest.mark.parametrize("model", sorted(emit.MODEL_NODE_MAP)) @@ -122,15 +167,18 @@ def test_emitted_node_covers_every_widget_input(model): values = {"prompt": "p"} if ns.image_params: values[next(iter(ns.image_params))] = "img.png" - wf = emit.build_workflow(model, values) - inputs = wf["1"]["inputs"] + wf, _ops = emit.build_workflow(model, values) + partner = _one(wf, ns.node_class) + order = graph.widget_order_default(ns.node_class) + widget_names = {name for name in order if name in graph.widget_defaults(ns.node_class) or True} + input_names = {i["name"] for i in partner["inputs"]} for port in meta.inputs: if port.is_link: if port.required: - assert port.name in inputs, f"{model}: required link input {port.name!r} not wired" + assert port.name in input_names, f"{model}: required link input {port.name!r} not wired" continue - assert port.name in inputs, ( + assert port.name in widget_names, ( f"{model}: widget input {port.name!r} missing from the emitted node — " f"schema-optional inputs may still be positionally required at execute() " f"time; add a default to MODEL_NODE_MAP[{model!r}].fixed" @@ -165,21 +213,23 @@ def test_flux_pro_is_rejected_no_node_for_flux_pro_1_1(): def test_build_flux_ultra_folds_width_height_into_aspect_ratio(): - wf = emit.build_workflow("flux-ultra", {"prompt": "a fox", "width": 1024, "height": 768}) - assert wf["1"]["class_type"] == "FluxProUltraImageNode" + wf, _ops = emit.build_workflow("flux-ultra", {"prompt": "a fox", "width": 1024, "height": 768}) + graph = _catalog() + partner = _one(wf, "FluxProUltraImageNode") # the node takes an aspect ratio, not w/h — the two flags fold into it - assert wf["1"]["inputs"]["aspect_ratio"] == "1024:768" - assert wf["1"]["inputs"]["prompt"] == "a fox" + assert _widget(partner, graph, "aspect_ratio") == "1024:768" + assert _widget(partner, graph, "prompt") == "a fox" # the Ultra node's own default, unlike Flux2Pro's True - assert wf["1"]["inputs"]["prompt_upsampling"] is False - save = [n for n in wf.values() if n["class_type"] == "SaveImage"] - assert len(save) == 1 - assert save[0]["inputs"]["images"] == ["1", 0] + assert _widget(partner, graph, "prompt_upsampling") is False + save = _one(wf, "SaveImage") + assert _linked_from(wf, save, "images") is partner def test_build_flux_ultra_without_width_height_keeps_default_aspect_ratio(): - wf = emit.build_workflow("flux-ultra", {"prompt": "a fox"}) - assert wf["1"]["inputs"]["aspect_ratio"] == "16:9" + wf, _ops = emit.build_workflow("flux-ultra", {"prompt": "a fox"}) + graph = _catalog() + partner = _one(wf, "FluxProUltraImageNode") + assert _widget(partner, graph, "aspect_ratio") == "16:9" def test_build_flux_ultra_only_width_errors_instead_of_dropping_it(): @@ -196,12 +246,33 @@ def test_build_flux_ultra_only_height_errors_instead_of_dropping_it(): assert "--width" in str(ei.value) and "--height" in str(ei.value) -def test_emitted_workflow_is_api_format_node_ids_are_strings(): - wf = emit.build_workflow("flux-2", {"prompt": "p"}) - for k, node in wf.items(): - assert isinstance(k, str) - assert "class_type" in node - assert "inputs" in node +def test_emitted_workflow_is_frontend_format(): + wf, _ops = emit.build_workflow("flux-2", {"prompt": "p"}) + assert isinstance(wf["nodes"], list) + assert isinstance(wf["links"], list) + for node in wf["nodes"]: + assert isinstance(node["id"], int) + assert "type" in node + assert "widgets_values" in node + + +def test_build_workflow_ops_replay_to_the_same_workflow(): + """P1 fidelity (workflow_ops.py docstring): apply(base, op) == primitive(base). + The op stream `build_workflow` returns must independently replay, through + `apply_op`, to the exact same graph — this is what makes it a real op batch + an `apply_ops` consumer can use, not just receipts alongside a fixed dict.""" + from comfy_cli.workflow_ops import apply_op + + wf, ops = emit.build_workflow("nano-banana", {"prompt": "p", "image": "cat.png"}) + graph = _catalog() + replayed: dict = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + for op in ops: + replayed = apply_op(replayed, op, graph) + # Compare the graph-shape (ids are minted fresh per call, so compare types/ + # widgets/links structurally rather than asserting dict equality against a + # second, independently-minted `wf`). + assert sorted(n["type"] for n in replayed["nodes"]) == sorted(n["type"] for n in wf["nodes"]) + assert len(replayed["links"]) == len(wf["links"]) def test_is_supported_answers_for_alias_and_canonical_id(): @@ -258,8 +329,9 @@ def test_cli_emit_writes_file_no_api_key(runner, tmp_path, monkeypatch): assert r.exit_code == 0, r.stdout assert out.is_file() wf = json.loads(out.read_text()) - assert wf["1"]["class_type"] == "Flux2ProImageNode" - assert wf["1"]["inputs"]["prompt"] == "a cat" + graph = _catalog() + partner = _one(wf, "Flux2ProImageNode") + assert _widget(partner, graph, "prompt") == "a cat" def test_cli_emit_json_mode_prints_workflow(runner, tmp_path, monkeypatch): @@ -330,30 +402,35 @@ def test_cli_emit_output_prefix(runner, tmp_path, monkeypatch): ) assert r.exit_code == 0, r.stdout wf = json.loads(out.read_text()) - save = next(n for n in wf.values() if n["class_type"] == "SaveImage") - assert save["inputs"]["filename_prefix"] == "myfox" + save = next(n for n in wf["nodes"] if n["type"] == "SaveImage") + graph = _catalog() + assert _widget(save, graph, "filename_prefix") == "myfox" def test_build_workflow_single_element_list_unwraps(): - wf = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["ref.jpg"]}) - loaders = [n for n in wf.values() if n["class_type"] == "LoadImage"] - assert len(loaders) == 1 - assert not any(n["class_type"] == "ImageBatch" for n in wf.values()) + wf, _ops = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["ref.jpg"]}) + assert len(_by_type(wf, "LoadImage")) == 1 + assert not _by_type(wf, "ImageBatch") def test_build_workflow_two_images_chains_imagebatch(): - wf = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["a.jpg", "b.jpg"]}) - loaders = {i: n for i, n in wf.items() if n["class_type"] == "LoadImage"} - batches = {i: n for i, n in wf.items() if n["class_type"] == "ImageBatch"} + wf, _ops = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["a.jpg", "b.jpg"]}) + loaders = _by_type(wf, "LoadImage") + batches = _by_type(wf, "ImageBatch") assert len(loaders) == 2 and len(batches) == 1 - ((batch_id, batch),) = batches.items() - assert {batch["inputs"]["image1"][0], batch["inputs"]["image2"][0]} == set(loaders) - assert wf["1"]["inputs"]["images"] == [batch_id, 0] + batch = batches[0] + loader_ids = {n["id"] for n in loaders} + fed_ids = {_linked_from(wf, batch, "image1")["id"], _linked_from(wf, batch, "image2")["id"]} + assert fed_ids == loader_ids + partner = _one(wf, "GeminiImageNode") + assert _linked_from(wf, partner, "images") is batch def test_build_workflow_three_images_chains_two_batches(): - wf = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["a.jpg", "b.jpg", "c.jpg"]}) - batches = [i for i, n in wf.items() if n["class_type"] == "ImageBatch"] + wf, _ops = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["a.jpg", "b.jpg", "c.jpg"]}) + batches = _by_type(wf, "ImageBatch") assert len(batches) == 2 # terminal batch feeds the partner node - assert wf["1"]["inputs"]["images"][0] in batches + partner = _one(wf, "GeminiImageNode") + upstream = _linked_from(wf, partner, "images") + assert upstream["type"] == "ImageBatch" From 17822421b2a2ff5286f56381e00995806ce90d34 Mon Sep 17 00:00:00 2001 From: bymyself Date: Wed, 2 Sep 2026 12:57:49 +0000 Subject: [PATCH 2/2] fix(hygiene): drop internal ticket ID from public-repo comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public-repo-hygiene flagged the internal Linear ticket id in two doc comments (emit.py, test_emit.py) — this repo is public. Restate the same rationale without the ticket reference; the plan doc that traces back to it lives in the internal program repo, not here. --- comfy_cli/command/generate/emit.py | 6 +++--- tests/comfy_cli/command/generate/test_emit.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/comfy_cli/command/generate/emit.py b/comfy_cli/command/generate/emit.py index a5f6dee4f..a3796ad65 100644 --- a/comfy_cli/command/generate/emit.py +++ b/comfy_cli/command/generate/emit.py @@ -306,9 +306,9 @@ def build_workflow( ``links``, minted via ``workflow_ops.add_node``/``connect``/``set_widget`` — the same CRDT-ready primitives ``comfy workflow`` slot-editing commands require), and ``ops`` is the replayable op stream those primitives emitted, - in apply order (BE-11131: this is what makes the result editable downstream - instead of a raw API-format dict `list_slots`/`set_widget`/`connect` reject - as ``workflow_not_frontend_format``). + in apply order. This is what makes the result editable downstream instead + of a raw API-format dict `list_slots`/`set_widget`/`connect` reject as + ``workflow_not_frontend_format``. The partner node classes in ``MODEL_NODE_MAP`` are addressed by name against the bundled snapshot regardless of that snapshot's own diff --git a/tests/comfy_cli/command/generate/test_emit.py b/tests/comfy_cli/command/generate/test_emit.py index 7fe6cf524..4050d1dac 100644 --- a/tests/comfy_cli/command/generate/test_emit.py +++ b/tests/comfy_cli/command/generate/test_emit.py @@ -2,7 +2,7 @@ ``emit`` module: model→node-class mapping, param translation, and the emitted frontend-format workflow + op-stream shape. -BE-11131 / ecw-57: ``build_workflow`` used to hand-construct a raw API-format +``build_workflow`` used to hand-construct a raw API-format dict (``workflow["1"] = {"class_type": ..., "inputs": ...}``). It now routes through ``workflow_ops.py``'s CRDT-ready primitives (``add_node``/``connect``/ ``set_widget``), so the output is frontend-format (``nodes``/``links``) plus a