Skip to content

fix(layout): charge multiline widgets their measured height, not a widget row - #887

Merged
christian-byrne merged 2 commits into
mainfrom
fix/layout-multiline-widget-height
Sep 19, 2026
Merged

christian-byrne merged 2 commits into
mainfrom
fix/layout-multiline-widget-height

Conversation

@christian-byrne

Copy link
Copy Markdown
Contributor

Stacked on #886. Closes the second half of the under-measure that ComfyUI_frontend #17901 reproduces.

What was wrong

A multiline text box is a text area, not a widget row. estimate_size charged every widget the same 24px, so a CLIPTextEncode came out 142px shorter than it draws — the dominant term in the overlap the browser harness catches.

The number is measured, not guessed

That distinction is the whole point of this change. I created twelve core node classes in a real browser, let the real renderer size them, and fitted their heights to:

H = 6 + 20 * max(link_inputs, outputs) + (Σ widget heights + 8)

All twelve fit exactly with an ordinary widget at 24 and a multiline one at 166:

class rendered predicted
CLIPTextEncode 200 200 1 multiline
KSampler 262 262 7 ordinary, 4 links
EmptyLatentImage 106 106
CheckpointLoaderSimple 98 98
SaveImage 58 58
LoadImage 102 102
VAEDecode 46 46 0 widgets
PreviewImage 26 26 0 widgets
ConditioningCombine 46 46 0 widgets
LatentUpscale 130 130
CLIPSetLastLayer 58 58
ImageScale 130 130

The fixture and the fit live in the test file, so the model is re-checkable without a browser.

Two things the measurement settled beyond the headline

It independently confirms #886. The 24 and the 8 there were derived by reading computeSize; this arrives at the same two values from rendered pixels, by a completely different route.

The fit needs true link inputs. KSampler has seven widgets but only six widget-backed inputs, because control_after_generate is a widget with no input at all. Deriving links as len(inputs) - len(widgets) is therefore off by a row — and an earlier fit that did exactly that matched 11 of 12 and looked close enough to accept. Worth knowing before anyone re-derives this.

Implementation

object_info already marks multiline inputs and the catalog parses it into PortOptions.multiline, so no new input is needed. Matching is by name, because widget_order is the render order rather than the declaration order.

The attribute guard covers options itself rather than the field on it. A port carrying no options is not hypothetical — every test double is one, and so is any catalog entry from an object_info that omitted the block; guarding only the inner field raises AttributeError before the default can apply. That was a real crash I introduced and caught here.

Additive: estimate_size keeps its signature and n_multiline defaults to 0, so every existing caller is unchanged.

Tests

17 new, including one parametrized case per measured class asserting the widget term reproduces rendered geometry. All verified red on the parent. Full layout suite: 103 passing.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: ef387d21-9234-48e9-87eb-881c5b2595b9

📥 Commits

Reviewing files that changed from the base of the PR and between 159c229 and e1da0ca.

📒 Files selected for processing (4)
  • comfy_cli/layout.py
  • comfy_cli/workflow_ops.py
  • tests/comfy_cli/test_layout.py
  • tests/comfy_cli/test_persisted_node_size.py

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Multiline widgets now contribute renderer-sized height estimates. Counts match widget names, tolerate missing port options, and are clamped to widget totals. New-node planning and persisted node creation use the same multiline sizing terms.

Changes

Multiline widget sizing

Layer / File(s) Summary
Widget height model
comfy_cli/layout.py, tests/comfy_cli/test_layout.py
count_multiline detects matching widgets. _widgets_height applies multiline heights, clamps counts, and preserves default sizing when no count is provided. Regression tests cover these cases.
Persisted size integration
comfy_cli/layout.py, comfy_cli/workflow_ops.py, tests/comfy_cli/test_persisted_node_size.py
assign_positions and add_node pass multiline counts into size estimation. Persisted-size tests verify multiline and non-multiline node dimensions.

Sequence Diagram(s)

sequenceDiagram
  participant CatalogMetadata
  participant assign_positions
  participant layout
  participant add_node
  participant PersistedWorkflow
  CatalogMetadata->>assign_positions: provide widget names and port metadata
  assign_positions->>layout: count multiline widgets
  layout-->>assign_positions: return estimated node size
  add_node->>layout: count multiline widgets
  layout-->>add_node: return persisted node size
  add_node->>PersistedWorkflow: save node dimensions
Loading

Priority: ⬇️ Low

Unblocks: 1 PR

Merge Risk: ⚪ Minimal · up to e1da0

Multiline widget heights are now included consistently in planned and saved node sizes, with no current PR-introduced issue remaining.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@christian-byrne

Copy link
Copy Markdown
Contributor Author

release-blocking rather than a follow-up, and I have numbers now rather than an argument.

I scored three realistic agent-built graphs by taking the positions the placer actually chooses and measuring them against the rendered geometry (the 12-class fit: base 6, slot 20, ordinary widget 24, multiline 166, block pad 8, title 30) instead of against the CLI's own model. Scoring against the model is what hides this — the model believes it left a gap.

fixture main today (#882+#883 merged) with this PR
txt2img, 7 nodes 19,492 px² 0
4 prompts + combines 58,477 px² 0
hires-fix, 9 nodes 19,492 px² 0

On main the two CLIPTextEncode nodes in a plain txt2img overlap by 250x78 px. That is the original report, still present after both merged PRs, because every one of those graphs has a multiline prompt node and multiline is the term that was wrong.

The reason #882 and #883 did not close it: #882 fixed the width model and the title band, #883 fixed ordering and collision between new nodes. Both are correct and both were necessary. Neither changes the node's modelled height, and the height error is 142px per prompt node — larger than the vertical gap the placer leaves.

browser_tests/tests/agent/agentLayoutQuality.spec.ts in ComfyUI_frontend#17901 is the independent check: it replays a recorded batched turn and reads boundingBox() off the rendered nodes, and its overlap case is marked test.fail() today for exactly this defect. When this lands and the fixtures are re-recorded, that marker flips and stops being expected-to-fail.

@christian-byrne
christian-byrne force-pushed the fix/layout-widget-block-padding branch from e7cf4e5 to 298fdae Compare September 17, 2026 22:48
@christian-byrne
christian-byrne force-pushed the fix/layout-multiline-widget-height branch from c8ce3bb to 773acde Compare September 17, 2026 22:48
@christian-byrne

Copy link
Copy Markdown
Contributor Author

correcting my own numbers above, and the direction of the correction matters.

that table used measured node heights but the CLI's estimated widths. the widths are wrong too. measured in the same browser pass, all 8 classes:

class CLI estimate rendered delta
CLIPTextEncode 250 400 -150
KSampler 289 270 +19
EmptyLatentImage 210 270 -60
CheckpointLoaderSimple 210 270 -60
SaveImage 230 270 -40
VAEDecode 146 140 +6
ConditioningCombine 263 215 +48
LatentUpscale 222 270 -48

the pattern is clean: every widget-bearing node renders at exactly 270, multiline at 400, and nodes with no widgets are content-derived. the CLI derives width from label text for all of them, which is the wrong model rather than a mistuned constant.

re-scored with measured widths and heights:

fixture main with #886+#887
txt2img, 7 nodes 47,463 px² 16,123 px²
4 prompts + combines 104,395 px² 0

so this PR is necessary and large, but not sufficient on txt2img. the residual is pos/ks 70x230: CLIPTextEncode renders 400 wide, the CLI models 250, so KSampler gets placed 330px right of it and the real node reaches 400.

nothing about this PR changes — the multiline height number is measured and the fix is right. flagging it because my earlier "all three go to zero" was wrong, and because the width error is the same root cause (multiline widgets modelled as ordinary rows) showing up in the other axis.

caveat on the width rule: the 270 floor is 5 independent classes and solid; the 400 is one class. worth more samples before encoding it. happy to open that as a third PR if wanted.

@christian-byrne

Copy link
Copy Markdown
Contributor Author

exact-head Fallow audit for 773acde3cf817bf7c8610397ba0f00277f218805: this repository and the pinned base 298fdae7821b2aa232f54820011b1131af5eef75 contain no Fallow workflow/configuration, and the PR changes only Python source/tests, so Fallow and a Fallow-triggering rebase are not applicable.

uv run pytest -q tests/comfy_cli/test_layout.py passes 41/41; uv run ruff check ., uv run ruff format --check ., and git diff --check pass. The ambient pytest attempt was invalid because it lacked typer; rerunning through the locked uv environment resolved that setup failure.

A repository-wide uv run pytest -q was stopped after 47% once unrelated existing suites had accumulated failures in run/preflight fixtures and Rich wrapping assertions; the changed layout suite remained green. No product edit or push was made by this audit.

@comfy-greenlight-bot

comfy-greenlight-bot commented Sep 18, 2026

Copy link
Copy Markdown

Swarmhost agentic review

The detailed evaluation is available to employees in the internal Slack review thread.

Updated by Swarmhost's agentic review process.

@skishore23 skishore23 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The multiline term only reaches the planner. The size that ends up saved on the node is still the old estimate.

assign_positions passes n_multiline, but workflow_ops.add_node calls estimate_size a second time without it (around workflow_ops.py:551) and writes that result into the node's size. apply_specs goes through the same add_node, so batched builds hit this too. The next call's collision check reads that saved size.

Repro at the #888 head: two CLIPTextEncode in one apply_specs, then one more in a second call.

planner in the batch:  a at y=60, b at y=366   (correct, uses 400x236)
saved node size:       [270, 94]               (no multiline term)
next call places c at: x=390                   (a really spans 40..440, so ~50px overlap)

Passing it in add_node fixes it. The saved size becomes [400, 236] and c lands at x=520:

    size = layout.estimate_size(
        len([p for p in m.inputs if p.is_link]),
        len(m.outputs),
        len(_widget_names),
        n_multiline=sum(
            1 for p in m.inputs
            if getattr(getattr(p, "options", None), "multiline", False) and p.name in _widget_names
        ),
        ...

Better still, move the count into one helper in layout.py that both callers use, and add a test that checks the saved size after apply_specs. The current tests only exercise assign_positions, which is why this got through.

Nit: test_multiline_count_cannot_exceed_widget_count accepts _widgets_height(1, 5) charging five multiline areas to a one-widget node. Clamping n_multiline to n_widgets seems more honest than codifying that.

@christian-byrne

Copy link
Copy Markdown
Contributor Author

you were right, and the bug was worse than a missed argument.

add_node calls estimate_size a second time and writes that into the node's size, and that saved size is what every later collision check reads. So the planner was correct and the persisted state was not — a batch places cleanly, then the next call measures against a record of the first that is 142px too short. Fixed, and both callers now go through one layout.count_multiline helper as you suggested; two call sites each computing it their own way is what let them diverge.

Took the nit too. _widgets_height clamps n_multiline to n_widgets, and the old test that asserted a one-widget node could be charged five multiline areas is gone — codifying a 700px over-measure as intended behaviour was the wrong call.

New tests/comfy_cli/test_persisted_node_size.py asserts the saved artefact rather than the planner's return value, which is the gap you identified: every existing layout test exercised assign_positions. Two of them verified red with the add_node argument removed.

One thing I did not do. Your two-batches-then-one-more overlap repro does not go red on this branch — the height is fixed here but width is still text-derived (249.9 for CLIPTextEncode), so the third node lands at x=369.9 and clears. It needs the 400px width floor, which is why you saw it at the #888 head. I moved that test there rather than assert it here, where it would have passed for the wrong reason. Caught only because I checked it failed without the fix.

christian-byrne added a commit that referenced this pull request Sep 18, 2026
…ender

The width model under-estimates, and under-estimating width is what produces the
overlap users report: the placer puts the next column a node-width away and the
real node reaches past it.

LiteGraph's own formula is NODE_WIDTH * (1.5 if widgets else 1.0) = 210, which no
widget-bearing node actually renders at. Measured across 27 classes in a real
browser: every node carrying a widget renders at 270 or wider, every node
carrying a MULTILINE widget at 400 or wider. Fourteen of fourteen non-multiline
widget classes land at exactly 270 when their content is narrower, and seven of
seven multiline classes at exactly 400 -- the shape of a floor, not a fixed
width. KSamplerAdvanced 312, ControlNetApply 317.9 and CheckpointLoader 396.9
exceed it on content, which is what confirms it.

Before this, on the eight classes in the release fixtures, five were
under-estimated: CLIPTextEncode by 150, EmptyLatentImage and
CheckpointLoaderSimple by 60, LatentUpscale by 48, SaveImage by 40. After, zero
are. The three over-estimates (KSampler +19, ConditioningCombine +48, VAEDecode
+6) are left alone: over-spacing wastes canvas, which nobody has filed.

Effect on the release fixtures, scoring placer output against measured width AND
height: txt2img 16,123 -> 0 px^2, four-prompts 0 -> 0. #886 and #887 fixed the
height term; this is the width term of the same defect, and txt2img needs all
three.

Known over-estimate, accepted: Note and MarkdownNote carry a multiline widget but
render at 140 because they are annotation nodes with no slots. They get floored
to 400 and will be over-spaced. Left deliberately -- the direction argument above
applies, and special-casing them on "no inputs and no outputs" is a guess I have
not measured.

17 parametrized cases assert the floor never exceeds what a class renders, plus
four behavioural tests. All verified red on the parent.
@christian-byrne

Copy link
Copy Markdown
Contributor Author

exact-head Fallow recheck for c7872982c4a5b2586b26f9520cf99113f94212bb: the pinned stacked base remains 298fdae7821b2aa232f54820011b1131af5eef75, which contains no Fallow workflow/configuration. The changed surface is Python source and tests only, so Fallow and a Fallow-triggering rebase remain not applicable.

uv run --extra dev pytest tests/comfy_cli/test_layout.py tests/comfy_cli/test_persisted_node_size.py passes 46/46; repository-wide uv run --extra dev ruff check ., uv run --extra dev ruff format --check ., and exact-range git diff --check pass. A repository-wide 7,654-test Pytest run was bounded to two minutes and reached 15% with no observed failure. No product edit, rebase, or push was needed.

@christian-byrne
christian-byrne changed the base branch from fix/layout-widget-block-padding to main September 18, 2026 20:21
@coderabbitai
coderabbitai Bot requested a review from skishore23 September 18, 2026 20:22
@christian-byrne
christian-byrne force-pushed the fix/layout-multiline-widget-height branch from c787298 to e1da0ca Compare September 18, 2026 20:22
christian-byrne added a commit that referenced this pull request Sep 18, 2026
…ender

The width model under-estimates, and under-estimating width is what produces the
overlap users report: the placer puts the next column a node-width away and the
real node reaches past it.

LiteGraph's own formula is NODE_WIDTH * (1.5 if widgets else 1.0) = 210, which no
widget-bearing node actually renders at. Measured across 27 classes in a real
browser: every node carrying a widget renders at 270 or wider, every node
carrying a MULTILINE widget at 400 or wider. Fourteen of fourteen non-multiline
widget classes land at exactly 270 when their content is narrower, and seven of
seven multiline classes at exactly 400 -- the shape of a floor, not a fixed
width. KSamplerAdvanced 312, ControlNetApply 317.9 and CheckpointLoader 396.9
exceed it on content, which is what confirms it.

Before this, on the eight classes in the release fixtures, five were
under-estimated: CLIPTextEncode by 150, EmptyLatentImage and
CheckpointLoaderSimple by 60, LatentUpscale by 48, SaveImage by 40. After, zero
are. The three over-estimates (KSampler +19, ConditioningCombine +48, VAEDecode
+6) are left alone: over-spacing wastes canvas, which nobody has filed.

Effect on the release fixtures, scoring placer output against measured width AND
height: txt2img 16,123 -> 0 px^2, four-prompts 0 -> 0. #886 and #887 fixed the
height term; this is the width term of the same defect, and txt2img needs all
three.

Known over-estimate, accepted: Note and MarkdownNote carry a multiline widget but
render at 140 because they are annotation nodes with no slots. They get floored
to 400 and will be over-spaced. Left deliberately -- the direction argument above
applies, and special-casing them on "no inputs and no outputs" is a guess I have
not measured.

17 parametrized cases assert the floor never exceeds what a class renders, plus
four behavioural tests. All verified red on the parent.
…dget row

A multiline text box is a text AREA, not a widget ROW, and the difference is
most of a node. estimate_size charged every widget the same 24px, which
under-measures a CLIPTextEncode by 142px -- the dominant term in the overlap
the browser harness reproduces on the batched recording.

The number is measured, not guessed. Twelve core node classes were created in a
real browser, sized by the real renderer, and their heights fitted to

    H = 6 + 20 * max(link_inputs, outputs) + (widget heights + 8)

That fits ALL TWELVE exactly with an ordinary widget at 24 and a multiline one
at 166. The fixture and the fit are in the test file, so the model can be
re-checked without a browser.

Two things the measurement settled beyond the headline:

  - It independently confirms the 24 and the 8 from the per-row-gap fix. Those
    were derived by reading computeSize; this arrives at the same values from
    rendered pixels.
  - The fit needs TRUE link inputs. KSampler has seven widgets but only six
    widget-backed inputs, because control_after_generate is a widget with no
    input at all, so deriving links as len(inputs) - len(widgets) is off by a
    row. An earlier fit that did exactly that matched 11 of 12 and looked close
    enough to accept.

object_info already marks multiline inputs and the catalog parses it into
PortOptions.multiline, so no new input is needed. Matching is by name because
widget_order is the render order, not the declaration order.

The attribute guard covers `options` itself rather than the field on it: a port
carrying no options is not hypothetical -- every test double is one, and so is
any catalog entry from an object_info that omitted the block. Guarding only the
inner field raises AttributeError before the default can apply.

Additive: estimate_size keeps its signature and n_multiline defaults to 0, so
every existing caller is unchanged. 17 tests, all verified red on the parent.
… the node

Review was right, and the bug was worse than a missed argument. add_node calls
estimate_size a SECOND time and writes that result into the node's `size`. That
saved size is what every later collision check reads, so the planner was correct
and the persisted state was not: a batch placed cleanly, then the next call
placed a node against a record of the first that was 142px too short.

Both callers now derive the count through one helper, layout.count_multiline,
which was the reviewer's preferred shape. Having two call sites each compute it
their own way is what allowed them to disagree in the first place.

Also takes the nit: _widgets_height clamps n_multiline to n_widgets. The old
test asserted that a one-widget node could be charged five multiline areas,
codifying a 700px over-measure as intended. It only arises from a bad catalog or
a caller bug and silently over-measuring hides both.

New tests assert the SAVED artefact rather than the planner's return value --
every existing layout test exercised assign_positions, which is exactly why this
got through. Two are verified red with the add_node argument removed.

NOT included: the reviewer's two-batches-then-one-more overlap repro. On this
branch the height is fixed but width is still text-derived (249.9 for
CLIPTextEncode), so the third node lands clear and the case cannot go red -- he
reproduced it at the #888 head, where the 400px width floor makes it overlap.
It goes there. Asserting it here would have passed for the wrong reason, which
I only caught by checking that it failed without the fix.
@christian-byrne
christian-byrne force-pushed the fix/layout-multiline-widget-height branch from e1da0ca to ca451e7 Compare September 19, 2026 19:42
@christian-byrne
christian-byrne dismissed skishore23’s stale review September 19, 2026 20:05

Dismissing as addressed, not disagreed with. The finding was correct and is fixed in 2492267/ca451e7: the multiline count now lives in one helper, layout.count_multiline, and workflow_ops.add_node passes it into estimate_size at line 560, so the size written onto the node matches the one the planner used. tests/comfy_cli/test_persisted_node_size.py asserts the saved size after apply_specs rather than the planner's return, which is the gap you identified. The nit is in too: _widgets_height clamps n_multiline to n_widgets. Merging for the release gate; happy to follow up in a new PR if any of it reads differently to you.

@christian-byrne
christian-byrne merged commit f59032e into main Sep 19, 2026
17 of 18 checks passed
@christian-byrne
christian-byrne deleted the fix/layout-multiline-widget-height branch September 19, 2026 20:05
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants