From d45fdb9d7c757c5fbb2b2d20078d7dde6f8c2328 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:21:04 +0800 Subject: [PATCH] Show a workflow's models, control and finishing in its flow, each box opening its values The flow now reads the whole graph: the model files and LoRAs (strength, the stages they feed), control models above the stage they guide, finishing nodes such as ColorMatch after the samplers. A stage the shown values do not run (a split's empty part, a bypassed sampler) hangs dashed beside a straight line and no longer counts as changed when only its model changed. Every box opens its node's values below, changed ones against the workflow's own, and an unused box names the presets that use it. The in-flow preset row is gone; the left column already lists them. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 14 +- CHANGELOG.md | 4 +- tests/recipe_flow.mjs | 79 ++++++++--- web/modules/locales.js | 54 ++++++-- web/modules/recipe_flow.js | 186 ++++++++++++++++++++------ web/modules/ui_recipe_detail.js | 3 +- web/modules/ui_recipe_flow.js | 157 +++++++++++++--------- web/modules/ui_recipe_flow_inspect.js | 81 +++++++++++ web/styles/29-workflow-detail.css | 107 ++++++++++----- 9 files changed, 512 insertions(+), 173 deletions(-) create mode 100644 web/modules/ui_recipe_flow_inspect.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a0333f3..7a72de9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1034,11 +1034,15 @@ covered by the `styles.css` manifest. (Back, the name, the models' state, Open, ⋯ edit and export), then `ui_recipe_sets.js`'s left column (the workflow's own values, the saved sets unlike them with their key values, Save the canvas, New; then all pictures and versions) and the chosen view on the right. - `ui_recipe_flow.js` draws the workflow's flow on top of it: one stage per sampler in the order - they run (base, goes on, enlarge and redraw; the main model through its LoRAs and patches, the - steps or the part of a split it runs, the redraw strength), then the saves; under it the presets - with the stages each changes, the one shown lit with its changed stages marked; the stages are read - from the saved graph alone by `recipe_flow.js` (`flowStages`, `changedNodes`; `tests/recipe_flow.mjs`). + `ui_recipe_flow.js` draws the workflow's flow on top of it, for the values shown: the model files + and LoRAs (strength, the stages they feed), one stage per sampler in the order they run under the + control models patched into it (base, goes on, enlarge and redraw; its model, the steps or the + part of a split it runs, the redraw strength; one these values do not run hangs dashed beside a + straight line), the picture nodes after the samplers (a colour match) and the saves; what the + preset changes from the workflow's own marked, what it leaves unused dashed. Every box opens its + node's values under it (`ui_recipe_flow_inspect.js`: changed values against the workflow's own, + the presets that use an unused box). All of it is read from the saved graph alone by + `recipe_flow.js` (`readFlow`, `changedNodes`, `touched`; `tests/recipe_flow.mjs`). `ui_recipe_set_view.js` is one set: Apply to canvas, ✦ hand it to the AI (`workflow / preset`), ⇄ (with the workflow's own or the canvas), ⋯ (copy as a new set, rename, delete); the cover, notes and tags for the workflow's own diff --git a/CHANGELOG.md b/CHANGELOG.md index 26cd138..2ce4806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## v1.63.0 — unreleased -- **工作流卡的“参数方案”改叫“预设”**,详情最上面多了一张 **流程图**:按出图顺序一段一段画出来(出底图、接着画、放大重画),每段写着主模型、步数(分段时写第几到第几步,不跑的写“这一段不跑”)和重画多少;下面一排是默认和各个预设,点哪个看哪个,和默认不一样的几段会标出来。“交给 AI”挪到了预设旁边,交的就是你正看着的那个预设。AI 也能用预设了:`list_workflows` 会列出每张卡的预设,写成“工作流名 / 预设名”;`run_workflow` 带上它,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个预设,然后填题材、种子出图。AI 只要选哪个预设,改数值的事由 AMB 做。 -- **A workflow card's parameter sets are now presets**, and its details open with a **flow**: the stages in the order they run (base picture, goes on, enlarge and redraw), each with its main model, its steps (or the steps it runs of a split, "Not run" when none) and how much it redraws; under it the default and each preset, the one shown lit and the stages it changes marked. "Hand it to the AI" now sits beside the preset and hands the one shown. AI apps can use presets too: `list_workflows` lists each card's as "workflow / preset"; with that name `run_workflow` opens the workflow, applies the preset as its Apply button does, then fills in the subject and seed. The AI only picks the preset; Anomalous changes the values. +- **工作流卡的“参数方案”改叫“预设”**,详情最上面多了一张 **流程图**:上面一排是用到的底模和 LoRA(写着强度、给哪几段用);中间按出图顺序一段一段画出来(出底图、接着画、放大重画),每段写着模型、步数(分段时写第几到第几步)和重画多少,Tile 锁图这类控制模型带着强度画在它管的那段上面;最后是 ColorMatch 这类后处理和保存。这次不跑的一段画成虚线挂在“直通”旁边,没用上的模型也是虚线;在左边点一个预设,它和默认不一样的地方标成黄色(只是不跑的那段换了模型不算改动)。每一格都能点,下面会列出这个节点的全部参数,改了的写成“默认 → 这个预设”,虚线的那格会告诉你哪个预设会用上它,点一下就切过去。“交给 AI”挪到了预设旁边,交的就是你正看着的那个预设。AI 也能用预设了:`list_workflows` 会列出每张卡的预设,写成“工作流名 / 预设名”;`run_workflow` 带上它,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个预设,然后填题材、种子出图。AI 只要选哪个预设,改数值的事由 AMB 做。 +- **A workflow card's parameter sets are now presets**, and its details open with a **flow**: on top the models and LoRAs it uses (strength, the stages they feed); then the stages in the order they run (base picture, goes on, enlarge and redraw), each with its model, its steps (or the steps it runs of a split) and how much it redraws, with control models such as a Tile LLLite and their strength above the stage they guide; then finishing nodes such as ColorMatch and the saves. A stage these values do not run hangs dashed beside a "Straight on" line, an unused model is dashed too; pick a preset on the left and what it changes from the default is amber (a model swapped only in a stage that does not run is no change). Every box opens its node's values below, changed ones as "default → this preset", and a dashed box names the presets that use it, one click away. "Hand it to the AI" now sits beside the preset and hands the one shown. AI apps can use presets too: `list_workflows` lists each card's as "workflow / preset"; with that name `run_workflow` opens the workflow, applies the preset as its Apply button does, then fills in the subject and seed. The AI only picks the preset; Anomalous changes the values. - 新增 **Anima Tile 放大细化** 做法:保留底图,以 1.22 倍潜空间放大和 Tile 引导高降噪细化,检查模型架构与必需权重,结果另存供对比;参数来自三组本机对照,不保证修手或复刻原图。配套的按需 skill **Anima 插画·底图与 Tile 细化** 只写怎么判断(先出底图给你看、再用这块做法、你存过这条路线的工作流或搭配就直接用、你没选的画风和 LoRA 不加),具体模型和数值留在做法和你自己的搭配里。 - New **Anima Tile detail upscale** way: retain the base and save a guided 1.22× latent redraw separately, checking the model family and required weight; settings come from three local comparisons and do not guarantee hand repair or reproduction. Its on-demand skill **Anima illustration: base and Tile detail** holds judgment only (show the base first, then this way; a workflow or combo you saved for this route is used as it is; no style or LoRA you did not pick); the models and values stay in the way and in your own combos. diff --git a/tests/recipe_flow.mjs b/tests/recipe_flow.mjs index 9128f7e..9b419c4 100644 --- a/tests/recipe_flow.mjs +++ b/tests/recipe_flow.mjs @@ -1,14 +1,15 @@ -// node tests/recipe_flow.mjs — a saved workflow's stages (web/modules/recipe_flow.js): one per sampler in -// the order they run, the model each is fed through LoRAs and a Tile patch, a split's steps, an -// enlargement; and which stages a preset changes. +// node tests/recipe_flow.mjs — a saved workflow's flow (web/modules/recipe_flow.js): one stage per sampler +// in the order they run, the model each is fed, a split's steps, an enlargement, whether it runs; the +// model files, LoRAs, control models and picture nodes around them; and which stages a preset changes. import assert from 'node:assert/strict'; -import { changedNodes, flowStages, touched } from '../web/modules/recipe_flow.js'; +import { changedNodes, nodeValues, readFlow, touched, usedIn } from '../web/modules/recipe_flow.js'; const NAMES = { UNETLoader: ['unet_name', 'weight_dtype'], LoraLoader: ['lora_name', 'strength_model', 'strength_clip'], KSamplerAdvanced: ['add_noise', 'noise_seed', 'control_after_generate', 'steps', 'cfg', 'sampler_name', 'scheduler', 'start_at_step', 'end_at_step', 'return_with_leftover_noise'], KSampler: ['seed', 'control_after_generate', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise'], LatentUpscaleBy: ['upscale_method', 'scale_by'], AnimaLLLiteApply_sdscripts: ['lllite_name', 'strength', 'start_percent', 'end_percent', 'preserve_wrapper'], + ControlNetApplyAdvanced: ['strength', 'start_percent', 'end_percent'], }; const names = type => NAMES[type] || []; let links = 0; @@ -21,11 +22,12 @@ function node(id, type, values, feeds = {}) { }); return { id, type, widgets_values: values, inputs, mode: 0 }; } -// Base model split at step 21 to a second model, then a Tile-guided 1.22 enlargement on the second. +const graph = nodes => ({ nodes, links: wires.map(wire => [...wire]) }); +// Base model split at step 21 to a second model, then a Tile-guided 1.22 enlargement on the second, its colours matched to the base. function workflow({ second = 'Anima/realskin.safetensors', switchAt = 21 } = {}) { links = 0; wires.length = 0; - const nodes = [ + return graph([ node(1, 'UNETLoader', ['Anima/harem.safetensors', 'default']), node(16, 'LoraLoader', ['style.safetensors', 1, 1], { model: 1 }), node(30, 'UNETLoader', [second, 'default']), @@ -35,34 +37,75 @@ function workflow({ second = 'Anima/realskin.safetensors', switchAt = 21 } = {}) node(32, 'KSamplerAdvanced', ['enable', 7, 'fixed', 35, 4.4, 'euler', 'beta', 0, switchAt, 'enable'], { model: 16, positive: 2, negative: 2, latent_image: 6 }), node(33, 'KSamplerAdvanced', ['disable', 7, 'fixed', 35, 4.4, 'euler', 'beta', switchAt, 35, 'disable'], { model: 31, positive: 2, negative: 2, latent_image: 32 }), node(7, 'VAEDecode', [], { samples: 33 }), + node(9, 'SaveImage', ['base'], { images: 7 }), node(10, 'VAEEncode', [], { pixels: 7 }), node(11, 'LatentUpscaleBy', ['bislerp', 1.22], { samples: 10 }), node(18, 'AnimaLLLiteApply_sdscripts', ['animaTileRepair_v20.safetensors', 0.7, 0, 0.8, true], { model: 31, image: 7 }), node(19, 'KSampler', [9, 'randomize', 30, 4.4, 'euler', 'sgm_uniform', 0.85], { model: 18, positive: 2, negative: 2, latent_image: 11 }), - node(22, 'SaveImage', ['out'], { images: 19 }), + node(20, 'VAEDecode', [], { samples: 19 }), + node(21, 'ColorMatch', ['mkl', 1, true], { image_ref: 7, image_target: 20 }), + node(22, 'SaveImage', ['out'], { images: 21 }), { id: 40, type: 'MarkdownNote', widgets_values: ['about'], inputs: [], mode: 0 }, - ]; - return { nodes, links: wires.map(wire => [...wire]) }; + ]); } +const part = (flow, kind, name) => flow.parts.find(item => item.kind === kind && item.name === name); const relay = workflow(); -const stages = flowStages(relay, names); -assert.deepEqual(stages.map(stage => [stage.n, stage.sampler, stage.kind]), [[1, '32', 'base'], [2, '33', 'continue'], [3, '19', 'upscale']], +const flow = readFlow(relay, names); +const { stages } = flow; +assert.deepEqual(stages.map(stage => [stage.n, stage.sampler, stage.kind, stage.state]), [[1, '32', 'base', 'run'], [2, '33', 'continue', 'run'], [3, '19', 'upscale', 'run']], 'in the order they run, whatever the node ids'); -assert.deepEqual(stages.map(stage => stage.model.name), ['harem', 'realskin', 'realskin'], 'followed through LoRAs and the Tile patch'); -assert.deepEqual([stages[0].model.loras, stages[2].model.control], [1, 'Tile']); +assert.deepEqual(stages.map(stage => stage.model), ['harem', 'realskin', 'realskin'], 'followed through LoRAs and the Tile patch'); assert.deepEqual([stages[0].window, stages[1].window, stages[2].window], [[0, 21], [21, 35], null]); assert.deepEqual([stages[2].upscale, stages[2].denoise, stages[2].steps], [1.22, 0.85, 30]); assert.ok(!stages[1].nodes.has('2'), 'a prompt shared by every stage is not one stage\'s'); assert.ok(stages[2].nodes.has('30') && !stages[0].nodes.has('30'), 'the second model is the later stages\''); +assert.deepEqual([part(flow, 'model', 'harem').stages, part(flow, 'model', 'realskin').stages], [[1], [2, 3]]); +assert.deepEqual(part(flow, 'lora', 'style'), { kind: 'lora', ids: ['16', '31'], file: 'style.safetensors', name: 'style', strength: 1, stages: [1, 2, 3], on: true }, + 'one LoRA at one strength on both models is one part'); +assert.deepEqual([part(flow, 'control', 'animaTileRepair_v20').strength, part(flow, 'control', 'animaTileRepair_v20').stages], [0.7, [3]]); +assert.deepEqual(flow.parts.filter(item => item.kind === 'post').map(item => [item.name, item.ids]), [['ColorMatch', ['21']]], + 'after the samplers: the colour match, not the decoders or saves'); +assert.equal(flow.saves, 2); const plain = workflow({ second: 'Anima/harem.safetensors', switchAt: 35 }); -const own = flowStages(plain, names); -assert.ok(own[1].skipped && !own[0].skipped, 'a split from the last step leaves its second part unrun'); -assert.deepEqual(touched(own, changedNodes(plain, relay)), [1, 2, 3], 'the relay changes the split and both later models'); -assert.deepEqual(touched(own, changedNodes(plain, workflow({ switchAt: 35 }))), [2, 3], 'only the second model'); +const own = readFlow(plain, names); +assert.deepEqual(own.stages.map(stage => stage.state), ['run', 'skipped', 'run'], 'a split from the last step leaves its second part unrun'); +assert.deepEqual(part(own, 'model', 'harem').ids, ['1', '30'], 'the same file in two loaders is one part'); +assert.deepEqual(touched(stages, changedNodes(plain, relay), own.stages), [1, 2, 3], 'the relay changes the split, runs the second part, and the later model'); +const later = workflow({ switchAt: 35 }); +assert.deepEqual(touched(readFlow(later, names).stages, changedNodes(plain, later), own.stages), [3], + 'a stage unrun with both is no change, though its model changed'); +assert.ok(usedIn(flow, '33') && !usedIn(own, '33') && usedIn(own, '30'), 'the second sampler runs only with the relay; its loader feeds the enlargement anyway'); const reseeded = workflow({ second: 'Anima/harem.safetensors', switchAt: 35 }); reseeded.nodes.find(item => item.id === 19).widgets_values[1] = 'fixed'; assert.equal(changedNodes(plain, reseeded).size, 0, 'a seed\'s after-run choice is no change'); -assert.deepEqual(flowStages({ nodes: [{ id: 1, type: 'SaveImage', inputs: [] }], links: [] }, names), [], 'no sampler, no stages'); + +const bypassed = workflow(); +bypassed.nodes.find(item => item.id === 16).mode = 4; +bypassed.nodes.find(item => item.id === 33).mode = 4; +const off = readFlow(bypassed, names); +assert.deepEqual([off.stages[0].model, off.stages[1].state], ['harem', 'off'], 'a bypassed LoRA is passed through; a bypassed sampler does not run'); +assert.deepEqual(part(off, 'lora', 'style').ids, ['31'], 'nor is a bypassed LoRA shown'); + +links = 0; +wires.length = 0; +const controlled = graph([ + node(1, 'CheckpointLoaderSimple', ['sd15/dream.safetensors']), + node(2, 'CLIPTextEncode', ['a cat'], { clip: 1 }), + node(3, 'ControlNetLoader', ['canny.safetensors']), + node(4, 'LoadImage', ['edges.png', 'image']), + node(5, 'ControlNetApplyAdvanced', [0.6, 0, 1], { positive: 2, negative: 2, control_net: 3, image: 4 }), + node(6, 'EmptyLatentImage', [512, 512, 1]), + node(7, 'KSampler', [1, 'fixed', 20, 7, 'euler', 'normal', 1], { model: 1, positive: 5, negative: 5, latent_image: 6 }), + node(8, 'VAEDecode', [], { samples: 7, vae: 1 }), + node(9, 'SaveImage', ['cat'], { images: 8 }), +]); +const prompted = readFlow(controlled, names); +assert.deepEqual([prompted.stages[0].model, part(prompted, 'control', 'canny')?.ids, part(prompted, 'control', 'canny')?.strength], ['dream', ['5'], 0.6], + 'a ControlNet applied to the prompts, named by its loader\'s file'); +assert.ok(prompted.stages[0].nodes.has('3'), 'its loader is the stage\'s too'); +assert.deepEqual(nodeValues(controlled.nodes[4], names).map(item => item.name), ['strength', 'start_percent', 'end_percent']); +assert.equal(changedNodes({ nodes: [{ id: 1, widgets_values: { a: 1 } }] }, { nodes: [{ id: 1, widgets_values: { a: 2 } }] }).size, 1, 'values saved by name compare too'); +assert.deepEqual(readFlow({ nodes: [{ id: 1, type: 'SaveImage', inputs: [] }], links: [] }, names).stages, [], 'no sampler, no stages'); console.log('recipe_flow ok'); diff --git a/web/modules/locales.js b/web/modules/locales.js index 953b05c..9e01509 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1983,18 +1983,35 @@ export const i18n = { workflowFlow_base: "出底图", workflowFlow_continue: "接着画", workflowFlow_upscale: "放大重画", - workflowFlowControl: "+ 控制", workflowFlowNoModel: "(没认出模型)", workflowFlowSkipped: "这一段不跑", + workflowFlowOff: "已绕过,不跑", workflowFlowWindow: "第 {from}–{to} 步", workflowFlowSteps: "{steps} 步", workflowFlowDenoise: "重画 {value}%", workflowFlowSave: "保存", workflowFlowSaves: "存 {count} 处", - workflowFlowOwn: "不改", - workflowFlowChanges: "改 {stages}", - workflowFlowSameStages: "只改了别的参数", - workflowFlowLitHint: "标出来的几段和默认不一样。", + workflowFlowLegendStage: "采样", + workflowFlowLegendSupply: "底模 · LoRA", + workflowFlowLegendGuard: "控制 · 后处理", + workflowFlowLegendChanged: "和默认不同", + workflowFlowLegendOff: "这次不用", + workflowFlowRole_model: "底模", + workflowFlowRole_lora: "LoRA", + workflowFlowRole_control: "控制", + workflowFlowRole_post: "后处理", + workflowFlowChanged: "改了", + workflowFlowStrength: "强度 {value}", + workflowFlowFeeds: "给 {stages}", + workflowFlowUnused: "这次没用上", + workflowFlowThrough: "直通", + workflowFlowPickHint: "点上面任意一格,看它的全部参数。", + workflowFlowRuns: "这次会跑", + workflowFlowRunsIn: "在这些预设里会用上:", + workflowFlowRunsNowhere: "哪个预设都没用上它。", + workflowFlowChangedHere: "这一段改的是:", + workflowFlowNodes: "{count} 个节点:{ids}", + workflowFlowDiffNote: "黄框是这个预设改了的,划掉的是默认参数里的值。", aiToolboxTitle: "给 AI 的素材", aiToolboxKind_nodes: "节点", aiToolboxKind_combos: "搭配", @@ -5522,18 +5539,35 @@ export const i18n = { workflowFlow_base: "Base picture", workflowFlow_continue: "Goes on", workflowFlow_upscale: "Enlarge and redraw", - workflowFlowControl: "+ control", workflowFlowNoModel: "(model not found)", workflowFlowSkipped: "Not run", + workflowFlowOff: "Bypassed, not run", workflowFlowWindow: "Steps {from}–{to}", workflowFlowSteps: "{steps} steps", workflowFlowDenoise: "Redraw {value}%", workflowFlowSave: "Save", workflowFlowSaves: "{count} saved", - workflowFlowOwn: "As saved", - workflowFlowChanges: "Changes {stages}", - workflowFlowSameStages: "Other values only", - workflowFlowLitHint: "The marked stages differ from the default.", + workflowFlowLegendStage: "Sampling", + workflowFlowLegendSupply: "Model · LoRA", + workflowFlowLegendGuard: "Control · finishing", + workflowFlowLegendChanged: "Unlike the default", + workflowFlowLegendOff: "Not used here", + workflowFlowRole_model: "Model", + workflowFlowRole_lora: "LoRA", + workflowFlowRole_control: "Control", + workflowFlowRole_post: "Finishing", + workflowFlowChanged: "Changed", + workflowFlowStrength: "Strength {value}", + workflowFlowFeeds: "For {stages}", + workflowFlowUnused: "Not used here", + workflowFlowThrough: "Straight on", + workflowFlowPickHint: "Click any box above to see all its values.", + workflowFlowRuns: "Runs here", + workflowFlowRunsIn: "Used in these presets:", + workflowFlowRunsNowhere: "No preset uses it.", + workflowFlowChangedHere: "Changed in this stage:", + workflowFlowNodes: "{count} nodes: {ids}", + workflowFlowDiffNote: "Amber boxes are what this preset changed; struck out is the default's value.", aiToolboxTitle: "Materials for the AI", aiToolboxKind_nodes: "Nodes", aiToolboxKind_combos: "Combos", diff --git a/web/modules/recipe_flow.js b/web/modules/recipe_flow.js index b6d3ad2..a24912a 100644 --- a/web/modules/recipe_flow.js +++ b/web/modules/recipe_flow.js @@ -1,15 +1,20 @@ /** - * A saved workflow's stages, read from its serialized graph alone (no page, no canvas): one per - * sampler in the order they run (a sampler fed, through any nodes, by another runs after it), each - * with the nodes it rests on (not through its prompts), the main model it is fed through LoRAs and - * patches (a Tile LLLite or a ControlNet named), its steps or the part of a split it runs, its - * redraw strength and an enlargement before it; and which nodes two saved workflows set differently. - * ui_recipe_flow.js draws it. Widget names come from ComfyUI's node types when there are some - * (`names` stands in for them in tests). + * A saved workflow's flow, read from its serialized graph alone (no page, no canvas). Its stages: + * one per sampler in the order they run (a sampler fed, through any nodes, by another runs after + * it), each with the nodes it rests on (not through its prompts, but with the control models in + * them), the model file it is fed, its steps or the part of a split it runs, its redraw strength, + * an enlargement before it, and whether it runs (a split's empty part is skipped, a bypassed or + * muted sampler is off). Its parts around the stages: the model files and LoRAs the stages are fed, + * the control models patched in (a Tile LLLite, a ControlNet) and the picture nodes after the + * samplers (a colour match), each with the stages it feeds and whether it is used. And which nodes + * two saved workflows set differently, and which stages that changes. ui_recipe_flow.js draws it. + * Widget names come from ComfyUI's node types when there are some (`names` stands in for them in tests). */ const CONTROL = /lllite|controlnet|t2iadapter|ipadapter/i; const UPSCALE = /upscale|scaleby|imagescale/i; +const SAVE = /^(SaveImage|PreviewImage)|save/i; +const PLUMBING = /^(VAEDecode|VAEEncode|Reroute|Primitive|Note|MarkdownNote)/; const LOADER_VALUE = /\.(safetensors|ckpt|pt|pth|bin|gguf|sft)$/i; const CONDITIONING = new Set(['positive', 'negative']); const CONTROL_VALUES = new Set(['fixed', 'increment', 'decrement', 'randomize']); // a seed's "after run" choice @@ -27,23 +32,49 @@ function widgetNames(type) { return namesCache.get(type); } -/** The saved workflow as lookups: nodes by id, each input's source node. */ -function graphOf(workflow, names = widgetNames) { - const nodes = new Map((workflow?.nodes || []).filter(node => node.mode !== 2 && node.mode !== 4).map(node => [String(node.id), node])); +const inputs = node => node.inputs || []; +const valuesOf = node => (Array.isArray(node?.widgets_values) ? node.widgets_values : Object.values(node?.widgets_values || {})); +const isOff = node => node.mode === 2 || node.mode === 4; +const fileOf = node => valuesOf(node).find(value => typeof value === 'string' && LOADER_VALUE.test(value)) || ''; +const isSampler = node => ['model', 'positive'].every(name => inputs(node).some(input => input.name === name)) + && inputs(node).some(input => /latent/.test(input.name)); + +const NUMBERS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧']; +/** A stage's number as it is drawn. */ +export const stageMark = n => NUMBERS[n - 1] || String(n); + +/** A model file's name without its folders and extension. */ +export const shortName = file => String(file).split(/[\\/]/).pop().replace(LOADER_VALUE, ''); + +/** Two saved values alike (a seed's after-run choice is never a change). */ +export const sameValue = (a, b) => CONTROL_VALUES.has(a) || JSON.stringify(a) === JSON.stringify(b); + +/** A saved node's values by name: [{name, value}] (an unnamed one by its place). */ +export function nodeValues(node, names = widgetNames) { + const known = names(node?.type); + return valuesOf(node).map((value, at) => ({ name: known[at] || `#${at + 1}`, value })); +} + +/** The saved workflow as lookups: nodes by id, each input's source node, each node's users. */ +function graphOf(workflow, names) { + const nodes = new Map((workflow?.nodes || []).map(node => [String(node.id), node])); const links = new Map((workflow?.links || []).map(link => (Array.isArray(link) ? [String(link[0]), String(link[1])] : [String(link.id), String(link.origin_id)]))); const from = input => (input?.link == null ? null : nodes.get(links.get(String(input.link))) || null); const value = (node, name) => { const at = names(node.type).indexOf(name); - return at >= 0 ? node.widgets_values?.[at] : undefined; + return at >= 0 ? valuesOf(node)[at] : undefined; }; - return { nodes, from, value }; + const users = new Map(); + for (const node of nodes.values()) { + for (const input of inputs(node)) { + const source = from(input); + if (source) users.set(String(source.id), [...(users.get(String(source.id)) || []), node]); + } + } + return { nodes, from, value, users }; } -const inputs = node => node.inputs || []; -const isSampler = node => ['model', 'positive'].every(name => inputs(node).some(input => input.name === name)) - && inputs(node).some(input => /latent/.test(input.name)); - /** The nodes feeding `node` (not through its prompts), up to the samplers before it, which are returned apart. */ function feeders(g, node) { const seen = new Set(); @@ -66,28 +97,54 @@ function feeders(g, node) { return { nodes: seen, before }; } -/** The main model a sampler is fed, through LoRAs and patches: {name, loras, control}. */ +/** The control nodes in a sampler's prompts (a ControlNet applied to them, with its loader), not up through the text encoders. */ +function promptControls(g, sampler) { + const seen = new Set(); + const walk = (current) => { + for (const input of inputs(current)) { + if (current === sampler ? !CONDITIONING.has(input.name) : input.name === 'clip' || input.name === 'model') continue; + const source = g.from(input); + const key = source && String(source.id); + if (!source || seen.has(key) || isSampler(source)) continue; + seen.add(key); + walk(source); + } + }; + walk(sampler); + return [...seen].filter(id => CONTROL.test(g.nodes.get(id).type)); +} + +/** The model file a sampler is fed and the LoRAs and patches on the way, bypassed ones passed through. */ function modelOf(g, sampler) { + const out = { file: '', loader: null, loras: [], controls: [] }; let node = g.from(inputs(sampler).find(input => input.name === 'model')); - const out = { name: '', loras: 0, control: '' }; for (let hop = 0; node && hop < 16; hop += 1) { - if (/lora/i.test(node.type)) out.loras += 1; - else if (CONTROL.test(node.type)) out.control = (node.widgets_values || []).some(value => /tile/i.test(String(value))) ? 'Tile' : 'control'; - const file = (node.widgets_values || []).find(value => typeof value === 'string' && LOADER_VALUE.test(value)); - if (file && !/lora/i.test(node.type) && !inputs(node).some(input => input.name === 'model')) { - out.name = file.split(/[\\/]/).pop().replace(LOADER_VALUE, ''); - return out; + const file = fileOf(node); + if (!isOff(node)) { + if (/lora/i.test(node.type)) out.loras.push(String(node.id)); + else if (CONTROL.test(node.type)) out.controls.push(String(node.id)); + else if (file && !inputs(node).some(input => input.name === 'model')) { + Object.assign(out, { file, loader: String(node.id) }); + return out; + } } node = g.from(inputs(node).find(input => input.name === 'model')); } return out; } +/** A control node's model file: its own, or its control loader's. */ +const controlFile = (g, node) => fileOf(node) + || fileOf(inputs(node).map(g.from).find(source => source && CONTROL.test(source.type) && fileOf(source))); + /** - * The stages of `workflow`: [{n, kind (base | upscale | continue), sampler, nodes (ids it rests on), - * model, steps, window ([from, to] of a split, or null), skipped, denoise, upscale}] in the order they run. + * The flow of `workflow`: { stages, parts, saves }. stages: [{n, kind (base | continue | upscale), + * sampler, state (run | skipped | off), nodes (ids it rests on), model, steps, window ([from, to] + * of a split, or null), denoise, upscale}] in the order they run. parts: [{kind (model | lora | + * control | post), ids, file, name, strength, stages (the numbers it feeds), on (used)}], one per + * file and strength. saves: the save nodes that run. */ -export function flowStages(workflow, names = widgetNames) { +export function readFlow(workflow, names = widgetNames) { const g = graphOf(workflow, names); const samplers = [...g.nodes.values()].filter(isSampler); const fed = new Map(samplers.map(node => [String(node.id), feeders(g, node)])); @@ -96,21 +153,60 @@ export function flowStages(workflow, names = widgetNames) { if (!depth.has(key)) depth.set(key, guard > 16 ? 0 : 1 + Math.max(0, ...[...fed.get(key).before].map(before => level(before, guard + 1)))); return depth.get(key); }; - return samplers.map(node => ({ node, key: String(node.id) })) + const parts = new Map(); + const add = (kind, id, n, file = '', strength = undefined) => { + const node = g.nodes.get(id); + const key = `${kind}|${file || id}|${strength ?? ''}`; + const part = parts.get(key) || { kind, ids: [], file, name: file ? shortName(file) : node.title || node.type, strength, stages: [], off: [] }; + if (!part.ids.includes(id)) part.ids.push(id); + if (isOff(node)) part.off.push(id); + if (n && !part.stages.includes(n)) part.stages.push(n); + parts.set(key, part); + }; + const stages = samplers.map(node => ({ node, key: String(node.id) })) .sort((a, b) => level(a.key) - level(b.key) || Number(a.key) - Number(b.key)) .map(({ node, key }, at) => { + const n = at + 1; const steps = Number(g.value(node, 'steps')); const start = g.value(node, 'start_at_step'); const end = g.value(node, 'end_at_step'); const window = start === undefined ? null : [Number(start), Math.min(Number(end ?? steps), steps)]; const upscale = [...fed.get(key).nodes].map(id => g.nodes.get(id)).find(item => UPSCALE.test(item.type)); + const model = modelOf(g, node); + const prompted = promptControls(g, node); + if (model.loader) add('model', model.loader, n, model.file); + for (const id of model.loras) add('lora', id, n, fileOf(g.nodes.get(id)), g.value(g.nodes.get(id), 'strength_model')); + for (const id of [...model.controls, ...prompted.filter(id => inputs(g.nodes.get(id)).some(input => input.link != null))]) { + add('control', id, n, controlFile(g, g.nodes.get(id)), g.value(g.nodes.get(id), 'strength')); + } return { - n: at + 1, sampler: key, nodes: new Set([key, ...fed.get(key).nodes]), model: modelOf(g, node), steps, - window, skipped: Boolean(window && window[0] >= window[1]), denoise: Number(g.value(node, 'denoise') ?? 1), - upscale: upscale ? Number(g.value(upscale, 'scale_by')) || true : null, - kind: fed.get(key).before.size ? (upscale ? 'upscale' : 'continue') : 'base', + n, sampler: key, kind: fed.get(key).before.size ? (upscale ? 'upscale' : 'continue') : 'base', + state: isOff(node) ? 'off' : window && window[0] >= window[1] ? 'skipped' : 'run', + nodes: new Set([key, ...fed.get(key).nodes, ...prompted]), model: model.file ? shortName(model.file) : '', steps, + window, denoise: Number(g.value(node, 'denoise') ?? 1), upscale: upscale ? Number(g.value(upscale, 'scale_by')) || true : null, }; }); + // the picture nodes after the samplers: not a stage's, not decoding or saving + const staged = new Set(stages.flatMap(stage => [...stage.nodes])); + const after = new Set(); + const forward = (node) => { + for (const user of g.users.get(String(node.id)) || []) { + const key = String(user.id); + if (after.has(key) || staged.has(key) || isSampler(user)) continue; + after.add(key); + if (!PLUMBING.test(user.type) && !SAVE.test(user.type)) add('post', key, null); + forward(user); + } + }; + samplers.forEach(forward); + const running = new Set(stages.filter(stage => stage.state === 'run').map(stage => stage.n)); + return { + stages, + parts: [...parts.values()].map(({ off, ...part }) => ({ + ...part, on: off.length < part.ids.length && (part.kind === 'post' || part.stages.some(n => running.has(n))), + })), + saves: [...g.nodes.values()].filter(node => SAVE.test(node.type) && !isOff(node)).length, + }; } /** The node ids whose values differ between saved workflows `a` and `b` (a seed's after-run choice aside). */ @@ -119,16 +215,24 @@ export function changedNodes(a, b) { const changed = new Set(); for (const node of a?.nodes || []) { const other = others.get(String(node.id)); - const mine = node.widgets_values || []; - const theirs = other?.widgets_values || []; - if (!other || mine.length !== theirs.length) { - if (other) changed.add(String(node.id)); - continue; - } - if (mine.some((value, at) => !CONTROL_VALUES.has(value) && JSON.stringify(value) !== JSON.stringify(theirs[at]))) changed.add(String(node.id)); + if (!other) continue; + const mine = valuesOf(node); + const theirs = valuesOf(other); + if (mine.length !== theirs.length || mine.some((value, at) => !sameValue(value, theirs[at]))) changed.add(String(node.id)); } return changed; } -/** The stage numbers a preset's changed nodes touch. */ -export const touched = (stages, changed) => stages.filter(stage => [...changed].some(id => stage.nodes.has(id))).map(stage => stage.n); +/** + * The stage numbers of `stages` a preset changes from `base` (the workflow's own stages): run + * there and not here or the other way round, or running with one of its nodes `changed`. A stage + * left unrun in both is no change, whatever feeds it. + */ +export const touched = (stages, changed, base) => stages.filter((stage) => { + const before = base.find(item => item.sampler === stage.sampler); + return !before || before.state !== stage.state || (stage.state === 'run' && [...stage.nodes].some(id => changed.has(id))); +}).map(stage => stage.n); + +/** Whether node `id` is used in `flow`: the sampler of a stage that runs, or in a part used. */ +export const usedIn = (flow, id) => flow.stages.some(stage => stage.sampler === id && stage.state === 'run') + || flow.parts.some(part => part.on && part.ids.includes(id)); diff --git a/web/modules/ui_recipe_detail.js b/web/modules/ui_recipe_detail.js index a6c4dbb..c7ba6f9 100644 --- a/web/modules/ui_recipe_detail.js +++ b/web/modules/ui_recipe_detail.js @@ -163,7 +163,8 @@ export function showRecipeDetail(owner, { recipe, filename, history = [] }) { const gallery = { status: 'idle', images: [], scanned: 0 }; const parameterGallery = { status: 'idle', images: [], scanned: 0, requestId: 0 }; - const parameterState = { status: 'idle', notebooks: [], selectedFilename: null, editor: null, refresh: null }; + // flowPick: the node picked in the flow (ui_recipe_flow.js), kept across presets until the details close + const parameterState = { status: 'idle', notebooks: [], selectedFilename: null, editor: null, refresh: null, flowPick: null }; // The view: { kind: 'set' | 'gallery' | 'versions', filename } (a set's file, null for the workflow's own). const legacyTabs = { overview: 'set', parameters: 'set' }; const viewState = { diff --git a/web/modules/ui_recipe_flow.js b/web/modules/ui_recipe_flow.js index c1bc1c8..6e0e85e 100644 --- a/web/modules/ui_recipe_flow.js +++ b/web/modules/ui_recipe_flow.js @@ -1,83 +1,120 @@ /** - * A workflow's flow at the top of its details (ui_recipe_set_view.js): its stages in a row, one per - * sampler in the order they run (as the AI's run view tells a run, ui_ai_run_story.js): what the - * stage does (makes the base, goes on from the stage before, enlarges and redraws), the main model - * its sampler is fed (through LoRAs and patches such as a Tile LLLite or a ControlNet), its steps - * or the steps it runs of a split, its redraw strength; then where the pictures are saved. Under it - * the presets: the workflow's own values and each saved set unlike them (listedSets), with the - * stages a set changes; the one shown is lit and its stages show its values, those it changes - * marked. A click on another one shows it (ctx.select). The stages are read by recipe_flow.js. + * A workflow's flow at the top of its details (ui_recipe_set_view.js), with the values shown (the + * workflow's own or a preset), read by recipe_flow.js. On top the model files and LoRAs the stages + * are fed, each with its strength and the stages it feeds; then the stages in the order they run, + * each under the control models patched into it (a Tile LLLite with its strength): what it does + * (makes the base, goes on, enlarges and redraws), its model, its steps or the part of a split it + * runs, how much it redraws. A stage these values do not run hangs dashed under a line straight + * past it. Then the picture nodes after the samplers and the saves. What a preset changes from the + * workflow's own is marked, what it leaves unused dashed. Every box is a button that opens its + * node's values under the flow (ui_recipe_flow_inspect.js); the one picked is + * parameterState.flowPick (a node id), kept while the details are open, across presets too. */ import { translate as t } from './locales.js'; -import { appendText } from './ui_recipe_detail_dom.js'; +import { appendText, displayValue } from './ui_recipe_detail_dom.js'; import { listedSets } from './ui_recipe_sets.js'; -import { changedNodes, flowStages, touched } from './recipe_flow.js'; +import { changedNodes, readFlow, stageMark, touched } from './recipe_flow.js'; +import { renderInspector, roleOf } from './ui_recipe_flow_inspect.js'; -const SAVE = /^(SaveImage|PreviewImage)|save/i; -const NUMBERS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧']; +const LEGEND = [['is-stage', 'workflowFlowLegendStage'], ['is-supply', 'workflowFlowLegendSupply'], ['is-guard', 'workflowFlowLegendGuard'], + ['is-changed', 'workflowFlowLegendChanged'], ['is-off', 'workflowFlowLegendOff']]; +const KIND_CLASS = { model: 'is-supply', lora: 'is-supply', control: 'is-guard', post: 'is-guard' }; -function stageBox(stage, lit) { - const box = document.createElement('div'); - box.className = `anomalous-wfd-flow-stage${lit ? ' is-changed' : ''}${stage.skipped ? ' is-skipped' : ''}`; - const head = appendText(box, 'div', `${NUMBERS[stage.n - 1] || stage.n} ${t(`workflowFlow_${stage.kind}`)}`, 'anomalous-wfd-flow-name'); - if (stage.model.control) appendText(head, 'span', stage.model.control === 'Tile' ? '+ Tile' : t('workflowFlowControl'), 'anomalous-wfd-flow-tag'); - appendText(box, 'div', stage.model.name || t('workflowFlowNoModel'), 'anomalous-wfd-flow-model').title = stage.model.name; +function legend(panel) { + const line = appendText(panel, 'div', '', 'anomalous-wfd-flow-legend'); + for (const [className, key] of LEGEND) { + const entry = appendText(line, 'span', ''); + appendText(entry, 'i', '', `anomalous-wfd-flow-dot ${className}`); + entry.append(t(key)); + } +} + +/** One box of the flow, a button: `role` above `name` and `facts`. */ +function box(parent, { className, role, name, facts, changed, off, picked, onPick }) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = `anomalous-wfd-flow-box ${className}${changed ? ' is-changed' : ''}${off ? ' is-off' : ''}${picked ? ' is-picked' : ''}`; + const head = appendText(item, 'span', role, 'anomalous-wfd-flow-role'); + if (changed) appendText(head, 'span', t('workflowFlowChanged'), 'anomalous-wfd-flow-badge'); + appendText(item, 'span', name, 'anomalous-wfd-flow-model').title = name; + if (facts) appendText(item, 'span', facts, 'anomalous-wfd-flow-facts'); + item.onclick = onPick; + parent.appendChild(item); +} + +function stageFacts(stage) { const facts = []; - if (stage.skipped) facts.push(t('workflowFlowSkipped')); + if (stage.state === 'off') facts.push(t('workflowFlowOff')); + else if (stage.state === 'skipped') facts.push(t('workflowFlowSkipped')); else if (stage.window) facts.push(t('workflowFlowWindow', { from: stage.window[0], to: stage.window[1] })); else if (stage.steps) facts.push(t('workflowFlowSteps', { steps: stage.steps })); if (typeof stage.upscale === 'number') facts.push(`×${stage.upscale}`); if (stage.denoise < 1) facts.push(t('workflowFlowDenoise', { value: Math.round(stage.denoise * 100) })); - if (stage.model.loras) facts.push(`LoRA ${stage.model.loras}`); - appendText(box, 'div', facts.join(' · '), 'anomalous-wfd-flow-facts'); - return box; + return facts.join(' · '); +} + +/** A part's values worth a glance, and the stages it feeds that run (a control model sits on its stage, so only when it has more). */ +function partFacts(part, shown, running) { + const facts = []; + if (part.kind === 'post') { + const node = (shown.nodes || []).find(item => String(item.id) === part.ids[0]); + const values = (Array.isArray(node?.widgets_values) ? node.widgets_values : []).filter(value => ['string', 'number'].includes(typeof value)); + facts.push(...values.slice(0, 2).map(displayValue)); + } + if (typeof part.strength === 'number') facts.push(t('workflowFlowStrength', { value: part.strength })); + const feeds = part.stages.filter(n => running.has(n)); + if (part.kind === 'post' || (part.kind === 'control' && part.on && feeds.length < 2)) return facts.join(' · '); + facts.push(part.on ? t('workflowFlowFeeds', { stages: feeds.map(stageMark).join('') }) : t('workflowFlowUnused')); + return facts.join(' · '); } /** The flow of `ctx.recipe` with preset `notebook` (null: its own values) shown; null when it has no sampler. */ export function renderFlow(ctx, notebook) { const { recipe, parameterState } = ctx; const shown = notebook?.data?.workflow || recipe.workflow; - const stages = flowStages(shown); - if (!stages.length) return null; - const changedHere = notebook ? changedNodes(recipe.workflow, shown) : new Set(); - const lit = new Set(touched(stages, changedHere)); + const flow = readFlow(shown); + if (!flow.stages.length) return null; + const own = notebook ? readFlow(recipe.workflow) : flow; + const changed = notebook ? changedNodes(recipe.workflow, shown) : new Set(); + const lit = new Set(touched(flow.stages, changed, own.stages)); + const running = new Set(flow.stages.filter(stage => stage.state === 'run').map(stage => stage.n)); const panel = document.createElement('section'); panel.className = 'anomalous-wfd-flow'; - const row = document.createElement('div'); - row.className = 'anomalous-wfd-flow-row'; - for (const stage of stages) { - row.appendChild(stageBox(stage, lit.has(stage.n))); - appendText(row, 'span', '→', 'anomalous-wfd-flow-arrow'); - } - const saves = (shown.nodes || []).filter(node => SAVE.test(node.type) && node.mode !== 2 && node.mode !== 4).length; - const save = document.createElement('div'); - save.className = 'anomalous-wfd-flow-stage is-save'; - appendText(save, 'div', t('workflowFlowSave'), 'anomalous-wfd-flow-name'); - appendText(save, 'div', t('workflowFlowSaves', { count: saves }), 'anomalous-wfd-flow-facts'); - row.appendChild(save); - const presets = document.createElement('div'); - presets.className = 'anomalous-wfd-flow-presets'; - const chip = (label, note, active, pick) => { - const node = document.createElement('button'); - node.type = 'button'; - node.className = `anomalous-wfd-flow-preset${active ? ' is-active' : ''}`; - appendText(node, 'span', label, 'anomalous-wfd-flow-preset-name'); - appendText(node, 'span', note, 'anomalous-wfd-flow-preset-note'); - node.onclick = () => { - parameterState.editor = null; - pick(); - }; - presets.appendChild(node); + const pick = (id) => { + parameterState.flowPick = parameterState.flowPick === id ? null : id; + panel.replaceWith(renderFlow(ctx, notebook)); }; - chip(t('workflowOwnValues'), t('workflowFlowOwn'), !notebook, () => ctx.select('set', null)); - for (const set of listedSets(recipe, parameterState.notebooks)) { - const changes = touched(stages, changedNodes(recipe.workflow, set.data?.workflow)); - chip(set.name || t('recipeParameterUntitled'), changes.length - ? t('workflowFlowChanges', { stages: changes.map(n => NUMBERS[n - 1] || n).join('') }) : t('workflowFlowSameStages'), - notebook?.filename === set.filename, () => ctx.select('set', set.filename)); - } - panel.append(row, presets); - if (lit.size) appendText(panel, 'p', t('workflowFlowLitHint'), 'anomalous-wfd-flow-hint'); + const partBox = (parent, part) => box(parent, { + className: KIND_CLASS[part.kind], role: roleOf(part), name: part.name, facts: partFacts(part, shown, running), + changed: part.ids.some(id => changed.has(id)), off: !part.on, picked: part.ids.includes(parameterState.flowPick), onPick: () => pick(part.ids[0]), + }); + legend(panel); + const supply = appendText(panel, 'div', '', 'anomalous-wfd-flow-supply'); + flow.parts.filter(part => part.kind === 'model' || part.kind === 'lora').forEach(part => partBox(supply, part)); + const row = appendText(panel, 'div', '', 'anomalous-wfd-flow-row'); + flow.stages.forEach((stage, at) => { + if (at) appendText(row, 'span', '→', 'anomalous-wfd-flow-arrow'); + const runs = stage.state === 'run'; + const column = appendText(row, 'div', '', `anomalous-wfd-flow-col${runs ? '' : ' is-branch'}`); + if (!runs) appendText(column, 'span', `${t('workflowFlowThrough')} ⟶`, 'anomalous-wfd-flow-through'); + flow.parts.filter(part => part.kind === 'control' && part.stages[0] === stage.n).forEach(part => partBox(column, part)); + box(column, { + className: 'is-stage', role: roleOf(stage), name: stage.model || t('workflowFlowNoModel'), facts: stageFacts(stage), + changed: lit.has(stage.n), off: !runs, picked: parameterState.flowPick === stage.sampler, onPick: () => pick(stage.sampler), + }); + }); + const after = flow.parts.filter(part => part.kind === 'post'); + appendText(row, 'span', '→', 'anomalous-wfd-flow-arrow'); + const end = appendText(row, 'div', '', 'anomalous-wfd-flow-col is-end'); + after.forEach(part => partBox(end, part)); + const save = appendText(end, 'div', '', 'anomalous-wfd-flow-save'); + appendText(save, 'span', t('workflowFlowSave'), 'anomalous-wfd-flow-role'); + appendText(save, 'span', t('workflowFlowSaves', { count: flow.saves }), 'anomalous-wfd-flow-facts'); + renderInspector(panel, ctx, { + flow, shown, changed, current: notebook?.filename ?? null, pick, + presets: () => [{ label: t('workflowOwnValues'), filename: null, flow: own }, + ...listedSets(recipe, parameterState.notebooks).map(set => ({ label: set.name || t('recipeParameterUntitled'), filename: set.filename, flow: readFlow(set.data?.workflow) }))], + }); return panel; } diff --git a/web/modules/ui_recipe_flow_inspect.js b/web/modules/ui_recipe_flow_inspect.js new file mode 100644 index 0000000..617f956 --- /dev/null +++ b/web/modules/ui_recipe_flow_inspect.js @@ -0,0 +1,81 @@ +/** + * The box picked in a workflow's flow, under it (ui_recipe_flow.js): what it is with its full file + * name, whether it runs with the values shown, and every value of its node, a value the preset + * changed struck out as the workflow's own with the preset's after it. A stage that runs names its + * other nodes that changed (its model, a LoRA), each a button to that one; a box unused with these + * values names the presets that use it, each a button to that preset. + */ + +import { translate as t } from './locales.js'; +import { appendText, button, displayValue } from './ui_recipe_detail_dom.js'; +import { nodeValues, sameValue, stageMark, usedIn } from './recipe_flow.js'; + +const nodeIn = (workflow, id) => (workflow?.nodes || []).find(node => String(node.id) === id) || null; + +/** What a box of the flow is: a stage by its number and job, anything else by its role. */ +export const roleOf = item => (item.sampler ? `${stageMark(item.n)} ${t(`workflowFlow_${item.kind}`)}` : t(`workflowFlowRole_${item.kind}`)); + +function stateKey(item, used) { + if (used) return 'workflowFlowRuns'; + if (item.state === 'off') return 'workflowFlowOff'; + return item.state === 'skipped' ? 'workflowFlowSkipped' : 'workflowFlowUnused'; +} + +/** A line of buttons after `label`. */ +function links(card, label, entries) { + const line = appendText(card, 'div', '', 'anomalous-wfd-flow-links'); + appendText(line, 'span', label); + for (const [text, run] of entries) button(line, text, 'anomalous-wfd-flow-link').onclick = run; +} + +/** + * Draws the picked box's card into `panel`. view: flow (shown), shown (its workflow), changed (node + * ids unlike the workflow's own), current (the preset shown, null for the own values), presets() + * ([{label, filename, flow}], the own values first), pick(id). + */ +export function renderInspector(panel, ctx, { flow, shown, changed, current, presets, pick }) { + const card = appendText(panel, 'div', '', 'anomalous-wfd-flow-inspect'); + const id = ctx.parameterState.flowPick; + const item = id && (flow.stages.find(stage => stage.sampler === id) || flow.parts.find(part => part.ids.includes(id))); + if (!item) { + appendText(card, 'p', t('workflowFlowPickHint'), 'anomalous-wfd-muted'); + return; + } + const node = nodeIn(shown, id); + const used = usedIn(flow, id); + const head = appendText(card, 'div', '', 'anomalous-wfd-flow-inspect-head'); + const titles = appendText(head, 'div', ''); + appendText(titles, 'strong', roleOf(item), 'anomalous-wfd-flow-inspect-title'); + appendText(titles, 'div', item.file || node?.title || node?.type || '', 'anomalous-wfd-flow-inspect-file'); + appendText(head, 'span', t(stateKey(item, used)), `anomalous-wfd-flow-state${used ? ' is-on' : ''}`); + if (item.ids?.length > 1) appendText(card, 'small', t('workflowFlowNodes', { count: item.ids.length, ids: item.ids.map(other => `#${other}`).join(' ') }), 'anomalous-wfd-muted'); + if (!used) { + const where = presets().filter(preset => preset.filename !== current && usedIn(preset.flow, id)); + links(card, t(where.length ? 'workflowFlowRunsIn' : 'workflowFlowRunsNowhere'), where.map(preset => [preset.label, () => { + ctx.parameterState.editor = null; + ctx.select('set', preset.filename); + }])); + } + const others = item.sampler && used ? [...item.nodes].filter(other => other !== id && changed.has(other)) : []; + if (others.length) { + links(card, t('workflowFlowChangedHere'), others.map((other) => { + const part = flow.parts.find(entry => entry.ids.includes(other)); + const otherNode = nodeIn(shown, other); + return [part ? `${roleOf(part)} ${part.name}` : `${otherNode?.title || otherNode?.type || ''} #${other}`, () => pick(other)]; + })); + } + const own = changed.has(id) ? nodeValues(nodeIn(ctx.recipe.workflow, id)) : null; + if (own) appendText(card, 'small', t('workflowFlowDiffNote'), 'anomalous-wfd-muted'); + const grid = appendText(card, 'div', '', 'anomalous-wfd-flow-values'); + nodeValues(node).forEach(({ name, value }, at) => { + const was = own && !sameValue(own[at]?.value, value) ? own[at] : null; + const cell = appendText(grid, 'div', '', `anomalous-wfd-flow-value${was ? ' is-changed' : ''}`); + appendText(cell, 'span', name); + const text = appendText(cell, 'strong', ''); + if (was) { + appendText(text, 'span', displayValue(was.value), 'anomalous-wfd-flow-was'); + text.append(' → '); + } + text.append(displayValue(value)); + }); +} diff --git a/web/styles/29-workflow-detail.css b/web/styles/29-workflow-detail.css index 32924b1..3818e23 100644 --- a/web/styles/29-workflow-detail.css +++ b/web/styles/29-workflow-detail.css @@ -760,55 +760,90 @@ .anomalous-wfd-tip { flex-wrap: wrap; } } -/* The flow on top of a workflow's details (ui_recipe_flow.js): its stages, then its presets. */ +/* The flow on top of a workflow's details (ui_recipe_flow.js): the models and LoRAs, the stages + with their control models, what comes after; blue stages, violet models, green control and + finishing, amber what a preset changed, dashed what it leaves unused; the picked box's card. */ .anomalous-wfd-flow { + --wfd-flow-violet: #a78bfa; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; margin-bottom: 16px; - padding: 14px; - border: 1px solid color-mix(in srgb, var(--amb-ok) 35%, var(--amb-border)); + padding: 12px 14px; + border: 1px solid var(--amb-border); border-radius: 14px; - background: color-mix(in srgb, var(--amb-ok) 7%, var(--amb-bg-panel)); + background: var(--amb-bg-panel); } -.anomalous-wfd-flow-row { display: flex; flex-wrap: wrap; align-items: stretch; gap: 6px; } +.anomalous-wfd-flow-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--amb-text-soft); font-size: 11.5px; } +.anomalous-wfd-flow-legend > span { display: inline-flex; align-items: center; gap: 5px; } +.anomalous-wfd-flow-dot { width: 9px; height: 9px; border: 1px solid transparent; border-radius: 50%; } +.anomalous-wfd-flow-dot.is-stage { background: var(--amb-link); } +.anomalous-wfd-flow-dot.is-supply { background: var(--wfd-flow-violet); } +.anomalous-wfd-flow-dot.is-guard { background: var(--amb-ok); } +.anomalous-wfd-flow-dot.is-changed { background: var(--amb-warn); } +.anomalous-wfd-flow-dot.is-off { border: 1px dashed var(--amb-text-soft); } +.anomalous-wfd-flow-supply { display: flex; flex-wrap: wrap; gap: 8px; } +.anomalous-wfd-flow-supply:empty { display: none; } +.anomalous-wfd-flow-supply > .anomalous-wfd-flow-box { flex: 0 1 230px; } +.anomalous-wfd-flow-row { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; } .anomalous-wfd-flow-arrow { display: flex; align-items: center; color: var(--amb-text-dim); font-size: 18px; } -.anomalous-wfd-flow-stage { - display: flex; - flex: 1 1 120px; - flex-direction: column; - gap: 3px; - min-width: 100px; - padding: 10px 12px; - border: 1px solid var(--amb-border-strong); - border-radius: 10px; - background: var(--amb-bg-card); +.anomalous-wfd-flow-col { display: flex; flex: 1 1 150px; flex-direction: column; gap: 6px; min-width: 130px; } +.anomalous-wfd-flow-col > .anomalous-wfd-flow-box.is-stage { flex: 1; } +.anomalous-wfd-flow-col.is-branch { padding: 6px; border: 1px dashed var(--amb-border-strong); border-radius: 12px; } +.anomalous-wfd-flow-col.is-end { flex: 0 1 170px; } +.anomalous-wfd-flow-through { + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--amb-link) 45%, var(--amb-border)); + border-radius: 999px; + background: color-mix(in srgb, var(--amb-link) 12%, transparent); + color: var(--amb-text-main); + font-size: 11.5px; + text-align: center; } -.anomalous-wfd-flow-stage.is-save { flex: 0 0 auto; min-width: 0; padding: 10px; } -.anomalous-wfd-flow-stage.is-changed { border-color: var(--amb-link); background: color-mix(in srgb, var(--amb-link) 12%, var(--amb-bg-card)); } -.anomalous-wfd-flow-stage.is-skipped .anomalous-wfd-flow-model { color: var(--amb-text-dim); text-decoration: line-through; } -.anomalous-wfd-flow-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; color: var(--amb-text-main); font-size: 14px; font-weight: 600; } -.anomalous-wfd-flow-tag { padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--amb-link) 16%, transparent); color: var(--amb-text-main); font-size: 11px; font-weight: 500; } -.anomalous-wfd-flow-model { overflow: hidden; color: var(--amb-text-main); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } -.anomalous-wfd-flow-facts { color: var(--amb-text-soft); font-size: 12px; } -.anomalous-wfd-flow-presets { display: flex; flex-wrap: wrap; gap: 8px; } -.anomalous-wfd-flow-preset { +.anomalous-wfd-flow-box { + --wfd-flow-tint: var(--amb-text-soft); display: flex; - flex: 0 1 200px; flex-direction: column; - align-items: center; gap: 2px; - min-width: 120px; - padding: 8px 12px; - border: 1px solid color-mix(in srgb, #8f86e8 45%, var(--amb-border)); + min-width: 0; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--wfd-flow-tint) 45%, var(--amb-border)); border-radius: 10px; - background: color-mix(in srgb, #8f86e8 10%, var(--amb-bg-card)); + background: color-mix(in srgb, var(--wfd-flow-tint) 10%, var(--amb-bg-card)); color: var(--amb-text-main); font: inherit; + text-align: left; cursor: pointer; } -.anomalous-wfd-flow-preset:hover { border-color: #8f86e8; } -.anomalous-wfd-flow-preset.is-active { border-color: var(--amb-link); box-shadow: 0 0 0 1px var(--amb-link); } -.anomalous-wfd-flow-preset-name { max-width: 100%; overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } -.anomalous-wfd-flow-preset-note { color: var(--amb-text-soft); font-size: 12px; } -.anomalous-wfd-flow-hint { margin: 0; color: var(--amb-text-soft); font-size: 12px; } +.anomalous-wfd-flow-box:hover { border-color: var(--wfd-flow-tint); } +.anomalous-wfd-flow-box:focus-visible { outline: 2px solid var(--amb-link); outline-offset: 1px; } +.anomalous-wfd-flow-box.is-stage { --wfd-flow-tint: var(--amb-link); } +.anomalous-wfd-flow-box.is-supply { --wfd-flow-tint: var(--wfd-flow-violet); } +.anomalous-wfd-flow-box.is-guard { --wfd-flow-tint: var(--amb-ok); } +.anomalous-wfd-flow-box.is-changed { --wfd-flow-tint: var(--amb-warn); border-width: 2px; padding: 7px 9px; } +.anomalous-wfd-flow-box.is-off { border-style: dashed; background: transparent; opacity: 0.6; } +.anomalous-wfd-flow-box.is-off:hover { opacity: 0.9; } +.anomalous-wfd-flow-box.is-picked { box-shadow: 0 0 0 2px var(--amb-link); opacity: 1; } +.anomalous-wfd-flow-role { display: flex; align-items: center; justify-content: space-between; gap: 6px; color: var(--amb-text-soft); font-size: 11.5px; } +.anomalous-wfd-flow-box.is-stage .anomalous-wfd-flow-role { color: var(--amb-text-main); font-size: 13.5px; font-weight: 600; } +.anomalous-wfd-flow-badge { padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--amb-warn) 22%, transparent); color: var(--amb-text-main); font-size: 10.5px; font-weight: 500; } +.anomalous-wfd-flow-model { overflow: hidden; font-size: 12.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.anomalous-wfd-flow-box.is-off.is-stage .anomalous-wfd-flow-model { color: var(--amb-text-dim); text-decoration: line-through; } +.anomalous-wfd-flow-facts { color: var(--amb-text-soft); font-size: 12px; } +.anomalous-wfd-flow-save { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: 1px solid var(--amb-border-strong); border-radius: 10px; background: var(--amb-bg-card); } +.anomalous-wfd-flow-inspect { display: flex; flex-direction: column; gap: 8px; padding-top: 10px; border-top: 1px dashed var(--amb-border); } +.anomalous-wfd-flow-inspect > p { margin: 0; font-size: 12px; } +.anomalous-wfd-flow-inspect-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 8px; } +.anomalous-wfd-flow-inspect-title { color: var(--amb-text-main); font-size: 14px; } +.anomalous-wfd-flow-inspect-file { color: var(--amb-text-soft); font-size: 12px; word-break: break-all; } +.anomalous-wfd-flow-state { padding: 2px 10px; border: 1px solid var(--amb-border); border-radius: 999px; color: var(--amb-text-soft); font-size: 11.5px; white-space: nowrap; } +.anomalous-wfd-flow-state.is-on { border-color: color-mix(in srgb, var(--amb-ok) 45%, var(--amb-border)); background: color-mix(in srgb, var(--amb-ok) 12%, transparent); color: var(--amb-text-main); } +.anomalous-wfd-flow-links { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--amb-text-soft); font-size: 12px; } +.anomalous-wfd-flow-link { padding: 2px 10px; border: 1px solid color-mix(in srgb, var(--amb-link) 45%, var(--amb-border)); border-radius: 999px; background: transparent; color: var(--amb-text-main); font: inherit; cursor: pointer; } +.anomalous-wfd-flow-link:hover { border-color: var(--amb-link); } +.anomalous-wfd-flow-values { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 6px; } +.anomalous-wfd-flow-value { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 6px 8px; border: 1px solid var(--amb-border); border-radius: 8px; background: var(--amb-bg-card); } +.anomalous-wfd-flow-value > span { color: var(--amb-text-soft); font-size: 11px; } +.anomalous-wfd-flow-value > strong { color: var(--amb-text-main); font-size: 12px; font-weight: 600; word-break: break-all; } +.anomalous-wfd-flow-value.is-changed { border-color: var(--amb-warn); background: color-mix(in srgb, var(--amb-warn) 10%, var(--amb-bg-card)); } +.anomalous-wfd-flow-was { color: var(--amb-text-dim); font-weight: 400; text-decoration: line-through; }