Compare commits

..

13 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
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
76 changed files with 3866 additions and 1773 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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -28,15 +28,13 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
BulkRevokeApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysData,
BulkRevokeWorkspaceMemberApiKeysError,
BulkRevokeWorkspaceMemberApiKeysErrors,
BulkRevokeWorkspaceMemberApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysResponses,
CancelAssetSeedData,
CancelAssetSeedResponse,
CancelAssetSeedResponses,
CancelJobData,
CancelJobError,
CancelJobErrors,
@@ -59,14 +57,11 @@ export type {
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClientOptions,
CreateAssetData,
CreateAssetDownloadData,
CreateAssetDownloadError,
CreateAssetDownloadErrors,
CreateAssetDownloadResponse,
CreateAssetDownloadResponses,
CreateAssetError,
CreateAssetErrors,
CreateAssetExportData,
CreateAssetExportError,
CreateAssetExportErrors,
@@ -77,8 +72,6 @@ export type {
CreateAssetFromHashErrors,
CreateAssetFromHashResponse,
CreateAssetFromHashResponses,
CreateAssetResponse,
CreateAssetResponses,
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
@@ -215,8 +208,6 @@ export type {
ForkWorkflowRequest,
ForkWorkflowResponse,
ForkWorkflowResponses,
FreeMemoryData,
FreeMemoryResponses,
GetAllSettingsData,
GetAllSettingsError,
GetAllSettingsErrors,
@@ -230,9 +221,6 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
GetAssetTagHistogramData,
GetAssetTagHistogramError,
GetAssetTagHistogramErrors,
@@ -271,9 +259,6 @@ export type {
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetEmbeddingsData,
GetEmbeddingsResponse,
GetEmbeddingsResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
@@ -320,18 +305,6 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetI18nData,
GetI18nResponse,
GetI18nResponses,
GetInternalFolderPathsData,
GetInternalFolderPathsResponse,
GetInternalFolderPathsResponses,
GetInternalLogsData,
GetInternalLogsRawData,
GetInternalLogsRawResponse,
GetInternalLogsRawResponses,
GetInternalLogsResponse,
GetInternalLogsResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -383,7 +356,10 @@ export type {
GetModelFoldersResponse,
GetModelFoldersResponses,
GetModelPreviewData,
GetModelPreviewError,
GetModelPreviewErrors,
GetModelPreviewResponse,
GetModelPreviewResponses,
GetModelsInFolderData,
GetModelsInFolderError,
GetModelsInFolderErrors,
@@ -413,26 +389,8 @@ export type {
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOAuthAuthorizationServerData,
GetOAuthAuthorizationServerError,
GetOAuthAuthorizationServerErrors,
GetOAuthAuthorizationServerResponse,
GetOAuthAuthorizationServerResponses,
GetOAuthAuthorizeData,
GetOAuthAuthorizeError,
GetOAuthAuthorizeErrors,
GetOAuthAuthorizeResponse,
GetOAuthAuthorizeResponses,
GetOAuthProtectedResourceByPathData,
GetOAuthProtectedResourceByPathError,
GetOAuthProtectedResourceByPathErrors,
GetOAuthProtectedResourceByPathResponse,
GetOAuthProtectedResourceByPathResponses,
GetOAuthProtectedResourceData,
GetOAuthProtectedResourceError,
GetOAuthProtectedResourceErrors,
GetOAuthProtectedResourceResponse,
GetOAuthProtectedResourceResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
@@ -469,11 +427,11 @@ export type {
GetSecretErrors,
GetSecretResponse,
GetSecretResponses,
GetSettingByIdData,
GetSettingByIdError,
GetSettingByIdErrors,
GetSettingByIdResponse,
GetSettingByIdResponses,
GetSettingByKeyData,
GetSettingByKeyError,
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetStaticExtensionsData,
GetStaticExtensionsErrors,
GetStaticExtensionsResponses,
@@ -489,7 +447,6 @@ export type {
GetTaskResponses,
GetTemplateProxyData,
GetTemplateProxyErrors,
GetTemplateProxyResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
@@ -577,11 +534,6 @@ export type {
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InsertDynamicConfigData,
InsertDynamicConfigError,
InsertDynamicConfigErrors,
InsertDynamicConfigResponse,
InsertDynamicConfigResponses,
InterruptJobData,
InterruptJobError,
InterruptJobErrors,
@@ -690,17 +642,6 @@ export type {
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
OAuthAuthorizationServerMetadata,
OAuthAuthorizeRedirectResponse,
OAuthConsentChallenge,
OAuthConsentChallengeWorkspace,
OAuthProtectedResourceMetadata,
OAuthRegisterBadRequestResponse,
OAuthRegisterError,
OAuthRegisterRequest,
OAuthRegisterResponse,
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -722,21 +663,6 @@ export type {
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
PostOAuthAuthorizeResponse,
PostOAuthAuthorizeResponses,
PostOAuthRegisterData,
PostOAuthRegisterError,
PostOAuthRegisterErrors,
PostOAuthRegisterResponse,
PostOAuthRegisterResponses,
PostOAuthTokenData,
PostOAuthTokenError,
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
@@ -761,9 +687,6 @@ export type {
PromptInfo,
PromptRequest,
PromptResponse,
PruneAssetsData,
PruneAssetsResponse,
PruneAssetsResponses,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
@@ -809,9 +732,6 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
SeedAssetsResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -831,8 +751,6 @@ export type {
SubscribeResponse,
SubscribeResponse2,
SubscribeResponses,
SubscribeToLogsData,
SubscribeToLogsResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
@@ -853,6 +771,11 @@ export type {
UpdateAssetErrors,
UpdateAssetResponse,
UpdateAssetResponses,
UpdateAssetTagsData,
UpdateAssetTagsError,
UpdateAssetTagsErrors,
UpdateAssetTagsResponse,
UpdateAssetTagsResponses,
UpdateHubProfileData,
UpdateHubProfileError,
UpdateHubProfileErrors,
@@ -876,11 +799,11 @@ export type {
UpdateSecretRequest,
UpdateSecretResponse,
UpdateSecretResponses,
UpdateSettingByIdData,
UpdateSettingByIdError,
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSettingByKeyData,
UpdateSettingByKeyError,
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
@@ -898,6 +821,11 @@ export type {
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,
UploadAssetData,
UploadAssetError,
UploadAssetErrors,
UploadAssetResponse,
UploadAssetResponses,
UploadImageData,
UploadImageError,
UploadImageErrors,

File diff suppressed because it is too large Load Diff

View File

@@ -879,155 +879,6 @@ export const zJwkKey = z.object({
y: z.string()
})
/**
* RFC 6749 §5.2 error response.
*/
export const zOAuthTokenError = z.object({
error: z.string(),
error_description: z.string().optional()
})
/**
* RFC 6749 §5.1 successful token response.
*/
export const zOAuthTokenResponse = z.object({
access_token: z.string(),
token_type: z.enum(['Bearer']),
expires_in: z.number().int(),
refresh_token: z.string(),
scope: z.string()
})
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export const zOAuthConsentChallengeWorkspace = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export const zOAuthAuthorizeRedirectResponse = z.object({
redirect_url: z.string().url()
})
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export const zOAuthConsentChallenge = z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export const zOAuthProtectedResourceMetadata = z.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()),
scopes_supported: z.array(z.string()),
bearer_methods_supported: z.array(z.string()).optional()
})
/**
* RFC 7591 §3.2.2 error response.
*/
export const zOAuthRegisterError = z.object({
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
error_description: z.string().nullish()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional()
})
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
*
*/
export const zOAuthRegisterBadRequestResponse = z.union([
zOAuthRegisterError,
zErrorResponse
])
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export const zOAuthRegisterResponse = z.object({
client_id: z.string(),
client_id_issued_at: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
client_name: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.enum(['none']),
application_type: z.enum(['native', 'web'])
})
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export const zOAuthRegisterRequest = z.object({
redirect_uris: z.array(z.string()).min(1).max(5),
client_name: z.string().max(100).optional(),
application_type: z.enum(['native', 'web']).optional(),
token_endpoint_auth_method: z.enum(['none']).optional(),
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token']))
.optional(),
response_types: z.array(z.enum(['code'])).optional(),
scope: z.string().nullish(),
resource_grants: z.record(z.array(z.string())).nullish(),
client_uri: z.string().nullish(),
logo_uri: z.string().nullish(),
tos_uri: z.string().nullish(),
policy_uri: z.string().nullish(),
software_id: z.string().nullish(),
software_version: z.string().nullish(),
contacts: z.array(z.string()).nullish(),
jwks: z.record(z.unknown()).nullish(),
jwks_uri: z.string().nullish()
})
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export const zOAuthAuthorizationServerMetadata = z.object({
issuer: z.string().url(),
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
jwks_uri: z.string().url(),
registration_endpoint: z.string().url().optional(),
response_types_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
token_endpoint_auth_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()).optional()
})
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -1089,7 +940,6 @@ export const zWorkspaceApiKeyInfo = z.object({
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
description: z.string().max(5000),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
@@ -1110,7 +960,6 @@ export const zListWorkspaceApiKeysResponse = z.object({
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().max(5000),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
@@ -1122,7 +971,6 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
*/
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
description: z.string().max(5000).optional(),
expires_at: z.string().datetime().optional()
})
@@ -1505,8 +1353,7 @@ export const zListTagsResponse = z.object({
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
display_name: z.string().nullish(),
hash: z
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1517,14 +1364,14 @@ export const zAsset = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
}),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
@@ -1538,8 +1385,7 @@ export const zAsset = z.object({
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean(),
next_cursor: z.string().optional()
has_more: z.boolean()
})
/**
@@ -1548,15 +1394,13 @@ export const zListAssetsResponse = z.object({
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
display_name: z.string().nullish(),
hash: z
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
tags: z.array(z.string()).optional(),
mime_type: z.string().optional(),
user_metadata: z.record(z.unknown()).optional(),
job_id: z.string().uuid().nullish(),
updated_at: z.string().datetime()
})
@@ -1909,6 +1753,21 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
})
/**
* Response returned after successfully queuing a workflow prompt.
*/
@@ -1937,8 +1796,7 @@ export const zPromptRequest = z.object({
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
display_name: z.string().nullish(),
hash: z
asset_hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1949,13 +1807,13 @@ export const zAssetWritable = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
}),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
@@ -1969,8 +1827,7 @@ export const zAssetWritable = z.object({
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean(),
next_cursor: z.string().optional()
has_more: z.boolean()
})
/**
@@ -2104,6 +1961,21 @@ export const zGetModelsInFolderData = z.object({
*/
export const zGetModelsInFolderResponse = z.array(zModelFile)
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
/**
* Success - Model preview image
*/
export const zGetModelPreviewResponse = z.string()
export const zGetLegacyHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2260,9 +2132,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
hash: z.string().optional(),
after: z.string().optional()
asset_hash: z.string().optional()
})
.optional()
})
@@ -2272,28 +2144,22 @@ export const zListAssetsData = z.object({
*/
export const zListAssetsResponse2 = zListAssetsResponse
export const zCreateAssetData = z.object({
export const zUploadAssetData = z.object({
body: z.object({
file: z.string(),
hash: z
.string()
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
.optional(),
tags: z.string().optional(),
id: z.string().uuid().optional(),
preview_id: z.string().uuid().optional(),
name: z.string().optional(),
mime_type: z.string().optional(),
user_metadata: z.string().optional()
url: z.string().url(),
name: z.string(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_id: z.string().uuid().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Asset created successfully
* Asset already exists (returned existing asset)
*/
export const zCreateAssetResponse = zAssetCreated
export const zUploadAssetResponse = zAssetCreated
export const zCreateAssetFromHashData = z.object({
body: z.object({
@@ -2308,7 +2174,7 @@ export const zCreateAssetFromHashData = z.object({
})
/**
* Asset reference created successfully
* Asset reference already exists (returned existing)
*/
export const zCreateAssetFromHashResponse = zAssetCreated
@@ -2348,8 +2214,7 @@ export const zCreateAssetExportData = z.object({
naming_strategy: z
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
include_previews: z.boolean().optional().default(false)
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
}),
path: z.never().optional(),
query: z.never().optional()
@@ -2382,7 +2247,7 @@ export const zDeleteAssetData = z.object({
})
/**
* Asset record deleted successfully
* Asset deleted successfully
*/
export const zDeleteAssetResponse = z.void()
@@ -2447,6 +2312,22 @@ export const zAddAssetTagsData = z.object({
*/
export const zAddAssetTagsResponse = zTagsModificationResponse
export const zUpdateAssetTagsData = z.object({
body: z.object({
add: z.array(z.string()).optional(),
remove: z.array(z.string()).optional()
}),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* Tags updated successfully
*/
export const zUpdateAssetTagsResponse = zTagsModificationResponse
export const zListTagsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2628,10 +2509,10 @@ export const zUpdateMultipleSettingsData = z.object({
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByIdData = z.object({
export const zGetSettingByKeyData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
key: z.string()
}),
query: z.never().optional()
})
@@ -2639,14 +2520,14 @@ export const zGetSettingByIdData = z.object({
/**
* Setting value response
*/
export const zGetSettingByIdResponse = z.object({
export const zGetSettingByKeyResponse = z.object({
value: z.unknown().optional()
})
export const zUpdateSettingByIdData = z.object({
export const zUpdateSettingByKeyData = z.object({
body: z.unknown(),
path: z.object({
id: z.string()
key: z.string()
}),
query: z.never().optional()
})
@@ -2654,7 +2535,7 @@ export const zUpdateSettingByIdData = z.object({
/**
* Updated setting value response
*/
export const zUpdateSettingByIdResponse = z.object({
export const zUpdateSettingByKeyResponse = z.object({
value: z.unknown().optional()
})
@@ -2810,7 +2691,21 @@ export const zUploadMaskData = z.object({
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional()
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
})
export const zGetLogsData = z.object({
@@ -2879,115 +2774,6 @@ export const zGetJwksData = z.object({
*/
export const zGetJwksResponse = zJwksResponse
export const zGetOAuthAuthorizationServerData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Authorization-server metadata
*/
export const zGetOAuthAuthorizationServerResponse =
zOAuthAuthorizationServerMetadata
export const zGetOAuthProtectedResourceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthProtectedResourceByPathData = z.object({
body: z.never().optional(),
path: z.object({
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
}),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceByPathResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthAuthorizeData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
response_type: z.string().optional(),
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
resource: z.string().optional(),
oauth_request_id: z.string().optional()
})
.optional()
})
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
export const zPostOAuthAuthorizeData = z.object({
body: z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
decision: z.enum(['allow', 'deny']),
workspace_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
export const zPostOAuthTokenData = z.object({
body: z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
client_id: z.string(),
code: z.string().optional(),
redirect_uri: z.string().optional(),
code_verifier: z.string().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
client_secret: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* New token pair
*/
export const zPostOAuthTokenResponse = zOAuthTokenResponse
export const zPostOAuthRegisterData = z.object({
body: zOAuthRegisterRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
export const zListWorkspacesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3292,28 +3078,6 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Config inserted successfully
*/
export const zInsertDynamicConfigResponse = z.object({
id: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
message: z.string().optional()
})
export const zSyncApiKeyData = z.object({
body: zSyncApiKeyRequest,
path: z.never().optional(),
@@ -3907,6 +3671,12 @@ export const zGetHealthData = z.object({
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetMonitoringTasksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3987,16 +3757,6 @@ export const zPostCustomNodeProxyData = z.object({
query: z.never().optional()
})
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -4072,150 +3832,3 @@ export const zGetLegacyViewMetadataData = z.object({
}),
query: z.never().optional()
})
export const zGetEmbeddingsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Embedding names
*/
export const zGetEmbeddingsResponse = z.array(z.string())
export const zFreeMemoryData = z.object({
body: z
.object({
unload_models: z.boolean().optional(),
free_memory: z.boolean().optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetI18nData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Nested map of locale to translation key-value pairs
*/
export const zGetI18nResponse = z.record(z.unknown())
export const zGetInternalFolderPathsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Map of folder type name to list of path entries
*/
export const zGetInternalFolderPathsResponse = z.record(
z.array(z.array(z.string()))
)
export const zGetInternalLogsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Log text
*/
export const zGetInternalLogsResponse = z.string()
export const zGetInternalLogsRawData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Structured log data
*/
export const zGetInternalLogsRawResponse = z.object({
entries: z
.array(
z.object({
t: z.number().optional(),
m: z.string().optional()
})
)
.optional(),
size: z
.object({
cols: z.number().int().optional(),
rows: z.number().int().optional()
})
.optional()
})
export const zSubscribeToLogsData = z.object({
body: z.object({
clientId: z.string(),
enabled: z.boolean()
}),
path: z.never().optional(),
query: z.never().optional()
})
export const zPruneAssetsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Prune result
*/
export const zPruneAssetsResponse = z.object({
status: z.string().optional(),
marked: z.number().int().optional()
})
export const zSeedAssetsData = z.object({
body: z
.object({
roots: z.array(z.string()).optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Seed started
*/
export const zSeedAssetsResponse = z.object({
status: z.string().optional()
})
export const zGetAssetSeedStatusData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan progress details (files scanned, total, status, etc.)
*/
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
export const zCancelAssetSeedData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan cancelled
*/
export const zCancelAssetSeedResponse = z.object({
status: z.string().optional()
})

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

@@ -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

@@ -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

@@ -302,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."
)
})

View File

@@ -723,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
})
}
@@ -840,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
})
}

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

@@ -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

@@ -725,7 +725,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
await preview3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'temp',
'prev/model.glb',
{ silentOnNotFound: true }
)
@@ -851,7 +851,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'temp',
'sub/nested/mesh.glb',
{ silentOnNotFound: true }
)

View File

@@ -688,7 +688,7 @@ useExtensionService().registerExtension({
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('output', lastTimeModelFile as string, {
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -782,7 +782,7 @@ useExtensionService().registerExtension({
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('output', normalizedPath, {
config.configureForSaveMesh('temp', normalizedPath, {
silentOnNotFound: true
})

View File

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

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

@@ -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",
@@ -3532,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",
@@ -3614,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",
@@ -3685,7 +3687,6 @@
"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": {
@@ -3693,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

@@ -6,6 +6,9 @@ import {
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
import { i18n } from '@/i18n'
function nodeValidationError(
@@ -55,6 +58,59 @@ function executionError(
}
}
function missingNodeType(
type: string,
nodeId: string,
cnrId?: string
): MissingNodeType {
return {
type,
nodeId,
cnrId,
isReplaceable: false
}
}
function replaceableNodeType(
type: string,
nodeId: string,
replacementNodeType: string
): MissingNodeType {
return {
type,
nodeId,
isReplaceable: true,
replacement: {
old_node_id: type,
new_node_id: replacementNodeType,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
}
}
function missingModelGroups(...names: string[]): MissingModelGroup[] {
return [
{
directory: 'checkpoints',
isAssetSupported: true,
models: names.map((name) => ({
name,
representative: {
name,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: []
}))
}
]
}
describe('errorMessageResolver', () => {
it('resolves required_input_missing to missing connection display copy', () => {
const result = resolveRunErrorMessage({
@@ -1307,17 +1363,342 @@ describe('errorMessageResolver', () => {
})
it('resolves missing error group display copy', () => {
const missingNodeTypes = [missingNodeType('FooNode', '7', 'foo-pack')]
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: missingNodeTypes,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Missing Node Packs (1)',
displayMessage: 'Install missing packs to use this workflow.',
toastTitle: 'Missing node: FooNode',
toastMessage:
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: missingNodeTypes,
count: 1,
isCloud: true
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Unsupported Node Packs (1)',
displayMessage:
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
toastTitle: "FooNode isn't available on Cloud",
toastMessage: "This node isn't supported on Cloud."
})
const multipleMissingNodeTypes = [
missingNodeType('FooNode', '7', 'foo-pack'),
missingNodeType('BarNode', '9', 'bar-pack')
]
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: multipleMissingNodeTypes,
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing nodes',
toastMessage: '2 nodes require missing node packs.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: multipleMissingNodeTypes,
count: 2,
isCloud: true
})
).toMatchObject({
toastTitle: "Nodes aren't available on Cloud",
toastMessage: "This workflow uses nodes that aren't supported on Cloud."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: [
missingNodeType('FooNode', '7', 'foo-pack'),
missingNodeType('FooNode', '8', 'foo-pack')
],
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing node: FooNode',
toastMessage:
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
})
const swapNodeTypes = [replaceableNodeType('OldNode', '8', 'NewNode')]
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: swapNodeTypes,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'swap_nodes',
displayTitle: 'Swap Nodes (1)',
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
})
const multipleSwapNodeTypes = [
replaceableNodeType('OldNodeA', '8', 'NewNodeA'),
replaceableNodeType('OldNodeB', '9', 'NewNodeB')
]
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: multipleSwapNodeTypes,
count: 2,
isCloud: false
})
).toMatchObject({
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'Nodes can be replaced',
toastMessage: '2 node types can be replaced with compatible alternatives.'
})
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: [
replaceableNodeType('OldNode', '8', 'NewNode'),
replaceableNodeType('OldNode', '9', 'NewNode')
],
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
})
const groups = missingModelGroups('sdxl.safetensors')
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: [],
groups,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayMessage: '1 required model is missing'
displayMessage: 'Download a model, or open the node to replace it.',
toastTitle: 'sdxl.safetensors is missing',
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups,
count: 1,
isCloud: true
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: "sdxl.safetensors isn't available on Cloud",
toastMessage: "This model isn't supported. Choose a different one."
})
})
it('resolves missing media group display and toast copy', () => {
const groups: MissingMediaGroup[] = [
{
mediaType: 'image',
items: [
{
name: 'portrait.png',
mediaType: 'image',
representative: {
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'portrait.png',
isMissing: true
},
referencingNodes: [{ nodeId: '4', widgetName: 'image' }]
}
]
}
]
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_media',
displayTitle: 'Missing Inputs (1)',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.'
})
})
it.for([
[
'image',
'LoadImage',
'image',
'portrait.png',
'Media input missing',
'Load Image is missing a required media file.'
],
[
'video',
'LoadVideo',
'file',
'clip.mp4',
'Media input missing',
'Load Video is missing a required media file.'
],
[
'audio',
'LoadAudio',
'audio',
'voice.wav',
'Media input missing',
'Load Audio is missing a required media file.'
]
] as const)(
'resolves missing %s toast copy from media type and node type',
([
mediaType,
nodeType,
widgetName,
mediaName,
toastTitle,
toastMessage
]) => {
const groups: MissingMediaGroup[] = [
{
mediaType,
items: [
{
name: mediaName,
mediaType,
representative: {
nodeId: '4',
nodeType,
widgetName,
mediaType,
name: mediaName,
isMissing: true
},
referencingNodes: [{ nodeId: '4', widgetName }]
}
]
}
]
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups,
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle,
toastMessage
})
}
)
it('summarizes multiple missing model and media items', () => {
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: modelGroups,
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing models',
toastMessage: '2 model files are missing.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: modelGroups,
count: 2,
isCloud: true
})
).toMatchObject({
toastTitle: "Models aren't available on Cloud",
toastMessage: "Some models aren't supported. Choose different ones."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups: [
{
mediaType: 'image',
items: [
{
name: 'a.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
referencingNodes: [{ nodeId: '1', widgetName: 'image' }]
},
{
name: 'b.png',
mediaType: 'image',
representative: {
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
},
referencingNodes: [{ nodeId: '2', widgetName: 'image' }]
}
]
}
],
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'
})
})
})

View File

@@ -2,19 +2,312 @@ import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { st, t } from '@/i18n'
import { translateCatalogMessage } from './catalogI18n'
import { st } from '@/i18n'
// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These
// are grouped catalog messages rather than individual execution error items.
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
function formatNodeTypeName(nodeType: string): string | null {
const trimmed = nodeType.trim()
if (!trimmed) return null
return trimmed
.replace(/[_-]+/g, ' ')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim()
}
type NodeTypeErrorSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_node' | 'swap_nodes' }
>
type NodeTypeErrorItem = NodeTypeErrorSource['nodeTypes'][number]
function getNodeTypeLabel(nodeType: NodeTypeErrorItem): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function getDistinctNodeTypeLabels(nodeTypes: NodeTypeErrorItem[]): string[] {
const labels = new Set<string>()
for (const nodeType of nodeTypes) labels.add(getNodeTypeLabel(nodeType))
return Array.from(labels)
}
type MissingNodeSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_node' }
>
function isMissingNodeType(nodeType: NodeTypeErrorItem): boolean {
return typeof nodeType === 'string' || !nodeType.isReplaceable
}
function resolveMissingNodeDisplayMessage(source: MissingNodeSource): string {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.displayMessageCloud'
: 'errorCatalog.missingErrors.missing_node.displayMessageOss'
const fallback = source.isCloud
? "Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
: 'Install missing packs to use this workflow.'
return translateCatalogMessage(key, fallback)
}
function resolveMissingNodeToastTitle(source: MissingNodeSource): string {
const labels = getDistinctNodeTypeLabels(
source.nodeTypes.filter(isMissingNodeType)
)
const [firstLabel] = labels
if (labels.length === 1 && firstLabel) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastTitleOneCloud'
: 'errorCatalog.missingErrors.missing_node.toastTitleOneOss'
const fallback = source.isCloud
? "{nodeType} isn't available on Cloud"
: 'Missing node: {nodeType}'
return translateCatalogMessage(key, fallback, { nodeType: firstLabel })
}
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastTitleManyCloud'
: 'errorCatalog.missingErrors.missing_node.toastTitleManyOss'
const fallback = source.isCloud
? "Nodes aren't available on Cloud"
: 'Missing nodes'
return translateCatalogMessage(key, fallback)
}
function resolveMissingNodeToastMessage(source: MissingNodeSource): string {
const labels = getDistinctNodeTypeLabels(
source.nodeTypes.filter(isMissingNodeType)
)
const count = labels.length || source.count
if (count === 1) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastMessageOneCloud'
: 'errorCatalog.missingErrors.missing_node.toastMessageOneOss'
const fallback = source.isCloud
? "This node isn't supported on Cloud."
: "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
return translateCatalogMessage(key, fallback)
}
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastMessageManyCloud'
: 'errorCatalog.missingErrors.missing_node.toastMessageManyOss'
const fallback = source.isCloud
? "This workflow uses nodes that aren't supported on Cloud."
: '{count} nodes require missing node packs.'
return translateCatalogMessage(key, fallback, { count })
}
type SwapNodeSource = Extract<MissingErrorMessageSource, { kind: 'swap_nodes' }>
function isSwapNodeType(nodeType: NodeTypeErrorItem): nodeType is Exclude<
NodeTypeErrorItem,
string
> & {
isReplaceable: true
} {
return typeof nodeType !== 'string' && nodeType.isReplaceable === true
}
function getSwapNodeTypes(source: SwapNodeSource) {
return source.nodeTypes.filter(isSwapNodeType)
}
function resolveSwapNodeToastTitle(source: SwapNodeSource): string {
const nodeTypes = getSwapNodeTypes(source)
const labels = getDistinctNodeTypeLabels(nodeTypes)
const [firstLabel] = labels
if (labels.length === 1 && firstLabel) {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastTitleOne',
'{nodeType} can be replaced',
{ nodeType: firstLabel }
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastTitleMany',
'Nodes can be replaced'
)
}
function resolveSwapNodeToastMessage(source: SwapNodeSource): string {
const nodeTypes = getSwapNodeTypes(source)
const labels = getDistinctNodeTypeLabels(nodeTypes)
const [firstNodeType] = nodeTypes
if (labels.length === 1 && firstNodeType?.replacement?.new_node_id) {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastMessageOne',
'Replace it with {replacementNodeType} from the error panel.',
{ replacementNodeType: firstNodeType.replacement.new_node_id }
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastMessageMany',
'{count} node types can be replaced with compatible alternatives.',
{ count: labels.length || source.count }
)
}
function resolveSwapNodeDisplayMessage(): string {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.displayMessage',
'Some nodes can be replaced with alternatives'
)
}
type MissingModelSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_model' }
>
function getMissingModelCount(source: MissingModelSource): number {
const count = source.groups.reduce(
(total, group) => total + group.models.length,
0
)
return count || source.count
}
function resolveMissingModelDisplayMessage(source: MissingModelSource): string {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.displayMessageCloud'
: 'errorCatalog.missingErrors.missing_model.displayMessageOss'
const fallback = source.isCloud
? 'Import a model, or open the node to replace it.'
: 'Download a model, or open the node to replace it.'
return translateCatalogMessage(key, fallback)
}
function resolveMissingModelToastTitle(source: MissingModelSource): string {
const [firstModel] = source.groups.flatMap((group) => group.models)
const count = getMissingModelCount(source)
if (count === 1 && firstModel) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.toastTitleOneCloud'
: 'errorCatalog.missingErrors.missing_model.toastTitleOneOss'
const fallback = source.isCloud
? "{modelName} isn't available on Cloud"
: '{modelName} is missing'
return translateCatalogMessage(key, fallback, {
modelName: firstModel.name
})
}
const useCloudPluralTitle = source.isCloud && count > 1
const key = useCloudPluralTitle
? 'errorCatalog.missingErrors.missing_model.toastTitleManyCloud'
: 'errorCatalog.missingErrors.missing_model.toastTitleMany'
const fallback = useCloudPluralTitle
? "Models aren't available on Cloud"
: 'Missing models'
return translateCatalogMessage(key, fallback)
}
function getMissingModelNodeName(
model: MissingModelSource['groups'][number]['models'][number]
): string {
return (
formatNodeTypeName(model.representative.nodeType) ??
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}
function resolveMissingModelToastMessage(source: MissingModelSource): string {
const [firstModel] = source.groups.flatMap((group) => group.models)
const count = getMissingModelCount(source)
if (!firstModel || count !== 1) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.toastMessageManyCloud'
: 'errorCatalog.missingErrors.missing_model.toastMessageManyOss'
const fallback = source.isCloud
? "Some models aren't supported. Choose different ones."
: '{count} model files are missing.'
return translateCatalogMessage(key, fallback, { count })
}
if (source.isCloud) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_model.toastMessageOneCloud',
"This model isn't supported. Choose a different one."
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_model.toastMessageOneOss',
'{nodeName} is missing a required model file.',
{ nodeName: getMissingModelNodeName(firstModel) }
)
}
type MissingMediaSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_media' }
>
function getMissingMediaItems(source: MissingMediaSource) {
return source.groups.flatMap((group) => group.items)
}
function getMissingMediaNodeName(
item: ReturnType<typeof getMissingMediaItems>[number]
): string | null {
return formatNodeTypeName(item.representative.nodeType)
}
function resolveMissingMediaDisplayMessage(): string {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.displayMessage',
'A required media input has no file selected.'
)
}
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
if (items.length !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleMany',
'Missing media inputs'
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleOne',
'Media input missing'
)
}
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
const [firstItem] = items
if (!firstItem || items.length !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageMany',
'Please select the missing media inputs before running this workflow.'
)
}
const nodeName = getMissingMediaNodeName(firstItem)
const displayNodeName =
nodeName ??
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageWithNode',
'{nodeName} is missing a required media file.',
{
nodeName: displayNodeName
}
)
}
export function resolveMissingErrorMessage(
@@ -33,10 +326,9 @@ export function resolveMissingErrorMessage(
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
displayMessage: resolveMissingNodeDisplayMessage(source),
toastTitle: resolveMissingNodeToastTitle(source),
toastMessage: resolveMissingNodeToastMessage(source)
}
case 'swap_nodes':
return {
@@ -45,10 +337,9 @@ export function resolveMissingErrorMessage(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
displayMessage: resolveSwapNodeDisplayMessage(),
toastTitle: resolveSwapNodeToastTitle(source),
toastMessage: resolveSwapNodeToastMessage(source)
}
case 'missing_model':
return {
@@ -60,7 +351,9 @@ export function resolveMissingErrorMessage(
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
displayMessage: resolveMissingModelDisplayMessage(source),
toastTitle: resolveMissingModelToastTitle(source),
toastMessage: resolveMissingModelToastMessage(source)
}
case 'missing_media':
return {
@@ -69,10 +362,9 @@ export function resolveMissingErrorMessage(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
displayMessage: resolveMissingMediaDisplayMessage(),
toastTitle: resolveMissingMediaToastTitle(source),
toastMessage: resolveMissingMediaToastMessage(source)
}
}
}

View File

@@ -3,10 +3,7 @@ import type {
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
@@ -73,6 +70,5 @@ export type MissingErrorMessageSource =
kind: 'missing_media'
groups: MissingMediaGroup[]
count: number
mediaTypes: MediaType[]
isCloud: boolean
}

View File

@@ -422,6 +422,7 @@ describe('groupCandidatesByName', () => {
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.mediaType).toBe('image')
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
const otherGroup = result.find((g) => g.name === 'other.png')
expect(otherGroup?.referencingNodes).toHaveLength(1)

View File

@@ -262,6 +262,7 @@ export function groupCandidatesByName(
map.set(c.name, {
name: c.name,
mediaType: c.mediaType,
representative: c,
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
})
}

View File

@@ -27,6 +27,7 @@ export interface MissingMediaCandidate {
export interface MissingMediaViewModel {
name: string
mediaType: MediaType
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
widgetName: string

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
@@ -19,14 +18,6 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
import SwapNodesCard from './SwapNodesCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function makeGroups(count = 2): SwapNodeGroup[] {
return Array.from({ length: count }, (_, i) => ({
type: `Type${i}`,
@@ -56,19 +47,13 @@ function mountCard(
...(callbacks?.onReplace ? { onReplace: callbacks.onReplace } : {})
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n]
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue]
}
})
}
describe('SwapNodesCard', () => {
describe('Rendering', () => {
it('renders guidance message', () => {
const { container } = mountCard()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('p')).not.toBeNull()
})
it('renders correct number of SwapNodeGroupRow components', () => {
const { container } = mountCard({ swapNodeGroups: makeGroups(3) })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access

View File

@@ -1,15 +1,5 @@
<template>
<div class="mt-2 px-4 pb-2">
<!-- Sub-label: guidance message shown above all swap groups -->
<p class="m-0 pb-5 text-sm/relaxed text-muted-foreground">
{{
t(
'nodeReplacement.swapNodesGuide',
'The following nodes can be automatically replaced with compatible alternatives.'
)
}}
</p>
<!-- Group Rows -->
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"
@@ -22,12 +12,9 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { t } = useI18n()
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean

View File

@@ -379,6 +379,16 @@ describe('ImagePreview', () => {
expect(gridThumbnails).toHaveLength(2)
})
it('requests lightweight thumbnails for grid cells instead of full-resolution images', () => {
renderImagePreview()
const gridImages = screen.getAllByRole('img')
expect(gridImages).toHaveLength(2)
for (const img of gridImages) {
expect(img.getAttribute('src')).toMatch(/[?&]preview=/)
}
})
it('defaults to gallery mode for single image', () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]

View File

@@ -13,7 +13,7 @@
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
>
<Button
v-for="(url, index) in imageUrls"
v-for="(url, index) in gridImageUrls"
:key="index"
size="unset"
class="ring-ring overflow-hidden rounded-none p-0 hover:ring-1 focus-visible:ring-2"
@@ -179,6 +179,7 @@ import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getGridThumbnailUrl } from '@/utils/imageUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -227,6 +228,7 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
)
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
const gridImageUrls = computed(() => imageUrls.map(getGridThumbnailUrl))
const hasMultipleImages = computed(() => imageUrls.length > 1)
const imageAltText = computed(() =>
t('g.viewImageOfTotal', {

View File

@@ -73,6 +73,7 @@
<Button
variant="textonly"
data-testid="advanced-inputs-button"
:class="
cn(
tabStyles,
@@ -169,6 +170,7 @@
>
<Button
variant="textonly"
data-testid="advanced-inputs-button"
:class="
cn(
tabStyles,

View File

@@ -24,7 +24,7 @@
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
@@ -128,13 +128,8 @@ onErrorCaptured((error) => {
return false
})
const {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(() => nodeData)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

View File

@@ -102,6 +102,14 @@ describe('isWidgetVisible', () => {
it('returns true for advanced widgets when showAdvanced is true', () => {
expect(isWidgetVisible({ advanced: true }, true)).toBe(true)
})
it('keeps advanced widgets visible when linked and showAdvanced is false', () => {
expect(isWidgetVisible({ advanced: true }, false, true)).toBe(true)
})
it('keeps hidden widgets hidden when linked', () => {
expect(isWidgetVisible({ hidden: true }, false, true)).toBe(false)
})
})
describe('hasWidgetError', () => {

View File

@@ -59,6 +59,7 @@ interface ProcessedWidget {
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
visible: boolean
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
@@ -154,11 +155,12 @@ export function getWidgetIdentity(
export function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean
showAdvanced: boolean,
linked = false
): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced)
return !hidden && (!advanced || showAdvanced || linked)
}
export function computeProcessedWidgets({
@@ -211,7 +213,11 @@ export function computeProcessedWidgets({
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions, showAdvanced)
const visible = isWidgetVisible(
mergedOptions,
showAdvanced,
widget.slotMetadata?.linked
)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
@@ -252,6 +258,7 @@ export function computeProcessedWidgets({
widget,
mergedOptions,
widgetState,
isVisible: visible,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = String(stripGraphPrefix(widget.nodeId ?? nodeId ?? ''))
@@ -343,6 +350,7 @@ export function computeProcessedWidgets({
vueComponent,
simplified,
value,
visible,
updateHandler,
tooltipConfig,
slotMetadata
@@ -396,12 +404,7 @@ export function useProcessedWidgets(
)
const visibleWidgets = computed(() =>
processedWidgets.value.filter((w) =>
isWidgetVisible(
{ hidden: w.hidden, advanced: w.advanced },
showAdvanced.value
)
)
processedWidgets.value.filter((w) => w.visible)
)
const gridTemplateRows = computed((): string =>
@@ -417,7 +420,6 @@ export function useProcessedWidgets(
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced,
visibleWidgets
}
}

View File

@@ -297,6 +297,7 @@ describe('useNodeDrag auto-pan', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(750, 300), '1')
drag.handleDrag(pointerEvent(760, 300), '1')
testState.mutationFns.batchMoveNodes.mockClear()
testState.mockDs.offset[0] -= 5
@@ -309,20 +310,46 @@ describe('useNodeDrag auto-pan', () => {
expect(nodeIds).toContain('2')
})
it('updates auto-pan pointer on handleDrag', () => {
it('starts auto-pan on handleDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
drag.handleDrag(pointerEvent(790, 300), '1')
expect(
testState.capturedAutoPanInstance.current!.updatePointer
).toHaveBeenCalledWith(790, 300)
const autoPan = testState.capturedAutoPanInstance.current
if (!autoPan) throw new Error('Auto-pan controller was not created')
expect(autoPan.start).toHaveBeenCalledTimes(1)
expect(autoPan.updatePointer).toHaveBeenCalledWith(790, 300)
})
it('reuses auto-pan controller across handleDrag calls', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
drag.handleDrag(pointerEvent(790, 300), '1')
const autoPan = testState.capturedAutoPanInstance.current
if (!autoPan) throw new Error('Auto-pan controller was not created')
testState.requestAnimationFrameCallback?.(0)
drag.handleDrag(pointerEvent(795, 305), '1')
expect(testState.capturedAutoPanInstance.current).toBe(autoPan)
expect(autoPan.start).toHaveBeenCalledTimes(1)
expect(autoPan.updatePointer).toHaveBeenLastCalledWith(795, 305)
})
it('does not start auto-pan before handleDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(790, 300), '1')
expect(testState.capturedAutoPanInstance.current).toBeNull()
})
it('stops auto-pan on endDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
drag.handleDrag(pointerEvent(400, 300), '1')
expect(testState.capturedAutoPanInstance.current).not.toBeNull()
drag.endDrag(pointerEvent(400, 300), '1')
@@ -333,6 +360,7 @@ describe('useNodeDrag auto-pan', () => {
it('does not move nodes if onPan fires after endDrag', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
drag.handleDrag(pointerEvent(400, 300), '1')
const onPan = testState.capturedOnPan.current!
drag.endDrag(pointerEvent(400, 300), '1')

View File

@@ -97,36 +97,41 @@ function useNodeDragIndividual() {
}
mutations.setSource(LayoutSource.Vue)
}
// Start auto-pan
const lgCanvas = canvasStore.canvas
if (lgCanvas?.ds) {
autoPan = new AutoPanController({
canvas: lgCanvas.canvas,
ds: lgCanvas.ds,
maxPanSpeed: lgCanvas.auto_pan_speed,
onPan: (panX, panY) => {
if (dragStartPos) {
dragStartPos.x += panX
dragStartPos.y += panY
}
if (otherSelectedNodesStartPositions) {
for (const pos of otherSelectedNodesStartPositions.values()) {
pos.x += panX
pos.y += panY
}
}
if (selectedGroups) {
for (const group of selectedGroups) {
group.move(panX, panY, true)
}
}
updateNodePositions(nodeId)
}
})
function startAutoPan(event: PointerEvent, nodeId: NodeId) {
if (autoPan) {
autoPan.updatePointer(event.clientX, event.clientY)
autoPan.start()
return
}
const lgCanvas = canvasStore.canvas
if (!lgCanvas?.ds) return
autoPan = new AutoPanController({
canvas: lgCanvas.canvas,
ds: lgCanvas.ds,
maxPanSpeed: lgCanvas.auto_pan_speed,
onPan: (panX, panY) => {
if (dragStartPos) {
dragStartPos.x += panX
dragStartPos.y += panY
}
if (otherSelectedNodesStartPositions) {
for (const pos of otherSelectedNodesStartPositions.values()) {
pos.x += panX
pos.y += panY
}
}
if (selectedGroups) {
for (const group of selectedGroups) {
group.move(panX, panY, true)
}
}
updateNodePositions(nodeId)
}
})
autoPan.updatePointer(event.clientX, event.clientY)
autoPan.start()
}
/**
@@ -206,7 +211,7 @@ function useNodeDragIndividual() {
lastPointerX = event.clientX
lastPointerY = event.clientY
autoPan?.updatePointer(event.clientX, event.clientY)
startAutoPan(event, nodeId)
rafId = requestAnimationFrame(() => {
rafId = null

View File

@@ -60,7 +60,7 @@
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal :to="portalTarget" :disabled="isPortalDisabled">
<ComboboxPortal>
<ComboboxContent
data-capture-wheel="true"
data-testid="widget-select-default-overlay"
@@ -161,7 +161,6 @@ import {
import { computed, ref } from 'vue'
import type { CSSProperties } from 'vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
@@ -242,19 +241,10 @@ const searchInputContainerRef = ref<HTMLElement>()
const { handleFocusOutside, handleViewportPointerDown } =
useRestoreFocusOnViewportPointer(focusSearchInput)
const transformCompatProps = useTransformCompatOverlayProps()
const widgetOptions = computed(
() => widget.options as SelectWidgetOptions | undefined
)
const portalTarget = computed(() => {
const appendTo = transformCompatProps.value.appendTo
return appendTo === 'self' ? undefined : appendTo
})
const isPortalDisabled = computed(() => !portalTarget.value)
const disabled = computed(() => Boolean(widgetOptions.value?.disabled))
const placeholder = computed(() => widgetOptions.value?.placeholder ?? '')
const filterPlaceholder = computed(

View File

@@ -2,7 +2,6 @@
import { computed, provide, ref, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
@@ -53,12 +52,9 @@ const outputMediaAssets = isCloud
? useFlatOutputAssets()
: useAssetsApi('output')
const transformCompatProps = useTransformCompatOverlayProps()
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
const combinedProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
const getAssetData = () => {
const nodeType: string | undefined =

View File

@@ -1,6 +1,34 @@
import { describe, expect, it } from 'vitest'
import { parseImageWidgetValue } from './imageUtil'
import { getGridThumbnailUrl, parseImageWidgetValue } from './imageUtil'
describe('getGridThumbnailUrl', () => {
it('adds a compact preview format to a full-resolution view URL', () => {
const result = getGridThumbnailUrl(
'/api/view?filename=image.png&type=output&subfolder='
)
expect(result).toMatch(/[?&]preview=webp(%3B|;)75/)
expect(result).toContain('filename=image.png')
})
it('preserves an existing preview param (user-configured format)', () => {
const result = getGridThumbnailUrl(
'/api/view?filename=image.png&type=output&preview=jpeg;50'
)
expect(result).toContain('preview=jpeg')
expect(result).not.toMatch(/preview=webp/)
})
it('leaves SVGs untouched since the server cannot rasterize them', () => {
const url = '/api/view?filename=diagram.svg&type=output'
expect(getGridThumbnailUrl(url)).toBe(url)
})
it('leaves non-view URLs (e.g. blob previews) untouched', () => {
const blob = 'blob:http://localhost/abc-123'
expect(getGridThumbnailUrl(blob)).toBe(blob)
})
})
describe('parseImageWidgetValue', () => {
it('parses a plain filename', () => {

View File

@@ -1,3 +1,36 @@
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
const GRID_THUMBNAIL_PREVIEW_FORMAT = 'webp;75'
/**
* Converts a full-resolution `/api/view` image URL into a lightweight
* thumbnail URL for small on-node grid cells: server-resized on cloud (`res`)
* and re-encoded to a compact format on every backend (`preview`). Non-view
* URLs, SVGs (the server cannot rasterize them into a preview), and URLs that
* already request a preview are returned unchanged.
*/
export function getGridThumbnailUrl(url: string): string {
if (!url) return url
let parsed: URL
try {
parsed = new URL(url, window.location.origin)
} catch {
return url
}
if (!parsed.pathname.endsWith('/view')) return url
const { searchParams } = parsed
const filename = searchParams.get('filename') ?? undefined
const isSvg = !!filename && filename.toLowerCase().endsWith('.svg')
appendCloudResParam(searchParams, filename)
if (!isSvg && !searchParams.has('preview'))
searchParams.set('preview', GRID_THUMBNAIL_PREVIEW_FORMAT)
return url.startsWith('http')
? parsed.toString()
: `${parsed.pathname}${parsed.search}`
}
/**
* Parses an image widget value like "subfolder/filename [type]" into its parts.
*/