From 1105811e1734df3d0932476b9fd284474c8aabca Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 6 Aug 2026 16:34:37 +0330 Subject: [PATCH 01/13] Fix sketch presets silently ignoring the sketch Both sketch-to-image presets fed ControlNet a pure-black control image for light-on-dark art and solid filled shapes: LineartStandardPreprocessor and ScribblePreprocessor each returned max=0, 0% ink on such a source. ControlNet had no signal, so the preset degraded to plain text-to-image and the user just saw their sketch ignored -- with no error anywhere. Measured live against ComfyUI on a 1024px filled silhouette: Scribble_PiDiNet_Preprocessor 3.18% ink AnyLineArtPreprocessor_aux 1.14% ink CannyEdgePreprocessor 0.24% ink ScribblePreprocessor 0.00% ink (dead) LineartStandardPreprocessor 0.00% ink (dead) The learned edge detectors are polarity-robust; the naive ones assume dark strokes on white paper. So: - sketch2img-linecn-basic switches to AnyLineArtPreprocessor_aux, keeping its existing LineArt ControlNet. This is a bug fix to a preset that shipped "stable" while being broken for that whole class of input. - New sketch2img-scribble-basic preset on Scribble_PiDiNet_Preprocessor and the Scribble ControlNet, for loose gestural strokes where LineArt holds the drawn line too tightly. Its ControlNet model was already installed; no new custom node package. Both preprocessors now run at 1024 rather than the hardcoded 512, which was discarding line detail before ControlNet ever saw it. Both new node classes are registered in CUSTOM_NODE_PACKAGES, without which Setup and Workflow Health would stop telling users they need controlnet_aux. LineartStandardPreprocessor stays mapped there for users' own custom workflows. Verified end-to-end against ComfyUI: both presets submitted with the reporting user's seed and prompt now compose to the sketch silhouette. Co-Authored-By: Claude Opus 5 --- src/comfy/presetRegistry.ts | 137 +++- src/comfy/setupManifest.ts | 11 + src/comfy/types.ts | 1 + src/comfy/workflowBuilder.ts | 2 + .../api/sketch2img-linecn-basic.json | 9 +- .../api/sketch2img-scribble-basic.json | 166 +++++ .../sketch2img-linecn-basic.workflow.json | 19 +- .../sketch2img-scribble-basic.workflow.json | 691 ++++++++++++++++++ tests/comfy/modelFolders.test.ts | 1 + tests/comfy/workflowCompatibility.test.ts | 2 +- tests/comfy/workflowDiagnostics.test.ts | 4 +- tests/comfy/workflowFiles.test.ts | 6 +- tests/comfy/workflowHealth.test.ts | 4 +- 13 files changed, 1035 insertions(+), 18 deletions(-) create mode 100644 src/workflows/api/sketch2img-scribble-basic.json create mode 100644 src/workflows/source/sketch2img-scribble-basic.workflow.json diff --git a/src/comfy/presetRegistry.ts b/src/comfy/presetRegistry.ts index c3c4572..d154c14 100644 --- a/src/comfy/presetRegistry.ts +++ b/src/comfy/presetRegistry.ts @@ -287,6 +287,21 @@ const SKETCH2IMG_LINECN_BASIC_NODES = { saveImage: "9" } as const; +// Same graph shape as the LineArt preset -- only the preprocessor and the +// ControlNet it feeds differ -- so the node ids deliberately match. +const SKETCH2IMG_SCRIBBLE_BASIC_NODES = { + checkpointLoader: "4", + loadImage: "10", + positivePrompt: "6", + negativePrompt: "7", + latentImage: "5", + scribblePreprocessor: "12", + controlNetLoader: "13", + controlNetApply: "14", + sampler: "3", + saveImage: "9" +} as const; + const INPAINT_BASIC_NODES = { checkpointLoader: "4", loadImage: "10", @@ -464,6 +479,20 @@ const SKETCH2IMG_LINECN_BASIC_INJECTIONS = { controlStrength: target(SKETCH2IMG_LINECN_BASIC_NODES.controlNetApply, "strength") } as const; +const SKETCH2IMG_SCRIBBLE_BASIC_INJECTIONS = { + checkpoint: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.checkpointLoader, "ckpt_name"), + sourceImage: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.loadImage, "image"), + positivePrompt: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.positivePrompt, "text"), + negativePrompt: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.negativePrompt, "text"), + width: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.latentImage, "width"), + height: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.latentImage, "height"), + seed: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.sampler, "seed"), + steps: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.sampler, "steps"), + cfg: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.sampler, "cfg"), + denoise: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.sampler, "denoise"), + controlStrength: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.controlNetApply, "strength") +} as const; + const INPAINT_BASIC_INJECTIONS = { checkpoint: target(INPAINT_BASIC_NODES.checkpointLoader, "ckpt_name"), sourceImage: target(INPAINT_BASIC_NODES.loadImage, "image"), @@ -714,6 +743,26 @@ const SKETCH2IMG_LINECN_BASIC_CAPABILITY: WorkflowCapability = { } }; +const SKETCH2IMG_SCRIBBLE_BASIC_CAPABILITY: WorkflowCapability = { + toolType: "sketch2img", + loaderType: "checkpoint", + artistLabel: "Sketch to Image", + technicalLabel: "sketch2img-scribble-basic", + requiredPhotoshopInputs: [{ anyOf: ["active-layer", "canvas"], label: "an active layer or captured canvas" }], + controls: ["prompt", "negativePrompt", "steps", "cfg", "denoise", "seed", "controlStrength"], + output: { + kind: "source-sized-image", + size: "source", + importBehavior: "new-layer" + }, + uiHints: { + showModelSelector: true, + modelSelectorLabel: "Checkpoint", + primaryActionLabel: "Generate Sketch to Image", + experimentalNote: "Starter SD 1.x Scribble ControlNet workflow." + } +}; + const INPAINT_BASIC_CAPABILITY: WorkflowCapability = { toolType: "inpaint", loaderType: "checkpoint", @@ -1188,7 +1237,7 @@ export const WORKFLOW_PRESETS: WorkflowPresetDefinition[] = [ capability: SKETCH2IMG_LINECN_BASIC_CAPABILITY, injections: SKETCH2IMG_LINECN_BASIC_INJECTIONS, compatibilityNote: - "sketch2img-linecn-basic generates from an empty latent at the sketch size while the SD 1.5 LineArt ControlNet guides structure, so colors render fully instead of inheriting the white sketch paper. Keep denoise at 1 for a full render, or lower it only when blending with a colored source.", + "sketch2img-linecn-basic generates from an empty latent at the sketch size while the SD 1.5 LineArt ControlNet guides structure, so colors render fully instead of inheriting the white sketch paper. Keep denoise at 1 for a full render, or lower it only when blending with a colored source. It uses the AnyLine detector rather than the standard Lineart preprocessor, which returns a blank control image -- and so silently degrades to plain text-to-image -- for light-on-dark art or solid filled shapes.", requiredModels: [ { kind: "controlnet", @@ -1231,7 +1280,7 @@ export const WORKFLOW_PRESETS: WorkflowPresetDefinition[] = [ }, { id: SKETCH2IMG_LINECN_BASIC_NODES.lineArtPreprocessor, - classType: "LineartStandardPreprocessor", + classType: "AnyLineArtPreprocessor_aux", requiredInputs: ["image"] }, { @@ -1256,6 +1305,90 @@ export const WORKFLOW_PRESETS: WorkflowPresetDefinition[] = [ } ] }, + { + id: "sketch2img-scribble-basic", + label: "sketch2img-scribble-basic", + displayName: "Scribble ControlNet", + mode: "sketch2img", + description: "Experimental SD 1.x Scribble ControlNet sketch guidance workflow.", + workflowFile: "workflows/api/sketch2img-scribble-basic.json", + sourceWorkflowFile: "workflows/source/sketch2img-scribble-basic.workflow.json", + status: "stable", + recommendedSettings: { steps: 20, cfg: 7 }, + supportedModelFamilies: ["sd1"], + experimentalModelFamilies: ["sdxl", "sd3", "flux", "zImage"], + modelSource: CHECKPOINT_MODEL_SOURCE, + capability: SKETCH2IMG_SCRIBBLE_BASIC_CAPABILITY, + injections: SKETCH2IMG_SCRIBBLE_BASIC_INJECTIONS, + compatibilityNote: + "sketch2img-scribble-basic suits loose, gestural strokes: it keeps the sketch's broad shapes and lets the model invent the detail, where the LineArt preset holds the drawn line. Pick Scribble for a rough thumbnail, LineArt for clean inked art. It uses the PiDiNet edge detector rather than the plain Scribble preprocessor, which returns a blank control image -- and so silently degrades to plain text-to-image -- for light-on-dark art or solid filled shapes.", + requiredModels: [ + { + kind: "controlnet", + objectInfoNode: "ControlNetLoader", + inputName: "control_net_name", + label: "Scribble ControlNet", + modelName: "control_v11p_sd15_scribble_fp16.safetensors", + setupHint: "Install an SD 1.5 Scribble ControlNet model in ComfyUI's controlnet models folder.", + downloadUrl: + "https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11p_sd15_scribble_fp16.safetensors", + sourcePageUrl: "https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors", + downloadSizeBytes: 722601100 + } + ], + requiredNodes: [ + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.checkpointLoader, + classType: "CheckpointLoaderSimple", + requiredInputs: ["ckpt_name"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.loadImage, + classType: "LoadImage", + requiredInputs: ["image"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.positivePrompt, + classType: "CLIPTextEncode", + requiredInputs: ["text", "clip"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.negativePrompt, + classType: "CLIPTextEncode", + requiredInputs: ["text", "clip"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.latentImage, + classType: "EmptyLatentImage", + requiredInputs: ["width", "height", "batch_size"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.scribblePreprocessor, + classType: "Scribble_PiDiNet_Preprocessor", + requiredInputs: ["image"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.controlNetLoader, + classType: "ControlNetLoader", + requiredInputs: ["control_net_name"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.controlNetApply, + classType: "ControlNetApplyAdvanced", + requiredInputs: ["positive", "negative", "control_net", "image", "strength", "start_percent", "end_percent"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.sampler, + classType: "KSampler", + requiredInputs: ["seed", "steps", "cfg", "denoise", "model", "positive", "negative", "latent_image"] + }, + { + id: SKETCH2IMG_SCRIBBLE_BASIC_NODES.saveImage, + classType: "SaveImage", + requiredInputs: ["images"] + } + ] + }, { id: "inpaint-basic", label: "inpaint-basic", diff --git a/src/comfy/setupManifest.ts b/src/comfy/setupManifest.ts index c3dd831..e2e7a05 100644 --- a/src/comfy/setupManifest.ts +++ b/src/comfy/setupManifest.ts @@ -27,10 +27,21 @@ import { getModelTargetFolder, getModelTargetPath, getRequiredModelKey, listPres * fails that test. */ export const CUSTOM_NODE_PACKAGES: Record = { + // Kept although no preset ships it any more: it is still a legal node in a + // user's own custom workflow, and naming its package is what lets Workflow + // Health say which install is missing rather than reporting an absent node. LineartStandardPreprocessor: { name: "comfyui_controlnet_aux", repoUrl: "https://github.com/Fannovel16/comfyui_controlnet_aux" }, + AnyLineArtPreprocessor_aux: { + name: "comfyui_controlnet_aux", + repoUrl: "https://github.com/Fannovel16/comfyui_controlnet_aux" + }, + Scribble_PiDiNet_Preprocessor: { + name: "comfyui_controlnet_aux", + repoUrl: "https://github.com/Fannovel16/comfyui_controlnet_aux" + }, Florence2ModelLoader: { name: "ComfyUI-Florence2", repoUrl: "https://github.com/kijai/ComfyUI-Florence2" diff --git a/src/comfy/types.ts b/src/comfy/types.ts index 7ca6b63..d742dce 100644 --- a/src/comfy/types.ts +++ b/src/comfy/types.ts @@ -9,6 +9,7 @@ export type WorkflowPreset = | "img2img-krea2-turbo" | "prompt-from-layer-florence2" | "sketch2img-linecn-basic" + | "sketch2img-scribble-basic" | "inpaint-basic" | "inpaint-flux-fill-basic" | "outpaint-flux-fill-basic" diff --git a/src/comfy/workflowBuilder.ts b/src/comfy/workflowBuilder.ts index 71a5f84..2c9c41f 100644 --- a/src/comfy/workflowBuilder.ts +++ b/src/comfy/workflowBuilder.ts @@ -7,6 +7,7 @@ import txt2imgKrea2TurboWorkflow from "../workflows/api/txt2img-krea2-turbo.json import img2imgKrea2TurboWorkflow from "../workflows/api/img2img-krea2-turbo.json"; import promptFromLayerFlorence2Workflow from "../workflows/api/prompt-from-layer-florence2.json"; import sketch2imgLinecnBasicWorkflow from "../workflows/api/sketch2img-linecn-basic.json"; +import sketch2imgScribbleBasicWorkflow from "../workflows/api/sketch2img-scribble-basic.json"; import inpaintBasicWorkflow from "../workflows/api/inpaint-basic.json"; import inpaintFluxFillBasicWorkflow from "../workflows/api/inpaint-flux-fill-basic.json"; import outpaintFluxFillBasicWorkflow from "../workflows/api/outpaint-flux-fill-basic.json"; @@ -40,6 +41,7 @@ const WORKFLOW_TEMPLATES: Partial> = { "img2img-krea2-turbo": img2imgKrea2TurboWorkflow as ComfyWorkflow, "prompt-from-layer-florence2": promptFromLayerFlorence2Workflow as ComfyWorkflow, "sketch2img-linecn-basic": sketch2imgLinecnBasicWorkflow as ComfyWorkflow, + "sketch2img-scribble-basic": sketch2imgScribbleBasicWorkflow as ComfyWorkflow, "inpaint-basic": inpaintBasicWorkflow as ComfyWorkflow, "inpaint-flux-fill-basic": inpaintFluxFillBasicWorkflow as ComfyWorkflow, "outpaint-flux-fill-basic": outpaintFluxFillBasicWorkflow as ComfyWorkflow, diff --git a/src/workflows/api/sketch2img-linecn-basic.json b/src/workflows/api/sketch2img-linecn-basic.json index 119a7e1..05c005f 100644 --- a/src/workflows/api/sketch2img-linecn-basic.json +++ b/src/workflows/api/sketch2img-linecn-basic.json @@ -45,13 +45,18 @@ }, "12": { "inputs": { - "resolution": 512, + "merge_with_lineart": "lineart_standard", + "resolution": 1024, + "lineart_lower_bound": 0, + "lineart_upper_bound": 1, + "object_min_size": 36, + "object_connectivity": 1, "image": [ "10", 0 ] }, - "class_type": "LineartStandardPreprocessor", + "class_type": "AnyLineArtPreprocessor_aux", "_meta": { "title": "Standard Lineart Preprocessor" } diff --git a/src/workflows/api/sketch2img-scribble-basic.json b/src/workflows/api/sketch2img-scribble-basic.json new file mode 100644 index 0000000..e18fe30 --- /dev/null +++ b/src/workflows/api/sketch2img-scribble-basic.json @@ -0,0 +1,166 @@ +{ + "4": { + "inputs": { + "ckpt_name": "epicrealism_naturalSinRC1VAE.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Load SD1 Checkpoint" + } + }, + "10": { + "inputs": { + "image": "openlayer-sketch-source.png" + }, + "class_type": "LoadImage", + "_meta": { + "title": "Load Sketch Source" + } + }, + "6": { + "inputs": { + "text": "a finished illustration guided by a rough scribble", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "Positive Prompt" + } + }, + "7": { + "inputs": { + "text": "", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "Negative Prompt" + } + }, + "12": { + "inputs": { + "safe": "enable", + "resolution": 1024, + "image": [ + "10", + 0 + ] + }, + "class_type": "Scribble_PiDiNet_Preprocessor", + "_meta": { + "title": "Standard Lineart Preprocessor" + } + }, + "13": { + "inputs": { + "control_net_name": "control_v11p_sd15_scribble_fp16.safetensors" + }, + "class_type": "ControlNetLoader", + "_meta": { + "title": "Load LineArt ControlNet" + } + }, + "14": { + "inputs": { + "strength": 0.8, + "start_percent": 0, + "end_percent": 1, + "positive": [ + "6", + 0 + ], + "negative": [ + "7", + 0 + ], + "control_net": [ + "13", + 0 + ], + "image": [ + "12", + 0 + ] + }, + "class_type": "ControlNetApplyAdvanced", + "_meta": { + "title": "Apply LineArt ControlNet" + } + }, + "5": { + "inputs": { + "width": 512, + "height": 512, + "batch_size": 1 + }, + "class_type": "EmptyLatentImage", + "_meta": { + "title": "Empty Latent At Sketch Size" + } + }, + "3": { + "inputs": { + "seed": 1, + "steps": 20, + "cfg": 7, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": [ + "4", + 0 + ], + "positive": [ + "14", + 0 + ], + "negative": [ + "14", + 1 + ], + "latent_image": [ + "5", + 0 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "Sampler" + } + }, + "8": { + "inputs": { + "samples": [ + "3", + 0 + ], + "vae": [ + "4", + 2 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "9": { + "inputs": { + "filename_prefix": "OpenLayer_Sketch", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "Save Image" + } + } +} diff --git a/src/workflows/source/sketch2img-linecn-basic.workflow.json b/src/workflows/source/sketch2img-linecn-basic.workflow.json index e8baf60..886442a 100644 --- a/src/workflows/source/sketch2img-linecn-basic.workflow.json +++ b/src/workflows/source/sketch2img-linecn-basic.workflow.json @@ -272,14 +272,14 @@ }, { "id": 12, - "type": "LineartStandardPreprocessor", + "type": "AnyLineArtPreprocessor_aux", "pos": [ 482.798828125, 790 ], "size": [ 270, - 82 + 154 ], "flags": {}, "order": 6, @@ -300,11 +300,11 @@ ] } ], - "title": "Standard Lineart Preprocessor", + "title": "AnyLine Lineart Preprocessor", "properties": { "cnr_id": "comfyui_controlnet_aux", "ver": "e8b689a513c3e6b63edc44066560ca5919c0576e", - "Node name for S&R": "LineartStandardPreprocessor", + "Node name for S&R": "AnyLineArtPreprocessor_aux", "ue_properties": { "widget_ue_connectable": {}, "input_ue_unconnectable": {}, @@ -312,9 +312,12 @@ } }, "widgets_values": [ - 6, - 8, - 512 + "lineart_standard", + 1024, + 0, + 1, + 36, + 1 ] }, { @@ -689,4 +692,4 @@ "VHS_KeepIntermediate": true }, "version": 0.4 -} \ No newline at end of file +} diff --git a/src/workflows/source/sketch2img-scribble-basic.workflow.json b/src/workflows/source/sketch2img-scribble-basic.workflow.json new file mode 100644 index 0000000..e4d1bbc --- /dev/null +++ b/src/workflows/source/sketch2img-scribble-basic.workflow.json @@ -0,0 +1,691 @@ +{ + "id": "5f2a91c4-7b3e-4d16-9c48-2ae70b5d8f31", + "revision": 0, + "last_node_id": 14, + "last_link_id": 28, + "nodes": [ + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 482.798828125, + 460 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 20 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 26 + ] + } + ], + "title": "Negative Prompt", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "" + ] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [ + 1722.798828125, + 130 + ], + "size": [ + 140, + 46 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 21 + }, + { + "name": "vae", + "type": "VAE", + "link": 22 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "VAEDecode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [] + }, + { + "id": 14, + "type": "ControlNetApplyAdvanced", + "pos": [ + 982.798828125, + 130 + ], + "size": [ + 270, + 186 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "link": 25 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 26 + }, + { + "name": "control_net", + "type": "CONTROL_NET", + "link": 27 + }, + { + "name": "image", + "type": "IMAGE", + "link": 28 + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 16 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "links": [ + 17 + ] + } + ], + "title": "Apply Scribble ControlNet", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "ControlNetApplyAdvanced", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 0.8, + 0, + 1 + ] + }, + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [ + 100, + 130 + ], + "size": [ + 270, + 98 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 15 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 19, + 20 + ] + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 22 + ] + } + ], + "title": "Load SD1 Checkpoint", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "CheckpointLoaderSimple", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "epicrealism_naturalSinRC1VAE.safetensors" + ] + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [ + 482.798828125, + 130 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 19 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 25 + ] + } + ], + "title": "Positive Prompt", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "a finished illustration guided by a rough scribble" + ] + }, + { + "id": 12, + "type": "Scribble_PiDiNet_Preprocessor", + "pos": [ + 482.798828125, + 790 + ], + "size": [ + 270, + 82 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 24 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 28 + ] + } + ], + "title": "Scribble (PiDiNet) Preprocessor", + "properties": { + "cnr_id": "comfyui_controlnet_aux", + "ver": "e8b689a513c3e6b63edc44066560ca5919c0576e", + "Node name for S&R": "Scribble_PiDiNet_Preprocessor", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "enable", + 1024 + ] + }, + { + "id": 13, + "type": "ControlNetLoader", + "pos": [ + 98.70262257668115, + 855.8390373677852 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CONTROL_NET", + "type": "CONTROL_NET", + "links": [ + 27 + ] + } + ], + "title": "Load Scribble ControlNet", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "ControlNetLoader", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "control_v11p_sd15_scribble_fp16.safetensors" + ] + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [ + 108.54752513930123, + 287.7204535163998 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 18 + ] + } + ], + "title": "Empty Latent At Sketch Size", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "EmptyLatentImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 512, + 512, + 1 + ] + }, + { + "id": 10, + "type": "LoadImage", + "pos": [ + 99.23656618600262, + 478.3031831162233 + ], + "size": [ + 282.798828125, + 314.00000000000006 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 24 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "title": "Load Sketch Source", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "LoadImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + }, + "#sdppp_variant": "default", + "#sdppp_simple_content": "canvas", + "#sdppp_simple_mask": "canvas", + "#sdppp_simple_boundary": "canvas", + "#sdppp_label": "" + }, + "widgets_values": [ + "z-image_01484_.png", + "image" + ] + }, + { + "id": 3, + "type": "KSampler", + "pos": [ + 1352.798828125, + 130 + ], + "size": [ + 270, + 474 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 15 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 16 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 17 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 18 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 21 + ] + } + ], + "title": "Sampler", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "KSampler", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 799774906435439, + "randomize", + 20, + 7, + "euler", + "normal", + 1 + ] + }, + { + "id": 9, + "type": "SaveImage", + "pos": [ + 1962.798828125, + 130 + ], + "size": [ + 270, + 270 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 23 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "SaveImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "OpenLayer_Sketch" + ] + } + ], + "links": [ + [ + 15, + 4, + 0, + 3, + 0, + "MODEL" + ], + [ + 16, + 14, + 0, + 3, + 1, + "CONDITIONING" + ], + [ + 17, + 14, + 1, + 3, + 2, + "CONDITIONING" + ], + [ + 18, + 5, + 0, + 3, + 3, + "LATENT" + ], + [ + 19, + 4, + 1, + 6, + 0, + "CLIP" + ], + [ + 20, + 4, + 1, + 7, + 0, + "CLIP" + ], + [ + 21, + 3, + 0, + 8, + 0, + "LATENT" + ], + [ + 22, + 4, + 2, + 8, + 1, + "VAE" + ], + [ + 23, + 8, + 0, + 9, + 0, + "IMAGE" + ], + [ + 24, + 10, + 0, + 12, + 0, + "IMAGE" + ], + [ + 25, + 6, + 0, + 14, + 0, + "CONDITIONING" + ], + [ + 26, + 7, + 0, + 14, + 1, + "CONDITIONING" + ], + [ + 27, + 13, + 0, + 14, + 2, + "CONTROL_NET" + ], + [ + 28, + 12, + 0, + 14, + 3, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ue_links": [], + "links_added_by_ue": [], + "ds": { + "scale": 0.7657731958762918, + "offset": [ + 1.502953707290921, + 176.13778347079736 + ] + }, + "frontendVersion": "1.45.20", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} diff --git a/tests/comfy/modelFolders.test.ts b/tests/comfy/modelFolders.test.ts index d101d24..60e2450 100644 --- a/tests/comfy/modelFolders.test.ts +++ b/tests/comfy/modelFolders.test.ts @@ -120,6 +120,7 @@ describe("required model inventory", () => { "LLM/Florence-2-base-PromptGen-v2.0", "checkpoints/flux1-dev-fp8.safetensors", "controlnet/control_v11p_sd15_lineart_fp16.safetensors", + "controlnet/control_v11p_sd15_scribble_fp16.safetensors", "diffusion_models/flux1-fill-dev.safetensors", "diffusion_models/flux2-dev-Q4_K_M.gguf", "diffusion_models/krea2_turbo_fp8_scaled.safetensors", diff --git a/tests/comfy/workflowCompatibility.test.ts b/tests/comfy/workflowCompatibility.test.ts index 9063e8b..e127f7e 100644 --- a/tests/comfy/workflowCompatibility.test.ts +++ b/tests/comfy/workflowCompatibility.test.ts @@ -76,7 +76,7 @@ describe("workflow compatibility", () => { it("reports missing ComfyUI node classes without touching ComfyUI", () => { const preset = getWorkflowPreset("sketch2img-linecn-basic"); const availableNodes = createAvailableNodes(preset); - delete availableNodes.LineartStandardPreprocessor; + delete availableNodes.AnyLineArtPreprocessor_aux; const result = evaluateWorkflowCompatibility(preset, { selectedModelName: "epicrealism_naturalSinRC1VAE.safetensors", diff --git a/tests/comfy/workflowDiagnostics.test.ts b/tests/comfy/workflowDiagnostics.test.ts index 5859439..2b548ad 100644 --- a/tests/comfy/workflowDiagnostics.test.ts +++ b/tests/comfy/workflowDiagnostics.test.ts @@ -36,7 +36,7 @@ describe("workflow diagnostics", () => { it("explains missing ComfyUI setup as setup-required", () => { const preset = getWorkflowPreset("sketch2img-linecn-basic"); const availableNodes = createAvailableNodes(preset); - delete availableNodes.LineartStandardPreprocessor; + delete availableNodes.AnyLineArtPreprocessor_aux; const message = createWorkflowDiagnosticMessage(preset, { selectedModelName: "epicrealism_naturalSinRC1VAE.safetensors", @@ -46,7 +46,7 @@ describe("workflow diagnostics", () => { expect(message.isWarning).toBe(true); expect(message.summary).toContain("needs setup"); - expect(message.detail).toContain("LineartStandardPreprocessor"); + expect(message.detail).toContain("AnyLineArtPreprocessor_aux"); }); it("explains missing required model files", () => { diff --git a/tests/comfy/workflowFiles.test.ts b/tests/comfy/workflowFiles.test.ts index 2755c41..c7abf4d 100644 --- a/tests/comfy/workflowFiles.test.ts +++ b/tests/comfy/workflowFiles.test.ts @@ -27,9 +27,13 @@ function loadApiWorkflow(preset: WorkflowPresetDefinition): ComfyWorkflow { * registered there would be silently reported as core, and this fails first. */ const EXPECTED_CUSTOM_NODE_CLASSES = [ + // Both sketch presets use a learned edge detector rather than the naive + // Lineart/Scribble preprocessors, which return a blank control image for + // light-on-dark art and so silently degrade the preset to text-to-image. + "AnyLineArtPreprocessor_aux", "Florence2ModelLoader", "Florence2Run", - "LineartStandardPreprocessor", + "Scribble_PiDiNet_Preprocessor", // Flux.2's quantised model. Note only the UNET loader appears: its text // encoder is a safetensors file read by core CLIPLoader, so CLIPLoaderGGUF is // mapped in the registry but not required by any shipped preset. diff --git a/tests/comfy/workflowHealth.test.ts b/tests/comfy/workflowHealth.test.ts index c5efb83..877f537 100644 --- a/tests/comfy/workflowHealth.test.ts +++ b/tests/comfy/workflowHealth.test.ts @@ -117,7 +117,7 @@ describe("workflow health", () => { it("reports missing ComfyUI node classes", () => { const preset = getWorkflowPreset("sketch2img-linecn-basic"); const availableNodes = createAvailableNodes(preset); - delete availableNodes.LineartStandardPreprocessor; + delete availableNodes.AnyLineArtPreprocessor_aux; const item = createWorkflowHealthItem(preset, { availableNodes, @@ -127,7 +127,7 @@ describe("workflow health", () => { }); expect(item.state).toBe("missing-node"); - expect(item.summary).toContain("LineartStandardPreprocessor"); + expect(item.summary).toContain("AnyLineArtPreprocessor_aux"); }); it("keeps enriched setup issues routed to missing-model and missing-node states", () => { From 7eb2d49cbc3e9a671a23217e581b625a696a3b97 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 6 Aug 2026 17:43:20 +0330 Subject: [PATCH 02/13] Make the Flux.2 GGUF preset selectable and runnable Two defects kept txt2img-flux2-dev-gguf from working at all. The Model dropdown was empty of .gguf files because the preset shared DIFFUSION_MODEL_SOURCE, which asks UNETLoader for its file list. Core UNETLoader does not enumerate .gguf at all -- comfyClient already documented this for the inventory buckets, but the preset dropdown asks preset.modelSource directly and never got the same treatment. A preset built on UnetLoaderGGUF now points at that loader instead, so the model sitting in models/diffusion_models/ is visible where it belongs. Generation then failed on the preset that has no negative conditioning node. The txt2img/img2img/sketch builders injected negativePrompt with required=true, which throws when a preset has no such target -- and Flux.2 deliberately has none, being guidance-distilled. buildInpaint already passed it non-required for exactly this reason (Flux Fill); the other three now match, so a preset without a negative prompt no longer crashes on Generate. Verified against the live ComfyUI: /object_info/UnetLoaderGGUF lists flux2-dev-Q4_K_M.gguf, and the preset generates end to end. Co-Authored-By: Claude Opus 5 --- src/comfy/presetRegistry.ts | 13 ++++++++++++- src/comfy/workflowBuilder.ts | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/comfy/presetRegistry.ts b/src/comfy/presetRegistry.ts index d154c14..4783ce5 100644 --- a/src/comfy/presetRegistry.ts +++ b/src/comfy/presetRegistry.ts @@ -62,6 +62,17 @@ const DIFFUSION_MODEL_SOURCE = { label: "Diffusion model" } as const; +// UNETLoader's object_info does not enumerate .gguf files at all (verified live +// against ComfyUI-GGUF) -- a preset built on UnetLoaderGGUF must ask that loader +// for its own file list, or every .gguf model is invisible in the Model dropdown +// no matter where it's placed. +const DIFFUSION_MODEL_GGUF_SOURCE = { + kind: "diffusion-model-stack", + objectInfoNode: "UnetLoaderGGUF", + inputName: "unet_name", + label: "Diffusion model (GGUF)" +} as const; + const FLORENCE_MODEL_SOURCE = { kind: "vision-language", objectInfoNode: "Florence2ModelLoader", @@ -1855,7 +1866,7 @@ export const WORKFLOW_PRESETS: WorkflowPresetDefinition[] = [ recommendedSettings: { steps: 20, cfg: 4 }, supportedModelFamilies: ["flux2"], experimentalModelFamilies: ["unknown"], - modelSource: DIFFUSION_MODEL_SOURCE, + modelSource: DIFFUSION_MODEL_GGUF_SOURCE, capability: FLUX2_DEV_GGUF_TXT2IMG_CAPABILITY, modelStack: [...FLUX2_DEV_GGUF_STACK], requiredModels: [...FLUX2_DEV_GGUF_STACK], diff --git a/src/comfy/workflowBuilder.ts b/src/comfy/workflowBuilder.ts index 2c9c41f..938b1b8 100644 --- a/src/comfy/workflowBuilder.ts +++ b/src/comfy/workflowBuilder.ts @@ -64,7 +64,7 @@ export async function buildTxt2ImgWorkflow(options: BuildWorkflowOptions): Promi } setPresetInput(workflow, preset, "positivePrompt", options.prompt, true); - setPresetInput(workflow, preset, "negativePrompt", options.negativePrompt ?? "", true); + setPresetInput(workflow, preset, "negativePrompt", options.negativePrompt ?? ""); setPresetInput(workflow, preset, "width", options.width, true); setPresetInput(workflow, preset, "height", options.height, true); setPresetInput(workflow, preset, "seed", seed, true); @@ -99,7 +99,7 @@ export async function buildImg2ImgWorkflow( setPresetInput(workflow, preset, "sourceImage", options.sourceImageName, true); setPresetInput(workflow, preset, "positivePrompt", options.prompt, true); - setPresetInput(workflow, preset, "negativePrompt", options.negativePrompt ?? "", true); + setPresetInput(workflow, preset, "negativePrompt", options.negativePrompt ?? ""); setPresetInput(workflow, preset, "seed", seed, true); setPresetInput(workflow, preset, "steps", options.steps, true); setPresetInput(workflow, preset, "cfg", options.cfg, true); @@ -132,7 +132,7 @@ export async function buildSketchToImageWorkflow( setPresetInput(workflow, preset, "sourceImage", options.sourceImageName, true); setPresetInput(workflow, preset, "positivePrompt", options.prompt, true); - setPresetInput(workflow, preset, "negativePrompt", options.negativePrompt ?? "", true); + setPresetInput(workflow, preset, "negativePrompt", options.negativePrompt ?? ""); setPresetInput(workflow, preset, "width", options.width, true); setPresetInput(workflow, preset, "height", options.height, true); setPresetInput(workflow, preset, "seed", seed, true); From 40714682c1a9153db9c5ef2de25f1646ab99f4f9 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 6 Aug 2026 17:44:13 +0330 Subject: [PATCH 03/13] Add a Depth ControlNet sketch-to-image preset LineArt and Scribble both hold the drawn stroke. Neither carries depth, so nothing in the panel could match a generated element to the camera angle of an existing composite -- the thing artists actually reach for ControlNet to do when compositing into a scene. sketch2img-depth-basic is the third variant of a graph shape the registry already documents as deliberately shared: the node ids match LineArt and Scribble exactly, and only the preprocessor and the ControlNet it feeds differ. controlStrength was already an injection name, so no new injection type was needed. DepthAnythingV2Preprocessor comes from comfyui_controlnet_aux, which was already the sole entry in CUSTOM_NODE_PACKAGES for the existing sketch presets -- the frozen custom-node inventory test passes unchanged, so this adds no new node dependency. The workflow pins ckpt_name and resolution explicitly rather than relying on the node pack's defaults, so a run stays reproducible across pack updates. The ControlNet weight is new: control_v11f1p_sd15_depth_fp16 from the same comfyanonymous repo the lineart and scribble weights come from, its size verified by HEAD request at 722601100 bytes -- byte-identical to its three siblings already in the registry. Verified against the live ComfyUI: all ten node classes resolve, and the source/API equivalence check covers the new pair rather than skipping it. Co-Authored-By: Claude Opus 5 --- src/comfy/presetRegistry.ts | 138 ++++ src/comfy/types.ts | 1 + src/comfy/workflowBuilder.ts | 2 + src/workflows/api/sketch2img-depth-basic.json | 166 +++++ .../sketch2img-depth-basic.workflow.json | 691 ++++++++++++++++++ tests/comfy/modelFolders.test.ts | 1 + 6 files changed, 999 insertions(+) create mode 100644 src/workflows/api/sketch2img-depth-basic.json create mode 100644 src/workflows/source/sketch2img-depth-basic.workflow.json diff --git a/src/comfy/presetRegistry.ts b/src/comfy/presetRegistry.ts index 4783ce5..ef7ac2f 100644 --- a/src/comfy/presetRegistry.ts +++ b/src/comfy/presetRegistry.ts @@ -313,6 +313,23 @@ const SKETCH2IMG_SCRIBBLE_BASIC_NODES = { saveImage: "9" } as const; +// Third variant of the same graph. Depth differs from LineArt and Scribble in +// what it preserves: those two hold the drawn *stroke*, while depth holds the +// scene's geometry, which is what matters when a generated image has to sit in +// an existing Photoshop composite at the right perspective. +const SKETCH2IMG_DEPTH_BASIC_NODES = { + checkpointLoader: "4", + loadImage: "10", + positivePrompt: "6", + negativePrompt: "7", + latentImage: "5", + depthPreprocessor: "12", + controlNetLoader: "13", + controlNetApply: "14", + sampler: "3", + saveImage: "9" +} as const; + const INPAINT_BASIC_NODES = { checkpointLoader: "4", loadImage: "10", @@ -504,6 +521,20 @@ const SKETCH2IMG_SCRIBBLE_BASIC_INJECTIONS = { controlStrength: target(SKETCH2IMG_SCRIBBLE_BASIC_NODES.controlNetApply, "strength") } as const; +const SKETCH2IMG_DEPTH_BASIC_INJECTIONS = { + checkpoint: target(SKETCH2IMG_DEPTH_BASIC_NODES.checkpointLoader, "ckpt_name"), + sourceImage: target(SKETCH2IMG_DEPTH_BASIC_NODES.loadImage, "image"), + positivePrompt: target(SKETCH2IMG_DEPTH_BASIC_NODES.positivePrompt, "text"), + negativePrompt: target(SKETCH2IMG_DEPTH_BASIC_NODES.negativePrompt, "text"), + width: target(SKETCH2IMG_DEPTH_BASIC_NODES.latentImage, "width"), + height: target(SKETCH2IMG_DEPTH_BASIC_NODES.latentImage, "height"), + seed: target(SKETCH2IMG_DEPTH_BASIC_NODES.sampler, "seed"), + steps: target(SKETCH2IMG_DEPTH_BASIC_NODES.sampler, "steps"), + cfg: target(SKETCH2IMG_DEPTH_BASIC_NODES.sampler, "cfg"), + denoise: target(SKETCH2IMG_DEPTH_BASIC_NODES.sampler, "denoise"), + controlStrength: target(SKETCH2IMG_DEPTH_BASIC_NODES.controlNetApply, "strength") +} as const; + const INPAINT_BASIC_INJECTIONS = { checkpoint: target(INPAINT_BASIC_NODES.checkpointLoader, "ckpt_name"), sourceImage: target(INPAINT_BASIC_NODES.loadImage, "image"), @@ -774,6 +805,26 @@ const SKETCH2IMG_SCRIBBLE_BASIC_CAPABILITY: WorkflowCapability = { } }; +const SKETCH2IMG_DEPTH_BASIC_CAPABILITY: WorkflowCapability = { + toolType: "sketch2img", + loaderType: "checkpoint", + artistLabel: "Sketch to Image", + technicalLabel: "sketch2img-depth-basic", + requiredPhotoshopInputs: [{ anyOf: ["active-layer", "canvas"], label: "an active layer or captured canvas" }], + controls: ["prompt", "negativePrompt", "steps", "cfg", "denoise", "seed", "controlStrength"], + output: { + kind: "source-sized-image", + size: "source", + importBehavior: "new-layer" + }, + uiHints: { + showModelSelector: true, + modelSelectorLabel: "Checkpoint", + primaryActionLabel: "Generate Sketch to Image", + experimentalNote: "Starter SD 1.x Depth ControlNet workflow." + } +}; + const INPAINT_BASIC_CAPABILITY: WorkflowCapability = { toolType: "inpaint", loaderType: "checkpoint", @@ -1400,6 +1451,93 @@ export const WORKFLOW_PRESETS: WorkflowPresetDefinition[] = [ } ] }, + { + id: "sketch2img-depth-basic", + label: "sketch2img-depth-basic", + displayName: "Depth ControlNet", + mode: "sketch2img", + description: "SD 1.x sketch-to-image workflow that conditions on the source layer's depth rather than its lines.", + workflowFile: "workflows/api/sketch2img-depth-basic.json", + sourceWorkflowFile: "workflows/source/sketch2img-depth-basic.workflow.json", + status: "stable", + recommendedSettings: { steps: 20, cfg: 7 }, + supportedModelFamilies: ["sd1"], + experimentalModelFamilies: ["sdxl", "sd3", "flux", "zImage"], + modelSource: CHECKPOINT_MODEL_SOURCE, + capability: SKETCH2IMG_DEPTH_BASIC_CAPABILITY, + injections: SKETCH2IMG_DEPTH_BASIC_INJECTIONS, + compatibilityNote: + "sketch2img-depth-basic conditions on estimated scene depth, so it holds perspective and the relative distance of forms while leaving surface detail free -- the preset to reach for when a generated element has to sit inside an existing composite at the right camera angle. LineArt and Scribble hold the drawn stroke instead, and neither carries depth. It works from any shaded image, not only a line drawing, and a flat drawing with no tonal variation gives the estimator little to read. DepthAnythingV2Preprocessor downloads its own estimator weights on first run, the same way the LineArt and Scribble preprocessors do, so the first generation after install is slower than later ones.", + requiredModels: [ + { + kind: "controlnet", + objectInfoNode: "ControlNetLoader", + inputName: "control_net_name", + label: "Depth ControlNet", + modelName: "control_v11f1p_sd15_depth_fp16.safetensors", + setupHint: "Install an SD 1.5 Depth ControlNet model in ComfyUI's controlnet models folder.", + downloadUrl: + "https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors/resolve/main/control_v11f1p_sd15_depth_fp16.safetensors", + sourcePageUrl: "https://huggingface.co/comfyanonymous/ControlNet-v1-1_fp16_safetensors", + downloadSizeBytes: 722601100 + } + ], + requiredNodes: [ + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.checkpointLoader, + classType: "CheckpointLoaderSimple", + requiredInputs: ["ckpt_name"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.loadImage, + classType: "LoadImage", + requiredInputs: ["image"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.positivePrompt, + classType: "CLIPTextEncode", + requiredInputs: ["text", "clip"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.negativePrompt, + classType: "CLIPTextEncode", + requiredInputs: ["text", "clip"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.latentImage, + classType: "EmptyLatentImage", + requiredInputs: ["width", "height", "batch_size"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.depthPreprocessor, + // Only `image` is required; ckpt_name and resolution are optional inputs + // that the workflow pins explicitly so a run is reproducible rather than + // dependent on whatever default the node pack ships that week. + classType: "DepthAnythingV2Preprocessor", + requiredInputs: ["image"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.controlNetLoader, + classType: "ControlNetLoader", + requiredInputs: ["control_net_name"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.controlNetApply, + classType: "ControlNetApplyAdvanced", + requiredInputs: ["positive", "negative", "control_net", "image", "strength", "start_percent", "end_percent"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.sampler, + classType: "KSampler", + requiredInputs: ["seed", "steps", "cfg", "denoise", "model", "positive", "negative", "latent_image"] + }, + { + id: SKETCH2IMG_DEPTH_BASIC_NODES.saveImage, + classType: "SaveImage", + requiredInputs: ["images"] + } + ] + }, { id: "inpaint-basic", label: "inpaint-basic", diff --git a/src/comfy/types.ts b/src/comfy/types.ts index d742dce..34158e8 100644 --- a/src/comfy/types.ts +++ b/src/comfy/types.ts @@ -10,6 +10,7 @@ export type WorkflowPreset = | "prompt-from-layer-florence2" | "sketch2img-linecn-basic" | "sketch2img-scribble-basic" + | "sketch2img-depth-basic" | "inpaint-basic" | "inpaint-flux-fill-basic" | "outpaint-flux-fill-basic" diff --git a/src/comfy/workflowBuilder.ts b/src/comfy/workflowBuilder.ts index 938b1b8..a52f10c 100644 --- a/src/comfy/workflowBuilder.ts +++ b/src/comfy/workflowBuilder.ts @@ -8,6 +8,7 @@ import img2imgKrea2TurboWorkflow from "../workflows/api/img2img-krea2-turbo.json import promptFromLayerFlorence2Workflow from "../workflows/api/prompt-from-layer-florence2.json"; import sketch2imgLinecnBasicWorkflow from "../workflows/api/sketch2img-linecn-basic.json"; import sketch2imgScribbleBasicWorkflow from "../workflows/api/sketch2img-scribble-basic.json"; +import sketch2imgDepthBasicWorkflow from "../workflows/api/sketch2img-depth-basic.json"; import inpaintBasicWorkflow from "../workflows/api/inpaint-basic.json"; import inpaintFluxFillBasicWorkflow from "../workflows/api/inpaint-flux-fill-basic.json"; import outpaintFluxFillBasicWorkflow from "../workflows/api/outpaint-flux-fill-basic.json"; @@ -42,6 +43,7 @@ const WORKFLOW_TEMPLATES: Partial> = { "prompt-from-layer-florence2": promptFromLayerFlorence2Workflow as ComfyWorkflow, "sketch2img-linecn-basic": sketch2imgLinecnBasicWorkflow as ComfyWorkflow, "sketch2img-scribble-basic": sketch2imgScribbleBasicWorkflow as ComfyWorkflow, + "sketch2img-depth-basic": sketch2imgDepthBasicWorkflow as ComfyWorkflow, "inpaint-basic": inpaintBasicWorkflow as ComfyWorkflow, "inpaint-flux-fill-basic": inpaintFluxFillBasicWorkflow as ComfyWorkflow, "outpaint-flux-fill-basic": outpaintFluxFillBasicWorkflow as ComfyWorkflow, diff --git a/src/workflows/api/sketch2img-depth-basic.json b/src/workflows/api/sketch2img-depth-basic.json new file mode 100644 index 0000000..106ef9a --- /dev/null +++ b/src/workflows/api/sketch2img-depth-basic.json @@ -0,0 +1,166 @@ +{ + "4": { + "inputs": { + "ckpt_name": "epicrealism_naturalSinRC1VAE.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Load SD1 Checkpoint" + } + }, + "10": { + "inputs": { + "image": "openlayer-sketch-source.png" + }, + "class_type": "LoadImage", + "_meta": { + "title": "Load Sketch Source" + } + }, + "6": { + "inputs": { + "text": "a finished illustration matching the depth and perspective of the source", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "Positive Prompt" + } + }, + "7": { + "inputs": { + "text": "", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "Negative Prompt" + } + }, + "12": { + "inputs": { + "ckpt_name": "depth_anything_v2_vitl.pth", + "resolution": 512, + "image": [ + "10", + 0 + ] + }, + "class_type": "DepthAnythingV2Preprocessor", + "_meta": { + "title": "Depth Anything V2 Preprocessor" + } + }, + "13": { + "inputs": { + "control_net_name": "control_v11f1p_sd15_depth_fp16.safetensors" + }, + "class_type": "ControlNetLoader", + "_meta": { + "title": "Load Depth ControlNet" + } + }, + "14": { + "inputs": { + "strength": 0.8, + "start_percent": 0, + "end_percent": 1, + "positive": [ + "6", + 0 + ], + "negative": [ + "7", + 0 + ], + "control_net": [ + "13", + 0 + ], + "image": [ + "12", + 0 + ] + }, + "class_type": "ControlNetApplyAdvanced", + "_meta": { + "title": "Apply Depth ControlNet" + } + }, + "5": { + "inputs": { + "width": 512, + "height": 512, + "batch_size": 1 + }, + "class_type": "EmptyLatentImage", + "_meta": { + "title": "Empty Latent At Sketch Size" + } + }, + "3": { + "inputs": { + "seed": 1, + "steps": 20, + "cfg": 7, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": [ + "4", + 0 + ], + "positive": [ + "14", + 0 + ], + "negative": [ + "14", + 1 + ], + "latent_image": [ + "5", + 0 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "Sampler" + } + }, + "8": { + "inputs": { + "samples": [ + "3", + 0 + ], + "vae": [ + "4", + 2 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "9": { + "inputs": { + "filename_prefix": "OpenLayer_Sketch", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "Save Image" + } + } +} diff --git a/src/workflows/source/sketch2img-depth-basic.workflow.json b/src/workflows/source/sketch2img-depth-basic.workflow.json new file mode 100644 index 0000000..186af9b --- /dev/null +++ b/src/workflows/source/sketch2img-depth-basic.workflow.json @@ -0,0 +1,691 @@ +{ + "id": "5f2a91c4-7b3e-4d16-9c48-2ae70b5d8f31", + "revision": 0, + "last_node_id": 14, + "last_link_id": 28, + "nodes": [ + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 482.798828125, + 460 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 20 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 26 + ] + } + ], + "title": "Negative Prompt", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "" + ] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [ + 1722.798828125, + 130 + ], + "size": [ + 140, + 46 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 21 + }, + { + "name": "vae", + "type": "VAE", + "link": 22 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "VAEDecode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [] + }, + { + "id": 14, + "type": "ControlNetApplyAdvanced", + "pos": [ + 982.798828125, + 130 + ], + "size": [ + 270, + 186 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "link": 25 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 26 + }, + { + "name": "control_net", + "type": "CONTROL_NET", + "link": 27 + }, + { + "name": "image", + "type": "IMAGE", + "link": 28 + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 16 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "links": [ + 17 + ] + } + ], + "title": "Apply Depth ControlNet", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "ControlNetApplyAdvanced", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 0.8, + 0, + 1 + ] + }, + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [ + 100, + 130 + ], + "size": [ + 270, + 98 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 15 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 19, + 20 + ] + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 22 + ] + } + ], + "title": "Load SD1 Checkpoint", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "CheckpointLoaderSimple", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "epicrealism_naturalSinRC1VAE.safetensors" + ] + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [ + 482.798828125, + 130 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 19 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 25 + ] + } + ], + "title": "Positive Prompt", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "a finished illustration matching the depth and perspective of the source" + ] + }, + { + "id": 12, + "type": "DepthAnythingV2Preprocessor", + "pos": [ + 482.798828125, + 790 + ], + "size": [ + 270, + 82 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 24 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 28 + ] + } + ], + "title": "Depth Anything V2 - Relative", + "properties": { + "cnr_id": "comfyui_controlnet_aux", + "ver": "e8b689a513c3e6b63edc44066560ca5919c0576e", + "Node name for S&R": "DepthAnythingV2Preprocessor", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "depth_anything_v2_vitl.pth", + 512 + ] + }, + { + "id": 13, + "type": "ControlNetLoader", + "pos": [ + 98.70262257668115, + 855.8390373677852 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CONTROL_NET", + "type": "CONTROL_NET", + "links": [ + 27 + ] + } + ], + "title": "Load Depth ControlNet", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "ControlNetLoader", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "control_v11f1p_sd15_depth_fp16.safetensors" + ] + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [ + 108.54752513930123, + 287.7204535163998 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 18 + ] + } + ], + "title": "Empty Latent At Sketch Size", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "EmptyLatentImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 512, + 512, + 1 + ] + }, + { + "id": 10, + "type": "LoadImage", + "pos": [ + 99.23656618600262, + 478.3031831162233 + ], + "size": [ + 282.798828125, + 314.00000000000006 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 24 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "title": "Load Sketch Source", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "LoadImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + }, + "#sdppp_variant": "default", + "#sdppp_simple_content": "canvas", + "#sdppp_simple_mask": "canvas", + "#sdppp_simple_boundary": "canvas", + "#sdppp_label": "" + }, + "widgets_values": [ + "z-image_01484_.png", + "image" + ] + }, + { + "id": 3, + "type": "KSampler", + "pos": [ + 1352.798828125, + 130 + ], + "size": [ + 270, + 474 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 15 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 16 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 17 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 18 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 21 + ] + } + ], + "title": "Sampler", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "KSampler", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 799774906435439, + "randomize", + 20, + 7, + "euler", + "normal", + 1 + ] + }, + { + "id": 9, + "type": "SaveImage", + "pos": [ + 1962.798828125, + 130 + ], + "size": [ + 270, + 270 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 23 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.1", + "Node name for S&R": "SaveImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "OpenLayer_Sketch" + ] + } + ], + "links": [ + [ + 15, + 4, + 0, + 3, + 0, + "MODEL" + ], + [ + 16, + 14, + 0, + 3, + 1, + "CONDITIONING" + ], + [ + 17, + 14, + 1, + 3, + 2, + "CONDITIONING" + ], + [ + 18, + 5, + 0, + 3, + 3, + "LATENT" + ], + [ + 19, + 4, + 1, + 6, + 0, + "CLIP" + ], + [ + 20, + 4, + 1, + 7, + 0, + "CLIP" + ], + [ + 21, + 3, + 0, + 8, + 0, + "LATENT" + ], + [ + 22, + 4, + 2, + 8, + 1, + "VAE" + ], + [ + 23, + 8, + 0, + 9, + 0, + "IMAGE" + ], + [ + 24, + 10, + 0, + 12, + 0, + "IMAGE" + ], + [ + 25, + 6, + 0, + 14, + 0, + "CONDITIONING" + ], + [ + 26, + 7, + 0, + 14, + 1, + "CONDITIONING" + ], + [ + 27, + 13, + 0, + 14, + 2, + "CONTROL_NET" + ], + [ + 28, + 12, + 0, + 14, + 3, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ue_links": [], + "links_added_by_ue": [], + "ds": { + "scale": 0.7657731958762918, + "offset": [ + 1.502953707290921, + 176.13778347079736 + ] + }, + "frontendVersion": "1.45.20", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} diff --git a/tests/comfy/modelFolders.test.ts b/tests/comfy/modelFolders.test.ts index 60e2450..05f43bc 100644 --- a/tests/comfy/modelFolders.test.ts +++ b/tests/comfy/modelFolders.test.ts @@ -119,6 +119,7 @@ describe("required model inventory", () => { [ "LLM/Florence-2-base-PromptGen-v2.0", "checkpoints/flux1-dev-fp8.safetensors", + "controlnet/control_v11f1p_sd15_depth_fp16.safetensors", "controlnet/control_v11p_sd15_lineart_fp16.safetensors", "controlnet/control_v11p_sd15_scribble_fp16.safetensors", "diffusion_models/flux1-fill-dev.safetensors", From d0f92be4836e2aac465eb5d4e3d11642c18744f0 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 6 Aug 2026 17:44:22 +0330 Subject: [PATCH 04/13] Correct the WFL agent's recorded ComfyUI version The verified-machine spec said ComfyUI 0.27.1; the running instance reports 0.30.0. A research agent judging what will run on this hardware was reasoning from a version two minors stale. Python 3.10.11 and PyTorch 2.6.0+cu124 were re-checked against /system_stats and are unchanged, so only the one number moves. The "ver": "0.27.1" strings in src/workflows/source/*.json are litegraph provenance stamps recording which version authored each node, not a statement about this machine, and are deliberately left alone. Co-Authored-By: Claude Opus 5 --- .claude/agents/WFL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/agents/WFL.md b/.claude/agents/WFL.md index 619601d..c9fc329 100644 --- a/.claude/agents/WFL.md +++ b/.claude/agents/WFL.md @@ -27,7 +27,7 @@ Read `docs/ORCHESTRATION.md` first, every time. It is the durable source of trut Judge everything against these. A suggestion that ignores them wastes the developer's time. **Hardware ceiling.** The only verified machine is an **RTX 4070 Ti, 12 GB VRAM**, 31.7 GB system -RAM, Windows 11. ComfyUI 0.27.1, Python 3.10.11, PyTorch 2.6.0+cu124. Anything you recommend must run +RAM, Windows 11. ComfyUI 0.30.0, Python 3.10.11, PyTorch 2.6.0+cu124. Anything you recommend must run in 12 GB, and you must say how — fp8, GGUF quant, tiled VAE, sequential offload — with a measured or sourced figure, not a guess. For reference, Krea-2 Turbo at 1024² 8 steps takes ~38-48 s on this card with offloading; SD 1.5 + LCM at 512² 5 steps is ~0.5-0.7 s. If a model cannot beat those on quality From ba19ee77979cb758f5ed4a36d5b3810d6334e13f Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 6 Aug 2026 18:14:25 +0330 Subject: [PATCH 05/13] Add optional LoRA support to the Krea-2 Turbo preset Eleven LoRAs sat on the reference machine that the panel could not use. getLoraNames() has been in comfyClient since before this change and was never called by anything. A LoRA cannot be injected the way every other control is. Core LoraLoader has no "none" entry -- lora_name is a combo of files that exist -- so a permanently wired loader would force everyone to own and load one. The node is therefore absent from the shipped workflow and spliced in only when an artist picks a LoRA, rewiring the sampler's model input and both text encodes' clip inputs. This is the first time building a workflow changes its topology rather than its values, so the wiring is declared per preset rather than inferred: guessing which MODEL and CLIP edges are the main ones would, when wrong, produce an image with the LoRA applied to nothing and no error to explain it. Choosing nothing leaves the graph byte-identical to what ships. Both text encodes are rewired, not just the positive one. At CFG 1 the negative contributes nothing, but leaving it on the bare CLIP would put two different text encoders in one graph -- harmless now and a confusing bug the first time this preset runs above CFG 1. The dropdown is labelled, not filtered. ComfyUI serves only a LoRA's name, size and timestamps, so the panel cannot inspect a file it offers; the metadata that would answer the question is not served and lies when read anyway -- illustration.safetensors and meat_v1.safetensors both declare ss_base_model_version sd_1.5 while their tensor keys are Flux. Filename hints therefore label entries and sort likely matches first, but never hide one: a strict filter keyed on any available signal would hide working LoRAs and still admit broken ones. The hint never claims a match it cannot support, which is the asymmetry the tests pin down -- over-claiming sends the artist hunting a bug in the wrong place, while a missed mismatch only costs a generation. Co-Authored-By: Claude Opus 5 --- src/comfy/loraCompatibility.ts | 92 +++++++++++++++ src/comfy/presetRegistry.ts | 25 ++++ src/comfy/types.ts | 50 ++++++++ src/comfy/workflowBuilder.ts | 68 +++++++++++ src/styles.css | 13 +++ src/ui/App.ts | 134 +++++++++++++++++++++- src/ui/appConstants.ts | 2 + src/ui/appMarkup.ts | 24 ++++ tests/comfy/loraCompatibility.test.ts | 78 +++++++++++++ tests/comfy/workflowLoraInsertion.test.ts | 102 ++++++++++++++++ 10 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 src/comfy/loraCompatibility.ts create mode 100644 tests/comfy/loraCompatibility.test.ts create mode 100644 tests/comfy/workflowLoraInsertion.test.ts diff --git a/src/comfy/loraCompatibility.ts b/src/comfy/loraCompatibility.ts new file mode 100644 index 0000000..5955366 --- /dev/null +++ b/src/comfy/loraCompatibility.ts @@ -0,0 +1,92 @@ +import { WorkflowPresetDefinition } from "./types"; + +/** + * A guess, from the filename alone, about whether a LoRA suits a preset. + * + * `unknown` is the honest majority case and must stay usable: the panel labels + * it and lets the artist proceed. + */ +export type LoraFamilyHint = "matches" | "foreign" | "unknown"; + +/** + * Tokens that name a model family in the wild. A filename containing one of + * these for a family the preset does not use is very likely trained for + * something else. + * + * This is filename matching, not detection. It exists because nothing better is + * reachable: ComfyUI serves only a LoRA's name, size and timestamps + * (`/models/loras` and `/experiment/models/loras` both stop there), so the + * panel cannot inspect a file it is offering. The safetensors metadata that + * would answer the question is not served, and is not trustworthy anyway -- + * measured on the reference machine, `illustration.safetensors` and + * `meat_v1.safetensors` both declare `ss_base_model_version: sd_1.5` while + * their tensor keys (`transformer.single_transformer_blocks.*`) are plainly + * Flux. A filename says less but at least does not actively lie. + */ +const FOREIGN_FAMILY_TOKENS = [ + "flux", + "sdxl", + "sd15", + "sd1.5", + "sd_1.5", + "z-image", + "zimage", + "z_image", + "pony", + "illustrious", + "wan", + "qwen", + "hunyuan", + "sd3" +] as const; + +function normalize(loraName: string): string { + return loraName.toLowerCase().replace(/\\/g, "/"); +} + +/** + * What the filename suggests about this LoRA's fit for the preset. + * + * Deliberately asymmetric: it returns `matches` only on a positive token the + * preset itself declares, and never infers a match from the absence of a + * foreign token. Over-claiming a match is the one error that would actively + * mislead, because a mismatched LoRA fails silently rather than erroring. + */ +export function getLoraFamilyHint(loraName: string, preset: WorkflowPresetDefinition): LoraFamilyHint { + const matchTokens = preset.loraInsertion?.familyTokens ?? []; + const normalized = normalize(loraName); + + if (matchTokens.some((token) => normalized.includes(token.toLowerCase()))) { + return "matches"; + } + + const ownTokens = matchTokens.map((token) => token.toLowerCase()); + + for (const token of FOREIGN_FAMILY_TOKENS) { + // A token the preset claims as its own is never foreign, even if it also + // appears in this list -- a Qwen-based preset would otherwise flag its own + // LoRAs. + if (ownTokens.includes(token)) { + continue; + } + + if (normalized.includes(token)) { + return "foreign"; + } + } + + return "unknown"; +} + +/** Short suffix for a dropdown entry. Empty when there is nothing useful to say. */ +export function formatLoraHintSuffix(hint: LoraFamilyHint): string { + if (hint === "matches") { + return " (name matches this model)"; + } + + if (hint === "foreign") { + return " (name suggests another model)"; + } + + return ""; +} diff --git a/src/comfy/presetRegistry.ts b/src/comfy/presetRegistry.ts index ef7ac2f..ef7b5cf 100644 --- a/src/comfy/presetRegistry.ts +++ b/src/comfy/presetRegistry.ts @@ -429,6 +429,30 @@ const KREA2_TURBO_TXT2IMG_NODES = { saveImage: "9" } as const; +/** + * Where an optional LoRA goes in the Krea-2 Turbo graph. + * + * Both text encodes are listed, not just the positive one. At CFG 1 the + * negative encode contributes nothing to the image, but leaving it reading the + * bare CLIP would mean two different text encoders in one graph -- harmless + * today and a confusing bug the moment this preset is used at a CFG above 1. + * + * Node id 23 is the next free id after the loaders (20-22); the shipped + * workflow stops at 22, and applyLoraSelection refuses to overwrite an + * occupied id rather than trusting this comment to stay true. + */ +const KREA2_TURBO_TXT2IMG_LORA_INSERTION = { + nodeId: "23", + familyTokens: ["krea2", "krea-2", "krea_2"], + modelSource: { nodeId: KREA2_TURBO_TXT2IMG_NODES.diffusionModelLoader, slot: 0 }, + clipSource: { nodeId: KREA2_TURBO_TXT2IMG_NODES.clipLoader, slot: 0 }, + modelConsumers: [{ nodeId: KREA2_TURBO_TXT2IMG_NODES.sampler, inputName: "model" }], + clipConsumers: [ + { nodeId: KREA2_TURBO_TXT2IMG_NODES.positivePrompt, inputName: "clip" }, + { nodeId: KREA2_TURBO_TXT2IMG_NODES.negativePrompt, inputName: "clip" } + ] +} as const; + const KREA2_TURBO_IMG2IMG_NODES = { diffusionModelLoader: "20", clipLoader: "21", @@ -1942,6 +1966,7 @@ export const WORKFLOW_PRESETS: WorkflowPresetDefinition[] = [ modelStack: [...KREA2_TURBO_STACK], requiredModels: [...KREA2_TURBO_STACK], injections: KREA2_TURBO_TXT2IMG_INJECTIONS, + loraInsertion: KREA2_TURBO_TXT2IMG_LORA_INSERTION, requiredNodes: [ { id: KREA2_TURBO_TXT2IMG_NODES.diffusionModelLoader, diff --git a/src/comfy/types.ts b/src/comfy/types.ts index 34158e8..5d512d6 100644 --- a/src/comfy/types.ts +++ b/src/comfy/types.ts @@ -141,6 +141,7 @@ export type BuildWorkflowOptions = { steps: number; cfg: number; seed: number; + lora?: WorkflowLoraSelection; }; export type BuildImageToImageWorkflowOptions = { @@ -230,6 +231,53 @@ export type WorkflowInjectionName = export type WorkflowInjectionTargetList = WorkflowInputTarget | readonly WorkflowInputTarget[]; +/** Where a link starts: a node and one of its output slots. */ +export type WorkflowLinkSource = { + nodeId: string; + slot: number; +}; + +/** + * How to splice an optional `LoraLoader` into a preset's graph. + * + * Every other injection sets a *value* on a node the shipped workflow already + * contains. A LoRA cannot work that way: core `LoraLoader` offers no "none" + * entry — `lora_name` is a combo of files that exist — so a permanently wired + * loader would force every user to own and load a LoRA they may not want. The + * node is therefore absent from the shipped workflow and spliced in only when + * an artist actually picks one, which is the first and only case where building + * a workflow changes its topology rather than its values. + * + * The wiring is declared here rather than inferred because inferring it means + * guessing which MODEL and CLIP edges are the "main" ones, and a wrong guess + * silently produces an image with the LoRA applied to nothing. + */ +export type WorkflowLoraInsertion = { + /** Id the inserted node takes. Must not already exist in the workflow. */ + nodeId: string; + /** + * Filename fragments that suggest a LoRA was trained for this preset's model. + * Used only to label the dropdown, never to hide an entry -- see + * `loraCompatibility.ts` for why nothing stronger is available. + */ + familyTokens?: readonly string[]; + /** Feeds the loader's `model` input — normally the diffusion model loader. */ + modelSource: WorkflowLinkSource; + /** Feeds the loader's `clip` input — normally the text encoder loader. */ + clipSource: WorkflowLinkSource; + /** Inputs reading the model directly, rewired to the loader's MODEL output. */ + modelConsumers: readonly WorkflowInputTarget[]; + /** Inputs reading the CLIP directly, rewired to the loader's CLIP output. */ + clipConsumers: readonly WorkflowInputTarget[]; +}; + +/** An artist's LoRA choice. Absent or nameless means "no LoRA". */ +export type WorkflowLoraSelection = { + loraName: string; + strengthModel: number; + strengthClip: number; +}; + export type WorkflowInjectionTargets = Partial>; export type WorkflowModelSourceKind = @@ -329,6 +377,8 @@ export type WorkflowPresetDefinition = { requiredNodes: WorkflowNodeRequirement[]; requiredModels?: WorkflowRequiredModel[]; capability?: WorkflowCapability; + /** Present only on presets that can take an optional LoRA. */ + loraInsertion?: WorkflowLoraInsertion; compatibilityNote?: string; disabledReason?: string; }; diff --git a/src/comfy/workflowBuilder.ts b/src/comfy/workflowBuilder.ts index a52f10c..b6a64fd 100644 --- a/src/comfy/workflowBuilder.ts +++ b/src/comfy/workflowBuilder.ts @@ -23,6 +23,7 @@ import { BuildWorkflowOptions, BuildWorkflowResult, ComfyWorkflow, + WorkflowLoraSelection, WorkflowPreset, WorkflowPresetDefinition, WorkflowInjectionTargetList @@ -73,6 +74,8 @@ export async function buildTxt2ImgWorkflow(options: BuildWorkflowOptions): Promi setPresetInput(workflow, preset, "steps", options.steps, true); setPresetInput(workflow, preset, "cfg", options.cfg, true); + applyLoraSelection(workflow, preset, options.lora); + validateWorkflowForPreset(workflow, preset); return { @@ -314,6 +317,71 @@ function normalizeTargets(target: WorkflowInjectionTargetList) { return Array.isArray(target) ? target : [target]; } +/** + * Splices a `LoraLoader` between a preset's model/CLIP loaders and everything + * downstream of them. + * + * This is the one place a workflow's topology changes at build time, for the + * reason spelled out on `WorkflowLoraInsertion`: core `LoraLoader` has no "off" + * value, so an optional LoRA cannot be a permanently wired node whose value is + * merely injected. No selection leaves the graph exactly as shipped. + * + * Order matters. This must run after the value injections, because rewiring an + * input that a later `setPresetInput` overwrites would silently drop the LoRA + * out of the chain while still loading it -- an image that looks untouched with + * no error to explain why. + */ +function applyLoraSelection( + workflow: ComfyWorkflow, + preset: WorkflowPresetDefinition, + lora: WorkflowLoraSelection | undefined +) { + if (!lora?.loraName) { + return; + } + + const insertion = preset.loraInsertion; + + if (!insertion) { + throw createOpenLayerError( + "WORKFLOW_INVALID", + `The ${preset.id} preset does not support a LoRA.`, + `Add a loraInsertion entry for ${preset.id} in src/comfy/presetRegistry.ts, or clear the LoRA selection.` + ); + } + + // A collision would overwrite a real node and produce a graph that still + // validates, because validateWorkflowForPreset only checks that required + // nodes are present -- so it has to be caught here or not at all. + if (workflow[insertion.nodeId]) { + throw createOpenLayerError( + "WORKFLOW_INVALID", + `The ${preset.id} workflow already uses node ${insertion.nodeId}.`, + `Give ${preset.id}'s loraInsertion an unused nodeId in src/comfy/presetRegistry.ts.` + ); + } + + workflow[insertion.nodeId] = { + class_type: "LoraLoader", + inputs: { + lora_name: lora.loraName, + strength_model: lora.strengthModel, + strength_clip: lora.strengthClip, + model: [insertion.modelSource.nodeId, insertion.modelSource.slot], + clip: [insertion.clipSource.nodeId, insertion.clipSource.slot] + }, + _meta: { title: "Apply LoRA" } + }; + + for (const consumer of insertion.modelConsumers) { + setInput(workflow, consumer.nodeId, consumer.inputName, [insertion.nodeId, 0]); + } + + for (const consumer of insertion.clipConsumers) { + setInput(workflow, consumer.nodeId, consumer.inputName, [insertion.nodeId, 1]); + } +} + function applyRequiredModelSelections( workflow: ComfyWorkflow, preset: WorkflowPresetDefinition, diff --git a/src/styles.css b/src/styles.css index 89263f4..da7dbb3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1620,6 +1620,19 @@ a:hover { margin-top: 1px; } +/* Margins rather than flex gap: gap is inert in the compact panel widths. */ +.lora-section { + display: block; +} + +.lora-section > .field { + margin-top: 8px; +} + +.lora-section > .diagnostics-line { + margin-top: 6px; +} + .settings-grid { display: flex; flex-wrap: wrap; diff --git a/src/ui/App.ts b/src/ui/App.ts index d8d35d3..63b0d6b 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -120,7 +120,8 @@ import { InstallStep } from "./setupInstallModel"; import type { WorkflowPhotoshopInputAvailability } from "../comfy/workflowCompatibility"; -import { GeneratedImageResult, WorkflowPresetDefinition } from "../comfy/types"; +import { GeneratedImageResult, WorkflowLoraSelection, WorkflowPresetDefinition } from "../comfy/types"; +import { formatLoraHintSuffix, getLoraFamilyHint } from "../comfy/loraCompatibility"; import { ExportedSourceImage, SelectedRegionSourceImage, @@ -268,6 +269,7 @@ import { DEFAULT_PROMPT_LAYER_NUM_BEAMS, DEFAULT_PROMPT_LAYER_TASK, DEFAULT_SERVER_URL, + DEFAULT_LORA_STRENGTH, DEFAULT_SKETCH_CONTROL_STRENGTH, DEFAULT_SKETCH_DENOISE, DEFAULT_SKETCH_STEPS, @@ -1064,12 +1066,17 @@ export function renderApp(rootElement: HTMLElement) { elements.workflow.addEventListener("change", () => { applyRecommendedPresetSettings(elements.workflow, DEFAULT_WORKFLOW, elements.steps, elements.cfg); void refreshTextModelOptionsForSelectedPreset(elements).then(() => updateTextCheckpointCompatibility(elements)); + void refreshLoraOptionsForSelectedPreset(elements); }); elements.checkpoint.addEventListener("change", () => { updateTextCheckpointCompatibility(elements); }); + elements.loraName.addEventListener("change", () => { + updateLoraStrengthVisibility(elements); + }); + elements.imgWorkflow.addEventListener("change", () => { applyRecommendedPresetSettings(elements.imgWorkflow, DEFAULT_IMAGE_WORKFLOW, elements.imgSteps, elements.imgCfg); void refreshImageModelOptionsForSelectedPreset(elements).then(() => ( @@ -1133,6 +1140,7 @@ export function renderApp(rootElement: HTMLElement) { await refreshInpaintModelOptionsForSelectedPreset(elements, client); await refreshOutpaintModelOptionsForSelectedPreset(elements, client); await refreshUpscaleModelOptionsForSelectedPreset(elements, client); + await refreshLoraOptionsForSelectedPreset(elements, client); updateImageCheckpointCompatibility(elements, allowExperimentalCheckpoints, imageSource); updateSketchCheckpointCompatibility(elements, sketchSource); updateInpaintCheckpointCompatibility(elements, inpaintSource); @@ -1829,7 +1837,8 @@ export function renderApp(rootElement: HTMLElement) { height: settings.height, steps: settings.steps, cfg: settings.cfg, - seed: settings.seed + seed: settings.seed, + lora: readLoraSelection(elements) }); const generatedResult = await generation.runPipeline({ @@ -4819,6 +4828,127 @@ function fillSingleCheckpointSelect(select: HTMLSelectElement, checkpoints: stri } } +/** + * Shows the LoRA controls only for presets that declare an insertion point, and + * fills the list from ComfyUI. + * + * The list is deliberately NOT filtered by architecture. ComfyUI exposes only a + * LoRA's name, size and timestamps -- `/models/loras` and + * `/experiment/models/loras` both stop there -- so nothing reachable over the + * wire says which base model a file was trained against. The safetensors + * metadata that would say is not served, and is not trustworthy even when read + * directly: two LoRAs on the reference machine declare + * `ss_base_model_version: sd_1.5` while their tensor keys are plainly Flux. + * A filter built on any of that would hide working LoRAs and still admit broken + * ones, so the panel lists everything and warns instead. + */ +async function refreshLoraOptionsForSelectedPreset( + elements: AppElements, + client = new ComfyClient(elements.serverUrl.value) +) { + const preset = getWorkflowPreset(readSelectValue(elements.workflow, DEFAULT_WORKFLOW)); + + updateLoraFieldVisibility(elements, preset); + + if (!preset.loraInsertion) { + return; + } + + try { + const loraNames = await client.getLoraNames(); + const preferred = readSelectValue(elements.loraName); + + fillLoraSelect(elements.loraName, loraNames, preset, preferred); + } catch { + // Keep whatever is listed if ComfyUI is offline; None stays selectable. + } + + updateLoraStrengthVisibility(elements); +} + +function updateLoraFieldVisibility(elements: AppElements, preset: WorkflowPresetDefinition) { + const supportsLora = Boolean(preset.loraInsertion); + + elements.loraField.hidden = !supportsLora; + + if (!supportsLora) { + // Leaving a stale selection behind would silently apply a LoRA the next + // time a LoRA-capable preset is chosen. + elements.loraName.value = ""; + updateLoraStrengthVisibility(elements); + } +} + +/** Strength only means something once a LoRA is actually selected. */ +function updateLoraStrengthVisibility(elements: AppElements) { + const selected = readSelectValue(elements.loraName); + + elements.loraStrengthField.hidden = !selected; + elements.loraNote.hidden = !selected; + + if (!selected) { + return; + } + + const preset = getWorkflowPreset(readSelectValue(elements.workflow, DEFAULT_WORKFLOW)); + const hint = getLoraFamilyHint(selected, preset); + + elements.loraNote.textContent = hint === "foreign" + ? "This LoRA's name suggests it was trained for a different model. A mismatched LoRA loads without any error and then does nothing, so an unchanged image is the symptom to expect." + : "ComfyUI reports only a LoRA's name, never which model it was trained for. A mismatched LoRA loads without error and quietly does nothing, so if the image looks unchanged, check the LoRA suits this workflow's model."; +} + +function fillLoraSelect( + select: HTMLSelectElement, + loraNames: string[], + preset: WorkflowPresetDefinition, + preferredValue?: string +) { + select.innerHTML = ""; + + const none = document.createElement("option"); + none.value = ""; + none.textContent = "None"; + select.append(none); + + // Likely matches first, then unlabelled, then the ones whose names point at + // another model -- so the entry most likely to work is nearest the top + // without any of them being hidden. + const order: Record = { matches: 0, unknown: 1, foreign: 2 }; + const sorted = [...loraNames].sort((left, right) => ( + order[getLoraFamilyHint(left, preset)] - order[getLoraFamilyHint(right, preset)] + )); + + for (const loraName of sorted) { + const option = document.createElement("option"); + option.value = loraName; + option.textContent = `${loraName}${formatLoraHintSuffix(getLoraFamilyHint(loraName, preset))}`; + select.append(option); + } + + select.value = preferredValue && loraNames.includes(preferredValue) ? preferredValue : ""; +} + +/** The artist's LoRA choice, or undefined when None is selected. */ +function readLoraSelection(elements: AppElements): WorkflowLoraSelection | undefined { + const loraName = readSelectValue(elements.loraName); + + if (!loraName) { + return undefined; + } + + const parsed = Number.parseFloat(elements.loraStrength.value); + // One control drives both strengths; a blank or junk value falls back to the + // default rather than sending NaN to ComfyUI. + const strength = Number.isFinite(parsed) ? parsed : Number.parseFloat(DEFAULT_LORA_STRENGTH); + + return { + loraName, + strengthModel: strength, + strengthClip: strength + }; +} + function ensureCoreSelectDefaults(elements: AppElements) { ensureSelectOption(elements.imgWorkflow, DEFAULT_IMAGE_WORKFLOW); ensureSelectOption(elements.sketchWorkflow, DEFAULT_SKETCH_WORKFLOW); diff --git a/src/ui/appConstants.ts b/src/ui/appConstants.ts index 4c974d0..a4ae5e8 100644 --- a/src/ui/appConstants.ts +++ b/src/ui/appConstants.ts @@ -23,6 +23,8 @@ export const DEFAULT_IMG2IMG_DENOISE = "0.55"; export const DEFAULT_SKETCH_STEPS = "20"; export const DEFAULT_SKETCH_DENOISE = "1"; export const DEFAULT_SKETCH_CONTROL_STRENGTH = "0.8"; +/** One control drives both strength_model and strength_clip; 0.8 is the usual starting point. */ +export const DEFAULT_LORA_STRENGTH = "0.8"; export const DEFAULT_INPAINT_STEPS = "16"; export const DEFAULT_INPAINT_DENOISE = "0.75"; export const DEFAULT_OUTPAINT_STEPS = "20"; diff --git a/src/ui/appMarkup.ts b/src/ui/appMarkup.ts index 8a5387a..8ef040b 100644 --- a/src/ui/appMarkup.ts +++ b/src/ui/appMarkup.ts @@ -7,6 +7,7 @@ import { DEFAULT_IMG2IMG_STEPS, DEFAULT_INPAINT_DENOISE, DEFAULT_INPAINT_STEPS, + DEFAULT_LORA_STRENGTH, DEFAULT_OUTPAINT_BOTTOM, DEFAULT_OUTPAINT_DENOISE, DEFAULT_OUTPAINT_FEATHERING, @@ -73,6 +74,11 @@ export type AppElements = { negativePrompt: HTMLTextAreaElement; workflow: HTMLSelectElement; checkpoint: HTMLSelectElement; + loraField: HTMLElement; + loraName: HTMLSelectElement; + loraStrengthField: HTMLElement; + loraStrength: HTMLInputElement; + loraNote: HTMLElement; width: HTMLInputElement; height: HTMLInputElement; steps: HTMLInputElement; @@ -565,6 +571,19 @@ export function createAppMarkup() { ${FALLBACK_CHECKPOINTS.map((checkpoint) => ``).join("")} +
+
@@ -802,6 +825,19 @@ export function createAppMarkup() { ${createInfoPanelMarkup("sketch-compatibility-note", "Recommended: epicrealism_naturalSinRC1VAE.safetensors with an SD 1.5 LineArt ControlNet workflow.")}
+
Steps @@ -1461,6 +1497,16 @@ export function getAppElements(rootElement: HTMLElement): AppElements { loraStrengthField: getElement(rootElement, "lora-strength-field"), loraStrength: getElement(rootElement, "lora-strength"), loraNote: getElement(rootElement, "lora-note"), + imgLoraField: getElement(rootElement, "img-lora-field"), + imgLoraName: getElement(rootElement, "img-lora-name"), + imgLoraStrengthField: getElement(rootElement, "img-lora-strength-field"), + imgLoraStrength: getElement(rootElement, "img-lora-strength"), + imgLoraNote: getElement(rootElement, "img-lora-note"), + sketchLoraField: getElement(rootElement, "sketch-lora-field"), + sketchLoraName: getElement(rootElement, "sketch-lora-name"), + sketchLoraStrengthField: getElement(rootElement, "sketch-lora-strength-field"), + sketchLoraStrength: getElement(rootElement, "sketch-lora-strength"), + sketchLoraNote: getElement(rootElement, "sketch-lora-note"), width: getElement(rootElement, "width"), height: getElement(rootElement, "height"), steps: getElement(rootElement, "steps"), diff --git a/tests/comfy/workflowLoraInsertion.test.ts b/tests/comfy/workflowLoraInsertion.test.ts index 83d328a..5818821 100644 --- a/tests/comfy/workflowLoraInsertion.test.ts +++ b/tests/comfy/workflowLoraInsertion.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { buildTxt2ImgWorkflow } from "../../src/comfy/workflowBuilder"; +import { + buildImg2ImgWorkflow, + buildSketchToImageWorkflow, + buildTxt2ImgWorkflow +} from "../../src/comfy/workflowBuilder"; import { WORKFLOW_PRESETS, getWorkflowPreset } from "../../src/comfy/presetRegistry"; +import { WorkflowPresetDefinition } from "../../src/comfy/types"; const BASE = { presetId: "txt2img-krea2-turbo", @@ -98,11 +103,47 @@ describe("optional LoRA insertion", () => { }); }); +/** Builds a preset through whichever builder its mode belongs to. */ +async function buildForMode(preset: WorkflowPresetDefinition, lora?: typeof LORA) { + const shared = { + presetId: preset.id, + prompt: "a test", + width: 512, + height: 512, + steps: 4, + cfg: 1, + seed: 1, + lora + }; + + if (preset.mode === "txt2img") { + return buildTxt2ImgWorkflow(shared); + } + + const imageShared = { ...shared, sourceImageName: "source.png", denoise: 0.6 }; + + if (preset.mode === "img2img") { + return buildImg2ImgWorkflow(imageShared); + } + + if (preset.mode === "sketch2img") { + return buildSketchToImageWorkflow({ ...imageShared, controlStrength: 0.8 }); + } + + throw new Error(`no builder wired for mode ${preset.mode}`); +} + describe("every declared LoRA insertion point", () => { const withLora = WORKFLOW_PRESETS.filter((preset) => preset.loraInsertion); it("covers the presets that are meant to have one", () => { expect(withLora.map((preset) => preset.id).sort()).toEqual([ + "img2img-basic", + "img2img-krea2-turbo", + "img2img-z-image-turbo", + "sketch2img-depth-basic", + "sketch2img-linecn-basic", + "sketch2img-scribble-basic", "txt2img-basic", "txt2img-flux1-dev-fp8", "txt2img-flux2-dev-gguf", @@ -116,15 +157,7 @@ describe("every declared LoRA insertion point", () => { const insertion = preset.loraInsertion!; it("takes a node id its workflow does not already use", async () => { - const built = await buildTxt2ImgWorkflow({ - presetId: preset.id, - prompt: "a test", - width: 512, - height: 512, - steps: 4, - cfg: 1, - seed: 1 - }); + const built = await buildForMode(preset); // Without this the splice would overwrite a real node and still pass // validation, because validateWorkflowForPreset only checks that the @@ -133,16 +166,7 @@ describe("every declared LoRA insertion point", () => { }); it("names sources and consumers that exist, and rewires all of them", async () => { - const built = await buildTxt2ImgWorkflow({ - presetId: preset.id, - prompt: "a test", - width: 512, - height: 512, - steps: 4, - cfg: 1, - seed: 1, - lora: LORA - }); + const built = await buildForMode(preset, LORA); expect(built.workflow[insertion.modelSource.nodeId]).toBeDefined(); expect(built.workflow[insertion.clipSource.nodeId]).toBeDefined(); @@ -157,16 +181,7 @@ describe("every declared LoRA insertion point", () => { }); it("leaves nothing downstream still reading the bare model or CLIP", async () => { - const built = await buildTxt2ImgWorkflow({ - presetId: preset.id, - prompt: "a test", - width: 512, - height: 512, - steps: 4, - cfg: 1, - seed: 1, - lora: LORA - }); + const built = await buildForMode(preset, LORA); // The failure this guards against is silent: a consumer left on the // original loader means the LoRA loads and then applies to nothing. From 8fdc024620ff6018e4c98cf2c6d3a84423a47f43 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Fri, 7 Aug 2026 03:10:12 +0330 Subject: [PATCH 09/13] Add a design note for batch generation Written before any code, because the feature touches result handling and the preview panel's URL ownership, and a mistake there affects every tool rather than only this feature. Two findings changed the shape of it. batch_size is already present on the empty-latent node of eight shipped workflows, so batching those is an ordinary value injection with no graph surgery -- only the three img2img presets need a RepeatLatentBatch splice, because they build their latent from the captured layer with VAEEncode. And the generation controller's one-run invariant is not threatened by this at all, which had been assumed. A batch is one prompt, one prompt id, one run; the controller gates on which run is current and never inspects the result's shape, and runPipeline is already generic in it. What actually breaks is narrower: the single-result type, the preview panel's two owned URL slots, and what Import means when there are four images. The last of those is a product question and is left open. Four decisions are called out for Mehran rather than guessed, and the work is staged so the retrieval half can be built before they land. Co-Authored-By: Claude Opus 5 --- docs/BATCH_GENERATION.md | 171 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/BATCH_GENERATION.md diff --git a/docs/BATCH_GENERATION.md b/docs/BATCH_GENERATION.md new file mode 100644 index 0000000..6b4192c --- /dev/null +++ b/docs/BATCH_GENERATION.md @@ -0,0 +1,171 @@ +# Batch / multi-variation generation — design + +Status: **draft for review**. No code written. Written 2026-08-07. + +Generate several variations from one prompt in a single run, look at them, and +import the one that works. Today every generation is one image, so exploring +means clicking Generate repeatedly and losing each previous result. + +--- + +## What is actually true today + +Verified against the current tree rather than assumed. + +**ComfyUI already batches.** `batch_size` is a plain input on the empty-latent +node, and it is already present in **8 of the shipped workflows**: + +| Preset | Latent node with `batch_size` | +| --- | --- | +| `txt2img-basic`, `txt2img-krea2-turbo` | `EmptyLatentImage` | +| `txt2img-flux1-dev-fp8`, `txt2img-z-image-turbo` | `EmptySD3LatentImage` | +| `txt2img-flux2-dev-gguf` | `EmptyFlux2LatentImage` | +| `sketch2img-linecn-basic`, `-scribble-basic`, `-depth-basic` | `EmptyLatentImage` | + +The three **img2img** presets do not have one: they build their latent with +`VAEEncode` from the captured layer, so there is no `batch_size` to set. Batching +those needs a `RepeatLatentBatch` node spliced in — the same kind of topology +change the LoRA work introduced, and a separate piece of work from this one. + +Inpaint, outpaint, upscale and prompt-from-layer are out of scope: their output +is a patch or a caption tied to one region, and "four variations" is not a +meaningful thing to import. + +**Nothing currently reads more than one image back.** `findImageOutput` +(`src/comfy/comfyClient.ts:954`) takes `output.images?.[0]` and returns the +first match; `retrieveFirstOutputImage` wraps it. ComfyUI's history already +contains every image of the batch — the client simply discards them. + +**There is no `batchSize` injection name** in `WorkflowInjectionName` +(`src/comfy/types.ts`). + +--- + +## Correcting an assumption + +The earlier concern — that batch breaks the generation controller's +one-run-at-a-time invariant (A4) — **does not hold**, and it changed the shape of +this design. + +`createGenerationController` (`src/ui/generationController.ts:110`) gates on +*which run is current*: `publish`, `assertCanCommit` and `finish` all compare a +run id. A batch is still **one prompt, one prompt id, one run**. The controller +never inspects the result's shape; `runPipeline` is generic in `TImage` and +simply hands whatever it retrieved to `commit`. + +So A4 is untouched. What breaks is narrower and real: + +1. **The result type.** `retrieveFirstOutputImage` returns one + `GeneratedImageResult`; `commit` takes one. +2. **Preview URL ownership (A5).** `createResultPreviewPanel` + (`src/ui/previewState.ts:99`) owns exactly two URL slots — one result, one + live frame. N results need N owned slots, all revoked together. +3. **Import.** One result means "Import" is unambiguous. N results is a + product question, not a plumbing one — see the decisions below. + +That reframing means the risky part is the **preview panel and import UX**, not +the run controller. + +--- + +## Proposed design + +### Getting N images back + +Add `findImageOutputs` alongside `findImageOutput` (keeping the single-image +path untouched for every tool that is not batching), and a +`retrieveOutputImages` that returns `GeneratedImageResult[]`. + +`runPipeline` needs no change: `TImage` becomes `GeneratedImageResult[]` for the +batching tools, and `commit` receives the array. The run-integrity gates are +shape-agnostic and stay exactly as they are. + +### Setting the batch size + +A new `batchSize` injection name, targeting each preset's existing empty-latent +node. **No graph surgery** — this is an ordinary value injection, unlike the +LoRA work. Eight presets get it by adding one line each to their injection map. + +### Preview + +The result panel gains a **thumbnail strip** under the main image. The main +image shows the selected variation; clicking a thumbnail selects it. With a +batch of 1 the strip is hidden and the panel behaves exactly as it does today — +that fallback is what keeps the change safe for every existing flow. + +Ownership: one `OwnedObjectUrl` per variation, released as a set whenever a new +run commits or the panel clears. The existing single `resultUrl` slot becomes +the *selected* one, which keeps `showResult(blob)` working unchanged for +non-batch tools. + +### Live preview during the run + +Unchanged. ComfyUI streams preview frames for the batch as a single image strip +or the first member depending on the sampler; either way the existing +`showProgress` path handles it, and the thumbnail strip only appears once the +run completes. + +--- + +## Decisions needed before implementation + +These change what gets built, so they are yours rather than mine. + +### D1 — What does Import do with N images? + +| Option | Behaviour | +| --- | --- | +| **A (recommended)** | "Import to Layers" imports **only the selected** variation. One extra button, "Import All", adds every variation as its own layer. | +| B | Import always brings in all N as layers; the artist deletes the ones they do not want. | +| C | Import only ever brings in the selected one; no bulk option. | + +A keeps today's one-click behaviour identical for a batch of 1, and makes the +bulk case explicit rather than surprising. B risks dumping four full-size layers +into a document on a single click. + +### D2 — Default and maximum batch size + +Cost is linear: on the reference machine Krea-2 Turbo is ~38–48 s per image, so +a batch of 4 is roughly 3 minutes. `txt2img-flux2-dev-gguf` measured **207 s for +one image**, so a batch of 4 is ~14 minutes. + +Recommendation: **default 1** (today's behaviour, so nothing changes until +asked), **maximum 4**, and a visible time-estimate note once the batch size is +above 1. A different, lower cap specifically for Flux.2 is worth considering. + +### D3 — One history entry per run, or per image? + +Recommendation: **one entry per imported image**, created at import time rather +than at generation time — history exists to get a result back, and a variation +that was never imported has no layer to return to. This needs confirming against +how `addHistoryEntry` is used today. + +### D4 — Does the sketch tool get batching in the first pass? + +The three sketch presets can batch with the same one-line injection as txt2img, +since their latent is empty and only the ControlNet reads the captured image. It +is nearly free. The question is whether the thumbnail strip is worth the panel +space on that card in the first pass, or whether pass 1 should be txt2img only. + +--- + +## Staging + +1. **Retrieval + injection** — `findImageOutputs`, `retrieveOutputImages`, + `batchSize` injection on the 8 eligible presets, plus tests. No UI. Nothing + user-visible changes because the panel still asks for 1. +2. **Preview strip + selection + import** — the real UI work, gated on D1–D4. +3. **img2img batching** — `RepeatLatentBatch` splice, reusing the insertion + machinery the LoRA work established. Separate piece. + +Stage 1 is safe to build before the decisions land; stage 2 is not. + +--- + +## What this design does not do + +- No parallel runs. One prompt, one run, one cancel — unchanged. +- No change to how cancellation works: cancelling a batch cancels the whole + prompt, as it does now. +- No re-roll-one-variation feature. That needs per-image seeds and is a + different design. From fa4e515927ee2718a54f0fcc66d6b5ae390ae581 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Fri, 7 Aug 2026 03:16:09 +0330 Subject: [PATCH 10/13] Bump the version to 0.13.0 Bumped early so the panel footer reads the new version during the release smoke test rather than after it. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- src/manifest.json | 2 +- src/ui/appConstants.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 078af5e..0b96a9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openlayer", - "version": "0.12.0", + "version": "0.13.0", "private": true, "description": "Local AI layers for Photoshop", "license": "MIT", diff --git a/src/manifest.json b/src/manifest.json index d52a7b0..1b789a9 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifestVersion": 5, "id": "com.openlayer.photoshop", "name": "OpenLayer", - "version": "0.12.0", + "version": "0.13.0", "main": "index.html", "host": [ { diff --git a/src/ui/appConstants.ts b/src/ui/appConstants.ts index a4ae5e8..9419d56 100644 --- a/src/ui/appConstants.ts +++ b/src/ui/appConstants.ts @@ -1,7 +1,7 @@ import { OpenLayerTheme } from "../utils/preferences"; export const DEFAULT_SERVER_URL = "http://127.0.0.1:8190"; -export const APP_VERSION = "0.12.0"; +export const APP_VERSION = "0.13.0"; export const DEVELOPER_GITHUB = "https://github.com/MehranMarxian"; export const HISTORY_LIMIT = 5; export const COMFY_PORT_CANDIDATES = [8190, 8188, 8189, 8191, 8192, 8193, 7860]; From b2f958e26d8c3328db64960cc0d5cc7e1e25cee2 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Fri, 7 Aug 2026 03:17:19 +0330 Subject: [PATCH 11/13] Add the v0.13.0 changelog entry Written from git log against the v0.12.0 tag and the code, not from a task list, per the lesson from v0.8.0: a draft written from tasks described a fix a later commit had replaced. Two entries are deliberately absent. The "None" LoRA defect was introduced and fixed inside this release, so it never reached a user and is not a fix worth reporting; and the sketch preprocessor fix predates this session's work on the branch but was never released, so it belongs here rather than being assumed shipped. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bf790..aadf1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## v0.13.0-alpha - 2026-08-07 + +LoRAs, at last: every preset that loads a model and a text encoder — eleven of them, across Text to Image, Image to Image and Sketch to Image — now takes an optional LoRA. The panel has been able to list the LoRAs on your disk since long before it could use one. + +Two presets that were quietly broken are fixed. Sketch to Image fed ControlNet a blank control image for light-on-dark art, so the sketch was ignored with no error at all. The Flux.2 dev (GGUF) preset that headlined v0.12.0 could not actually be run: its model dropdown never listed a `.gguf` file, and choosing it and pressing Generate failed outright. Both were reported by a tester using the release, which is what the alpha is for. + +### Added + +- **An optional LoRA on eleven presets.** One dropdown and one strength control per tool. Choosing nothing leaves the workflow byte-identical to the one that ships, because a LoRA cannot be a permanently wired node whose value is merely set: ComfyUI's `LoraLoader` has no "none" entry, so a wired-in loader would force everyone to own and load a LoRA they may not want. The loader is spliced into the graph only when a LoRA is actually chosen, and the model and text-encode inputs downstream are rewired to it. This is the first time building a workflow changes its shape rather than its values, so each preset declares its own wiring rather than having it guessed — three genuinely different shapes turned up among the eleven, including one where the LoRA must be applied before a sampling-mode wrapper rather than at the sampler. +- **A Depth ControlNet Sketch to Image preset.** LineArt and Scribble both hold the drawn stroke; neither carries depth. This one conditions on estimated scene depth, so it holds perspective and the relative distance of forms — the preset to reach for when a generated element has to sit inside an existing composite at the right camera angle. It works from any shaded image, not only a line drawing, and needs one new ControlNet model. No new custom node package: the depth estimator comes from `comfyui_controlnet_aux`, which the sketch presets already required. +- **A Scribble Sketch to Image preset**, on the PiDiNet edge detector and the Scribble ControlNet, for loose gestural strokes where LineArt holds the drawn line too tightly. Its ControlNet model was already installed for most users and it needs no new node package. + +### Fixed + +- **Sketch to Image ignored the sketch entirely for a whole class of drawing.** `LineartStandardPreprocessor` assumes dark strokes on white paper, so light-on-dark art and solid filled shapes produced a pure-black control image — measured at 0% ink on a 1024px filled silhouette. ControlNet had no signal, the preset degraded to plain text-to-image, and nothing anywhere reported a problem. It now uses a learned, polarity-robust detector (`AnyLineArtPreprocessor_aux`, 1.14% ink on the same source). Both sketch preprocessors also run at 1024 rather than a hardcoded 512, which had been discarding line detail before ControlNet ever saw it. +- **The Flux.2 dev (GGUF) preset could not be selected or run.** Its Model dropdown asked ComfyUI's core `UNETLoader` for the file list, and that loader does not enumerate `.gguf` files at all — so a correctly installed quantised model was invisible no matter where it was placed. Pressing Generate then failed regardless, because the builder required a negative-prompt target on a preset that deliberately has none, Flux.2 being guidance-distilled with no negative conditioning node in its reference graph. + +### Changed + +- The Flux.2 GGUF workflow is now bundled with the panel like every other preset's, instead of being fetched at runtime — it was the only runnable preset left out of that map. + +### Known limitations + +- **The LoRA list cannot be filtered by which model a LoRA suits, and a mismatched one fails silently.** ComfyUI reports only a LoRA's name, size and timestamps; nothing reachable over the wire says what it was trained against. The metadata that would say is not served, and is not reliable even when read directly — two LoRAs on the reference machine declare `ss_base_model_version: sd_1.5` while their tensor keys are plainly Flux. So the panel lists every LoRA, labels the entries whose *filenames* suggest a match or a mismatch, sorts likely matches first, and warns. Picking a LoRA meant for another model loads without any error and then does nothing: an unchanged image is the symptom to expect. +- **A LoRA roughly doubles Flux.2's inference time**, which is already minutes per image on a 12 GB card. +- **Whether a Krea-2 LoRA trained on Raw behaves correctly on the Turbo checkpoint is unverified.** Krea's own guidance is to train on Raw and marks Turbo as not recommended for training, but no source addresses applying the result at Turbo's 8 steps. +- **The Depth preset downloads its depth estimator on first use.** The ControlNet weight is a normal setup download, but `DepthAnythingV2Preprocessor` fetches its own estimator the first time it runs, so the first generation is much slower than later ones. Depth estimation also needs tonal variation — a flat line drawing gives it little to read. +- Batch generation is designed but not built; the design note is in `docs/BATCH_GENERATION.md`. Image to Image cannot batch at all without further work, because it builds its latent from the captured layer. +- **Assisted install remains withheld, and the Setup screen still only reports and copies.** ComfyUI-Manager's `install_model` endpoint only accepts entries from its own curated catalogue, which holds 7 of the 16 model files this project pins, and for several of those its download URL is not the one the registry pins. Unchanged from v0.12.0. +- **The Flux.2 GGUF preset is slow on a 12 GB card, by a wide margin** — an 18.7 GB quantised model plus a 16.8 GB text encoder means ComfyUI streams most of it from system RAM. It also needs `mistral_3_small_flux2_fp8.safetensors`, which is licence-gated: accept the licence in a browser and download it by hand. +- **Live Painting is experimental.** The live tier needs an SD 1.5 LCM LoRA in `models/loras/`; the Refine tier additionally needs the three Krea-2 Turbo files. +- Setup and Workflow Health overlap on purpose for now. Setup answers "what do I need and where does it go"; Health answers "can I run this preset right now". +- "What will run well" reads the VRAM ComfyUI reports for its primary device. With ComfyUI stopped, every preset falls back to "Not known". +- The panel still cannot open a browser, which is why rows offer Copy Link rather than a button that opens the page. +- **The `.ccx` one-click install is verified on one configuration only** — Windows 11, Photoshop 2025 (26.1.0). macOS and every other Photoshop version remain untested. +- The Layer Tools card on Home does not dim when ComfyUI is unreachable, unlike the generation tools. +- Layer, canvas, selection, and mask capture is limited to 16 megapixels (4096 x 4096) until a downscale option is added. +- The Preview panel offers each tool's primary import only. +- The setup pack contains no model weights — it ships the list and the downloader instead, so an internet connection is required. +- Inpaint and Outpaint remain experimental and should be tested on duplicate layers or disposable documents. +- CI covers pure TypeScript behavior but does not run Photoshop, UXP Developer Tool, or ComfyUI integration tests. + ## v0.12.0-alpha - 2026-08-01 Two things for people running Flux: a Text to Image preset for the GGUF-quantised FLUX.2-dev, and the end of a long-standing embarrassment — every preset the panel lists is now one you can actually run, because the two that never could have been are gone rather than still promising a workflow that was never coming. From de41e5b3f8e4d0b14a78944f952859e7bf3bd5ea Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Fri, 7 Aug 2026 03:18:17 +0330 Subject: [PATCH 12/13] Update the README for v0.13.0 Replaces the alpha checkpoint line, the "New in" list, the tester focus and the version-stamped zip and plugin names. The one remaining v0.12.0 mention is deliberate: it names the release the Flux.2 defects shipped in, which is the point of that line. The tester focus leads with the LoRA paths because they are the release's new surface, and includes the mismatched-LoRA case explicitly -- a generation that succeeds with a visibly unchanged image is the expected behaviour there, and a tester who is not told that will file it. Co-Authored-By: Claude Opus 5 --- README.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 05094f9..f42f37f 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ OpenLayer is an open-source Adobe Photoshop UXP plugin that connects Photoshop t ## Alpha Release -`v0.12.0-alpha` is the current public alpha checkpoint. It is intended for testing the core local workflows in Photoshop UXP, not for production work yet. +`v0.13.0-alpha` is the current public alpha checkpoint. It is intended for testing the core local workflows in Photoshop UXP, not for production work yet. -New in `v0.12.0-alpha`: +New in `v0.13.0-alpha`: -- **A Flux.2 dev (GGUF) Text to Image preset**, built on ComfyUI's own shipped Flux.2 template and the advanced sampler chain. Experimental, and honestly slow on a 12 GB card: an 18.7 GB model plus a 16.8 GB text encoder means minutes per image, not seconds. -- **Every preset the panel lists is now one you can actually run.** The two Flux1-dev presets that had been advertising themselves as awaiting a workflow JSON since v0.2.2 are removed rather than finished — the full-precision weight has no 12 GB story and `txt2img-flux1-dev-fp8` already covers Flux Text to Image. -- **Assisted install is withheld from this release.** It was built on the belief that ComfyUI-Manager's install endpoint would fetch a given URL on request; it will not, and only accepts models from its own curated catalogue on its own terms. The Setup screen therefore still reports and copies, as in v0.11.0. See the CHANGELOG for the full reasoning — including why mapping the fields across would not have fixed it. +- **An optional LoRA on eleven presets**, across Text to Image, Image to Image and Sketch to Image. One dropdown and one strength control per tool. Choosing nothing leaves the shipped workflow untouched — the loader is spliced into the graph only when you actually pick a LoRA. +- **A Depth ControlNet Sketch to Image preset.** LineArt and Scribble hold the drawn stroke; this one holds the scene's perspective, which is what you want when a generated element has to sit in an existing composite at the right camera angle. Needs one new ControlNet model, no new node package. +- **Sketch to Image no longer ignores your sketch.** Light-on-dark art and solid filled shapes produced a blank control image, so ControlNet had no signal and the preset quietly became plain text-to-image. Fixed, and both sketch preprocessors now run at 1024 instead of 512. +- **The Flux.2 dev (GGUF) preset actually runs now.** In v0.12.0 its model dropdown never listed a `.gguf` file and pressing Generate failed outright. Both were reported by a tester on the release. Also new in `v0.11.0-alpha`: @@ -111,13 +112,15 @@ The earlier card-based dashboard established OpenLayer's honest available/experi -v0.12.0-alpha tester focus: +v0.13.0-alpha tester focus: -- Open **Setup** with ComfyUI running and confirm **no row anywhere offers an Install button**, including missing rows, with ComfyUI-Manager installed. Every row should offer only Copy Link, Copy Folder Path and Copy Page, exactly as in v0.11.0. This is the check that matters most in this release: an Install button appearing is a button that errors. -- To make a missing row that is *not* licence-gated, temporarily rename a model you already have — `models/upscale_models/4x-UltraSharp.pth` is the smallest at 64 MB — then click **Check Again**. Confirm that row reports Missing, still offers no Install button, and names the folder and size correctly. Rename it back afterwards. -- Open **Check Workflow Health** and confirm the **Flux.2 dev (GGUF)** preset appears, is marked experimental, and reports only the Mistral-3 encoder as missing if that is the only file you lack — not the 18.7 GB GGUF model you already have. -- Confirm the two Flux1-dev presets are **gone** from every preset list, and that no remaining preset says it needs a workflow JSON. -- Confirm the panel footer reads `v0.12.0`. +- Open **Text to Image**, pick `txt2img-krea2-turbo`, and confirm a **LoRA (optional)** row appears with `None` selected and no strength field. Generate once with `None` — it must succeed — then pick a LoRA and generate again at the same seed. The two images must differ. +- Switch the Workflow dropdown across every preset in Text to Image, Image to Image and Sketch to Image and confirm the LoRA row appears for all of them. Confirm it does **not** appear on Inpaint, Outpaint or Upscale. +- Set a different LoRA in each of the three tools and generate in each. None should leak into another tool. +- Pick a LoRA whose name mentions a different model family than the preset. It should still be selectable, marked `(name suggests another model)`, and generating should **succeed with a visibly unchanged image** — that is the silent failure the warning describes, not a bug. +- Open **Sketch to Image**, pick the new **Depth ControlNet** preset, and generate from a *shaded* layer rather than flat line art. The first run downloads the depth estimator and is much slower than later ones. +- Draw light strokes on a dark layer and run **LineArt**. The result must follow the drawing rather than ignoring it. +- Confirm the panel footer reads `v0.13.0`. Also worth rechecking from v0.11.0-alpha: @@ -152,7 +155,7 @@ Also worth rechecking from v0.9.0-alpha: - Run `npm run setup-pack` and confirm it reports no source/API mismatches at all. - Recheck the existing local generation, cancel, preview, import, History, and Workflow Health paths for regressions. -Known v0.12.0-alpha boundaries: +Known v0.13.0-alpha boundaries: - **The Setup screen reports and copies. It does not download or install anything.** Assisted install was built for this release and withheld: ComfyUI-Manager's install endpoint only accepts models from its own curated catalogue, matched exactly on `save_path`, `base` and `filename`, so every request OpenLayer could build was rejected. Only 7 of the 16 models this project pins are in that catalogue, and for several of those the catalogue's URL is not the one the registry verified — so the fix is a download path that honours our own URLs, not a field mapping. Full reasoning in the CHANGELOG. - The **Flux.2 GGUF preset is slow on a 12 GB card**: minutes per image, not seconds, and its text encoder is licence-gated, so accept the licence in a browser and download it by hand. @@ -326,10 +329,10 @@ npm run package This creates a zip package from `dist` in the `packages` folder. For the current alpha, the expected package name is: ```text -packages/openlayer-v0.12.0-alpha.zip +packages/openlayer-v0.13.0-alpha.zip ``` -`npm run package` also writes `packages/openlayer-v0.12.0-alpha.ccx` beside it, from the same files. +`npm run package` also writes `packages/openlayer-v0.13.0-alpha.ccx` beside it, from the same files. ## One-click install (verified 2026-08-03) @@ -339,7 +342,7 @@ other installed plugin. This was an open question across three releases and is now answered. What was checked, on Windows 11 with Photoshop 2025 (26.1.0): the package installs, Adobe's Unified Plugin Installer Agent reports it -as `Enabled OpenLayer 0.12.0` under *Photoshop 2025 64*, it unpacks to +as `Enabled OpenLayer 0.13.0` under *Photoshop 2025 64*, it unpacks to `%APPDATA%\Adobe\UXP\Plugins\External\com.openlayer.photoshop_\` with no `debug.json` — a packaged install rather than a developer load — and the panel opens and works in Photoshop. @@ -494,7 +497,7 @@ Inpaint output quality, mask interpretation, and Photoshop alignment are still b ## Pre-release Tester Checklist -Use this quick pass before reporting a v0.12.0-alpha test result: +Use this quick pass before reporting a v0.13.0-alpha test result: 1. Start ComfyUI on `http://127.0.0.1:8190`. 2. Build OpenLayer and load `dist/manifest.json` in Adobe UXP Developer Tool. From b2cbe1b6696aa48e47b07cebc1ee9b86d0abb80f Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Fri, 7 Aug 2026 03:19:07 +0330 Subject: [PATCH 13/13] Update the landing page for v0.13.0 Nine version references, plus three claims that stopped being true. "LoRA browser" was listed as future work; LoRAs ship in this release, so the page would have been advertising a shipped feature as unbuilt. The Sketch to Image line named LINECN and one ControlNet model as the only option, where there are now three presets each needing their own. And a new boundary is added for the one real limitation of the LoRA work: the list cannot be filtered by compatibility, because ComfyUI does not report it, so a mismatched LoRA fails silently. Co-Authored-By: Claude Opus 5 --- docs/index.html | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/index.html b/docs/index.html index f94f703..00ccac5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -33,10 +33,10 @@ "operatingSystem": "Windows, macOS", "applicationCategory": "DesignApplication", "applicationSubCategory": "Adobe Photoshop plugin", - "softwareVersion": "0.12.0-alpha", + "softwareVersion": "0.13.0-alpha", "description": "Open-source Photoshop UXP plugin that connects to a local ComfyUI server for AI image generation: text to image, image to image, sketch to image, inpaint, outpaint, and upscale with Stable Diffusion, SDXL, and Flux models.", "url": "https://mehran-ahmadi.com/OpenLayer/", - "downloadUrl": "https://github.com/MehranMarxian/OpenLayer/releases/tag/v0.12.0-alpha", + "downloadUrl": "https://github.com/MehranMarxian/OpenLayer/releases/tag/v0.13.0-alpha", "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, "author": { "@type": "Person", "name": "Mehran Ahmadi", "url": "https://github.com/MehranMarxian" } } @@ -65,7 +65,7 @@
-

Alpha v0.12.0 · free & open source

+

Alpha v0.13.0 · free & open source

Local AI layers,
inside Photoshop.

OpenLayer connects Photoshop to your own ComfyUI server. @@ -74,7 +74,7 @@

Local AI layers,
inside Photoshop.No cloud. No credits. No subscription.

    @@ -285,7 +285,7 @@

    Run it locally in minutes

    1

    Download and double-click

    -

    Grab openlayer-v0.12.0-alpha.ccx from the latest release and double-click it. Creative Cloud installs the panel — no developer tools, nothing to build.

    +

    Grab openlayer-v0.13.0-alpha.ccx from the latest release and double-click it. Creative Cloud installs the panel — no developer tools, nothing to build.

    2 @@ -322,11 +322,11 @@

    Separated preview panel is ready to test

    Testing alpha

    -

    OpenLayer v0.12.0-alpha is for local testing and feedback. It is not production-ready yet.

    +

    OpenLayer v0.13.0-alpha is for local testing and feedback. It is not production-ready yet.

    Inpaint is experimental

    -

    v0.12.0 retains the upload and alignment fixes while output quality across checkpoints continues to be validated by testers.

    +

    v0.13.0 retains the upload and alignment fixes while output quality across checkpoints continues to be validated by testers.

    Flux Fill is experimental

    @@ -342,14 +342,15 @@

    Custom workflows are future work

    Honest alpha boundaries
    • txt2img-basic, img2img-basic, and sketch2img-linecn-basic are starter workflows, not final production presets.
    • -
    • Sketch to Image currently targets SD 1.x LINECN with epicrealism_naturalSinRC1VAE.safetensors and control_v11p_sd15_lineart_fp16.safetensors.
    • +
    • Sketch to Image targets SD 1.x with epicrealism_naturalSinRC1VAE.safetensors and offers LineArt, Scribble and Depth ControlNet presets, each needing its own ControlNet model.
    • Custom ComfyUI workflows may need node ID updates in the preset registry.
    • GPU-aware recommendations are advisory only. OpenLayer does not auto-switch models or workflows yet.
    • Layer, canvas, and mask capture is limited to 16 megapixels until a downscale option is added.
    • Inpaint and Outpaint should be tested on duplicate layers first.
    • Prompt from Layer requires the local Florence-2 PromptGen model and the comfyui-florence2 nodes. The custom-scripts pack is no longer needed.
    • Upscale uses pixel/model upscale only. Generative upscale, tiled diffusion, and creative enhancement are future work.
    • -
    • Custom workflow import, LoRA browser, batch variants, and true persistent Photoshop AI layer metadata are future work. v0.12.0 keeps the shared metadata foundation and session-history wiring.
    • +
    • Custom workflow import, batch variants, and true persistent Photoshop AI layer metadata are future work. v0.13.0 keeps the shared metadata foundation and session-history wiring.
    • +
    • The LoRA list cannot be filtered by which model a LoRA suits: ComfyUI reports only a name and size. Entries are labelled from their filenames and likely matches sorted first, but a mismatched LoRA loads without error and quietly does nothing.
    • CI does not run Photoshop, UXP, or ComfyUI integration tests.
    • Live sampler previews require ComfyUI to be started with --preview-method auto.
    • Progress is shown in the generation status panel rather than pinned to the screen header, so it scrolls with the form.
    • @@ -431,7 +432,7 @@

      Build local AI layers, one careful step at a time.