Compare commits

..

22 Commits

Author SHA1 Message Date
Dante
7bc4e5ce7f Merge branch 'main' into test/fe-741-grid-thumbnail-e2e 2026-06-06 07:53:40 +09:00
Willie
b907423526 feat: add Arrange action to multi-select toolbox (#12068)
## Summary

Adds an Arrange popover to the multi-select toolbox that repositions
selected nodes into vertical, horizontal, or grid layouts, with a
follow-up slider to tune the spacing.



https://github.com/user-attachments/assets/f9a55ef9-2619-462b-a83f-2b86eb076fe3


## Changes

- **What**: New `ArrangeButton` placed between the color picker and
frame icons. Three layouts (vertical, horizontal, grid) sort selected
nodes by current position and lay them out from the smallest-`x+y`
anchor with a 12-unit gap. Visual bounds account for
`LiteGraph.NODE_TITLE_HEIGHT` so titles don't overlap stacked bodies.
After picking a layout, the popover swaps to a 0–48 spacing slider —
drag previews live (rAF-throttled, no undo capture), release commits one
undo entry. Closing the popover ends the session.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

- `useArrangeNodes.computeArrangement` — pure layout math separated from
side effects; covered by 8 unit tests including the title-height
handling and `TitleMode.NO_TITLE` case.
- `useArrangeSession` — owns the slider's state machine. rAF-throttled
`previewGap` collapses rapid drag events into one frame; `commitGap`
cancels any pending preview before capturing undo. Covered by 5 unit
tests.
- Position mutations go through `useLayoutMutations().batchMoveNodes`
(single batched layout-store transaction) followed by
`changeTracker.captureCanvasState()` for undo — same pattern as drag and
other selection-toolbox actions.
- Anchor selection uses smallest `pos.x + pos.y` rather than min-x or
min-y alone, to keep the layout origin stable across re-runs (slider
drags).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12068-feat-add-Arrange-action-to-multi-select-toolbox-3596d73d365081e58c25ffac41dc0b2a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-05 21:56:54 +00:00
AustinMroz
7d99189211 Ensure dropdowns display over selection toolbox (#12513)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/a7fc3432-3db3-40a5-b28e-11a309db76ce"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/a6a702cd-2ba3-4900-8afa-227cd9d61492"
/>|

Dropdowns were appended to self, but the selectionToolbox isappended to
body. As a result, changes to z-index on the dropdown would not allow it
to display above the selectionToolbox. Since dropdowns have been
migrated to reka-ui, dropdowns can now be safely appended to body as
well. Doing so cleans up a lot of no-longer-needed code. Of note
WidgetSelectDropdown seemed to never actually bind the `appendTo` and is
unaffected by the removal

As a secondary consequence of this change, dropdowns will no longer
scale with the current zoom level of the graph. Since litegraph would
not scale the size of popovers, this had been reported as a regression
by some users.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/0cb200c2-0811-4023-9ff1-aaa61113cbd5"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c82c087f-1f25-49c5-a85d-d9502b438526"
/>|
2026-06-05 21:49:03 +00:00
imick-io
5ddf5faef3 feat: scaffold Learning page with Featured Workflow, Tutorials, and CTA sections (#12602)
## Summary

Adds a new Learning page to the website with a hero, featured workflow
showcase, tutorials section, and CTA, wired into site nav and footer
Resources.

## Changes

- **What**:
- New `/learning` page (Astro) with `HeroSection`,
`FeaturedWorkflowSection`, `TutorialsSection`, and `CallToActionSection`
  - Localized for `zh-CN` at `/zh-CN/learning`
  - Featured workflow CTA links out to `comfy.org/workflows/<slug>`
- Added `nav.learning` translation; added Learning entry to `SiteNav`
and `SiteFooter` Resources
- New shared `PillButton`, `MaskRevealButton`, `Badge`, and
`VideoPlayer` work used by the page; `TutorialDetailDialog` for tutorial
deep-dives
  - Featured demo video updated; poster image added
  - `routes.ts`: added `learning` route entry
  - `EventsSection` temporarily hidden pending content

## Review Focus

- Copy on `learning.featured.description` (newly written, both `en` and
`zh-CN`)
- Tutorial data shape in `data/learningTutorials*.ts`
- Internal-vs-external link styling: Learning shows the active-page
yellow when viewing `/learning` (expected — internal route, no external
arrow)

## Screenshots (if applicable)

_Add deployment preview screenshots here._

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-05 18:11:14 +00:00
Terry Jia
4fe78997b0 fix(load3d): load Preview3DAdvanced output from temp/, allow temp loadFolder (#12661)
follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/12527
use temp folder for new Preview3DAdvanced node

BE https://github.com/Comfy-Org/ComfyUI/pull/14294
2026-06-05 10:41:45 -04:00
dante01yoon
7f707cfef5 test: verify on-node grid cells request thumbnail previews (FE-741) 2026-06-05 23:36:01 +09:00
Dante
cda2929572 fix: use thumbnail previews for on-node image grid cells (FE-741) (#12561)
## Summary

On-node image grids rendered **full-resolution** `/api/view` images into
small grid cells. `nodeOutputStore.buildImageUrls` only appends a
thumbnail flag via `app.getPreviewFormatParam()`, which returns `''`
whenever `Comfy.PreviewFormat` is empty (the default on a fresh
install). So each grid `<img>` received the original URL with no
`&preview=`, and the browser downloaded + decoded the full asset into a
~98px cell.

Fix: grid cells request a lightweight thumbnail URL via a new
`getGridThumbnailUrl` helper — server-resized on cloud (`res`, mirroring
`ResultItemImpl.previewUrl`) and re-encoded to a compact format on every
backend (`preview=webp;75`). Gallery / full-view URLs stay at full
resolution; a URL that already carries a `preview` (user-set
`Comfy.PreviewFormat`) and SVGs (the server cannot rasterize them into a
preview) are left untouched. Scoped to the Vue `ImagePreview.vue` path;
the legacy canvas preview is deprecated.

- Linear: [FE-741](https://linear.app/comfyorg/issue/FE-741)

## Before / After (CDP, localhost:5173 → OSS backend, default settings)

Reproduced by rendering an on-node grid of 3 input images on a
`SaveImage` node. Measured the `/api/view` request for
`02_tangled_code.png` (2560×1440) in one ~98px grid cell:

| | Grid cell `<img>` src | Transfer | Type |
|---|---|---|---|
| **Before** | `…&filename=02_tangled_code.png&subfolder=&rand=…` |
**3,222,654 B (3.07 MB)** | `image/png` |
| **After** |
`…&filename=02_tangled_code.png&subfolder=&rand=…&preview=webp;75` |
**119,098 B (116 KB)** | `image/webp` |

**~27× less bandwidth** per cell (-96.3%). On cloud the URL additionally
gets `res=512` for a true server-side downscale.

| Before | After |
|---|---|
| <img width="430" alt="fe-741-before"
src="https://github.com/user-attachments/assets/677b19aa-1b89-4a22-a98f-79c122f5b9d6"
/> | <img width="430" alt="fe-741-after"
src="https://github.com/user-attachments/assets/6b4b30b0-14bb-4f19-8407-01f8e5de9143"
/> |

On-node rendering is pixel-identical (OSS re-encodes rather than
downscales) — no visual regression; the win is the transferred
bytes/format shown above.

## Red-Green Verification

| Commit | CI | Result |
|--------|----|--------|
| [`test:`
`b1b410b5`](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740459200)
| 🔴 Red | grid `<img>` still used the full-res URL → test
fails |
| [`fix:`
`2025cbe7`](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740850069)
| 🟢 Green | grid `<img>` requests a thumbnail → test passes
|

Follow-up `4fff42ed` hardens SVG handling and adds `getGridThumbnailUrl`
unit tests (CI green).

## Test Plan

- [x] CI red on test-only commit
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740459200))
- [x] CI green on fix commit
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26740850069))
- [x] CDP before/after on a live on-node grid (network transfer 3.07 MB
→ 116 KB per cell)
- [x] Unit suites green: `ImagePreview` 31/31, `imageUtil` 10/10;
gallery/full-view URLs unchanged
2026-06-05 14:24:14 +00:00
jaeone94
82bea29dda fix: defer node auto-pan until drag starts (#12654)
## Summary

Fix a Vue node drag edge case where holding the partially off-screen
Advanced inputs button could continuously auto-pan the canvas even
though the pointer had not moved into an actual drag.

Linear:
[FE-938](https://linear.app/comfyorg/issue/FE-938/holding-partially-off-screen-advanced-inputs-causes-continuous)

## Changes

- **What**: Move Vue node auto-pan initialization from `startDrag()` to
`handleDrag()`, so auto-pan starts only after the pointer interaction
has become a real drag.
- **What**: Keep the existing auto-pan behavior during active drags by
creating the controller on the first `handleDrag()` call, updating its
pointer position on later drag frames, and preserving the existing
`onPan` position adjustments.
- **What**: Add unit coverage for the important drag lifecycle
invariants: no auto-pan on pointerdown/startDrag, auto-pan starts on
handleDrag, the same controller is reused across handleDrag calls, and
cleanup still stops auto-pan on endDrag.
- **What**: Add a Playwright regression that places the Advanced inputs
button partially beyond the visible canvas edge, holds the pointer down
without moving, and verifies the canvas offset stays stable.
- **What**: Add `data-testid="advanced-inputs-button"` to the Advanced
inputs footer button variants so the regression test does not depend on
translated button text.
- **Breaking**: None.
- **Dependencies**: None.

## Root Cause

`useNodeDrag.startDrag()` created and started `AutoPanController`
immediately on pointerdown. When the Advanced inputs button was partly
outside the canvas bounds, a stationary pointer near the visible canvas
edge was enough for auto-pan to begin, even before any drag movement
occurred.

The pointer interaction layer already distinguishes press/hold from real
dragging before calling `handleDrag()`. Deferring auto-pan to
`handleDrag()` aligns auto-pan startup with that drag threshold and
prevents a plain hold from panning the canvas.

## Review Focus

- Auto-pan should not start from `startDrag()`/pointerdown alone.
- Auto-pan should still start promptly once `handleDrag()` runs for an
actual drag.
- Repeated `handleDrag()` calls should reuse the existing controller
rather than recreate it.
- Existing `onPan` behavior should continue to update drag start
positions, selected node start positions, selected groups, and node
positions during active drags.
- The E2E intentionally asserts the canvas offset, not node bounds,
because the reported bug is unintended canvas auto-pan while the pointer
is stationary.

## Red-Green Verification

- `a00b5d2fb test: add failing advanced button hold pan regression`:
adds the Playwright regression and test id plumbing. This was verified
red against the pre-fix production code.
- `5c207ae28 fix: defer node auto-pan until drag starts`: adds the
production fix and unit coverage. The same regression is verified green
with the fix.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm test:unit`
- `pnpm test:unit
src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts`
- `PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm
test:browser:local
browser_tests/tests/vueNodes/interactions/node/move.spec.ts --grep
"should not pan while holding the Advanced button without dragging"`
- Pre-push hook: `pnpm knip --cache`

## Screenshots (Before / After)

Before 


https://github.com/user-attachments/assets/6080de2d-e2da-4b38-a1ed-1f1f88548c2d

After 


https://github.com/user-attachments/assets/f331271a-9ea1-41ec-92cb-974bc57be56b
2026-06-05 05:26:18 +00:00
jaeone94
d129f757c0 fix: keep connected advanced inputs visible (#12652)
## Summary

Keep connected advanced widget inputs visible on Vue-rendered nodes when
advanced inputs are collapsed.

This fixes Linear
[FE-924](https://linear.app/comfyorg/issue/FE-924/bug-connected-advanced-input-parameters-become-hidden-when-advanced),
where a user could connect a noodle to an advanced input, collapse
advanced inputs, and then lose visual access to the connected parameter
even though it was actively used by the workflow.

## Changes

- **What**: Treat a widget-backed input as visible when its slot is
linked, even if the widget is advanced and the node-level advanced
section is collapsed.
- **What**: Move Vue node widget rendering to use the processed
`widget.visible` value instead of reimplementing visibility in
`NodeWidgets.vue`.
- **What**: Keep the visibility decision as a single source of truth
during widget processing, including the existing deduplication path.
- **What**: Add unit coverage for the new linked-widget visibility
behavior and the precedence rule that explicit hidden state still wins.
- **What**: Add an E2E regression that connects a `PrimitiveFloat` to
the advanced `max_shift` input on `ModelSamplingFlux`, collapses
advanced inputs, and verifies the connected input remains visible while
an unconnected advanced input remains hidden.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The key behavior is that linked advanced widgets should be promoted into
the visible widget set only while they are connected. Explicitly hidden
widgets must remain hidden even when linked.

The fix uses existing slot metadata from `useGraphNodeManager`; no new
graph state is introduced. This keeps the change scoped to Vue node
widget processing and rendering.

## Red-Green Verification

| Commit | Purpose | Local result |
| --- | --- | --- |
| `4fa5932c6` | Adds the E2E regression only | Red: `max_shift` was not
found after collapsing advanced inputs |
| `e5d1ee06a` | Adds the production fix and focused unit coverage |
Green: targeted E2E passed |

## Test Plan

- `pnpm test:unit
src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:5175
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm test:browser -g
"should keep connected advanced widgets visible when advanced inputs are
hidden" browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts`
- `pnpm typecheck && pnpm typecheck:browser`

## Screenshots (Before / After)
Before 


https://github.com/user-attachments/assets/bf1e88f3-2983-4bef-9cef-48ffe6dbfd6d



After 


https://github.com/user-attachments/assets/4dee7766-0252-478f-9b1c-4b801fc20eb2
2026-06-05 05:26:01 +00:00
jaeone94
874b486640 fix: resolve missing resource error messages (#12646)
## Summary

Resolve missing resource error groups through the error catalog so
missing nodes, replaceable nodes, missing models, and missing media use
consistent panel and single-error overlay copy.

## Changes

- **What**: Adds missing-resource resolvers for `missing_node`,
`swap_nodes`, `missing_model`, and `missing_media` that provide
`displayMessage`, `toastTitle`, and `toastMessage` alongside the
existing group titles. The Errors tab now renders a group-level
`displayMessage` under non-execution group headers, which gives grouped
missing-resource cards the same explanatory message path used by
validation/runtime errors without adding per-row detail fields that
these grouped cards do not need.
- **What**: Moves missing node and swap node explanatory copy out of
card-local hardcoded text and into `errorCatalog.missingErrors.*` keys.
`MissingNodeCard` and `SwapNodesCard` now focus on rendering their
grouped rows and actions, while the shared error group header owns the
explanatory copy.
- **What**: Adds environment-aware copy for missing node packs and
missing models. Cloud messages explain unsupported resources and
replacement/import paths without suggesting local execution, while OSS
messages point users toward installing or downloading the missing
resources.
- **What**: Adds single-error overlay/toast copy for missing resources.
Missing media uses a concise input-focused title/message, missing models
distinguish Cloud unsupported models from OSS missing files, and missing
nodes/swap nodes use node-type-aware copy.
- **What**: Deduplicates missing node and swap node toast decisions by
distinct node type so repeated instances of the same missing/replaceable
node do not accidentally switch the single-error copy to plural copy.
- **What**: Preserves representative missing media candidate metadata so
missing media toast copy can use a human-readable node name such as
`Load Image is missing a required media file.`
- **What**: Removes unused missing-resource resolver fields such as
grouped `displayDetails`, grouped `displayItemLabel`, and the unused
`mediaTypes` source parameter after deciding those fields do not fit
grouped missing-resource cards.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- Missing resource groups are still grouped cards. This PR intentionally
gives them group-level display and toast copy, but does not split
missing resources into one error item per underlying candidate.
- Missing resource count semantics are intentionally not normalized
here. Error overlay totals, store counts, and grouped card counts still
follow the existing behavior; a follow-up PR can define those count
units separately.
- The Cloud/OSS message variants remain explicit in the resolver instead
of being abstracted into a generic variant helper. That keeps this PR
focused on the messaging behavior and avoids a broader resolver
refactor.
- Only `src/locales/en/main.json` is updated directly. Other locales
should be synced by the existing localization flow.

## Screenshots (if applicable)

<img width="668" height="245" alt="스크린샷 2026-06-05 오전 3 16 49"
src="https://github.com/user-attachments/assets/98b50ac3-67e1-438d-8c37-e06c7bf465ee"
/>
<img width="666" height="195" alt="스크린샷 2026-06-05 오전 3 16 58"
src="https://github.com/user-attachments/assets/92da95b1-03d6-4739-97e6-c573982bfec9"
/>
<img width="505" height="358" alt="스크린샷 2026-06-05 오전 3 17 27"
src="https://github.com/user-attachments/assets/4d0e1a6e-13b9-4097-9fb5-19fe0c5331dc"
/>
<img width="507" height="324" alt="스크린샷 2026-06-05 오전 3 17 44"
src="https://github.com/user-attachments/assets/054e42f8-0d0c-44b5-8a67-e467fc04f1fc"
/>


## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm test:unit src/platform/errorCatalog/errorMessageResolver.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts`
- `pnpm typecheck`
- push hook: `knip --cache`
2026-06-05 05:25:46 +00:00
Matt Miller
1eede54582 fix(cloud): render the OAuth consent view in the dark theme (#12655)
## Problem

On the Comfy Cloud login flow, the OAuth / MCP consent modal
(`/cloud/oauth/consent`) renders with broken colors: a light-gray card
on the dark onboarding background, with a washed-out, barely-legible
title, section headers, and permission labels. Reported via internal QA.

## Root cause

The cloud onboarding shell (`CloudTemplate`) paints a dark surface using
hardcoded brand tokens (`primary-comfy-ink` / `primary-comfy-canvas`)
but never applies `.dark-theme` to the DOM. That class — which flips the
design-system **semantic** tokens to their dark values — is only set by
`GraphView` / `BaseViewTemplate`, neither of which mounts on the cloud
onboarding routes.

`OAuthConsentView` is the first onboarding view whose primary surface
(the card) is built on semantic tokens, so without a dark-theme context
they fall back to their light values:

| token | fell back to (light) | should be (dark) |
| --- | --- | --- |
| `secondary-background` (card) | `#e9e9e9` | `#262729` |
| inherited foreground (title / headers / labels) |
`primary-comfy-canvas` `#c2bfb9` on `#e9e9e9` → illegible | `#c2bfb9` on
`#262729` → legible |
| `muted` (subtitle / help / card border) | `#71717a` | `#a1a1aa` |
| `primary-background` (scope checks + CTA) | azure-400 `#31b9f4` |
azure-600 `#0b8ce9` |

The card sets no foreground of its own, so its title/headers/labels
inherit `primary-comfy-canvas` from the shell — fine on a dark card,
illegible on the light fallback one. The component markup is already
correct; it just never receives a dark-theme context. (The sibling
login/signup pages avoid this because they use hardcoded `primary-comfy`
/ `brand-yellow` tokens, not semantic ones.)

## Fix

Scope `.dark-theme` to the consent view's own root `<main>` — the same
class mechanism `BaseViewTemplate` uses for its `dark` prop. The card's
semantic tokens now resolve dark and the text is legible, while the
sibling login / signup / survey screens are untouched.

This is intentionally scoped to the reported view. Theming the whole
cloud onboarding shell (so any future semantic-token component there is
covered automatically) is a reasonable follow-up, but it shifts several
other views and has a wider blast radius, so it's left out of this fix.

## Preview (real component)

CI auto-deploys a Storybook preview of this branch to Cloudflare Pages
(see the **🎨 Storybook** comment below). The `Cloud/OAuth/Consent` story
renders the populated card with no backend — and with this change it
themes dark correctly:

**▶ [Cloud/OAuth/Consent — live
story](https://fbae9a69.comfy-storybook.pages.dev/?path=/story/cloud-oauth-consent--single-workspace)**

And the fix on the **live app**, captured in a preview environment via a
real OAuth flow:

![OAuth consent screen — dark-theme
fix](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/mattmillerai/oauth-consent-screenshot/oauth-consent-fixed.png)

## Verification

The `Cloud/OAuth/Consent` Storybook story renders the real component;
switching the Storybook theme global from **light → dark** reproduces
this exact before/after. This change makes the live route match the dark
rendering. Existing `OAuthConsentView.test.ts` assertions are on
text/roles and are unaffected by the added class.

## ELI-5

The "allow access?" popup was wearing light-colored clothes inside a
dark room, so the words blended into the card and you couldn't read
them. This tells the popup it's in a dark room, so it dresses for the
dark and the text shows up again. Nothing else in the room changes.
2026-06-04 21:53:45 +00:00
Terry Jia
be131f7e9a feat(load3d): register Preview3DAdvanced extension (#12527)
## Summary
Preview3DAdvanced is a Preview3D variant that emits live viewport state
as outputs (model_file, camera_info, model_3d_info, width, height) plus
a width/height pair. Reuses the LOAD_3D capture widget for the viewport
but
skips upload/clear buttons (it has no model_file widget). 

Adds a camera-only + gizmo serializeValue path (no scene/mask/normal
image uploads since no image outputs are emitted) and an onExecuted that
loads the saved preview model via
Load3DConfiguration.configureForSaveMesh and applies the input
camera_info forwarded via PreviewUI3D when one was connected, so a chain
of Preview3DAdvanced nodes propagates camera state in a single run
instead of lagging one hop per execution.

Drops the per-node camera lock toggle (retainViewOnReload) across all 3D
nodes (Load3D, Preview3D, Preview3DAdvanced).
The viewport now always preserves the user's adjusted framing after the
first model load; the toggle was rarely needed and made chained
propagation ambiguous. Saved workflows with retainViewOnReload in their
cameraConfig continue
to load, the field is simply ignored at runtime.

camera_info / model_3d_info resolved by execute: the input when
connected, otherwise the viewport's own live state. Applying them
overrides the preserved viewport in the input case and is a no-op echo
when the viewport already matches. Only the first model_3d_info entry is
applied, single-object viewer currently.

BE: https://github.com/Comfy-Org/ComfyUI/pull/14175

## Screenshots (if applicable)


https://github.com/user-attachments/assets/42536469-e188-492a-9864-cdb6bfa76e97
2026-06-04 18:09:12 -04:00
pythongosssss
daf07a7442 fix: node library drag drop to add node appears in wrong place in firefox (#12419)
## Summary

Firefox can give invalid drag coordinates causing incorrect drop
position (https://bugzilla.mozilla.org/show_bug.cgi?id=1773886)
I was unable to consistently recreate this issue and it only happened in
Firefox, so not a candidate for e2e tests.

## Changes

- **What**: 
- store position on `dragover` which looks to reliably report the
correct position and use that value on drop

## Screenshots (if applicable)



https://github.com/user-attachments/assets/6ff604b7-92fb-4a70-bd9f-c37cdba292ba
2026-06-04 19:50:41 +00:00
Dante
454e124099 feat(graph): add mask icon to "Open in Mask Editor" context menu option (FE-929) (#12642)
## Summary
<img width="1600" height="882" alt="fe-mask-icon-after"
src="https://github.com/user-attachments/assets/7ddd6ae3-4c6a-4c4d-9a61-2059851bd4f9"
/>


Stacked on #12563 (FE-839). Follow-up requested in the FE-839 thread by
@AlexisRolland and @alextov: the media-node context menu gives every
image action an icon **except** `Open in Mask Editor`, leaving its label
misaligned with the rest of the group.

This reuses the existing custom `comfy--mask` icon — the same one shown
on the node image overlay / selection-toolbox `MaskEditorButton` — for
the `Open in Mask Editor` entry. With it, all five image actions (`Open
Image`, `Open in Mask Editor`, `Copy Image`, `Paste Image`, `Save
Image`) now carry an icon and their labels line up.

- Follow-up to FE-839
- Context (FE-839 Slack thread): Alexis — *"it would be nice to have an
icon for open in mask editor. There is one on the image overlay in the
node which could be reused"*; Alex Tov — icon name is `mask`, a custom
icon.

> The broader idea of reserving a placeholder/buffer for options that
have no icon so **all** menu labels align (Alex Tov) is intentionally
out of scope here — this PR only completes the image-action group.

## Implementation

- `useImageMenuOptions.ts`: add `icon: 'icon-[comfy--mask]'` to the
`Open in Mask Editor` option.

No rendering changes are needed — `NodeContextMenu.vue` already renders
the icon via `<i v-if="item.icon" :class="[item.icon, 'size-4']" />`,
and `comfy--mask` is already registered (custom collection loaded from
`packages/design-system/src/icons`, used today by `MaskEditorButton.vue`
and `linearMode/DropZone.vue`).

## Red-Green Verification (local)

| Step | Result |
|------|--------|
| Test-only commit (`d8b7e2c`) | 🔴 Red — `expected undefined to be
'icon-[comfy--mask]'`; image group not fully iconed |
| Fix commit (`feeb505`) | 🟢 Green — 10/10 passing |

## Before / After

Right-click a media node (Load / Preview / Save Image) → image-action
group at the top of the menu:

| Before (base `FE-839`) | After (this PR) |
| --- | --- |
| `Open in Mask Editor` has no icon — its label sits flush-left while
the other four image actions are indented past their icons. | `Open in
Mask Editor` shows the mask icon; all five image actions line up. |

<!-- drag fe-mask-icon-before.png and fe-mask-icon-after.png into the
two cells above -->

Captured locally against a Load Image node with `example.png` (Vue nodes
enabled), dev server proxied to a live backend.


https://comfy-organization.slack.com/archives/C0A7ADM4797/p1780467176353799?thread_ts=1779462362.995709&cid=C0A7ADM4797
## Test Plan

- [x] Local red on the test-only commit
- [x] Local green on the fix commit
- [x] `useImageMenuOptions.test.ts` — 2 new tests (mask icon present on
`Open in Mask Editor`; every image action carries an icon)
- [x] eslint clean on changed files
- [ ] Manual: right-click a media node and confirm the mask icon renders
next to `Open in Mask Editor`
2026-06-04 18:40:10 +00:00
Matt Miller
8a819fa2be refactor(assets): read content hash from the canonical hash field (#12638)
## Summary
The assets API exposes an asset's content hash as `hash`. An older
`asset_hash` field was a deprecated alias carrying the same value. This
PR moves the frontend fully onto `hash` and removes `asset_hash` from
the frontend entirely.

## Changes
- Read `asset.hash` (no `?? asset_hash` fallback) across the asset
consumers:
- `useMediaAssetActions` — widget-value variants + cloud-mode
stored-filename resolution
  - `assetsStore` — input-asset-by-filename map
  - `assetMetadataUtils.getAssetUrlFilename`
  - `missingMedia` resolver/scan and `missingModel` scan hash matching
  - `useComboWidget` / `useWidgetSelectItems`
- `assetPreviewUtil.findOutputAsset` now queries `/assets?hash=` instead
of the deprecated `?asset_hash=` param and matches on `a.hash`.
- Removed `asset_hash` from the zod asset schema and the local
`AssetRecord` type. Responses that still include the alias parse cleanly
— zod strips unknown keys — so the declared field protected nothing once
the reads were gone.
- Purged `asset_hash` from all test fixtures/mocks; tests key on the
canonical `hash`.

## Safety / rollout
The API currently emits **both** `hash` and `asset_hash` with identical
values, so reading `hash` is safe today. This is the frontend half of
retiring the alias; the backend stops emitting `asset_hash` only after
this ships and old bundles age out, so there is no window where the
field the UI reads is absent.

## Verification
- `pnpm typecheck`: clean.
- Affected unit tests pass (asset utils, store, media/model scans,
widget composables).
- `grep -rn asset_hash src/`: zero matches.
2026-06-04 18:18:12 +00:00
Deep Mehta
35157f1af0 feat(telemetry): capture desktop entry props in cloud build (#12647)
## Summary

When a visitor arrives at the cloud product with
`utm_source=comfy.desktop`, register `source_app` and
`desktop_device_id` as PostHog super-properties and persist them onto
the person on identify.

Backend-fired billing events (Stripe webhook →
`billing:subscription_created`) then inherit the desktop attribution via
person-on-events, closing the cross-session gap where browser-side utm
capture and backend webhook events live on different `distinct_id`s.

## Why

The desktop app appends
`?desktop_device_id=<id>&utm_source=comfy.desktop` to its cloud links.
`utm_source` is auto-captured by posthog-js as a session super-property,
but arbitrary query params like `desktop_device_id` are ignored. Without
this, cross-session Desktop→Cloud sub attribution silently shows zero.

Confirmed empirically: across 24h of recent billing events,
`person.$initial_utm_source = 'comfy.desktop'` matched 0 rows, because
users had visited the cloud surface before they ever launched desktop.
Person-property based join via this PR is the durable fix.

## Test plan

- [x] Unit tests for: no utm, wrong utm, utm with device id, utm without
device id, person.set after identify, no-op for non-desktop visitors
- [x] `pnpm vitest run PostHogTelemetryProvider.test.ts` — 28/28 pass
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [ ] Manual verification post-merge: visit cloud.comfy.org with
`?utm_source=comfy.desktop&desktop_device_id=test-abc`, log in, check
PostHog person profile gains `desktop_device_id`,
`last_seen_via_desktop`, `first_seen_via_desktop`, `source_app`
2026-06-04 18:17:58 +00:00
jaeone94
f9cbaf750f fix: simplify error overlay messaging (#12598)
## Summary

Simplifies the error overlay so it presents one clear title, one clear
message, and one stable details action instead of rendering a list of
per-error messages.

## Changes

- **What**: Extracts the error overlay view model into
`useErrorOverlayState`, adds focused unit coverage for the overlay copy
resolution rules, and updates the overlay E2E coverage to match the new
behavior.
- **Breaking**: None.
- **Dependencies**: None.

### Behavior changes

- The overlay body no longer renders a `<ul>` of individual error
messages. It now always renders a single paragraph message.
- Single-error overlays now prefer toast-specific copy when it exists.
For execution errors, the overlay resolves the message in this order:
`toastMessage`, `displayMessage`, raw `message`, group `displayMessage`,
then group `displayTitle`. The title resolves from `toastTitle`, then
`displayTitle`, then the group title.
- Single non-execution groups use group-level toast/display copy. This
lets grouped error types supply overlay-friendly copy without the
overlay needing to understand each card implementation.
- Multiple-error overlays now ignore individual error item copy in the
overlay itself. The header becomes the pluralized count title, for
example `7 errors found`, and the body becomes the fixed guidance
message: `Resolve them before running the workflow.`
- The overlay is hidden if the store reports an error count but no
resolved overlay message exists. This avoids rendering a visible shell
with an empty body.
- The action button no longer varies by error type in normal app mode.
Missing nodes, missing models, missing media, swap nodes, validation
errors, and runtime errors all use `View details` instead of labels like
`Show missing nodes`, `Show missing models`, `Show missing inputs`, or
`See Errors`.
- App mode keeps its existing `Show errors in graph` action label.
- The overlay width now keeps the previous width as its minimum and
allows a wider maximum, reducing avoidable wrapping in longer error
headers.
- The live region was softened from an assertive alert-style
announcement to `role="status"` with `aria-live="polite"` so updates
such as count changes are less disruptive.

### Tests

- Adds component coverage for the rendered overlay shape and app-mode
action label.
- Adds composable coverage for single execution errors, runtime errors,
grouped missing media errors, multiple-error aggregate copy, hidden
empty-message state, and display-copy fallback behavior.
- Updates `errorOverlay.spec.ts` so the E2E suite checks the new
single-message overlay, the stable `View details` action, and the fixed
multiple-error body guidance.
- Removes the old type-specific button-label E2E expectations because
that branch no longer exists in product behavior.

### Follow-up PR

A follow-up PR is stacked on top of this one:
`jaeone/fe-816-missing-resource-error-messaging`.

That follow-up will wire missing resource error resolvers into the copy
model consumed here. It covers missing node packs, missing models,
missing media, and swap-node groups, including the group-level
`toastTitle`, `toastMessage`, `displayMessage`, `displayDetails`, and
item label copy those cards need. This PR intentionally keeps the
overlay behavior separate so it can merge first without depending on the
missing-resource resolver copy.

## Review Focus

- Please check the single-error versus multiple-error overlay behavior,
especially the fallback order for execution error copy.
- Please check that the `View details` action is now intentionally
error-type agnostic in normal app mode while app mode keeps `Show errors
in graph`.
- Please check the empty-message guard and the requirement that a
single-error overlay only resolves a single group when the total error
count and group list agree.
- Please check the E2E reduction: the old type-specific action-label
assertions were removed because the UI branch they tested was removed.

## Screenshots (if applicable)

N/A
2026-06-04 06:28:14 +00:00
jaeone94
4bfb0c36be Fix Cloud media input defaults (#12562)
## Summary

Fix Cloud media loader widgets so `LoadImage`, `LoadVideo`, and
`LoadAudio` resolve their default values from Cloud input assets instead
of blindly accepting backend `object_info` combo options. When no
matching Cloud input asset exists, the widgets now start empty instead
of selecting a server-only value that immediately trips missing-input
detection.

## Changes

- **What**: Cloud media input widgets now derive their available values
from `assetsStore.inputAssets`, filtered to the node's media type and to
assets with a valid `asset_hash`.
- **What**: Cloud media defaults now prefer an explicit default only
when it matches an available Cloud input asset hash or name, then fall
back to the first matching Cloud input asset, and otherwise use an empty
value.
- **What**: The media path keeps the existing model-widget
implementation style by resolving Cloud asset state through the store
internally, while preserving media-specific hash/name matching because
media widgets submit Cloud `asset_hash` values.
- **What**: Added regression coverage for adding empty Cloud
`LoadImage`, `LoadVideo`, and `LoadAudio` nodes when backend
`object_info` advertises server-only media options.
- **What**: Expanded media widget unit coverage for image/video/audio
inputs, empty defaults, first-asset fallback, default-by-hash matching,
default-by-name matching, hashless asset filtering, unrelated media
filtering, dynamic values, option labels, lazy input loading, and
`control_after_generate` wiring.
- **What**: Kept existing OSS and Cloud runtime missing-media E2E
coverage, with Cloud fixtures consistently using the local setup backend
for `object_info` so tests do not depend on live Cloud backend startup
details.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- Cloud media loader defaults should no longer be sourced from backend
file lists unless the value also corresponds to a Cloud input asset.
- Empty Cloud input-asset libraries should produce empty media widget
values, not missing-input errors at node creation time.
- Model asset-browser behavior is intentionally unchanged. The media
path mirrors the store-access style, but media defaults still resolve to
asset hashes because those are the values submitted by Cloud media
widgets.
- The Cloud E2E fixture stubs bootstrap endpoints and routes
`object_info` through the local setup backend. This keeps the test
focused on frontend behavior while still using realistic node
definitions.

## Testing

- `pnpm exec oxfmt
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm exec vitest run
src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts`
- `PLAYWRIGHT_TEST_URL=http://127.0.0.1:5174
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=chromium --workers=1`
- `PLAYWRIGHT_TEST_URL=http://127.0.0.1:5175
PLAYWRIGHT_SETUP_API_URL=http://127.0.0.1:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --workers=1`
- `git diff --check`
- `pnpm knip`
- `.claude/skills/reviewing-unit-tests/SKILL.md` red-flag review

## Screenshots
Before


https://github.com/user-attachments/assets/5df04036-d15c-4f94-bdcd-df8b26a29329

After 


https://github.com/user-attachments/assets/abe7caf5-a83b-4960-aa6f-65a377424a85
2026-06-04 06:27:42 +00:00
Dante
b996ed3ab5 fix: surface image actions at top of node context menu (FE-839) (#12563)
## Summary

On media nodes (Load/Preview/Save Image), the right-click context menu
buried the image actions at the very bottom — and `Open Image` sat
*second* within that group, behind `Open in Mask Editor`. In legacy
LiteGraph (Node 1.x) `Open Image` was the first menu item
(`options.unshift`), so the Vue menu was a regression in muscle memory.

This hoists the image-action group (`Open Image`, `Open in Mask Editor`,
`Copy Image`, `Paste Image`, `Save Image`) to a new top section of the
menu, with `Open Image` first. Because these labels only ever appear for
media nodes, non-media node menus are unchanged (keeps the core menu
order stable — per Alex Tov's request in the FE-839 thread).

- FE-839
- Related: DES-368 (lightbox idea this replaces)

## Implementation

- `contextMenuConverter.ts`: move the image group to the front of
`MENU_ORDER` (new "Section 0"), shift `getSectionNumber` boundaries, and
change the section-divider sentinel `lastSection` from `0` to `-1`
(Section 0 is now a real section, so the old `> 0` guard would have
dropped the divider before `Rename`).
- `useImageMenuOptions.ts`: push `Open Image` before `Open in Mask
Editor` for source/display consistency.

## Red-Green Verification

| Commit | Unit CI | Result |
|--------|---------|--------|
| `test: add failing tests …` | [run
26749384308](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26749384308)
| Red — fails on `expected 'Rename' to be 'Open Image'` |
| `fix: surface image actions …` | [run
26749817184](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26749817184)
| Green — passing |

## Before / After

Right-click on a media node (Load Image with `example.png`):

- **Before:** `Open Image` buried near the bottom, second within its
group.
- **After:** image actions group surfaced at the top, `Open Image`
first; core order (Rename/Copy/…) unchanged below the divider.

| Before (`main`) | After (this PR) |
| --- | --- |
| <img width="460" alt="Before: image actions buried at the bottom of
the menu, Open in Mask Editor above Open Image"
src="https://github.com/user-attachments/assets/2d0a6345-6cbb-4391-8cee-60632a6af72f"
/> | <img width="460" alt="After: image actions hoisted to the top of
the menu, Open Image first"
src="https://github.com/user-attachments/assets/6af0840f-0623-41f2-9fa1-b4a16798f9a5"
/> |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit regression in `contextMenuConverter.test.ts` (6 tests: Open
Image first / image group above Rename / divider between /
Copy<Paste<Save / core order preserved / non-media unchanged)
- [x] Manual verification in live app (before/after)
2026-06-04 02:24:47 +00:00
dante01yoon
4fff42edb7 fix: skip thumbnail preview param for SVG on-node grid cells (FE-741) 2026-06-01 16:31:59 +09:00
dante01yoon
2025cbe78a fix: use thumbnail previews for on-node image grid cells (FE-741) 2026-06-01 16:18:10 +09:00
dante01yoon
b1b410b5fb test: add failing test for on-node grid thumbnail preview (FE-741) 2026-06-01 16:07:15 +09:00
137 changed files with 6040 additions and 713 deletions

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { BadgeVariants } from './badge.variants'
import { badgeVariants } from './badge.variants'
const { variant, class: className } = defineProps<{
variant?: BadgeVariants['variant']
class?: string
}>()
</script>
<template>
<span :class="cn(badgeVariants({ variant }), className)">
<slot />
</span>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
const {
locale = 'en',
headingKey,
primaryLabelKey,
primaryHref,
secondaryLabelKey,
secondaryHref
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
primaryLabelKey: TranslationKey
primaryHref?: string
secondaryLabelKey?: TranslationKey
secondaryHref?: string
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<BrandButton
:href="primaryHref"
variant="solid"
size="xs"
class="uppercase"
>
{{ t(primaryLabelKey, locale) }}
</BrandButton>
<BrandButton
v-if="secondaryLabelKey"
:href="secondaryHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(secondaryLabelKey, locale) }}
</BrandButton>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
descriptionKey,
notifyLabelKey,
notifyHref,
events
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
descriptionKey: TranslationKey
notifyLabelKey: TranslationKey
notifyHref?: string
events: readonly EventItem[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-12">
<div
class="bg-transparency-white-t4 rounded-4xl px-6 py-12 lg:px-16 lg:py-20"
>
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
<div>
<BrandButton
:href="notifyHref"
variant="outline"
size="xs"
class="uppercase"
>
{{ t(notifyLabelKey, locale) }}
</BrandButton>
</div>
</div>
<div class="flex flex-col">
<a
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label[locale] }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title[locale] }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta[locale] }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</span>
</a>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,165 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import MaskRevealButton from './MaskRevealButton.vue'
const meta: Meta<typeof MaskRevealButton> = {
title: 'Website/Common/MaskRevealButton',
component: MaskRevealButton,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
},
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
options: ['right', 'left']
},
hideLabel: { control: 'boolean' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { href: '#' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>`
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Read More</MaskRevealButton>'
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Go Back</MaskRevealButton>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Try Workflow</MaskRevealButton>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `<MaskRevealButton v-bind="args">Let's Collaborate</MaskRevealButton>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: `
<MaskRevealButton v-bind="args">
Next Step
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
`
})
}
export const LabelVisible: Story = {
args: { href: '#', hideLabel: false },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template:
'<MaskRevealButton v-bind="args">Always Visible</MaskRevealButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { MaskRevealButton },
setup: () => ({ args }),
template: '<MaskRevealButton v-bind="args">Unavailable</MaskRevealButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { MaskRevealButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="solid" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="solid" size="lg">Large</MaskRevealButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" variant="ghost" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" variant="ghost" size="lg">Large</MaskRevealButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Icon Left</span>
<div class="flex flex-wrap items-center gap-4">
<MaskRevealButton href="#" iconPosition="left" size="sm">Small</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="md">Medium</MaskRevealButton>
<MaskRevealButton href="#" iconPosition="left" size="lg">Large</MaskRevealButton>
</div>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { MaskRevealButtonVariants } from './maskRevealButton.variants'
import {
maskRevealButtonBadgeVariants,
maskRevealButtonVariants,
maskRevealLabelVariants
} from './maskRevealButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = true,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: MaskRevealButtonVariants['variant']
size?: MaskRevealButtonVariants['size']
iconPosition?: MaskRevealButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(maskRevealButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:data-icon-position="iconPosition ?? 'right'"
:data-hidden="hideLabel ? 'true' : 'false'"
:class="maskRevealLabelVariants()"
>
<slot />
</span>
<span
:class="maskRevealButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -0,0 +1,165 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PillButton from './PillButton.vue'
const meta: Meta<typeof PillButton> = {
title: 'Website/Common/PillButton',
component: PillButton,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-12"><story /></div>'
})
],
argTypes: {
href: { control: 'text' },
target: { control: 'text' },
rel: { control: 'text' },
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset']
},
disabled: { control: 'boolean' },
ariaLabel: { control: 'text' },
variant: {
control: { type: 'select' },
options: ['solid', 'ghost']
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg']
},
iconPosition: {
control: { type: 'select' },
options: ['right', 'left']
},
hideLabel: { control: 'boolean' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AsAnchor: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const AsButton: Story = {
args: { type: 'button' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Submit</PillButton>'
})
}
export const Ghost: Story = {
args: { href: '#', variant: 'ghost' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Read More</PillButton>'
})
}
export const SmallSolid: Story = {
args: { href: '#', size: 'sm' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const LargeSolid: Story = {
args: { href: '#', size: 'lg' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `<PillButton v-bind="args">Let's Collaborate</PillButton>`
})
}
export const WithCustomIcon: Story = {
args: { href: '#' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: `
<PillButton v-bind="args">
Next Step
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</PillButton>
`
})
}
export const IconLeft: Story = {
args: { href: '#', iconPosition: 'left' },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Go Back</PillButton>'
})
}
export const RevealLabelOnHover: Story = {
args: { href: '#', hideLabel: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Try Workflow</PillButton>'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { PillButton },
setup: () => ({ args }),
template: '<PillButton v-bind="args">Unavailable</PillButton>'
})
}
export const AllVariants: Story = {
render: () => ({
components: { PillButton },
template: `
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Solid</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="solid" size="sm">Small</PillButton>
<PillButton href="#" variant="solid" size="md">Medium</PillButton>
<PillButton href="#" variant="solid" size="lg">Large</PillButton>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-primary-comfy-canvas text-xs uppercase tracking-wider">Ghost</span>
<div class="flex flex-wrap items-center gap-4">
<PillButton href="#" variant="ghost" size="sm">Small</PillButton>
<PillButton href="#" variant="ghost" size="md">Medium</PillButton>
<PillButton href="#" variant="ghost" size="lg">Large</PillButton>
</div>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { PillButtonVariants } from './pillButton.variants'
import {
pillButtonBadgeVariants,
pillButtonVariants
} from './pillButton.variants'
const {
href,
target,
rel,
type = 'button',
disabled,
ariaLabel,
variant,
size,
iconPosition,
hideLabel = false,
class: customClass = ''
} = defineProps<{
href?: string
target?: string
rel?: string
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
ariaLabel?: string
variant?: PillButtonVariants['variant']
size?: PillButtonVariants['size']
iconPosition?: PillButtonVariants['iconPosition']
hideLabel?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href="href || undefined"
:target="href ? target : undefined"
:rel="href ? rel : undefined"
:type="!href ? type : undefined"
:disabled="!href ? disabled : undefined"
:aria-label="ariaLabel"
:class="
cn(pillButtonVariants({ variant, size, iconPosition }), customClass)
"
>
<span
:class="
cn(
'relative leading-none transition-all duration-500',
hideLabel && 'opacity-0 group-hover:opacity-100'
)
"
>
<slot />
</span>
<span
:class="pillButtonBadgeVariants({ variant, size, iconPosition })"
aria-hidden="true"
>
<span class="inline-flex transition-transform duration-500">
<slot name="icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
</slot>
</span>
</span>
</component>
</template>

View File

@@ -43,6 +43,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{
title: t('footer.resources', locale),
links: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('footer.blog', locale),
href: externalLinks.blog,

View File

@@ -52,6 +52,7 @@ const navLinks: NavLink[] = [
{
label: t('nav.resources', locale),
items: [
{ label: t('nav.learning', locale), href: routes.learning },
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -0,0 +1,17 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'text-primary-warm-gray focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-4 py-1 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas'
}
},
defaultVariants: {
variant: 'default'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -0,0 +1,110 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const maskRevealButtonVariants = cva({
base: 'group relative uppercase inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{ size: 'sm', iconPosition: 'right', class: 'ps-12 pe-4' },
{ size: 'md', iconPosition: 'right', class: 'ps-14 pe-6' },
{ size: 'lg', iconPosition: 'right', class: 'ps-16 pe-8' },
{ size: 'sm', iconPosition: 'left', class: 'ps-4 pe-12' },
{ size: 'md', iconPosition: 'left', class: 'ps-6 pe-14' },
{ size: 'lg', iconPosition: 'left', class: 'ps-8 pe-16' }
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const maskRevealLabelVariants = cva({
base: [
'relative inline-block align-baseline',
'[will-change:mask-size,-webkit-mask-size]',
'[mask-image:linear-gradient(black,black)] [-webkit-mask-image:linear-gradient(black,black)]',
'mask-no-repeat [-webkit-mask-repeat:no-repeat]',
'transition-[mask-size,-webkit-mask-size] duration-500 ease-in-out',
'data-[icon-position=right]:[mask-position:100%_0] data-[icon-position=right]:[-webkit-mask-position:100%_0]',
'data-[icon-position=left]:[mask-position:0_0] data-[icon-position=left]:[-webkit-mask-position:0_0]',
'data-[hidden=true]:[mask-size:0%_100%] data-[hidden=true]:[-webkit-mask-size:0%_100%]',
'data-[hidden=false]:[mask-size:100%_100%] data-[hidden=false]:[-webkit-mask-size:100%_100%]',
'group-hover:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-hover:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]',
'group-focus-visible:data-[hidden=true]:[mask-size:calc(100%_+_1px)_100%] group-focus-visible:data-[hidden=true]:[-webkit-mask-size:calc(100%_+_1px)_100%]'
].join(' ')
})
export type MaskRevealButtonVariants = VariantProps<
typeof maskRevealButtonVariants
>

View File

@@ -0,0 +1,116 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const pillButtonVariants = cva({
base: 'group relative inline-flex w-fit cursor-pointer items-center overflow-hidden rounded-lg p-1 font-bold text-nowrap transition-all duration-500 disabled:cursor-not-allowed disabled:opacity-50',
variants: {
variant: {
solid: 'bg-primary-comfy-yellow text-primary-comfy-ink',
ghost: 'text-primary-comfy-yellow bg-transparent'
},
size: {
sm: 'h-10 text-xs',
md: 'h-12 text-sm',
lg: 'h-14 text-base'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'ps-4 pe-12 hover:ps-12 hover:pe-4'
},
{
size: 'md',
iconPosition: 'right',
class: 'ps-6 pe-14 hover:ps-14 hover:pe-6'
},
{
size: 'lg',
iconPosition: 'right',
class: 'ps-8 pe-16 hover:ps-16 hover:pe-8'
},
{
size: 'sm',
iconPosition: 'left',
class: 'ps-12 pe-4 hover:ps-4 hover:pe-12'
},
{
size: 'md',
iconPosition: 'left',
class: 'ps-14 pe-6 hover:ps-6 hover:pe-14'
},
{
size: 'lg',
iconPosition: 'left',
class: 'ps-16 pe-8 hover:ps-8 hover:pe-16'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export const pillButtonBadgeVariants = cva({
base: 'absolute z-10 flex items-center justify-center rounded-lg transition-all duration-500',
variants: {
variant: {
solid: 'bg-primary-comfy-ink text-primary-comfy-yellow',
ghost: 'bg-primary-comfy-yellow text-primary-comfy-ink'
},
size: {
sm: 'size-8',
md: 'size-10',
lg: 'size-12'
},
iconPosition: {
right: '',
left: ''
}
},
compoundVariants: [
{
size: 'sm',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'right',
class: 'right-1 group-hover:right-[calc(100%-52px)]'
},
{
size: 'sm',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-36px)]'
},
{
size: 'md',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-44px)]'
},
{
size: 'lg',
iconPosition: 'left',
class: 'left-1 group-hover:left-[calc(100%-52px)]'
}
],
defaultVariants: {
variant: 'solid',
size: 'md',
iconPosition: 'right'
}
})
export type PillButtonVariants = VariantProps<typeof pillButtonVariants>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import BrandButton from '../common/BrandButton.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const tags = ['Seadance 2.0', 'Image To Video']
const demoVideoSrc =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4'
const demoVideoPoster =
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg'
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<div>
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
<p class="text-primary-warm-gray mt-4 text-sm lg:text-base">
{{ t('learning.featured.author', locale) }}
</p>
</div>
<p
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
>
{{ t('learning.featured.description', locale) }}
</p>
<div class="flex flex-wrap gap-3">
<BrandButton
variant="outline"
size="xs"
class="uppercase"
href="https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/"
>
{{ t('cta.tryWorkflow', locale) }}
</BrandButton>
</div>
<ul class="mt-2 flex flex-wrap gap-3">
<li v-for="tag in tags" :key="tag">
<Badge variant="subtle">{{ tag }}</Badge>
</li>
</ul>
</div>
<div class="border-primary-warm-gray rounded-4.5xl border p-4">
<VideoPlayer
:locale
:src="demoVideoSrc"
:poster="demoVideoPoster"
minimal
/>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span
>{{ t('learning.heroTitle.after', locale) }}
<br />
{{ t('learning.heroTitle.line2', locale) }}
</h1>
</section>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
locale?: Locale
}>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const playFromStart = () => {
const video = videoRef.value
if (!video) return
video.currentTime = 0
void video.play().catch(() => {})
}
watch(
() => tutorial.id,
() => {
playFromStart()
}
)
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) emit('close')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
playFromStart()
})
onUnmounted(() => {
unlockScroll()
})
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title[locale]"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@close="emit('close')"
>
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
>
<video
ref="videoRef"
:src="tutorial.videoSrc"
:poster="tutorial.poster"
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
controls
autoplay
playsinline
></video>
</div>
<h2
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
>
{{ t('learning.tutorials.heading', locale) }}
</h2>
<ul
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in learningTutorials"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorialId = tutorial.id"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
>
<svg
class="ml-1 size-5 text-white lg:size-6"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="sm"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</MaskRevealButton>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
</li>
</ul>
<TutorialDetailDialog
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

@@ -12,6 +12,7 @@ const baseRoutes = {
careers: '/careers',
customers: '/customers',
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',

View File

@@ -0,0 +1,31 @@
import type { EventItem } from '../components/common/EventsSection.vue'
export const learningEvents: readonly EventItem[] = [
{
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
title: {
en: 'Zero to Node: Building Your First Workflow',
'zh-CN': '从零到节点:构建你的第一个工作流'
},
cta: { en: 'Link', 'zh-CN': '链接' },
href: '#'
},
{
label: { en: 'Event 1', 'zh-CN': '活动 1' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
href: '#'
},
{
label: { en: 'Event 2', 'zh-CN': '活动 2' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
href: '#'
}
] as const

View File

@@ -0,0 +1,84 @@
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const learningTutorials: readonly LearningTutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'seedance_demo_comfyui_v03',
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
videoSrc:
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -1,6 +1,22 @@
type Locale = 'en' | 'zh-CN'
const translations = {
// Tags (global, reusable across sections)
'tags.partnerNodes': {
en: 'Partner Nodes',
'zh-CN': '合作伙伴节点'
},
'tags.imageToVideo': {
en: 'Image To Video',
'zh-CN': '图像生成视频'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
@@ -1435,6 +1451,62 @@ const translations = {
'player.subtitlesOn': { en: 'Subtitles on', 'zh-CN': '开启字幕' },
'player.subtitlesOff': { en: 'Subtitles off', 'zh-CN': '关闭字幕' },
// LearningHeroSection
'learning.heroTitle.before': { en: 'Learn', 'zh-CN': '学习' },
'learning.heroTitle.after': { en: '.', 'zh-CN': '。' },
'learning.heroTitle.line2': {
en: 'Build what doesnt exist yet.',
'zh-CN': '构建尚未存在之物。'
},
// LearningFeaturedWorkflowSection
'learning.featured.title': {
en: 'Sky Replacement',
'zh-CN': '天空替换'
},
'learning.featured.author': {
en: 'by Doug Hogan',
'zh-CN': '作者Doug Hogan'
},
'learning.featured.description': {
en: 'A sky replacement workflow built on Wan AI models. WanVideoSampler and WanVideoDecode synthesize new sky visuals into existing footage. CLIPVisionLoader and WanVideoClipVisionEncode ensure replacements feel native, not composited.',
'zh-CN':
'基于 Wan AI 模型构建的天空替换工作流。WanVideoSampler 与 WanVideoDecode 将全新的天空视觉合成到现有素材中。CLIPVisionLoader 与 WanVideoClipVisionEncode 确保替换效果自然融合,而非生硬叠加。'
},
'learning.featured.watchDemo': {
en: 'Watch Demo',
'zh-CN': '观看演示'
},
// LearningTutorialsSection
'learning.tutorials.heading': {
en: 'Featured Demos',
'zh-CN': '精选演示'
},
'learning.tutorials.titlePrefix': {
en: 'Learn how to:',
'zh-CN': '学习如何:'
},
// LearningCallToActionSection
'learning.cta.heading': {
en: 'Schedule a demo and see how ComfyUI fits your teams creative needs.',
'zh-CN': '预约演示,了解 ComfyUI 如何契合你的团队创作需求。'
},
'learning.cta.contactSales': {
en: 'Contact Sales',
'zh-CN': '联系销售'
},
// LearningEventsSection
'learning.events.heading': { en: 'Events', 'zh-CN': '活动' },
'learning.events.description': {
en: 'Check out our upcoming live streams and community meetings. Were always open to your questions, ideas, and conversations.',
'zh-CN':
'查看我们即将举办的直播和社区聚会。我们随时欢迎你的提问、想法和交流。'
},
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
// GalleryHeroSection
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
'gallery.heroTitle.before': {
@@ -1471,9 +1543,13 @@ const translations = {
},
'about.hero.body': {
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
'zh-CN':
'Comfy \u80cc\u540e\u7684\u56e2\u961f\u89c4\u6a21\u867d\u5c0f\uff0c\u4f46\u5145\u6ee1\u70ed\u60c5\uff0c\u81f4\u529b\u4e8e\u6253\u9020\u6211\u4eec\u6bd5\u751f\u7684\u4e8b\u4e1a\u3002'
},
'about.hero.cta': {
en: 'SEE OPEN ROLES',
'zh-CN': '\u67e5\u770b\u5f00\u653e\u804c\u4f4d'
},
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
// AboutStorySection
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
@@ -1743,6 +1819,7 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
@@ -4736,6 +4813,8 @@ const translations = {
type TranslationKey = keyof typeof translations
type LocalizedText = Record<Locale, string>
export function t(key: TranslationKey, locale: Locale = 'en'): string {
return translations[key][locale] ?? translations[key].en
}
@@ -4746,4 +4825,4 @@ export function hasKey(key: string): boolean {
return key in translations
}
export type { Locale, TranslationKey }
export type { Locale, LocalizedText, TranslationKey }

View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../components/learning/TutorialsSection.vue'
import CallToActionSection from '../components/common/CallToActionSection.vue'
// import EventsSection from '../components/common/EventsSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
// import { learningEvents } from '../data/events'
const routes = getRoutes('en')
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -1,10 +1,11 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-model-001',
name: 'model.safetensors',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
function createInputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-input-001',
name: 'input.png',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
function createOutputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-output-001',
name: 'output_00001.png',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],

View File

@@ -54,6 +54,7 @@ export const TestIds = {
errorDialogFindIssues: 'error-dialog-find-issues',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
errorGroupDisplayMessage: 'error-group-display-message',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',

View File

@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
in_library: false
}
const defaultInputAsset: Asset = {
const defaultInputAsset: Asset & { hash?: string } = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
const importedInputAsset: Asset & { hash?: string } = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
@@ -91,3 +91,21 @@ export async function measureSelectionBounds(
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
) as Promise<MeasureResult>
}
export async function intersection(a: Locator, b: Locator) {
const aBounds = await a.boundingBox()
const bBounds = await b.boundingBox()
if (!aBounds || !bBounds) return undefined
const y = Math.max(aBounds.y, bBounds.y)
const x = Math.max(aBounds.x, bBounds.x)
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
if (y > bot || x > right) return undefined
const width = right - x
const height = bot - y
return { x, y, width, height }
}

View File

@@ -19,63 +19,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
return page.getByTestId(TestIds.dialogs.errorOverlay)
}
function getSeeErrorsButton(page: Page) {
function getDetailsButton(page: Page) {
return getOverlay(page).getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
}
test.describe('Labels', () => {
test('Should display singular error count label for single error', async ({
test('Should display single error copy and View details action', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getOverlay(comfyPage.page)).toContainText(/1 ERROR/i)
})
test('Should display "Show missing nodes" button for missing node errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing nodes/i
)
})
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
await cleanupFakeModel(comfyPage)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing models/i
)
})
test('Should display "Show missing inputs" button for missing media errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing inputs/i
)
})
test('Should display generic "See Errors" button for multiple error types', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_and_media')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/See Errors/i
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).not.toContainText(/1 ERROR/i)
await expect(
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toHaveText(/\S/)
await expect(getDetailsButton(comfyPage.page)).toContainText(
/View details/i
)
})
})
@@ -137,7 +98,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
})
})
test.describe('See Errors flow', () => {
test.describe('View details flow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
@@ -166,7 +127,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
test('"View details" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
@@ -178,7 +139,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
test('"View details" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
@@ -229,19 +190,24 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
}) => {
// Regression: ErrorOverlay previously read the selection-filtered
// missingModelGroups from useErrorGroups, so selecting one of two
// missing-model nodes would shrink the overlay label from
// "2 required models are missing" to "1". The overlay must show
// the workflow total regardless of canvas selection.
// missing-model nodes could shrink the overlay count. The overlay must
// show the workflow total regardless of canvas selection.
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).toContainText(/2 required models are missing/i)
await expect(overlay).toContainText(/2 errors found/i)
await expect(
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toHaveText(/Resolve them before running the workflow\./i)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await expect(overlay).toContainText(/2 required models are missing/i)
await expect(overlay).toContainText(/2 errors found/i)
await expect(
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toHaveText(/Resolve them before running the workflow\./i)
})
})
})

View File

@@ -11,6 +11,7 @@ import {
getSwapNodesGroup,
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
@@ -38,6 +39,9 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(
swapGroup.getByRole('button', { name: 'Replace All', exact: true })

View File

@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset = {
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],

View File

@@ -36,6 +36,10 @@ function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -46,14 +50,24 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const overlay = getErrorOverlay(comfyPage)
await expect(overlay).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toContainText(/Load Image/)
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
await expect(missingMediaGroup).toBeVisible()
await expect(
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Shows correct number of missing media rows', async ({

View File

@@ -23,11 +23,31 @@ const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
widgetName: 'image',
serverOnlyOption: 'server-only-image.png',
position: { x: 150, y: 150 }
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
serverOnlyOption: 'server-only-video.mp4',
position: { x: 450, y: 150 }
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
serverOnlyOption: 'server-only-audio.wav',
position: { x: 750, y: 150 }
}
]
const cloudOutputAsset: Asset = {
const cloudOutputAsset: Asset & { hash?: string } = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -36,10 +56,10 @@ const cloudOutputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -50,10 +70,10 @@ const cloudUploadedVideoAsset: Asset = {
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -66,12 +86,168 @@ interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
type ObjectInfoResponse = Record<
string,
{ input?: { required?: Record<string, unknown> } }
>
function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: string[]
) {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!Array.isArray(input)) {
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
const [valuesOrType, options] = input
const optionsObject =
options && typeof options === 'object' && !Array.isArray(options)
if (Array.isArray(valuesOrType)) {
input[0] = values
} else if (valuesOrType !== 'COMBO') {
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
}
if (optionsObject) {
Object.assign(options, { options: values })
} else if (!Array.isArray(valuesOrType)) {
throw new Error(
`Expected ${nodeType}.${inputName} to have options metadata`
)
}
}
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/api/userdata**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
})
await page.route('**/i18n', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/customers/cloud-subscription-status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ is_active: true })
})
})
}
async function routeSetupObjectInfo(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void
) {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
const objectInfo = (await response.json()) as ObjectInfoResponse
customize?.(objectInfo)
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
}
}
await page.route('**/object_info', objectInfoRouteHandler)
return async () =>
await page.unroute('**/object_info', objectInfoRouteHandler)
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
})
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
const cloudUploadRaceTest = comfyPageFixture.extend<{
markUploadedCloudAssetAvailable: () => void
}>({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false
}
@@ -106,9 +282,13 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await use(page)
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
cloudUploadAssetStateByPage.delete(page)
try {
await use(page)
} finally {
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await unrouteObjectInfo()
cloudUploadAssetStateByPage.delete(page)
}
},
markUploadedCloudAssetAvailable: async ({ page }, use) => {
await use(() => {
@@ -139,7 +319,41 @@ async function expectNoErrorsTab(comfyPage: ComfyPage) {
).toBeHidden()
}
async function delayNextUpload(comfyPage: ComfyPage) {
async function closeTemplatesDialogIfOpen(comfyPage: ComfyPage) {
const templatesDialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.templates.content
})
const closeButton = templatesDialog.getByRole('button', {
name: 'Close dialog'
})
await closeButton
.waitFor({ state: 'visible', timeout: 1_000 })
.catch(() => undefined)
if (await closeButton.isVisible()) {
await closeButton.click()
await expect(templatesDialog).toBeHidden()
}
}
async function getMediaLoaderWidgetValues(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate((nodes) => {
return nodes.map(({ nodeType, widgetName }) => {
const node = window.app!.graph.nodes.find(
(graphNode) => graphNode.type === nodeType
)
const widget = node?.widgets?.find(
(candidate) => candidate.name === widgetName
)
return widget?.value ?? null
})
}, emptyMediaLoaderNodes)
}
async function delayNextUpload(
comfyPage: ComfyPage,
uploadResult?: { name: string; subfolder: string; type: 'input' }
) {
let releaseUpload!: () => void
let resolveUploadStarted!: () => void
const uploadStarted = new Promise<void>((resolve) => {
@@ -152,6 +366,14 @@ async function delayNextUpload(comfyPage: ComfyPage) {
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
if (uploadResult) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(uploadResult)
})
return
}
await route.continue()
}
@@ -295,12 +517,51 @@ ossTest.describe(
}
)
cloudEmptyMediaInputsTest.describe(
'Errors tab - Cloud empty media loader inputs',
{ tag: '@cloud' },
() => {
cloudEmptyMediaInputsTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudEmptyMediaInputsTest(
'does not surface missing inputs after adding LoadImage, LoadVideo, and LoadAudio nodes with no cloud input assets',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
for (const node of emptyMediaLoaderNodes) {
await comfyPage.nodeOps.addNode(
node.nodeType,
undefined,
node.position
)
}
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'input')
)
)
.toBe(true)
await expect
.poll(() => getMediaLoaderWidgetValues(comfyPage))
.toEqual(['', '', ''])
await expectNoErrorsTab(comfyPage)
}
)
}
)
cloudOutputTest.describe(
'Errors tab - Cloud missing media runtime sources',
{ tag: '@cloud' },
() => {
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudOutputTest(
@@ -329,13 +590,18 @@ cloudUploadRaceTest.describe(
() => {
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudUploadRaceTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
const delayedUpload = await delayNextUpload(comfyPage, {
name: plainVideoFileName,
subfolder: '',
type: 'input'
})
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition

View File

@@ -25,9 +25,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeVisible()
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should display model name with referencing node count', async ({

View File

@@ -23,9 +23,13 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Should show missing node packs group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(missingNodeGroup).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should expand pack group to reveal node type names', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -180,4 +180,44 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
wstest(
'requests lightweight thumbnail URLs for grid cells',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
const gridImages = node.imageGrid.locator('img')
await test.step('Inject a multi-image grid', async () => {
const images = Array.from({ length: 4 }, (_, index) => ({
filename: `grid-${index}.png`,
subfolder: '',
type: 'output'
}))
execution.executed('', '1', { images })
await expect(gridImages).toHaveCount(4)
})
// FE-741: small on-node grid cells must request a server re-encoded
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
// downloading the full-resolution image, while still pointing at the
// real `/api/view` URL for that output. Verifies the full path: WS
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
// rendered grid `<img>`.
for (const cell of await gridImages.all()) {
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
}
}
)
})

View File

@@ -54,6 +54,35 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
})
}
const advancedButtonOverflowPx = 24
const holdPointCanvasInsetPx = 8
const getAdvancedInputsButton = (node: Locator) =>
node.getByTestId('advanced-inputs-button')
const moveAdvancedButtonRightEdgePastCanvas = async (
comfyPage: ComfyPage,
button: Locator,
overflow: number
) => {
const box = await button.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!box) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const scale = await comfyPage.canvasOps.getScale()
const deltaX = canvasBox.x + canvasBox.width + overflow - box.x - box.width
await comfyPage.page.evaluate(
({ deltaX, scale }) => {
const canvas = window.app!.canvas
canvas.ds.offset[0] += deltaX / scale
canvas.setDirty(true, true)
},
{ deltaX, scale }
)
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -123,7 +152,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const showButton = getAdvancedInputsButton(node)
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
@@ -143,6 +172,83 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(beforePos, afterPos)
})
test(
'should not pan while holding the Advanced button without dragging',
{ tag: ['@canvas', '@widget'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = getAdvancedInputsButton(node)
await expect(showButton).toBeVisible()
await moveAdvancedButtonRightEdgePastCanvas(
comfyPage,
showButton,
advancedButtonOverflowPx
)
const buttonBox = await showButton.boundingBox()
const canvasBox = await comfyPage.canvas.boundingBox()
if (!buttonBox) throw new Error('Advanced button has no bounding box')
if (!canvasBox) throw new Error('Canvas has no bounding box')
const canvasRight = canvasBox.x + canvasBox.width
const buttonRight = buttonBox.x + buttonBox.width
expect(
buttonRight,
'Advanced button should extend past the canvas right edge'
).toBeGreaterThan(canvasRight)
const holdPoint = {
x: canvasRight - holdPointCanvasInsetPx,
y: buttonBox.y + buttonBox.height / 2
}
expect(
holdPoint.x,
'Hold point should stay inside the visible part of the Advanced button'
).toBeGreaterThanOrEqual(buttonBox.x)
expect(
holdPoint.x,
'Hold point should stay inside the visible canvas'
).toBeLessThanOrEqual(canvasRight)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeGreaterThanOrEqual(buttonBox.y)
expect(
holdPoint.y,
'Hold point should stay inside the Advanced button height'
).toBeLessThanOrEqual(buttonBox.y + buttonBox.height)
const beforeOffset = await comfyPage.canvasOps.getOffset()
await comfyPage.page.mouse.move(holdPoint.x, holdPoint.y)
await comfyPage.page.mouse.down()
try {
await comfyPage.idleFrames(8)
} finally {
await comfyPage.page.mouse.up()
}
const afterOffset = await comfyPage.canvasOps.getOffset()
expect(afterOffset[0]).toBeCloseTo(beforeOffset[0], 3)
expect(afterOffset[1]).toBeCloseTo(beforeOffset[1], 3)
}
)
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {

View File

@@ -6,6 +6,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
const FLOAT_SOURCE_POSITION_LEFT_OF_NODE = { x: 100, y: 200 }
test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -32,6 +33,20 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
return getNode(comfyPage).locator('.lg-node-widget')
}
async function getWidgetIndex(comfyPage: ComfyPage, widgetName: string) {
const index = await comfyPage.page.evaluate((name) => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return node?.widgets?.findIndex((widget) => widget.name === name) ?? -1
}, widgetName)
expect(
index,
`${widgetName} widget should exist on ModelSamplingFlux`
).toBeGreaterThanOrEqual(0)
return index
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
@@ -72,6 +87,47 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => {
await expect(widgets).toHaveCount(2)
})
test('should keep connected advanced widgets visible when advanced inputs are hidden', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const maxShiftWidget = node.getByLabel('max_shift', { exact: true })
const baseShiftWidget = node.getByLabel('base_shift', { exact: true })
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeVisible()
const primitive = await comfyPage.nodeOps.addNode(
'PrimitiveFloat',
{},
FLOAT_SOURCE_POSITION_LEFT_OF_NODE
)
const [target] =
await comfyPage.nodeOps.getNodeRefsByType('ModelSamplingFlux')
const maxShiftIndex = await getWidgetIndex(comfyPage, 'max_shift')
await primitive.connectWidget(0, target, maxShiftIndex)
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => node.type === 'ModelSamplingFlux'
)
return (
node?.inputs.find((input) => input.widget?.name === 'max_shift')
?.link ?? null
)
})
)
.not.toBeNull()
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await expect(maxShiftWidget).toBeVisible()
await expect(baseShiftWidget).toBeHidden()
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {

View File

@@ -1,10 +1,12 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Locator } from '@playwright/test'
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
async function openSamplerDropdown(comfyPage: ComfyPage) {
@@ -278,4 +280,31 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
.getByRole('combobox', { name: 'scheduler', exact: true })
await expect(schedulerComboAfterReload).toContainText('karras')
})
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const nodeName = 'Resize Image/Mask'
await comfyPage.searchBoxV2.addNode(nodeName, {
position: { x: 200, y: 630 }
})
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
await node.select()
await expect(comfyPage.selectionToolbox).toBeVisible()
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
await combo.click()
const dropdown = comfyPage.page.getByTestId(
TestIds.widgets.selectDefaultViewport
)
await expect(dropdown).toBeVisible()
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
const cX = bounds.x + bounds.width / 2
const cY = bounds.y + bounds.height / 2
const dropdownBounds = (await dropdown.boundingBox())!
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
await dropdown.click({ position, trial: true })
})
})

View File

@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -50,7 +49,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const resolvedInputs = useResolvedSelectedInputs()

View File

@@ -0,0 +1,180 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ErrorOverlay from './ErrorOverlay.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeError } from '@/schemas/apiSchema'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
}))
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
useNodeErrorFlagSync: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: false,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((id: string) => id),
getActiveGraphNodeIds: vi.fn(() => new Set()),
getExecutionIdByNode: vi.fn(),
getNodeByExecutionId: vi.fn()
}))
const mockOpenPanel = vi.hoisted(() => vi.fn())
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ openPanel: mockOpenPanel })
}))
const mockCanvasStore = vi.hoisted(() => ({
linearMode: false,
canvas: null,
currentGraph: null,
updateSelectedItems: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
dismiss: 'Dismiss'
},
errorOverlay: {
errorCount: '{count} ERROR | {count} ERRORS',
multipleErrorCount: '{count} error found | {count} errors found',
multipleErrorsMessage: 'Resolve them before running the workflow.',
viewDetails: 'View details'
},
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
}
function makeNodeError(messages: string[]): NodeError {
return {
class_type: 'KSampler',
dependent_outputs: [],
errors: messages.map((message) => ({
type: 'execution_error',
message,
details: 'details'
}))
}
}
function renderOverlay(props: { appMode?: boolean } = {}) {
const pinia = createPinia()
setActivePinia(pinia)
return render(ErrorOverlay, {
props,
global: {
plugins: [pinia, createTestI18n()],
stubs: {
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
}
})
}
describe('ErrorOverlay', () => {
beforeEach(() => {
mockAllErrorGroups.value = []
mockOpenPanel.mockClear()
mockCanvasStore.linearMode = false
mockCanvasStore.canvas = null
mockCanvasStore.currentGraph = null
mockCanvasStore.updateSelectedItems.mockClear()
})
it('renders a single overlay message without list markup', async () => {
renderOverlay()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'Only error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByRole('status')).toHaveTextContent('Only error')
expect(screen.getByRole('status')).not.toHaveTextContent('1 ERROR')
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
'View details'
)
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
it('keeps the app mode button label', async () => {
renderOverlay({ appMode: true })
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'Only error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
'Show errors in graph'
)
})
})

View File

@@ -6,15 +6,15 @@
>
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
<div
role="alert"
aria-live="assertive"
role="status"
aria-live="polite"
data-testid="error-overlay"
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
>
<!-- Header -->
<div class="flex h-12 items-center gap-2 px-4">
<span class="flex-1 text-sm font-bold text-destructive-background">
{{ errorCountLabel }}
{{ overlayTitle }}
</span>
<Button
variant="muted-textonly"
@@ -28,20 +28,11 @@
<!-- Body -->
<div class="px-4 pb-3" data-testid="error-overlay-messages">
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="(message, idx) in overlayMessages"
:key="idx"
class="flex min-w-0 items-baseline gap-2 text-sm/snug text-muted-foreground"
>
<span
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
/>
<span class="line-clamp-3 wrap-break-word whitespace-pre-wrap">{{
message
}}</span>
</li>
</ul>
<p
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
<!-- Footer -->
@@ -60,7 +51,11 @@
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
{{ appMode ? t('linearMode.error.goto') : seeErrorsLabel }}
{{
appMode
? t('linearMode.error.goto')
: t('errorOverlay.viewDetails')
}}
</Button>
</div>
</div>
@@ -69,73 +64,22 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
defineProps<{ appMode?: boolean }>()
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { allErrorGroups } = useErrorGroups(ref(''))
const singleErrorType = computed(() => {
const types = new Set(allErrorGroups.value.map((g) => g.type))
return types.size === 1 ? [...types][0] : null
})
const overlayMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
if (group.type === 'execution') {
// TODO(FE-816 overlay-redesign): Keep runtime overlay copy raw until the
// overlay redesign decides how to use catalog toast fields.
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
} else {
messages.add(group.displayMessage ?? group.displayTitle)
}
}
return Array.from(messages)
})
const seeErrorsLabel = computed(() => {
const labelMap: Record<string, string> = {
missing_node: t('errorOverlay.showMissingNodes'),
missing_model: t('errorOverlay.showMissingModels'),
swap_nodes: t('errorOverlay.showSwapNodes'),
missing_media: t('errorOverlay.showMissingMedia')
}
if (singleErrorType.value) {
return labelMap[singleErrorType.value] ?? t('errorOverlay.seeErrors')
}
return t('errorOverlay.seeErrors')
})
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const isVisible = computed(
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
)
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()

View File

@@ -0,0 +1,337 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useErrorOverlayState } from './useErrorOverlayState'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { NodeError } from '@/schemas/apiSchema'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
}))
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
useNodeErrorFlagSync: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: false,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((id: string) => id),
getActiveGraphNodeIds: vi.fn(() => new Set()),
getExecutionIdByNode: vi.fn(),
getNodeByExecutionId: vi.fn()
}))
function createTestI18n() {
return createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
errorOverlay: {
errorCount: '{count} ERROR | {count} ERRORS',
multipleErrorCount: '{count} error found | {count} errors found',
multipleErrorsMessage: 'Resolve them before running the workflow.'
}
}
}
})
}
function makeNodeError(messages: string[]): NodeError {
return {
class_type: 'KSampler',
dependent_outputs: [],
errors: messages.map((message) => ({
type: 'execution_error',
message,
details: 'details'
}))
}
}
function mountOverlayState() {
const pinia = createPinia()
setActivePinia(pinia)
const Harness = defineComponent({
setup() {
return useErrorOverlayState()
},
template: `
<section>
<span data-testid="visible">{{ isVisible }}</span>
<span data-testid="title">{{ overlayTitle }}</span>
<span data-testid="message">{{ overlayMessage }}</span>
</section>
`
})
return render(Harness, {
global: {
plugins: [pinia, createTestI18n()]
}
})
}
describe('useErrorOverlayState', () => {
beforeEach(() => {
mockAllErrorGroups.value = []
})
it('uses the raw message for a single uncataloged execution error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'Only error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('true')
expect(screen.getByTestId('title')).toHaveTextContent('Execution failed')
expect(screen.getByTestId('message')).toHaveTextContent('Only error')
})
it('uses toast copy for a single validation error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Required input is missing'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Required input is missing',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'Required input is missing',
toastTitle: 'Required input missing',
toastMessage: 'KSampler is missing a required input: model'
}
]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent(
'Required input missing'
)
expect(screen.getByTestId('message')).toHaveTextContent(
'KSampler is missing a required input: model'
)
})
it('uses display copy before raw copy when toast copy is absent', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Raw validation error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Friendly validation title',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'Raw validation error',
displayMessage: 'Friendly validation message'
}
]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent(
'Friendly validation title'
)
expect(screen.getByTestId('message')).toHaveTextContent(
'Friendly validation message'
)
})
it('uses toast copy for a single runtime error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
prompt_id: 'prompt',
node_id: 1,
node_type: 'KSampler',
executed: [],
exception_message: 'CUDA out of memory',
exception_type: 'torch.OutOfMemoryError',
traceback: [],
timestamp: Date.now()
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Generation failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{
message: 'torch.OutOfMemoryError: CUDA out of memory',
toastTitle: 'Generation failed',
toastMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
}
]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Generation failed')
expect(screen.getByTestId('message')).toHaveTextContent(
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
)
})
it('uses group toast copy for a single missing media error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
const missingMediaStore = useMissingMediaStore()
missingMediaStore.setMissingMedia([
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'image.png',
isMissing: true
}
])
mockAllErrorGroups.value = [
{
type: 'missing_media',
groupKey: 'missing_media',
displayTitle: 'Media input missing',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.',
priority: 3
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Media input missing')
expect(screen.getByTestId('message')).toHaveTextContent(
'Load Image is missing a required media file.'
)
})
it('does not show when a raw error has no resolved overlay message', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('false')
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
})
it('uses aggregate copy for multiple errors', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError([
'First error',
'Second error',
'Third error',
'Fourth error',
'Fifth error',
'Sixth error',
'Seventh error'
])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
priority: 0,
cards: [
{
id: '1',
title: 'KSampler',
errors: [{ message: 'First error' }]
}
]
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('true')
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
expect(screen.getByTestId('message')).toHaveTextContent(
'Resolve them before running the workflow.'
)
})
})

View File

@@ -0,0 +1,103 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
function resolveSingleOverlayCopy(
group: ErrorGroup
): { title?: string; message: string } | undefined {
if (group.type === 'execution') {
const [card] = group.cards
const [error] = card?.errors ?? []
const message =
error?.toastMessage ??
error?.displayMessage ??
error?.message ??
group.displayMessage ??
group.displayTitle
if (!message) return undefined
return {
title: error?.toastTitle ?? error?.displayTitle ?? group.displayTitle,
message
}
}
const message =
group.toastMessage ?? group.displayMessage ?? group.displayTitle
if (!message) return undefined
return {
title: group.toastTitle ?? group.displayTitle,
message
}
}
export function useErrorOverlayState() {
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const { totalErrorCount, isErrorOverlayOpen } =
storeToRefs(executionErrorStore)
const { allErrorGroups } = useErrorGroups('')
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
const singleErrorGroup = computed(() =>
hasExactlyOneError.value && allErrorGroups.value.length === 1
? allErrorGroups.value[0]
: undefined
)
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const multipleErrorCountLabel = computed(() =>
t(
'errorOverlay.multipleErrorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const singleOverlayCopy = computed(() =>
singleErrorGroup.value
? resolveSingleOverlayCopy(singleErrorGroup.value)
: undefined
)
const overlayMessage = computed(() => {
if (hasMultipleErrors.value) {
return t('errorOverlay.multipleErrorsMessage')
}
return singleOverlayCopy.value?.message ?? ''
})
const overlayTitle = computed(() =>
hasMultipleErrors.value
? multipleErrorCountLabel.value
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
)
const isVisible = computed(
() =>
isErrorOverlayOpen.value &&
totalErrorCount.value > 0 &&
overlayMessage.value.trim().length > 0
)
return {
isVisible,
overlayMessage,
overlayTitle
}
}

View File

@@ -20,6 +20,7 @@
<InfoButton v-if="canOpenNodeInfo" />
<ColorPickerButton v-if="showColorPicker" />
<ArrangeButton v-if="showArrange" />
<FrameNodes v-if="showFrameNodes" />
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
<ConfigureSubgraph v-if="showSubgraphButtons" />
@@ -49,6 +50,7 @@
import Panel from 'primevue/panel'
import { computed, ref } from 'vue'
import ArrangeButton from '@/components/graph/selectionToolbox/ArrangeButton.vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
@@ -110,6 +112,7 @@ const {
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)
const showArrange = computed(() => hasMultipleSelection.value)
const showFrameNodes = computed(() => hasMultipleSelection.value)
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
@@ -128,6 +131,7 @@ const showAnyPrimaryActions = computed(
() =>
showColorPicker.value ||
showConvertToSubgraph.value ||
showArrange.value ||
showFrameNodes.value ||
showSubgraphButtons.value
)

View File

@@ -0,0 +1,115 @@
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<Button
v-tooltip.top="{ value: t('g.arrange'), showDelay: 1000 }"
variant="muted-textonly"
:aria-label="t('g.arrange')"
>
<div class="flex items-center gap-1 px-0">
<i class="icon-[lucide--layout-grid]" />
<i class="icon-[lucide--chevron-down]" />
</div>
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
:side-offset="5"
:collision-padding="10"
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
>
<div
v-if="activeLayout"
class="flex w-32 flex-row items-center px-2 py-1"
>
<Slider
:model-value="[gap]"
:min="MIN_ARRANGE_GAP"
:max="MAX_ARRANGE_GAP"
:step="1"
:aria-label="t('g.arrangeSpacing')"
@update:model-value="onSliderUpdate"
@value-commit="onSliderCommit"
/>
</div>
<div v-else class="flex flex-row gap-1">
<Button
v-tooltip.top="{
value: t('g.arrangeVertically'),
showDelay: 1000
}"
variant="muted-textonly"
:aria-label="t('g.arrangeVertically')"
@click="start('vertical')"
>
<i class="icon-[lucide--stretch-horizontal]" />
</Button>
<Button
v-tooltip.top="{
value: t('g.arrangeHorizontally'),
showDelay: 1000
}"
variant="muted-textonly"
:aria-label="t('g.arrangeHorizontally')"
@click="start('horizontal')"
>
<i class="icon-[lucide--stretch-vertical]" />
</Button>
<Button
v-tooltip.top="{ value: t('g.arrangeAsGrid'), showDelay: 1000 }"
variant="muted-textonly"
:aria-label="t('g.arrangeAsGrid')"
@click="start('grid')"
>
<i class="icon-[lucide--grid-3x3]" />
</Button>
</div>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<script setup lang="ts">
import {
PopoverArrow,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import {
MAX_ARRANGE_GAP,
MIN_ARRANGE_GAP
} from '@/composables/graph/useArrangeNodes'
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
const { t } = useI18n()
const { activeLayout, gap, start, previewGap, commitGap, reset } =
useArrangeSession()
const isOpen = ref(false)
watch(isOpen, (open) => {
if (!open) reset()
})
const firstValue = (value: number[] | undefined): number | undefined =>
value?.[0]
const onSliderUpdate = (value: number[] | undefined) => {
const next = firstValue(value)
if (next !== undefined) previewGap(next)
}
const onSliderCommit = (value: number[]) => {
const next = firstValue(value)
if (next !== undefined) commitGap(next)
}
</script>

View File

@@ -71,7 +71,6 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -18,32 +18,10 @@
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<Button
v-tooltip.right="{
value: $t('load3d.retainViewOnReload'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.retainViewOnReload')"
:aria-pressed="retainViewOnReload"
@click="retainViewOnReload = !retainViewOnReload"
>
<i
:class="
cn(
'pi text-lg text-base-foreground',
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
)
"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
@@ -52,9 +30,6 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
default: false
})
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {

View File

@@ -90,8 +90,6 @@ const i18n = createI18n({
en: {
rightSidePanel: {
missingNodePacks: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
ossManagerDisabledHint:
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
applyChanges: 'Apply Changes'
@@ -159,21 +157,6 @@ describe('MissingNodeCard', () => {
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
renderCard()
expect(
screen.getByText('Unsupported node packs detected.')
).toBeInTheDocument()
})
it('renders OSS message when isCloud is false', () => {
renderCard()
expect(
screen.getByText('Missing node packs detected. Install them.')
).toBeInTheDocument()
})
it('renders correct number of MissingPackGroupRow components', () => {
renderCard({ missingPackGroups: makePackGroups(3) })
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)

View File

@@ -36,19 +36,6 @@
</div>
</div>
</div>
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
<p
class="m-0 text-sm/relaxed text-muted-foreground"
:class="showManagerHint ? 'pb-3' : 'pb-5'"
>
{{
isCloud
? t('rightSidePanel.missingNodePacks.cloudMessage')
: t('rightSidePanel.missingNodePacks.ossMessage')
}}
</p>
<!-- Manager disabled hint: shown on OSS when manager is not active -->
<i18n-t
v-if="showManagerHint"

View File

@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
@@ -36,6 +38,16 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
}),
isModelDownloadable: vi.fn(() => true),
toBrowsableUrl: vi.fn((url: string) => url)
}))
describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
@@ -57,6 +69,18 @@ describe('TabErrors.vue', () => {
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
},
missingMedia: {
missingMediaTitle: 'Missing Inputs',
image: 'Images',
uploadFile: 'Upload {type}',
useFromLibrary: 'Use from Library',
confirmSelection: 'Confirm selection',
locateNode: 'Locate node',
expandNodes: 'Show referencing nodes',
collapseNodes: 'Hide referencing nodes',
cancelSelection: 'Cancel selection',
or: 'OR'
}
}
}
@@ -282,6 +306,85 @@ describe('TabErrors.vue', () => {
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
})
it('renders missing model display message below the section title', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
})
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.getByText('Download a model, or open the node to replace it.')
).toBeInTheDocument()
})
it('renders missing media display message below the section title', () => {
const missingMedia = {
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'portrait.png',
isMissing: true
} satisfies MissingMediaCandidate
renderComponent({
missingMedia: {
missingMediaCandidates: [missingMedia]
}
})
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
expect(
screen.getByText('A required media input has no file selected.')
).toBeInTheDocument()
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',
nodeId: '1',
isReplaceable: true,
replacement: {
old_node_id: 'OldSampler',
new_node_id: 'KSampler',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
} satisfies MissingNodeType
renderComponent({
missingNodesError: {
missingNodesError: {
message: 'Missing Node Packs',
nodeTypes: [swapNode]
}
}
})
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })
).toBeInTheDocument()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
const missingModel = {
nodeId: '1',

View File

@@ -154,6 +154,18 @@
</div>
</template>
<div
v-if="group.type !== 'execution' && group.displayMessage"
data-testid="error-group-display-message"
class="px-4 pt-1 pb-3"
>
<p
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
@@ -166,7 +178,7 @@
<!-- Swap Nodes -->
<SwapNodesCard
v-else-if="group.type === 'swap_nodes'"
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@@ -174,7 +186,7 @@
/>
<!-- Execution Errors -->
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
@@ -189,7 +201,7 @@
<!-- Missing Models -->
<MissingModelCard
v-else-if="group.type === 'missing_model'"
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
@@ -197,7 +209,7 @@
<!-- Missing Media -->
<MissingMediaCard
v-else-if="group.type === 'missing_media'"
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"

View File

@@ -174,6 +174,7 @@ describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockIsCloud.value = false
vi.mocked(isLGraphNode).mockReturnValue(false)
})
describe('missingPackGroups', () => {
@@ -301,7 +302,24 @@ describe('useErrorGroups', () => {
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayMessage).toBe(
'Some nodes are missing and need to be installed'
'Install missing packs to use this workflow.'
)
})
it('uses Cloud copy for missing_node group in Cloud', async () => {
mockIsCloud.value = true
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup?.displayMessage).toBe(
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
)
})
@@ -524,6 +542,28 @@ describe('useErrorGroups', () => {
expect(promptGroup).toBeDefined()
})
it('includes prompt error when a node is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
store.lastPromptError = {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeDefined()
})
it('sorts cards within an execution group by nodeId numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
@@ -960,4 +1000,28 @@ describe('useErrorGroups', () => {
)
})
})
describe('tabErrorGroups', () => {
it('filters prompt error when a node is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '1' }])
store.lastPromptError = {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
await nextTick()
const promptGroup = groups.tabErrorGroups.value.find(
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeUndefined()
})
})
})

View File

@@ -347,8 +347,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
card.errors.push(...errors)
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
function processPromptError(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (
(filterBySelection && selectedNodeInfo.value.nodeIds) ||
!executionErrorStore.lastPromptError
)
return
const error = executionErrorStore.lastPromptError
@@ -717,7 +723,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
isCloud
})
}
@@ -834,9 +839,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
mediaTypes: filteredMissingMediaGroups.value.map(
(group) => group.mediaType
),
isCloud
})
}
@@ -861,7 +863,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const tabErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap)
processPromptError(groupsMap, true)
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)

View File

@@ -204,6 +204,90 @@ describe('contextMenuConverter', () => {
})
})
describe('media node ordering (FE-839)', () => {
const IMAGE_GROUP = [
'Open Image',
'Open in Mask Editor',
'Copy Image',
'Paste Image',
'Save Image'
]
// Input order is intentionally scrambled to prove the rendered order is
// governed by MENU_ORDER, not by the order options are passed in.
const mediaNodeOptions = (): MenuOption[] => [
{ label: 'Rename', source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Pin', source: 'vue' },
{ label: 'Node Info', source: 'vue' },
{ label: 'Open in Mask Editor', source: 'vue' },
{ label: 'Open Image', source: 'vue' },
{ label: 'Copy Image', source: 'vue' },
{ label: 'Paste Image', source: 'vue' },
{ label: 'Save Image', source: 'vue' }
]
const firstActionLabel = (result: MenuOption[]) =>
result.find((opt) => opt.type !== 'divider' && opt.type !== 'category')
?.label
const indexOfLabel = (result: MenuOption[], label: string) =>
result.findIndex((opt) => opt.label === label)
it('places Open Image as the first item for media nodes', () => {
expect(firstActionLabel(buildStructuredMenu(mediaNodeOptions()))).toBe(
'Open Image'
)
})
it('surfaces the whole image action group above generic node actions', () => {
const result = buildStructuredMenu(mediaNodeOptions())
const renameIndex = indexOfLabel(result, 'Rename')
for (const label of IMAGE_GROUP) {
expect(indexOfLabel(result, label)).toBeLessThan(renameIndex)
}
})
it('keeps Copy Image, Paste Image, Save Image in that relative order', () => {
const result = buildStructuredMenu(mediaNodeOptions())
expect(indexOfLabel(result, 'Copy Image')).toBeLessThan(
indexOfLabel(result, 'Paste Image')
)
expect(indexOfLabel(result, 'Paste Image')).toBeLessThan(
indexOfLabel(result, 'Save Image')
)
})
it('separates the image group from core actions with a divider', () => {
const result = buildStructuredMenu(mediaNodeOptions())
const saveIndex = indexOfLabel(result, 'Save Image')
const renameIndex = indexOfLabel(result, 'Rename')
const hasDividerBetween = result
.slice(saveIndex + 1, renameIndex)
.some((opt) => opt.type === 'divider')
expect(hasDividerBetween).toBe(true)
})
it('preserves the relative order of core actions below the image group', () => {
const result = buildStructuredMenu(mediaNodeOptions())
expect(indexOfLabel(result, 'Rename')).toBeLessThan(
indexOfLabel(result, 'Pin')
)
expect(indexOfLabel(result, 'Pin')).toBeLessThan(
indexOfLabel(result, 'Node Info')
)
})
it('leaves non-media node menus starting with Rename', () => {
const result = buildStructuredMenu([
{ label: 'Node Info', source: 'vue' },
{ label: 'Pin', source: 'vue' },
{ label: 'Rename', source: 'vue' },
{ label: 'Copy', source: 'vue' }
])
expect(firstActionLabel(result)).toBe('Rename')
})
})
describe('convertContextMenuToOptions', () => {
it('should convert empty array to empty result', () => {
const result = convertContextMenuToOptions([])

View File

@@ -227,6 +227,12 @@ function removeDuplicateMenuOptions(options: MenuOption[]): MenuOption[] {
* Order groups for menu items - defines the display order of sections
*/
const MENU_ORDER: string[] = [
// Section 0: Media operations (surfaced at the top for media nodes)
'Open Image',
'Open in Mask Editor',
'Copy Image',
'Paste Image',
'Save Image',
// Section 1: Basic operations
'Rename',
'Copy',
@@ -248,12 +254,7 @@ const MENU_ORDER: string[] = [
// Section 4: Node properties
'Node Info',
'Color',
// Section 5: Node-specific operations
'Open in Mask Editor',
'Open Image',
'Copy Image',
'Paste Image',
'Save Image',
// Section 5: Clipspace operations
'Copy (Clipspace)',
'Paste (Clipspace)'
]
@@ -309,27 +310,29 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
coreLabels.sort((a, b) => getMenuItemOrder(a) - getMenuItemOrder(b))
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-14 (Convert to Subgraph, Frame selection, Frame Nodes, Minimize Node, Expand Node, Clone)
// Section 4: 15-16 (Node Info, Color)
// Section 5: 17+ (Image operations and fallback items)
// Section 0: 0-4 (Open Image, Open in Mask Editor, Copy Image, Paste Image, Save Image)
// Section 1: 5-7 (Rename, Copy, Duplicate)
// Section 2: 8-13 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 14-19 (Convert to Subgraph, Frame selection, Frame Nodes, Minimize Node, Expand Node, Clone)
// Section 4: 20-21 (Node Info, Color)
// Section 5: 22+ (Clipspace and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 14) return 3
if (index <= 16) return 4
if (index <= 4) return 0
if (index <= 7) return 1
if (index <= 13) return 2
if (index <= 19) return 3
if (index <= 21) return 4
return 5
}
let lastSection = 0
let lastSection = -1
for (const label of coreLabels) {
const item = coreItemsMap.get(label)!
const itemIndex = getMenuItemOrder(label)
const currentSection = getSectionNumber(itemIndex)
// Add divider when moving to a new section
if (lastSection > 0 && currentSection !== lastSection) {
if (lastSection !== -1 && currentSection !== lastSection) {
orderedCoreItems.push({ type: 'divider' })
}

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest'
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
interface MockNodeSpec {
id: number | string
pos: [number, number]
size: [number, number]
title_mode?: TitleMode
}
const makeNode = (spec: MockNodeSpec): LGraphNode =>
({
id: spec.id,
pos: spec.pos,
size: spec.size,
title_mode: spec.title_mode
}) as unknown as LGraphNode
const GAP = 12
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
describe('computeArrangement', () => {
it('returns no updates when fewer than 2 nodes are selected', () => {
expect(computeArrangement([], 'vertical')).toEqual([])
expect(
computeArrangement(
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
'grid'
)
).toEqual([])
})
describe('vertical', () => {
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
const nodes = [
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
]
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
// Each node's pos.y = visualTop + its titleHeight (30).
// b: pos.y = 70+30 = 100; visualTop += (30+30)+12 = 142
// a: pos.y = 142+30 = 172; visualTop += (50+30)+12 = 234
// c: pos.y = 234+30 = 264
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 'b', position: { x: 10, y: 100 } },
{ nodeId: 'a', position: { x: 10, y: 172 } },
{ nodeId: 'c', position: { x: 10, y: 264 } }
])
})
it('omits the title-height contribution for NO_TITLE nodes', () => {
const nodes = [
makeNode({
id: 1,
pos: [0, 0],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
}),
makeNode({
id: 2,
pos: [0, 200],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
})
]
// No titles: visualHeight = size[1] = 100. visualTop = 0. Gap = 12.
// 1: pos.y = 0; visualTop = 0 + 100 + 12 = 112.
// 2: pos.y = 112.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
])
})
it('preserves heterogeneous heights when computing gaps', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
]
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
// 2: pos.y = 212+30 = 242.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
])
})
})
describe('horizontal', () => {
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
const nodes = [
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
]
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
// Sort by X: b(0), a(100), c(300)
// Lay out from (100, 50):
// b at (100, 50)
// a at (100 + 60 + 12, 50) = (172, 50)
// c at (172 + 80 + 12, 50) = (264, 50)
const result = computeArrangement(nodes, 'horizontal')
expect(result).toEqual([
{ nodeId: 'b', position: { x: 100, y: 50 } },
{ nodeId: 'a', position: { x: 172, y: 50 } },
{ nodeId: 'c', position: { x: 264, y: 50 } }
])
})
})
describe('grid', () => {
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
]
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
// Row visual heights: row0=max(50+30,60+30)=90; row1=max(40+30,30+30)=70.
// colX=[0, 132]. rowVisualTop=[-30, -30+90+12=72].
// pos.y = rowVisualTop + 30 (titleHeight).
const result = computeArrangement(nodes, 'grid')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 132, y: 0 } },
{ nodeId: 3, position: { x: 0, y: 102 } },
{ nodeId: 4, position: { x: 132, y: 102 } }
])
})
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
const nodes = Array.from({ length: 5 }, (_, i) =>
makeNode({
id: i + 1,
pos: [i * 50, i * 50],
size: [40, 40]
})
)
const result = computeArrangement(nodes, 'grid')
expect(result).toHaveLength(5)
// Sorted by Y then X = original order. Anchor = node 1 at (0,0).
// colWidths=[40,40,40]. rowVisualHeight = 40+30 = 70 each.
// colX=[0,52,104]. rowVisualTop=[-30, -30+70+12=52]. pos.y = visualTop+30.
expect(result[0].position).toEqual({ x: 0, y: 0 })
expect(result[1].position).toEqual({ x: 52, y: 0 })
expect(result[2].position).toEqual({ x: 104, y: 0 })
expect(result[3].position).toEqual({ x: 0, y: 82 })
expect(result[4].position).toEqual({ x: 52, y: 82 })
})
})
describe('anchor selection', () => {
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
const nodes = [
// min y but large x: x+y = 1000
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
// min x but large y: x+y = 1000
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
// smallest x+y: 600
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
]
const result = computeArrangement(nodes, 'vertical')
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
expect(result[0]).toEqual({
nodeId: 'minY',
position: { x: 300, y: 300 }
})
expect(result.every((u) => u.position.x === 300)).toBe(true)
})
})
})

View File

@@ -0,0 +1,186 @@
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { Point } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'
export const DEFAULT_ARRANGE_GAP = 12
export const MIN_ARRANGE_GAP = 0
export const MAX_ARRANGE_GAP = 48
interface NodeBox {
id: NodeId
posX: number
posY: number
visualWidth: number
visualHeight: number
titleHeight: number
}
interface ArrangeUpdate {
nodeId: NodeId
position: Point
}
const titleHeightOf = (node: LGraphNode): number => {
const mode = node.title_mode
if (mode === TitleMode.TRANSPARENT_TITLE || mode === TitleMode.NO_TITLE) {
return 0
}
return LiteGraph.NODE_TITLE_HEIGHT
}
const toBox = (node: LGraphNode): NodeBox => {
const titleHeight = titleHeightOf(node)
return {
id: node.id,
posX: node.pos[0],
posY: node.pos[1],
visualWidth: node.size[0],
visualHeight: node.size[1] + titleHeight,
titleHeight
}
}
const byTopDown = (a: NodeBox, b: NodeBox) => a.posY - b.posY || a.posX - b.posX
const byLeftRight = (a: NodeBox, b: NodeBox) =>
a.posX - b.posX || a.posY - b.posY
const findAnchor = (boxes: NodeBox[]): NodeBox =>
boxes.reduce((best, box) =>
box.posX + box.posY < best.posX + best.posY ? box : best
)
const cumulativeOffsets = (
sizes: number[],
origin: number,
gap: number
): number[] => {
const offsets: number[] = [origin]
for (let i = 1; i < sizes.length; i++) {
offsets.push(offsets[i - 1] + sizes[i - 1] + gap)
}
return offsets
}
const arrangeVertical = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byTopDown)
let visualTop = anchor.posY - anchor.titleHeight
return sorted.map((box) => {
const update: ArrangeUpdate = {
nodeId: box.id,
position: { x: anchor.posX, y: visualTop + box.titleHeight }
}
visualTop += box.visualHeight + gap
return update
})
}
const arrangeHorizontal = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byLeftRight)
const visualTop = anchor.posY - anchor.titleHeight
let cursorX = anchor.posX
return sorted.map((box) => {
const update: ArrangeUpdate = {
nodeId: box.id,
position: { x: cursorX, y: visualTop + box.titleHeight }
}
cursorX += box.visualWidth + gap
return update
})
}
const arrangeGrid = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byTopDown)
const cols = Math.ceil(Math.sqrt(sorted.length))
const rows = Math.ceil(sorted.length / cols)
const colWidths = new Array<number>(cols).fill(0)
const rowHeights = new Array<number>(rows).fill(0)
sorted.forEach((box, i) => {
const col = i % cols
const row = Math.floor(i / cols)
if (box.visualWidth > colWidths[col]) colWidths[col] = box.visualWidth
if (box.visualHeight > rowHeights[row]) rowHeights[row] = box.visualHeight
})
const colX = cumulativeOffsets(colWidths, anchor.posX, gap)
const rowVisualTop = cumulativeOffsets(
rowHeights,
anchor.posY - anchor.titleHeight,
gap
)
return sorted.map((box, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return {
nodeId: box.id,
position: {
x: colX[col],
y: rowVisualTop[row] + box.titleHeight
}
}
})
}
export function computeArrangement(
nodes: LGraphNode[],
layout: ArrangeLayout,
gap: number = DEFAULT_ARRANGE_GAP
): ArrangeUpdate[] {
if (nodes.length < 2) return []
const boxes = nodes.map(toBox)
const anchor = findAnchor(boxes)
if (layout === 'vertical') return arrangeVertical(boxes, anchor, gap)
if (layout === 'horizontal') return arrangeHorizontal(boxes, anchor, gap)
return arrangeGrid(boxes, anchor, gap)
}
interface ArrangeOptions {
gap?: number
captureUndo?: boolean
}
export function useArrangeNodes() {
const { selectedNodes, hasMultipleSelection } = useSelectionState()
const mutations = useLayoutMutations()
const workflowStore = useWorkflowStore()
const arrangeNodes = (
layout: ArrangeLayout,
{ gap = DEFAULT_ARRANGE_GAP, captureUndo = true }: ArrangeOptions = {}
) => {
if (!hasMultipleSelection.value) return
const updates = computeArrangement(selectedNodes.value, layout, gap)
if (updates.length === 0) return
mutations.setSource(LayoutSource.Canvas)
mutations.batchMoveNodes(updates)
app.canvas?.setDirty(true, true)
if (captureUndo) {
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
}
return { arrangeNodes, canArrange: hasMultipleSelection }
}

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ArrangeNodesModule from '@/composables/graph/useArrangeNodes'
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
const mockArrangeNodes = vi.fn()
vi.mock('@/composables/graph/useArrangeNodes', async () => {
const actual = await vi.importActual<typeof ArrangeNodesModule>(
'@/composables/graph/useArrangeNodes'
)
return {
...actual,
useArrangeNodes: () => ({ arrangeNodes: mockArrangeNodes })
}
})
describe('useArrangeSession', () => {
let frameCallbacks: Array<FrameRequestCallback>
let nextHandle: number
beforeEach(() => {
mockArrangeNodes.mockReset()
frameCallbacks = []
nextHandle = 1
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(
(cb: FrameRequestCallback) => {
frameCallbacks.push(cb)
return nextHandle++
}
)
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {
frameCallbacks[id - 1] = () => {}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
const flushFrames = () => {
const callbacks = frameCallbacks
frameCallbacks = []
callbacks.forEach((cb) => cb(performance.now()))
}
it('start() applies layout immediately and captures undo', () => {
const session = useArrangeSession()
session.start('vertical')
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('vertical', {
gap: 12,
captureUndo: true
})
expect(session.activeLayout.value).toBe('vertical')
expect(session.gap.value).toBe(12)
})
it('previewGap() throttles repeated calls into a single frame', () => {
const session = useArrangeSession()
session.start('grid')
mockArrangeNodes.mockClear()
session.previewGap(20)
session.previewGap(30)
session.previewGap(40)
expect(mockArrangeNodes).not.toHaveBeenCalled()
flushFrames()
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('grid', {
gap: 40,
captureUndo: false
})
})
it('previewGap() is a no-op outside an active session', () => {
const session = useArrangeSession()
session.previewGap(20)
flushFrames()
expect(mockArrangeNodes).not.toHaveBeenCalled()
})
it('commitGap() cancels any pending preview frame', () => {
const session = useArrangeSession()
session.start('horizontal')
mockArrangeNodes.mockClear()
session.previewGap(25)
session.commitGap(36)
flushFrames()
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('horizontal', {
gap: 36,
captureUndo: true
})
})
it('reset() ends the session and prevents pending frames from arranging', () => {
const session = useArrangeSession()
session.start('vertical')
mockArrangeNodes.mockClear()
session.previewGap(40)
session.reset()
flushFrames()
expect(mockArrangeNodes).not.toHaveBeenCalled()
expect(session.activeLayout.value).toBeNull()
expect(session.gap.value).toBe(12)
})
})

View File

@@ -0,0 +1,60 @@
import { readonly, ref } from 'vue'
import {
DEFAULT_ARRANGE_GAP,
useArrangeNodes
} from '@/composables/graph/useArrangeNodes'
import type { ArrangeLayout } from '@/composables/graph/useArrangeNodes'
export function useArrangeSession() {
const { arrangeNodes } = useArrangeNodes()
const activeLayout = ref<ArrangeLayout | null>(null)
const gap = ref(DEFAULT_ARRANGE_GAP)
let pendingFrame: number | null = null
const cancelPendingFrame = () => {
if (pendingFrame === null) return
cancelAnimationFrame(pendingFrame)
pendingFrame = null
}
const reset = () => {
cancelPendingFrame()
activeLayout.value = null
gap.value = DEFAULT_ARRANGE_GAP
}
const start = (layout: ArrangeLayout) => {
gap.value = DEFAULT_ARRANGE_GAP
activeLayout.value = layout
arrangeNodes(layout, { gap: gap.value, captureUndo: true })
}
const previewGap = (nextGap: number) => {
if (activeLayout.value === null) return
gap.value = nextGap
cancelPendingFrame()
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null
if (activeLayout.value === null) return
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: false })
})
}
const commitGap = (nextGap: number) => {
if (activeLayout.value === null) return
cancelPendingFrame()
gap.value = nextGap
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: true })
}
return {
activeLayout: readonly(activeLayout),
gap: readonly(gap),
start,
previewGap,
commitGap,
reset
}
}

View File

@@ -101,6 +101,23 @@ describe('useImageMenuOptions', () => {
expect(copyIdx).toBeLessThan(pasteIdx)
expect(pasteIdx).toBeLessThan(saveIdx)
})
it('gives the Open in Mask Editor option the mask icon', () => {
const node = createImageNode()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const maskOption = options.find((o) => o.label === 'Open in Mask Editor')
expect(maskOption?.icon).toBe('icon-[comfy--mask]')
})
it('gives every image action option an icon so labels stay aligned', () => {
const node = createImageNode()
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
expect(options.every((o) => !!o.icon)).toBe(true)
})
})
describe('pasteImage action', () => {

View File

@@ -116,15 +116,16 @@ export function useImageMenuOptions() {
if (hasImages) {
options.push(
{
label: t('contextMenu.Open in Mask Editor'),
action: () => openMaskEditor()
},
{
label: t('contextMenu.Open Image'),
icon: 'icon-[lucide--external-link]',
action: () => openImage(node)
},
{
label: t('contextMenu.Open in Mask Editor'),
icon: 'icon-[comfy--mask]',
action: () => openMaskEditor()
},
{
label: t('contextMenu.Copy Image'),
icon: 'icon-[lucide--copy]',

View File

@@ -456,4 +456,105 @@ describe('useNodeDragToCanvas', () => {
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
})
})
describe('native drag position tracking', () => {
beforeEach(() => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([300, 300])
})
// happy-dom has no DragEvent constructor; MouseEvent works since the
// handler only reads clientX/clientY.
function fireDrag(x: number, y: number) {
document.dispatchEvent(
new MouseEvent('dragover', { clientX: x, clientY: y, bubbles: true })
)
}
it('should prefer tracked drag position over dragend coordinates', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
fireDrag(250, 250)
// dragend supplies a bad position (the Firefox bug); the tracked one
// from the last drag event should win.
handleNativeDrop(1505, 102)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 250,
clientY: 250
})
})
it('should ignore drag events with (0, 0)', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
fireDrag(250, 250)
fireDrag(0, 0)
handleNativeDrop(1505, 102)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 250,
clientY: 250
})
})
it('should fall back to dragend coordinates when no drag fired', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 250,
clientY: 250
})
})
it('should ignore dragover events fired before startDrag', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
fireDrag(250, 250)
startDrag(mockNodeDef, 'native')
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 300,
clientY: 300
})
})
it('should clear tracked position between drags', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
fireDrag(250, 250)
handleNativeDrop(1505, 102)
// Second drag - no drag events, so we should fall back to args.
startDrag(mockNodeDef, 'native')
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({
clientX: 300,
clientY: 300
})
})
})
})

View File

@@ -11,16 +11,27 @@ const isDragging = ref(false)
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
const cursorPosition = ref({ x: 0, y: 0 })
const dragMode = ref<DragMode>('click')
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
let listenersSetup = false
function updatePosition(e: PointerEvent) {
cursorPosition.value = { x: e.clientX, y: e.clientY }
}
// Firefox dragend can report stale clientX/Y and `drag` can fire with
// (0, 0). dragover on the target reliably reports real client coords.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
function trackNativeDragPosition(e: DragEvent) {
if (dragMode.value !== 'native') return
if (e.clientX === 0 && e.clientY === 0) return
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
}
function isOverCanvas(clientX: number, clientY: number): boolean {
@@ -85,6 +96,7 @@ function setupGlobalListeners() {
document.addEventListener('pointerdown', blockCommitPointerDown, true)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
document.addEventListener('dragover', trackNativeDragPosition)
}
function cleanupGlobalListeners() {
@@ -95,6 +107,7 @@ function cleanupGlobalListeners() {
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('dragover', trackNativeDragPosition)
if (isDragging.value && dragMode.value === 'click') {
cancelDrag()
@@ -110,8 +123,9 @@ export function useNodeDragToCanvas() {
function handleNativeDrop(clientX: number, clientY: number) {
if (dragMode.value !== 'native') return
const tracked = lastNativeDragPosition.value
try {
addNodeAtPosition(clientX, clientY)
addNodeAtPosition(tracked?.x ?? clientX, tracked?.y ?? clientY)
} finally {
cancelDrag()
}

View File

@@ -144,7 +144,6 @@ describe('useLoad3d', () => {
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setRetainViewOnReload: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
@@ -334,6 +333,20 @@ describe('useLoad3d', () => {
expect(composable.isPreview.value).toBe(true)
})
it('should set preview mode when comfyClass starts with Preview, even with width/height widgets', async () => {
Object.defineProperty(mockNode, 'constructor', {
value: { comfyClass: 'Preview3DAdvanced' },
configurable: true
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -570,21 +583,17 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
vi.mocked(mockLoad3d.setFOV!).mockClear()
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
composable.cameraConfig.value.retainViewOnReload = true
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null,
retainViewOnReload: true
state: null
})
})

View File

@@ -152,7 +152,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (!(widthWidget && heightWidget)) {
if (
node.constructor.comfyClass?.startsWith('Preview') ||
!(widthWidget && heightWidget)
) {
isPreview.value = true
}
@@ -484,7 +487,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
}
},
{ deep: true }

View File

@@ -1,55 +0,0 @@
import type { HintedString } from '@primevue/core'
import type { InjectionKey } from 'vue'
import { computed, inject } from 'vue'
/**
* Options for configuring transform-compatible overlay props
*/
interface TransformCompatOverlayOptions {
/**
* Where to append the overlay. 'self' keeps overlay within component
* for proper transform inheritance, 'body' teleports to document body
*/
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
// Future: other props needed for transform compatibility
// scrollTarget?: string | HTMLElement
// autoZIndex?: boolean
}
export const OverlayAppendToKey: InjectionKey<
HintedString<'body' | 'self'> | undefined | HTMLElement
> = Symbol('OverlayAppendTo')
/**
* Composable that provides props to make PrimeVue overlay components
* compatible with CSS-transformed parent elements.
*
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
* body by default, breaking transform inheritance. This composable provides
* the necessary props to keep overlays within their component elements.
*
* @param overrides - Optional overrides for specific use cases
* @returns Computed props object to spread on PrimeVue overlay components
*
* @example
* ```vue
* <template>
* <Select v-bind="overlayProps" />
* </template>
*
* <script setup>
* const overlayProps = useTransformCompatOverlayProps()
* </script>
* ```
*/
export function useTransformCompatOverlayProps(
overrides: TransformCompatOverlayOptions = {}
) {
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
return computed(() => ({
appendTo: injectedAppendTo ?? ('self' as const),
...overrides
}))
}

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
@@ -9,17 +10,21 @@ const {
waitForLoad3dMock,
onLoad3dReadyMock,
configureMock,
configureForSaveMeshMock,
getLoad3dMock,
toastAddAlertMock,
getNodeByLocatorIdMock
getNodeByLocatorIdMock,
nodeToLoad3dMap
} = vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
waitForLoad3dMock: vi.fn(),
onLoad3dReadyMock: vi.fn(),
configureMock: vi.fn(),
configureForSaveMeshMock: vi.fn(),
getLoad3dMock: vi.fn(),
toastAddAlertMock: vi.fn(),
getNodeByLocatorIdMock: vi.fn()
getNodeByLocatorIdMock: vi.fn(),
nodeToLoad3dMap: new Map<object, unknown>()
}))
vi.mock('@/services/extensionService', () => ({
@@ -38,12 +43,13 @@ vi.mock('@/composables/useLoad3d', () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap: new Map()
nodeToLoad3dMap
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configure = configureMock
configureForSaveMesh = configureForSaveMeshMock
}
}))
@@ -116,18 +122,21 @@ type ExtCreated = ComfyExtension & {
onNodeOutputsUpdated: (
nodeOutputs: Record<string, Record<string, unknown>>
) => void
getCustomWidgets: () => Record<string, (node: LGraphNode) => unknown>
}
async function loadExtensionsFresh(): Promise<{
load3DExt: ExtCreated
preview3DExt: ExtCreated
preview3DAdvancedExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3d')
return {
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
}
}
@@ -153,6 +162,22 @@ function makePreview3DNode(
} as unknown as LGraphNode
}
function makePreview3DAdvancedNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
widgets: FakeWidget[]
}> = {}
): LGraphNode {
return {
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
size: [400, 550],
setSize: vi.fn(),
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
function makeLoad3DNode(
overrides: Partial<{
comfyClass: string
@@ -179,7 +204,14 @@ interface FakeLoad3d {
whenLoadIdle: () => Promise<void>
setCameraFromMatrices: ReturnType<typeof vi.fn>
setBackgroundImage: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
getModelInfo: ReturnType<typeof vi.fn>
applyModelTransform: ReturnType<typeof vi.fn>
isSplatModel: ReturnType<typeof vi.fn>
forceRender: ReturnType<typeof vi.fn>
cameraManager: { perspectiveCamera: { fov: number } }
currentLoadGeneration: number
}
@@ -188,7 +220,14 @@ function makeLoad3dMock(): FakeLoad3d {
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
setCameraFromMatrices: vi.fn(),
setBackgroundImage: vi.fn(),
setCameraState: vi.fn(),
getCameraState: vi.fn(() => ({ position: [0, 0, 5], target: [0, 0, 0] })),
getCurrentCameraType: vi.fn(() => 'perspective'),
getModelInfo: vi.fn(() => null),
applyModelTransform: vi.fn(),
isSplatModel: vi.fn(() => false),
forceRender: vi.fn(),
cameraManager: { perspectiveCamera: { fov: 35 } },
currentLoadGeneration: 0
}
}
@@ -199,6 +238,7 @@ async function flush() {
function setupBaseMocks() {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
cb(makeLoad3dMock())
})
@@ -210,12 +250,14 @@ function setupBaseMocks() {
describe('load3d module registration', () => {
beforeEach(setupBaseMocks)
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
})
})
@@ -476,6 +518,47 @@ describe('Comfy.Load3D.nodeCreated', () => {
})
})
describe('Comfy.Load3D.getCustomWidgets LOAD_3D', () => {
beforeEach(setupBaseMocks)
it('adds upload and clear buttons when the node has a model_file widget', async () => {
const { load3DExt } = await loadExtensionsFresh()
const node = makeLoad3DNode()
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
load3DExt.getCustomWidgets().LOAD_3D(node)
const buttonNames = addWidget.mock.calls
.filter(([type]) => type === 'button')
.map(([, name]) => name)
expect(buttonNames).toEqual([
'upload 3d model',
'upload extra resources',
'clear'
])
})
it('skips upload and clear buttons when the node has no model_file widget (e.g. Preview3DAdvanced)', async () => {
const { load3DExt } = await loadExtensionsFresh()
const node = makeLoad3DNode({
comfyClass: 'Preview3DAdvanced',
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 },
{ name: 'image', value: '' }
]
})
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
load3DExt.getCustomWidgets().LOAD_3D(node)
const buttonCalls = addWidget.mock.calls.filter(
([type]) => type === 'button'
)
expect(buttonCalls).toEqual([])
})
})
describe('getNodeMenuItems', () => {
beforeEach(setupBaseMocks)
@@ -610,3 +693,324 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
)
})
})
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Preview3DAdvanced', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'OtherNode' })
await preview3DAdvancedExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('does not call configureForSaveMesh on creation when no Last Time Model File is persisted', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores via configureForSaveMesh when Last Time Model File is persisted', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
properties: { 'Last Time Model File': 'prev/model.glb' }
})
await preview3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'temp',
'prev/model.glb',
{ silentOnNotFound: true }
)
})
it('restores the saved camera state after model load when reloading the page', async () => {
const persistedCameraState = {
position: [1, 2, 3],
target: [0, 0, 0]
} as unknown as CameraState
const load3dInstance = makeLoad3dMock()
onLoad3dReadyMock.mockImplementationOnce(
(cb: (load3d: FakeLoad3d) => void) => {
cb(load3dInstance)
}
)
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
properties: {
'Last Time Model File': 'prev/model.glb',
'Camera Config': {
cameraType: 'perspective',
fov: 35,
state: persistedCameraState
}
}
})
await preview3DAdvancedExt.nodeCreated(node)
await flush()
expect(load3dInstance.setCameraState).toHaveBeenCalledWith(
persistedCameraState
)
expect(load3dInstance.forceRender).toHaveBeenCalled()
})
it('does not call setCameraState when no Camera Config state is persisted', async () => {
const load3dInstance = makeLoad3dMock()
onLoad3dReadyMock.mockImplementationOnce(
(cb: (load3d: FakeLoad3d) => void) => {
cb(load3dInstance)
}
)
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
properties: { 'Last Time Model File': 'prev/model.glb' }
})
await preview3DAdvancedExt.nodeCreated(node)
await flush()
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
})
it('attaches a camera-only serializeValue to the image widget', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
await preview3DAdvancedExt.nodeCreated(node)
expect(typeof widgets[0].serializeValue).toBe('function')
})
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
nodeToLoad3dMap.set(node, load3d)
await preview3DAdvancedExt.nodeCreated(node)
const payload = await widgets[0].serializeValue!()
expect(payload).toEqual({
image: '',
mask: '',
normal: '',
camera_info: { position: [0, 0, 5], target: [0, 0, 0] },
recording: '',
model_3d_info: []
})
})
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
const modelInfo = {
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 }
}
load3d.getModelInfo = vi.fn(() => modelInfo)
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
nodeToLoad3dMap.set(node, load3d)
await preview3DAdvancedExt.nodeCreated(node)
const payload = (await widgets[0].serializeValue!()) as {
model_3d_info: unknown[]
}
expect(payload.model_3d_info).toEqual([modelInfo])
})
it('onExecuted persists Last Time Model File with normalized slashes and calls configureForSaveMesh', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'temp',
'sub/nested/mesh.glb',
{ silentOnNotFound: true }
)
})
it('onExecuted applies the input cameraState when one is forwarded via PreviewUI3D', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const cameraState = { position: [1, 2, 3] }
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['mesh.glb', cameraState] })
await flush()
expect(load3d.setCameraState).toHaveBeenCalledWith(cameraState)
})
it('onExecuted applies the first model_3d_info entry to the viewport when present', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const transform = {
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 2, y: 2, z: 2 }
}
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({
result: ['mesh.glb', undefined, [transform]]
})
await flush()
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
})
it('onExecuted does not call applyModelTransform when model_3d_info is empty', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({
result: ['mesh.glb', undefined, []]
})
await flush()
expect(load3d.applyModelTransform).not.toHaveBeenCalled()
})
it('onExecuted defensively skips cameraState apply when result[1] is missing', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['mesh.glb'] })
await flush()
expect(load3d.setCameraState).not.toHaveBeenCalled()
})
it('onExecuted skips cameraState apply when load3d generation changes before whenLoadIdle resolves', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
let resolveIdle: () => void = () => {}
load3d.whenLoadIdle = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveIdle = resolve
})
)
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['mesh.glb', { position: [1, 2, 3] }] })
load3d.currentLoadGeneration = 6
resolveIdle()
await flush()
expect(load3d.setCameraState).not.toHaveBeenCalled()
})
it('onExecuted shows an error toast when no file path is returned', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: [] })
expect(toastAddAlertMock).toHaveBeenCalledWith(
'toastMessages.unableToGetModelFilePath'
)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
})
describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
beforeEach(setupBaseMocks)
it('returns [] for non-Preview3DAdvanced nodes', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = {
constructor: { comfyClass: 'OtherNode' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
})
it('returns [] when no load3d instance exists for the node', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue(null)
const node = {
constructor: { comfyClass: 'Preview3DAdvanced' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
})
it('returns [] for splat models', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
const node = {
constructor: { comfyClass: 'Preview3DAdvanced' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
})
it('returns export menu items for non-splat models', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
const node = {
constructor: { comfyClass: 'Preview3DAdvanced' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([
{ content: 'Export' }
])
})
})

View File

@@ -29,6 +29,9 @@ type Matrix = number[][]
type Load3dPreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
}>
type Preview3DAdvancedOutput = NodeOutputWith<{
result?: [string?, CameraState?, Model3DInfo?]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
@@ -269,7 +272,10 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
if (node.constructor.comfyClass === 'Load3D') {
const hasModelFileWidget = node.widgets?.some(
(w) => w.name === 'model_file'
)
if (hasModelFileWidget) {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
node.properties['Resource Folder'] = ''
@@ -651,3 +657,156 @@ useExtensionService().registerExtension({
})
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAdvanced',
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
'Failed to restore camera state for Preview3DAdvanced:',
error
)
})
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
silentOnNotFound: true
})
const cameraState = result?.[1]
const modelTransform = result?.[2]?.[0]
if (cameraState || modelTransform) {
const targetGeneration = currentLoad3d.currentLoadGeneration
void currentLoad3d
.whenLoadIdle()
.then(() => {
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
return
if (cameraState) currentLoad3d.setCameraState(cameraState)
if (modelTransform)
currentLoad3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
error
)
})
}
}
})
}
})

View File

@@ -287,6 +287,41 @@ describe('GizmoManager', () => {
})
})
describe('applyModelTransform', () => {
it('sets position, quaternion, and scale on target and notifies', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyModelTransform({
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.92 },
scale: { x: 2, y: 2, z: 2 }
})
expect(model.position.x).toBeCloseTo(1)
expect(model.position.y).toBeCloseTo(2)
expect(model.position.z).toBeCloseTo(3)
expect(model.quaternion.x).toBeCloseTo(0.1)
expect(model.quaternion.y).toBeCloseTo(0.2)
expect(model.quaternion.z).toBeCloseTo(0.3)
expect(model.quaternion.w).toBeCloseTo(0.92)
expect(model.scale.x).toBeCloseTo(2)
expect(onTransformChange).toHaveBeenCalledOnce()
})
it('does nothing without a target', () => {
manager.init()
expect(() =>
manager.applyModelTransform({
position: { x: 0, y: 0, z: 0 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 }
})
).not.toThrow()
})
})
describe('getTransform', () => {
it('returns current target transform', () => {
manager.init()

View File

@@ -159,6 +159,27 @@ export class GizmoManager {
}
}
applyModelTransform(transform: Model3DTransform): void {
if (!this.targetObject) return
this.targetObject.position.set(
transform.position.x,
transform.position.y,
transform.position.z
)
this.targetObject.quaternion.set(
transform.quaternion.x,
transform.quaternion.y,
transform.quaternion.z,
transform.quaternion.w
)
this.targetObject.scale.set(
transform.scale.x,
transform.scale.y,
transform.scale.z
)
this.onTransformChange?.()
}
getInitialTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }

View File

@@ -46,7 +46,7 @@ class Load3DConfiguration {
) {}
configureForSaveMesh(
loadFolder: 'input' | 'output',
loadFolder: 'input' | 'output' | 'temp',
filePath: string,
options?: { silentOnNotFound?: boolean }
) {

View File

@@ -39,6 +39,7 @@ type GizmoStub = {
setMode: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
applyTransform: ReturnType<typeof vi.fn>
applyModelTransform: ReturnType<typeof vi.fn>
getTransform: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
updateCamera: ReturnType<typeof vi.fn>
@@ -73,6 +74,7 @@ function makeGizmoStub(): GizmoStub {
setMode: vi.fn(),
reset: vi.fn(),
applyTransform: vi.fn(),
applyModelTransform: vi.fn(),
getTransform: vi.fn(() => ({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
@@ -203,6 +205,19 @@ describe('Load3d', () => {
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
})
it('applyModelTransform forwards the full position/quaternion/scale payload', () => {
const transform = {
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.4 },
scale: { x: 2, y: 2, z: 2 }
}
ctx.load3d.applyModelTransform(transform)
expect(ctx.gizmo.applyModelTransform).toHaveBeenCalledWith(transform)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('getGizmoTransform returns the gizmoManager transform', () => {
const transform = {
position: { x: 5, y: 6, z: 7 },
@@ -772,8 +787,8 @@ describe('Load3d', () => {
})
})
describe('retainViewOnReload', () => {
function setupLoadInternal(initialFlag: boolean) {
describe('camera framing across reloads', () => {
function setupLoadInternal() {
const getCameraState = vi.fn<() => CameraState>(() => ({
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(),
@@ -802,25 +817,23 @@ describe('Load3d', () => {
setupModelAnimations: vi.fn()
},
handleResize: vi.fn(),
retainViewOnReload: initialFlag,
hasLoadedModel: false
})
return { getCameraState, setCameraState, getCurrentCameraType }
}
it('first load uses default framing even with retain enabled', async () => {
const mocks = setupLoadInternal(true)
it('first load uses default framing', async () => {
const mocks = setupLoadInternal()
await ctx.load3d.loadModel('a.glb')
// hasLoadedModel started false, so retain shouldn't kick in yet.
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('subsequent load captures camera state, skips reset, and restores it', async () => {
const mocks = setupLoadInternal(true)
it('subsequent load preserves the user-adjusted camera framing', async () => {
const mocks = setupLoadInternal()
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
@@ -834,23 +847,8 @@ describe('Load3d', () => {
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('does not retain when the flag is off, even after a prior load', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('toggles to the saved camera type before restoring state when types differ', async () => {
const mocks = setupLoadInternal(true)
const mocks = setupLoadInternal()
mocks.getCameraState.mockImplementation(() => ({
position: new THREE.Vector3(0, 0, 5),
target: new THREE.Vector3(),
@@ -870,7 +868,7 @@ describe('Load3d', () => {
})
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
const mocks = setupLoadInternal(true)
const mocks = setupLoadInternal()
await ctx.load3d.loadModel('a.glb')
ctx.load3d.clearModel()
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
@@ -881,22 +879,6 @@ describe('Load3d', () => {
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
})
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.setRetainViewOnReload(true)
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
})
describe('captureScene', () => {

View File

@@ -105,7 +105,6 @@ class Load3d {
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private retainViewOnReload: boolean = false
private hasLoadedModel: boolean = false
constructor(
@@ -579,17 +578,14 @@ class Load3d {
}
}
public setRetainViewOnReload(value: boolean): void {
this.retainViewOnReload = value
}
private async _loadModelInternal(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
// First load always uses default framing; retain only applies on reload.
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
// First load always uses default framing; subsequent reloads preserve
// the user's framing.
const shouldRetainView = this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
: null
@@ -919,6 +915,12 @@ class Load3d {
this.forceRender()
}
public applyModelTransform(transform: Model3DTransform): void {
if (!this.getCurrentModelCapabilities().gizmoTransform) return
this.gizmoManager.applyModelTransform(transform)
this.forceRender()
}
public getGizmoTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }

View File

@@ -191,7 +191,11 @@ export class LoaderManager implements LoaderManagerInterface {
return null
}
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
const requestedType = params.get('type')
const loadRootFolder =
requestedType === 'output' || requestedType === 'temp'
? requestedType
: 'input'
const subfolder = params.get('subfolder') ?? ''
const path =
'api/view?type=' +

View File

@@ -80,7 +80,6 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "جاري إعادة تحميل النموذج...",
"removeBackgroundImage": "إزالة صورة الخلفية",
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
"scene": "المشهد",
"showGrid": "عرض الشبكة",
"showSkeleton": "إظهار الهيكل العظمي",

View File

@@ -350,6 +350,11 @@
"nodeSlotsError": "Node Slots Error",
"nodeWidgetsError": "Node Widgets Error",
"frameNodes": "Frame Nodes",
"arrange": "Arrange",
"arrangeVertically": "Arrange vertically",
"arrangeHorizontally": "Arrange horizontally",
"arrangeAsGrid": "Arrange as grid",
"arrangeSpacing": "Arrangement spacing",
"listening": "Listening...",
"ready": "Ready",
"play": "Play",
@@ -1963,7 +1968,6 @@
},
"load3d": {
"switchCamera": "Switch Camera",
"retainViewOnReload": "Lock camera view across model reloads",
"showGrid": "Show Grid",
"backgroundColor": "Background Color",
"lightIntensity": "Light Intensity",
@@ -3533,7 +3537,6 @@
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
"willBeReplacedBy": "This node will be replaced by:",
"replaceNode": "Replace Node",
"replaceAll": "Replace All",
@@ -3615,8 +3618,6 @@
"missingNodePacks": {
"title": "Missing Node Packs",
"unsupportedTitle": "Unsupported Node Packs",
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
"installAll": "Install All",
"installNodePack": "Install node pack",
@@ -3681,14 +3682,11 @@
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORS",
"seeErrors": "See Errors",
"showMissingNodes": "Show missing nodes",
"showMissingModels": "Show missing models",
"showSwapNodes": "Show swap nodes",
"showMissingMedia": "Show missing inputs",
"multipleErrorCount": "{count} error found | {count} errors found",
"multipleErrorsMessage": "Resolve them before running the workflow.",
"viewDetails": "View details",
"missingNodes": "Some nodes are missing and need to be installed",
"missingModels": "{count} required model is missing | {count} required models are missing",
"swapNodes": "Some nodes can be replaced with alternatives",
"missingMedia": "Some nodes are missing required inputs"
},
"errorCatalog": {
@@ -3696,6 +3694,46 @@
"nodeName": "This node",
"inputName": "unknown input"
},
"missingErrors": {
"missing_node": {
"displayMessageCloud": "Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
"displayMessageOss": "Install missing packs to use this workflow.",
"toastTitleOneCloud": "{nodeType} isn't available on Cloud",
"toastTitleOneOss": "Missing node: {nodeType}",
"toastTitleManyCloud": "Nodes aren't available on Cloud",
"toastTitleManyOss": "Missing nodes",
"toastMessageOneCloud": "This node isn't supported on Cloud.",
"toastMessageOneOss": "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node.",
"toastMessageManyCloud": "This workflow uses nodes that aren't supported on Cloud.",
"toastMessageManyOss": "{count} nodes require missing node packs."
},
"swap_nodes": {
"displayMessage": "Some nodes can be replaced with alternatives",
"toastTitleOne": "{nodeType} can be replaced",
"toastTitleMany": "Nodes can be replaced",
"toastMessageOne": "Replace it with {replacementNodeType} from the error panel.",
"toastMessageMany": "{count} node types can be replaced with compatible alternatives."
},
"missing_model": {
"displayMessageCloud": "Import a model, or open the node to replace it.",
"displayMessageOss": "Download a model, or open the node to replace it.",
"toastTitleOneCloud": "{modelName} isn't available on Cloud",
"toastTitleOneOss": "{modelName} is missing",
"toastTitleMany": "Missing models",
"toastTitleManyCloud": "Models aren't available on Cloud",
"toastMessageOneCloud": "This model isn't supported. Choose a different one.",
"toastMessageOneOss": "{nodeName} is missing a required model file.",
"toastMessageManyCloud": "Some models aren't supported. Choose different ones.",
"toastMessageManyOss": "{count} model files are missing."
},
"missing_media": {
"displayMessage": "A required media input has no file selected.",
"toastTitleOne": "Media input missing",
"toastTitleMany": "Missing media inputs",
"toastMessageWithNode": "{nodeName} is missing a required media file.",
"toastMessageMany": "Please select the missing media inputs before running this workflow."
}
},
"validationErrors": {
"required_input_missing": {
"title": "Missing connection",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "Recargando modelo...",
"removeBackgroundImage": "Eliminar imagen de fondo",
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
"retainViewOnReload": "Bloquear la vista de la cámara al recargar el modelo",
"scene": "Escena",
"showGrid": "Mostrar cuadrícula",
"showSkeleton": "Mostrar esqueleto",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "در حال بارگذاری مجدد مدل...",
"removeBackgroundImage": "حذف تصویر پس‌زمینه",
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
"scene": "صحنه",
"showGrid": "نمایش شبکه",
"showSkeleton": "نمایش اسکلت",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "Rechargement du modèle...",
"removeBackgroundImage": "Supprimer l'image de fond",
"resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie",
"retainViewOnReload": "Verrouiller la vue de la caméra lors du rechargement du modèle",
"scene": "Scène",
"showGrid": "Afficher la grille",
"showSkeleton": "Afficher le squelette",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "モデルを再読み込み中...",
"removeBackgroundImage": "背景画像を削除",
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
"scene": "シーン",
"showGrid": "グリッドを表示",
"showSkeleton": "スケルトンを表示",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "모델 다시 로드 중...",
"removeBackgroundImage": "배경 이미지 제거",
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
"scene": "장면",
"showGrid": "그리드 표시",
"showSkeleton": "스켈레톤 표시",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "Recarregando modelo...",
"removeBackgroundImage": "Remover Imagem de Fundo",
"resizeNodeMatchOutput": "Redimensionar Nó para corresponder à saída",
"retainViewOnReload": "Manter a visão da câmera ao recarregar o modelo",
"scene": "Cena",
"showGrid": "Mostrar Grade",
"showSkeleton": "Mostrar Esqueleto",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "Перезагрузка модели...",
"removeBackgroundImage": "Удалить фоновое изображение",
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
"scene": "Сцена",
"showGrid": "Показать сетку",
"showSkeleton": "Показать скелет",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "Model yeniden yükleniyor...",
"removeBackgroundImage": "Arka Plan Resmini Kaldır",
"resizeNodeMatchOutput": "Düğümü çıktıya uyacak şekilde yeniden boyutlandır",
"retainViewOnReload": "Model yeniden yüklendiğinde kamera görünümünü kilitle",
"scene": "Sahne",
"showGrid": "Izgarayı Göster",
"showSkeleton": "İskeleti Göster",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "重新載入模型中...",
"removeBackgroundImage": "移除背景圖片",
"resizeNodeMatchOutput": "調整節點以符合輸出",
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
"scene": "場景",
"showGrid": "顯示格線",
"showSkeleton": "顯示骨架",

View File

@@ -2077,7 +2077,6 @@
"reloadingModel": "正在重新加载模型...",
"removeBackgroundImage": "移除背景图片",
"resizeNodeMatchOutput": "调整节点以匹配输出",
"retainViewOnReload": "模型重新加载时锁定相机视角",
"scene": "场景",
"showGrid": "显示网格",
"showSkeleton": "显示骨架",

View File

@@ -176,7 +176,7 @@ describe('AssetBrowserModal', () => {
): AssetItem => ({
id,
name,
asset_hash: `blake3:${id.padEnd(64, '0')}`,
hash: `blake3:${id.padEnd(64, '0')}`,
size: 1024000,
mime_type: 'application/octet-stream',
tags: ['models', category, 'test'],

View File

@@ -49,10 +49,10 @@ const ORIGINAL_FILENAME = 'sunset_photo.png'
function createDisplayAsset(
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem {
return {
const base = {
id: 'asset-1',
name: HASH,
asset_hash: HASH,
hash: HASH,
tags: ['input'],
preview_url: '/preview.png',
secondaryText: '',
@@ -62,6 +62,7 @@ function createDisplayAsset(
metadata: { filename: ORIGINAL_FILENAME },
...overrides
}
return base
}
function renderCard(asset: AssetDisplayItem) {
@@ -97,7 +98,7 @@ describe('AssetCard', () => {
})
describe('FE-228: filename rendering', () => {
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
it('renders the human-readable filename instead of hash when asset.name equals hash', () => {
const asset = createDisplayAsset()
renderCard(asset)
@@ -130,7 +131,7 @@ describe('AssetCard', () => {
const asset = createDisplayAsset({
id: 'model-1',
name: MODEL_FILENAME,
asset_hash: undefined,
hash: undefined,
tags: ['models', 'loras'],
user_metadata: { name: CURATED_NAME },
metadata: { filename: MODEL_FILENAME }
@@ -146,7 +147,7 @@ describe('AssetCard', () => {
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
const asset = createDisplayAsset({
name: HASH,
asset_hash: HASH,
hash: HASH,
user_metadata: { name: HASH },
metadata: { filename: ORIGINAL_FILENAME }
})

View File

@@ -32,7 +32,7 @@ function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'mesh.glb',
asset_hash: null,
hash: null,
mime_type: 'model/gltf-binary',
tags: [],
kind: '3D',

View File

@@ -13,7 +13,7 @@ function createVideoAsset(
return {
id: 'video-1',
name: 'clip.mp4',
asset_hash: null,
hash: null,
mime_type: mimeType,
tags: [],
kind: 'video',

View File

@@ -28,7 +28,7 @@ describe('ModelInfoPanel', () => {
): AssetDisplayItem => ({
id: 'test-id',
name: 'test-model.safetensors',
asset_hash: 'hash123',
hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

View File

@@ -26,7 +26,7 @@ function makeAsset(index: number): AssetItem {
return {
id: `asset-${index}`,
name: `asset-${index}.safetensors`,
asset_hash: `blake3:${index}`,
hash: `blake3:${index}`,
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', category],

View File

@@ -35,7 +35,7 @@ describe('useAssetBrowser', () => {
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
name: 'test-asset.safetensors',
asset_hash: 'blake3:abc123',
hash: 'blake3:abc123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

View File

@@ -296,7 +296,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
name: 'my-image.jpeg',
asset_hash: 'hash123.jpeg'
hash: 'hash123.jpeg'
})
await actions.addWorkflow(asset)
@@ -310,12 +310,12 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use asset_hash as filename when available', async () => {
it('should use hash as filename when available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'original.jpeg',
asset_hash: 'abc123hash.jpeg'
hash: 'abc123hash.jpeg'
})
await actions.addWorkflow(asset)
@@ -323,12 +323,12 @@ describe('useMediaAssetActions', () => {
expect(capturedFilenames.values).toContain('abc123hash.jpeg')
})
it('should fall back to asset.name when asset_hash is not available', async () => {
it('should fall back to asset.name when hash is not available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-name.jpeg',
asset_hash: undefined
hash: undefined
})
await actions.addWorkflow(asset)
@@ -336,12 +336,12 @@ describe('useMediaAssetActions', () => {
expect(capturedFilenames.values).toContain('fallback-name.jpeg')
})
it('should fall back to asset.name when asset_hash is null', async () => {
it('should fall back to asset.name when hash is null', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-null.jpeg',
asset_hash: null
hash: null
})
await actions.addWorkflow(asset)
@@ -357,19 +357,19 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use asset_hash for each asset', async () => {
it('should use hash for each asset', async () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({
id: '1',
name: 'file1.jpeg',
asset_hash: 'hash1.jpeg'
hash: 'hash1.jpeg'
}),
createMockAsset({
id: '2',
name: 'file2.jpeg',
asset_hash: 'hash2.jpeg'
hash: 'hash2.jpeg'
})
]
@@ -973,7 +973,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-match',
name: 'foo.png',
asset_hash: 'abc123.png',
hash: 'abc123.png',
tags: ['input']
})
@@ -1051,7 +1051,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-failed',
name: 'failed.png',
asset_hash: 'failhash.png'
hash: 'failhash.png'
})
await actions.deleteAssets(asset)

View File

@@ -43,8 +43,8 @@ const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
*
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
* present in metadata). Input/temp assets emit the bare name plus the explicit
* annotation. `asset_hash` is included whenever present, since cloud-stored
* assets can be referenced by hash.
* annotation. The content `hash` is included whenever present, since
* cloud-stored assets can be referenced by hash.
*/
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
const variants: string[] = []
@@ -62,7 +62,7 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
variants.push(`${name} [input]`)
}
}
const hash = asset.hash ?? asset.asset_hash
const hash = asset.hash
if (hash) variants.push(hash)
return variants
}
@@ -300,10 +300,9 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
// In Cloud mode, use the content hash (the actual stored filename),
// preferring hash and falling back to the deprecated asset_hash alias.
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = targetAsset.hash ?? targetAsset.asset_hash
const cloudHash = targetAsset.hash
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
// Create annotated path for the asset
@@ -445,10 +444,9 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
// In Cloud mode, use the content hash (the actual stored filename),
// preferring hash and falling back to the deprecated asset_hash alias.
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = asset.hash ?? asset.asset_hash
const cloudHash = asset.hash
const filename = isCloud && cloudHash ? cloudHash : asset.name
const annotated = createAnnotatedPath(

View File

@@ -97,11 +97,12 @@ export function createMockAssets(count: number = 20): AssetItem[] {
const lastAccessTime = getRandomISODate()
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
const fakeAssetHash = generateFakeAssetHash()
return {
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
name: fakeFileName,
asset_hash: generateFakeAssetHash(),
hash: fakeAssetHash,
size: sizeInBytes,
mime_type: mimeType,
tags: [

View File

@@ -6,7 +6,6 @@ const zAsset = z.object({
id: z.string(),
name: z.string(),
hash: z.string().nullish(),
asset_hash: z.string().nullish(),
size: z.number().optional(), // TBD: Will be provided by history API in the future
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),

View File

@@ -21,7 +21,7 @@ describe('assetMetadataUtils', () => {
const mockAsset: AssetItem = {
id: 'test-id',
name: 'test-model',
asset_hash: 'hash123',
hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

Some files were not shown because too many files have changed in this diff Show More