Compare commits

...

8 Commits

Author SHA1 Message Date
Alexander Piskun
09942a5b7f Merge branch 'main' into feat/RemoteComboOptions 2026-05-04 19:15:08 +03:00
Christian Byrne
560e53c68f fix: remove coming soon badge from parallel job execution (#11819)
*PR Created by the Glary-Bot Agent*

---

Removes the "coming soon" badge from the Parallel Job Execution feature
card on the cloud pricing page (`comfy.org/cloud/pricing`).

## Changes

- `apps/website/src/components/pricing/WhatsIncludedSection.vue`: drop
`isComingSoon: true` from feature11 so it renders with the standard
check icon and no badge.

The `isComingSoon` mechanism (clock icon + yellow badge) is preserved in
the component for future use on other features.

## Note

The FAQ copy elsewhere on the site (`cloud.faq.9.a`) still references
"one active job at a time" and "parallel runs soon". That copy will be
updated separately.

## Verification

- `pnpm typecheck` (website): 0 errors
- `pnpm lint`: clean (1 pre-existing warning unrelated to this change)
- `pnpm format:check`: clean
- `pnpm test:unit` (website): 20 passed
- Visual check via Playwright on local dev server (see screenshot)

## Screenshots

![Pricing page after change: Parallel job execution row shows green
check icon and no coming soon
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/54c41067c2ba0bce5de11dd3b919e3c370be4eba2fd44eb3c411921f34bc088e/pr-images/1777688853166-87c5c07e-e4ad-4ef3-a892-f3e01e2f980f.png)

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11819-fix-remove-coming-soon-badge-from-parallel-job-execution-3546d73d365081d19060f976095d03ac)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:27:31 +00:00
Yourz
1999b7fba0 fix: remove (beta) from cloud.faq.3.a (#11905)
*PR Created by the Glary-Bot Agent*

---

## Summary

Remove `(beta)` from the `cloud.faq.3.a` translation entry in both
English and Simplified Chinese (`zh-CN`), since Comfy Cloud is no longer
in beta.

## Changes

`apps/website/src/i18n/translations.ts`:
- en: `Comfy Cloud (beta) has zero setup...` → `Comfy Cloud has zero
setup...`
- zh-CN: `Comfy Cloud(测试版)无需任何设置...` → `Comfy Cloud 无需任何设置...`

## Verification

- Pre-commit hooks (oxfmt, oxlint, eslint, typecheck, typecheck:website)
all passed
- Code review (oracle): 0 issues, ready to merge
- Manual verification via Playwright on `/cloud` and `/zh-CN/cloud` —
FAQ item 3 renders updated copy in both locales (screenshots attached)

## Screenshots

![English FAQ item 3 expanded — 'Comfy Cloud has zero setup...' (no
beta)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906512798-b5b8fc07-1ed1-43e2-88f5-35efd6ee7254.png)

![Simplified Chinese FAQ item 3 expanded — 'Comfy Cloud 无需任何设置...' (no
测试版)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906513275-1c0c0f6b-0408-4cc2-93e6-4a5e0d02a101.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11905-fix-remove-beta-from-cloud-faq-3-a-3566d73d36508150997bcf2c89826091)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:03:06 +00:00
Christian Byrne
285421a87c feat: add queue progress overlay feature survey (#11560)
*PR Created by the Glary-Bot Agent*

---

## Summary

Registers a new nightly feature survey for the Queue Progress Overlay
using the existing feature-survey registry (same pattern as the merged
node-search survey, PRs #8175/#8355/#9934).

- New registry entry `queue-progress-overlay` → Typeform `HZ5saxry`,
threshold **16**, 5s display delay.
- `trackFeatureUsed()` wired at the major user-initiated handlers inside
the overlay so the survey triggers regardless of panel location
(floating-right v1 or docked-left v2).
- Run button and other ActionBar items that the overlay pops over from
are deliberately **not** tracked — tracking is scoped to interactions
that originate inside the job panel / queue progress overlay itself.

## Tracked interactions

Both variants share most sub-components, so tracking is instrumented
once at each logical surface:

- **`QueueProgressOverlay.vue`** (v1 container): `viewAllJobs`,
`interruptAll`, `cancelQueuedWorkflows`, `onClearHistoryFromMenu`,
`toggleAssetsSidebar`, `onCancelItem`, `onDeleteItem`, `inspectJobAsset`
- **`QueueOverlayExpanded.vue`**: job tab switches
- **`JobHistorySidebarTab.vue`** (v2 docked): job tab switches,
`clearQueuedWorkflows`, `onClearHistory`, `onCancelItem`,
`onDeleteItem`, `onViewItem`
- **`JobFilterActions.vue`** (shared): workflow filter + sort mode
selections
- **`JobHistoryActionsMenu.vue`** (shared): docked-history toggle +
run-progress-bar toggle

Deliberately **not tracked** to keep the signal clean:
- Hover handlers (ambient preview behaviour)
- Search-box keystrokes (debounced typing)
- Context menu open and menu-item dispatch — menu actions either bubble
through already-tracked terminal handlers (e.g. inspect-asset →
`onViewItem`) or are secondary operations (copy-id, open-workflow,
download). Avoids double-counting per code review feedback.

## How it works (inherits from existing infrastructure)

1. `surveyRegistry.ts` drives `NightlySurveyController` →
`NightlySurveyPopover`, which handles the Typeform embed.
2. Eligibility already gated on `isNightly && !isCloud && !isDesktop`,
once-per-user, 4-day global cooldown across all surveys, and opt-out.
3. Typeform response routing to #C0ALLT6Q3SQ is handled on the Typeform
side.

## Verification

- `pnpm typecheck` 
- `pnpm lint`  (no new warnings)
- `pnpm knip` 
- `pnpm test:unit` on `src/components/queue`,
`src/components/sidebar/tabs/JobHistorySidebarTab`,
`src/platform/surveys` → **123/123 passing**
- Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck) all
pass
- Manual: dev server + backend boot cleanly, app loads without new
runtime errors, `localStorage['Comfy.FeatureUsage']` layout verified to
match what `useFeatureUsageTracker` writes

## Notes

- Survey key `queue-progress-overlay` covers both v1 (floating-right)
and v2 (docked-sidebar) per product guidance: _"This should trigger
regardless of the location of the panel (docked from left or floating on
right)."_ Both surfaces are the same product feature — the survey is
intentionally scoped to the whole job-panel experience.


## Screenshots

![App loads cleanly with the new survey code in place — empty canvas
with Run button and sidebar, no runtime
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/fd18977704544ba278ad3fa42c695289ae7e02001550ce38955d6fb47d872146/pr-images/1776914667332-03e4ef0a-4137-47c6-87b8-b554770b8900.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11560-feat-add-queue-progress-overlay-feature-survey-34b6d73d3650819a9a50fd67fd9b5941)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:50:18 +00:00
Christian Byrne
5523df1aea fix(website): unstretch See all case studies button (#11854)
*PR Created by the Glary-Bot Agent*

---

## Summary

The "See all case studies" button on the homepage
`CaseStudySpotlightSection` was rendering oddly stretched because it had
`class="flex-1 text-center"` while being the sole child of a `flex-row`
container — it expanded to fill the entire content column (~592px)
instead of sizing to its label.

This drops `flex-1`/`text-center` and adds `items-start` to the wrapper
so the button sizes to its content and is left-aligned, matching the
proportions of every other outline `BrandButton` on the site (Hero,
UseCase, customer detail, etc.).

## Changes

- `apps/website/src/components/home/CaseStudySpotlightSection.vue`:
remove `flex-1 text-center` from the `BrandButton` and align the row's
items to the start.

`BrandButton` already centers its label internally via `inline-flex …
justify-center`, so dropping `text-center` is a no-op visually.

## Before / After

- Desktop before: button width = 592px (stretched across the column)
- Desktop after: button width = 223px (natural)
- Mobile: 1-column layout, now consistently left-aligned

## Review Focus

Whether the fix should also live on the `BrandButton` component itself
(e.g. a global `max-width`) instead of at the call site. I went with the
instance-level fix because every other CTA in the website intentionally
uses bare `BrandButton` and lets the content size it; only this one had
`flex-1`. A blanket `max-width` would risk changing Hero/MobileMenu
buttons that explicitly opt into `w-full lg:w-auto lg:min-w-60`.

## Screenshots

![Before: button stretched across the full content
column](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244354-4dd9af45-2458-4d8a-a1a7-1f6b88b6fc4b.png)

![After: button sized to content,
left-aligned](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244808-5bab2801-0140-4b4a-9d9e-61a467090de3.png)

![After: mobile view, left-aligned natural
width](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774245316-1ca9609d-3de0-4c85-973e-a87e296fa65f.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11854-fix-website-unstretch-See-all-case-studies-button-3556d73d365081abb3bbe9dbc51cbc07)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:03:39 +00:00
Christian Byrne
65876c635d feat(website): add responsive media tooling for marketing assets (#11869)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the building blocks for a responsive media system on
`apps/website`, motivated by the gallery video blurriness raised in
Slack. Three independent pieces:

1. **`<SiteVideo>` Vue component + URL helper** — emits a `<video>` with
multiple `<source>` tags, designed to pair with assets named
`${name}-${width}.${format}` on `media.comfy.org`.
2. **`scripts/process-videos.sh`** — local-developer `ffmpeg` helper
that produces VP9/WebM + H.264/MP4 variants and a poster JPG. Not wired
into CI; the team uploads to `media.comfy.org` out-of-band.
3. **Marketing image conventions** — shared `MARKETING_FORMATS` /
`MARKETING_WIDTHS` constants and a README documenting how to render
local marketing images via Astro's built-in `<Picture>` from
`astro:assets`.

This PR is **infrastructure only** — no existing pages are modified.
Adoption (e.g. converting `HeroSection`, gallery videos) is a follow-up.
The new files are added to knip's ignore list with the existing "pending
stacked PR" pattern.

## Why this shape

- **No custom `<Picture>` wrapper.** Astro 5 already ships a
`ResponsiveImage` component (name conflict), and Astro's
`LocalImageProps | RemoteImageProps` discriminated union does not
survive a thin wrapper without unsafe `as` casts. Shared constants give
the consistency benefit at lower cost.
- **No CI media-upload step.** The `Release: Website` workflow currently
only refreshes the Ashby snapshot; wiring GCS uploads into it would
require new secrets and team coordination beyond this PR's scope. The
script runs locally and outputs are uploaded to `media.comfy.org` the
same way as today.
- **Single resolution per `<video>`.** `<source media="...">` inside
`<video>` is unreliable across browsers (Safari ignores it). The script
generates multiple widths so callers can pick one per page; JS-based
selection can be layered on later if metrics demand it.

## What's verified

- `pnpm --filter @comfyorg/website test:unit` — 30 pass (7 new for
`buildVideoSources` / `videoKey`)
- `pnpm --filter @comfyorg/website typecheck` — clean
- `pnpm --filter @comfyorg/website build` — 41 pages built clean
- `pnpm knip` — exit 0
- `oxfmt --check` and `oxlint` clean on all changed files
- `bash -n` on `process-videos.sh` clean; usage and missing-deps paths
exercised manually
- Manual: home page and `/gallery` rendered via `astro dev` — both
unchanged with zero console errors (screenshots attached)

## Review feedback addressed

After Oracle review, three follow-up commits land:

- **`SiteVideo` reactivity** — `sources` is now `computed`; the
`<video>` is keyed on the joined source URLs so it remounts when the
source set changes (browsers don't reload on `<source>` mutation).
- **`SiteVideo` accessibility** — `aria-hidden="true"` only when truly
decorative (no `alt` and no `controls`).
- **Shell script robustness** — probes duration with `ffprobe` and falls
back to `t=0` for clips shorter than 1s; enables `nocaseglob` so
`CLIP.MP4` is picked up.
- **Docs** — clarifies when to use `<SiteVideo>` (lightweight
multi-source) vs `<VideoPlayer>` (captions, controls, scrubber).

## Out of scope (follow-ups)

- Converting existing pages (`HeroSection`, customer detail heros,
gallery) to use the new components. Most current images are CDN-hosted
and migrating them is a separate decision.
- Re-encoding the gallery videos at a higher source width to actually
fix the blurriness — that requires the team to run `process-videos.sh`
against the source clips and re-upload.
- Combining `<SiteVideo>`'s multi-source support with `<VideoPlayer>`'s
rich chrome.

## Screenshots

![Home page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791647863-fb1ea2bf-32fc-40d9-852d-cceb3bc148f7.png)

![Gallery page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791648186-0b598260-a836-4866-9c55-9d0e99de6d4c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11869-feat-website-add-responsive-media-tooling-for-marketing-assets-3556d73d3650818899c7f9ed3204c9a5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:25:20 +00:00
bigcat88
28c97d3687 fix: use "item.id" if both label and title are missing. 2026-05-04 15:18:21 +03:00
bigcat88
97c2a0d364 feat(widgets): rich combo widget for remote options with previews
Adds a Vue-native renderer for combo inputs that declare `remote_combo=`
(RemoteComboOptions on the backend). Wired through WidgetSelect; runs in
parallel to the existing useRemoteWidget composable, which continues to
handle plain `remote=` combos.

The widget fetches a single items array from a relative `/proxy/...`
route — the frontend always prepends the comfy-api base URL and injects
auth headers (no opt-out flag while the feature is partner-node-only).
Items are mapped via the per-node `item_schema`, with image/video/audio
previews, search across multiple fields, optional auto-select first/last,
and a refresh button.

Caching: browser Cache API with TTL from `refresh`, partitioned by full
auth scope (workspace / firebase uid / api-key / anon). Refresh button
sequences cache delete before refetch to avoid the fast-response race.
Logging: auth headers and response bodies are redacted from error logs.

Also adds an audio preview branch to FormDropdownMenuItem — used by the
new widget when `preview_type='audio'`.

Tests cover: single-shot fetch, error classification, retry exhaustion,
refresh, deselect, stale-id preservation, cache-key partitioning,
route resolution, item-schema mapping, and Zod relative-route
validation.
2026-05-03 14:15:32 +03:00
36 changed files with 2166 additions and 32 deletions

View File

@@ -69,6 +69,50 @@ test.describe('Homepage @smoke', () => {
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()

View File

@@ -0,0 +1,83 @@
# Website Scripts
## `refresh-ashby-snapshot.ts`
Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
## `process-videos.sh`
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.
```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```
### Output
For each source video at `./video-sources/foo.mp4`, you get:
```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```
The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.
### Pairing with `<SiteVideo>`
Once the assets are uploaded, render them with:
```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```
### `<SiteVideo>` vs `<VideoPlayer>`
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.
If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.
### Encoder choices
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.
### Why a single resolution per video
`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
set -euo pipefail
if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi
input_dir=$1
output_dir=$2
widths=${3:-1280}
for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done
if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi
mkdir -p "$output_dir"
shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob
if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi
for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"
for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"
echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done
poster_out="$output_dir/${name}-poster.jpg"
duration_raw=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
duration="$duration_raw"
else
duration=0
fi
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
poster_seek=1
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done
echo "done. upload contents of $output_dir to media.comfy.org."

View File

@@ -0,0 +1,51 @@
# Marketing Assets
Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
## Usage
Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:
```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.
A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.
## When to use this vs. `media.comfy.org`
- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.
## Source image guidelines
- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'
const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()
const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>
<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>

View File

@@ -35,7 +35,10 @@ const routes = getRoutes(locale)
</div>
<!-- Right: content -->
<div class="flex flex-col justify-between p-6 lg:flex-1">
<div
data-testid="case-study-content"
class="flex flex-col justify-between p-6 lg:flex-1"
>
<div class="flex flex-col gap-8">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
@@ -52,12 +55,8 @@ const routes = getRoutes(locale)
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
:href="routes.customers"
variant="outline"
class="flex-1 text-center"
>
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
<BrandButton :href="routes.customers" variant="outline">
{{ t('caseStudy.seeAll', locale) }}
</BrandButton>
</div>

View File

@@ -101,17 +101,9 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<span
v-if="feature.isComingSoon"
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
>
{{ t('pricing.included.comingSoon', locale) }}
</span>
</div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->

View File

@@ -914,9 +914,9 @@ const translations = {
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1280,10 +1280,6 @@ const translations = {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
'pricing.included.comingSoon': {
en: 'coming soon',
'zh-CN': '即将推出'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },

View File

@@ -0,0 +1,3 @@
export const MARKETING_FORMATS = ['avif', 'webp'] as const
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { buildVideoSources, videoKey } from './video'
describe('buildVideoSources', () => {
it('builds a source per requested format', () => {
const sources = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/website/marketing',
width: 1280,
formats: ['webm', 'mp4']
})
expect(sources).toEqual([
{
src: 'https://media.comfy.org/website/marketing/hero-1280.webm',
type: 'video/webm',
format: 'webm'
},
{
src: 'https://media.comfy.org/website/marketing/hero-1280.mp4',
type: 'video/mp4',
format: 'mp4'
}
])
})
it('preserves caller-supplied format order', () => {
const sources = buildVideoSources({
name: 'clip',
baseUrl: 'https://cdn.example.com/v',
width: 960,
formats: ['mp4', 'webm']
})
expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm'])
})
it('strips a single trailing slash from baseUrl', () => {
const sources = buildVideoSources({
name: 'reel',
baseUrl: 'https://media.comfy.org/website/marketing/',
width: 1920,
formats: ['webm']
})
expect(sources[0]?.src).toBe(
'https://media.comfy.org/website/marketing/reel-1920.webm'
)
})
it('returns an empty list when no formats are requested', () => {
const sources = buildVideoSources({
name: 'x',
baseUrl: 'https://example.com',
width: 640,
formats: []
})
expect(sources).toEqual([])
})
})
describe('videoKey', () => {
it('changes when the source URL list changes', () => {
const at1280 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const at640 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 640,
formats: ['webm', 'mp4']
})
expect(videoKey(at1280)).not.toBe(videoKey(at640))
})
it('is stable across repeated calls with the same inputs', () => {
const args = {
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4'] as const
}
expect(
videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))
).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] })))
})
it('reflects format-order changes', () => {
const webmFirst = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const mp4First = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['mp4', 'webm']
})
expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First))
})
})

View File

@@ -0,0 +1,49 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
/** @knipIgnoreUsedByStackedPR */
export type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat
}
const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = {
webm: 'video/webm',
mp4: 'video/mp4'
}
type BuildArgs = {
name: string
baseUrl: string
width: number
formats: VideoFormat[]
}
/**
* Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`,
* matching the output of `apps/website/scripts/process-videos.sh`.
*/
export function buildVideoSources({
name,
baseUrl,
width,
formats
}: BuildArgs): VideoSource[] {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
return formats.map((format) => ({
src: `${base}/${name}-${width}.${format}`,
type: MIME_TYPES[format],
format
}))
}
/**
* Stable identifier for a list of video sources, suitable as a Vue `key`.
* Browsers do not reload a `<video>` when nested `<source>` children change;
* keying the parent forces a remount when the source set changes.
*/
export function videoKey(sources: VideoSource[]): string {
return sources.map((s) => s.src).join('|')
}

View File

@@ -54,6 +54,9 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -98,6 +98,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
@@ -107,6 +108,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
@@ -119,6 +121,7 @@ const onClearHistoryFromMenu = (close: () => void) => {
}
const onToggleDockedJobHistory = async (close: () => void) => {
trackFeatureUsed()
close()
try {
@@ -138,6 +141,7 @@ const onToggleDockedJobHistory = async (close: () => void) => {
}
const onToggleRunProgressBar = async () => {
trackFeatureUsed()
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value

View File

@@ -13,7 +13,7 @@
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-job-tab="onUpdateSelectedJobTab"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@@ -50,6 +50,7 @@ import type {
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -81,6 +82,7 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -95,6 +97,11 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
emit('update:selectedJobTab', value)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)

View File

@@ -66,6 +66,7 @@ import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -93,6 +94,7 @@ const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const {
totalPercentFormatted,
@@ -188,6 +190,7 @@ const {
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const jobId = item.taskRef?.jobId
if (!jobId) return
@@ -209,6 +212,7 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
@@ -224,10 +228,12 @@ const setExpanded = (expanded: boolean) => {
}
const viewAllJobs = () => {
trackFeatureUsed()
setExpanded(true)
}
const toggleAssetsSidebar = () => {
trackFeatureUsed()
sidebarTabStore.toggleSidebarTab('assets')
}
@@ -257,12 +263,14 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
trackFeatureUsed()
await openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
// Capture pending jobIds before clearing
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
@@ -275,6 +283,7 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const tasks = queueStore.runningTasks
const jobIds = tasks
.map((task) => task.jobId)
@@ -298,6 +307,7 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
})
const onClearHistoryFromMenu = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -122,6 +122,7 @@ import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
const {
hideShowAssetsAction = false,
@@ -147,6 +148,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
@@ -170,6 +172,7 @@ const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
trackFeatureUsed()
selectWorkflowFilter(value)
close()
}
@@ -179,6 +182,7 @@ const selectSortMode = (value: JobSortMode) => {
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
trackFeatureUsed()
selectSortMode(value)
close()
}

View File

@@ -2,15 +2,16 @@
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
<JobHistoryActionsMenu @clear-history="onClearHistory" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
v-model:selected-job-tab="selectedJobTab"
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="onUpdateSelectedJobTab"
/>
</div>
<JobFilterActions
@@ -81,13 +82,14 @@ import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem, JobTab } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -104,6 +106,17 @@ const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const onClearHistory = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
selectedJobTab.value = value
}
const {
selectedJobTab,
selectedWorkflowFilter,
@@ -145,6 +158,7 @@ const activeQueueSummary = computed(() => {
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
@@ -160,6 +174,7 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
@@ -194,10 +209,12 @@ const { jobMenuEntries, cancelJob } = useJobMenu(
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})

View File

@@ -2700,6 +2700,12 @@
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
},
"remoteCombo": {
"loading": "Loading...",
"loadFailed": "Failed to load options",
"playAudioPreview": "Play audio preview",
"pauseAudioPreview": "Pause audio preview"
},
"valueControl": {
"header": {
"prefix": "Automatically update the value",

View File

@@ -11,6 +11,12 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
triggerThreshold: 3,
delayMs: 5000
},
'queue-progress-overlay': {
featureId: 'queue-progress-overlay',
typeformId: 'HZ5saxry',
triggerThreshold: 16,
delayMs: 5000
},
'error-panel': {
featureId: 'error-panel',
typeformId: 'iFp4p4mV',

View File

@@ -0,0 +1,272 @@
import { createTestingPinia } from '@pinia/testing'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import axios, { AxiosError, AxiosHeaders } from 'axios'
import type * as AxiosModule from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
// Preserve everything axios exports — only `default.get` is the call site we
// drive. Other modules in the import graph (e.g. workspaceApi) call
// axios.create() at module-load time, so we can't replace the default outright.
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
// All four auth-related composables are mocked at module level so the SFC's
// imports never pull in firebase / vuefire. Their return shapes only need to
// satisfy the call sites the widget actually hits.
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { teamWorkspacesEnabled: false } })
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: undefined,
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({ getApiKey: () => null })
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
// Minimal stub: surfaces the props the widget binds (so we can assert on them)
// and exposes click affordances that emit `update:selected` for the user-action
// tests. The real FormDropdown's rendering is tested in its own suite.
const FormDropdownStub = {
name: 'FormDropdown',
props: [
'selected',
'items',
'placeholder',
'multiple',
'showSort',
'showLayoutSwitcher',
'searcher',
'layoutMode'
],
emits: ['update:selected', 'update:layoutMode'],
template: `
<div data-testid="dropdown">
<span data-testid="placeholder">{{ placeholder }}</span>
<span data-testid="items-count">{{ items.length }}</span>
<button
v-for="item in items"
:key="item.id"
:data-testid="'item-' + item.id"
@click="$emit('update:selected', new Set([item.id]))"
>
{{ item.name }}
</button>
<button
data-testid="deselect"
@click="$emit('update:selected', new Set())"
>×</button>
</div>
`
}
const baseSchema: RemoteItemSchema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
function buildWidget(
remoteCombo: Partial<Omit<RemoteComboConfig, 'route' | 'item_schema'>> = {},
value: string | undefined = undefined
): SimplifiedWidget<string | undefined> {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'voice',
remote_combo: {
route: '/voices',
item_schema: baseSchema,
...remoteCombo
}
}
return createMockWidget<string | undefined>({
name: 'voice',
type: 'COMBO',
value,
spec
})
}
function renderWidget(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined = undefined
) {
return render(RichComboWidget, {
props: {
widget,
modelValue: modelValue ?? widget.value
},
global: {
plugins: [createTestingPinia(), i18n],
stubs: { FormDropdown: FormDropdownStub }
}
})
}
function mockAxiosResponseOnce(data: unknown) {
vi.mocked(axios.get).mockResolvedValueOnce({ data })
}
function mockAxiosErrorOnce(status: number) {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError(`HTTP ${status}`, 'ERR_BAD_RESPONSE', undefined, undefined, {
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
})
)
}
function mockAxiosNetworkErrorOnce() {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError('Network Error', 'ERR_NETWORK')
)
}
beforeEach(() => {
vi.clearAllMocks()
// Cache API isn't in happy-dom by default. Stub a no-op cache so getCached
// always returns null (forces a fetch) and setCache/clearCache resolve.
vi.stubGlobal('caches', {
open: vi.fn(() =>
Promise.resolve({
match: vi.fn(() => Promise.resolve(undefined)),
put: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve(true))
})
)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('RichComboWidget', () => {
it('mounts, fetches, and renders the items returned from the route', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(screen.getByText('Alice')).toBeTruthy()
expect(screen.getByText('Bob')).toBeTruthy()
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder on a non-retriable 404 without retrying', async () => {
mockAxiosErrorOnce(404)
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder when retries are exhausted', async () => {
// max_retries=1 lets us assert exhaustion without sleeping through the
// exponential backoff (`attempts++` then `attempts >= maxRetries` breaks
// before any setTimeout call).
mockAxiosNetworkErrorOnce()
renderWidget(buildWidget({ max_retries: 1 }))
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('refetches when the refresh button is clicked', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('1')
)
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
await userEvent.click(screen.getByLabelText('g.refresh'))
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('clears modelValue to undefined when the selected item is toggled off (B1 regression)', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
const { emitted } = renderWidget(buildWidget(), 'a')
expect(await screen.findByTestId('item-a')).toBeTruthy()
await userEvent.click(screen.getByTestId('deselect'))
const updates = emitted('update:modelValue')
expect(updates).toBeTruthy()
expect(updates!.at(-1)).toEqual([undefined])
})
it('preserves a stale modelValue when the fetched items do not contain that id', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
const { emitted } = renderWidget(buildWidget(), 'stale-id')
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
// The selection sync watcher only mutates the internal selectedSet — it
// never writes to modelValue, so the stale id round-trips intact when the
// workflow is later saved.
expect(emitted('update:modelValue')).toBeFalsy()
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.uploadSelect.placeholder'
)
})
})

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem
} from '../utils/itemSchemaUtils'
import { fetchRemoteRoute } from '../utils/fetchRemoteRoute'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '../utils/richComboHelpers'
const DEFAULT_MAX_RETRIES = 5
const DEFAULT_TIMEOUT = 30000
// --- Persistent cache using browser Cache API (survives page reloads) ---
const CACHE_NAME = 'comfy-remote-widget'
// Mirrors useAuthStore().getAuthHeader()'s priority chain so the cache is
// partitioned by the *active* auth context, not just the firebase user.
// Same firebase user across two workspaces, or across workspace ↔ personal,
// would otherwise share a cache and bleed data.
//
// Returns an opaque, non-secret identifier. The API-key branch deliberately
// returns a constant rather than the key value or a hash of it: hashing is
// async (SubtleCrypto), and grouping all keys on one machine under a single
// scope is an acceptable tradeoff for the rare key-rotation case.
function getAuthScope(): string {
const { flags } = useFeatureFlags()
if (flags.teamWorkspacesEnabled) {
const wsId = useWorkspaceAuthStore().currentWorkspace?.id
if (wsId) return `ws:${wsId}`
}
const uid = useAuthStore().userId
if (uid) return `fb:${uid}`
return useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
}
function cacheKeyFor(config: RemoteComboConfig): string {
return buildCacheKey(config, getAuthScope())
}
async function getCached(config: RemoteComboConfig): Promise<unknown[] | null> {
try {
const cache = await caches.open(CACHE_NAME)
const resp = await cache.match(cacheKeyFor(config))
if (!resp) return null
const entry = await resp.json()
const ttl = config.refresh
if (!ttl || ttl <= 0) return entry.data
if (Date.now() - entry.timestamp < ttl) return entry.data
return null
} catch {
return null
}
}
async function clearCache(config: RemoteComboConfig) {
try {
const cache = await caches.open(CACHE_NAME)
await cache.delete(cacheKeyFor(config))
} catch {
// ignore
}
}
async function setCache(config: RemoteComboConfig, data: unknown[]) {
try {
const cache = await caches.open(CACHE_NAME)
const body = JSON.stringify({ data, timestamp: Date.now() })
await cache.put(cacheKeyFor(config), new Response(body))
} catch {
// Cache API unavailable — widget still works, just no persistence
}
}
const { widget } = defineProps<{
widget: SimplifiedWidget<string | undefined>
}>()
const modelValue = defineModel<string>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (widget.spec && isComboInputSpec(widget.spec)) {
return widget.spec
}
return undefined
})
const remoteConfig = computed<RemoteComboConfig | undefined>(
() => comboSpec.value?.remote_combo
)
const itemSchema = computed<RemoteItemSchema | undefined>(
() => remoteConfig.value?.item_schema
)
// --- Fetch state ---
const rawItems = ref<unknown[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | undefined
// --- Auto-select policy ---
// Only sets modelValue when it's empty; never overrides an existing value
// (valid or stale) — user intent and workflow portability are preserved.
function applyAutoSelect(config: RemoteComboConfig) {
if (modelValue.value) return
const list = items.value
if (list.length === 0) return
if (config.auto_select === 'first') {
modelValue.value = list[0].id
} else if (config.auto_select === 'last') {
modelValue.value = list[list.length - 1].id
}
}
async function fetchAll(config: RemoteComboConfig) {
const controller = abortController!
const maxRetries = config.max_retries ?? DEFAULT_MAX_RETRIES
loading.value = true
error.value = null
let attempts = 0
while (!controller.signal.aborted) {
try {
const res = await fetchRemoteRoute(config.route, {
timeout: config.timeout ?? DEFAULT_TIMEOUT,
signal: controller.signal
})
if (controller.signal.aborted) return
const fetchedItems = extractItems(res.data, config.response_key)
if (fetchedItems === null) {
console.error('RichComboWidget: expected array response', {
route: config.route,
responseKey: config.response_key,
received: summarizePayload(res.data)
})
error.value = t('widgets.remoteCombo.loadFailed')
break
}
await setCache(config, fetchedItems)
if (controller.signal.aborted) return
rawItems.value = fetchedItems
applyAutoSelect(config)
break
} catch (err: unknown) {
if (controller.signal.aborted) return
console.error('RichComboWidget: fetch error', {
route: config.route,
error: summarizeError(err)
})
if (!isRetriableError(err)) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
attempts++
if (attempts >= maxRetries) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
const delay = getBackoff(attempts)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
if (!controller.signal.aborted) {
loading.value = false
}
}
async function fetchItems(bypassCache = false) {
const config = remoteConfig.value
if (!config) return
// Claim the active controller before any async work so the cache-hit
// path can bail out if a later fetchItems supersedes us.
abortController?.abort()
const myController = new AbortController()
abortController = myController
// Check cache first (unless manual refresh)
if (!bypassCache) {
const cached = await getCached(config)
if (myController.signal.aborted) return
if (cached) {
rawItems.value = cached
applyAutoSelect(config)
return
}
}
// Reset items for fresh fetch
rawItems.value = []
await fetchAll(config)
}
onMounted(() => {
void fetchItems()
})
onUnmounted(() => {
abortController?.abort()
})
// --- Preview type ---
const assetKind = computed(() => itemSchema.value?.preview_type ?? 'image')
provide(AssetKindKey, assetKind)
// --- Item mapping ---
const items = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (schema) {
return rawItems.value.map((raw) => mapToDropdownItem(raw, schema))
}
return rawItems.value.map((raw) => {
const val = String(raw ?? '')
return { id: val, name: val }
})
})
// --- Search ---
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema?.search_fields
if (!schema || !fields?.length) return new Map<string, string>()
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
const text = buildSearchText(raw, fields)
if (text) index.set(id, text)
}
return index
})
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
async function searcher(query: string, searchItems: FormDropdownItem[]) {
if (!query.trim()) return searchItems
const q = query.toLowerCase()
return searchItems.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
}
// --- Selection sync ---
watch(
[modelValue, items],
([val]) => {
selectedSet.value.clear()
if (val) {
const item = items.value.find((i) => i.id === val)
if (item) selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
function handleRefresh() {
abortController?.abort()
error.value = null
const config = remoteConfig.value
// Sequence the cache delete before the refetch: otherwise the (very fast)
// setCache from a quickly-resolved network response can land the new entry
// before the still-pending cache.delete removes it, silently dropping the
// freshly-cached data on the next mount.
void (async () => {
if (config) await clearCache(config)
await fetchItems(true)
})()
}
function handleSelection(selected: Set<string>) {
modelValue.value = selected.values().next().value
}
const placeholder = computed(() => {
if (loading.value) return t('widgets.remoteCombo.loading')
if (error.value) return error.value
return t('widgets.uploadSelect.placeholder')
})
</script>
<template>
<div
class="flex w-full min-w-0 items-center gap-1 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<FormDropdown
v-model:selected="selectedSet"
v-model:layout-mode="layoutMode"
:items="items"
:placeholder="placeholder"
:multiple="false"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="min-w-0 flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
type="button"
:aria-label="t('g.refresh')"
:title="t('g.refresh')"
class="text-secondary flex size-7 shrink-0 items-center justify-center rounded-sm hover:bg-component-node-widget-background-hovered"
@click.stop="handleRefresh"
>
<i
:class="
cn('icon-[lucide--refresh-cw] size-3.5', loading && 'animate-spin')
"
/>
</button>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<template>
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-else-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -24,6 +25,7 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -53,6 +55,8 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -33,6 +33,8 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -59,6 +61,8 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -229,6 +233,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -68,7 +68,11 @@ const theButtonStyle = computed(() =>
{{ placeholder }}
</span>
<span v-else>
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
{{
selectedItems
.map((item) => item.label || item.name || item.id)
.join(', ')
}}
</span>
</span>
<i

View File

@@ -20,6 +20,8 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -31,6 +33,8 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -112,6 +116,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -145,6 +151,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -16,8 +16,10 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
defineProps<{
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -112,6 +114,7 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
:aria-label="t('assetBrowser.sortBy')"
:title="t('assetBrowser.sortBy')"
@@ -132,6 +135,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -306,6 +310,7 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -28,11 +28,15 @@ const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isMesh = computed(() => assetKind?.value === 'mesh')
const isAudio = computed(() => assetKind?.value === 'audio')
const mediaContainerRef = ref<HTMLElement>()
const resolvedMeshPreview = ref<string | null>(null)
const meshPreviewAttempted = ref(false)
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function toLookupName(name: string): string {
const stripped = name.replace(/ \[output\]$/, '')
const slash = stripped.lastIndexOf('/')
@@ -68,6 +72,17 @@ function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
const audio = audioRef.value
if (!audio) return
if (audio.paused) {
void audio.play().catch(() => {})
} else {
audio.pause()
}
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -148,6 +163,35 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<button
v-else-if="previewUrl && isAudio"
type="button"
:aria-label="
isPlayingAudio
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlayingAudio"
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@play="isPlayingAudio = true"
@pause="isPlayingAudio = false"
@ended="isPlayingAudio = false"
/>
<i
:class="
cn(
'text-secondary size-5',
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
/>
</button>
<img
v-else-if="displayedPreviewUrl"
:src="displayedPreviewUrl"
@@ -193,6 +237,13 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,7 +12,9 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Preview image/video URL */
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean
@@ -47,6 +49,7 @@ export interface FormDropdownMenuItemProps {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}

View File

@@ -0,0 +1,81 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchRemoteRoute } from '@/renderer/extensions/vueNodes/widgets/utils/fetchRemoteRoute'
import type { AuthHeader } from '@/types/authTypes'
const COMFY_API_BASE = 'https://api.example.test'
const mockAuth = vi.hoisted(() => ({
authHeader: null as AuthHeader | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/config/comfyApi', () => ({
getComfyApiBaseUrl: () => COMFY_API_BASE
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockAuth.authHeader))
}))
}))
describe('fetchRemoteRoute', () => {
beforeEach(() => {
vi.mocked(axios.get).mockResolvedValue({ data: [] })
})
afterEach(() => {
mockAuth.authHeader = null
vi.clearAllMocks()
})
it('prepends the comfy api base URL to the route', async () => {
await fetchRemoteRoute('/voices')
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe(`${COMFY_API_BASE}/voices`)
})
it('injects the auth header when one is available', async () => {
mockAuth.authHeader = { Authorization: 'Bearer token-123' }
await fetchRemoteRoute('/voices')
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toEqual({ Authorization: 'Bearer token-123' })
})
it('does not set headers when no auth header is available', async () => {
mockAuth.authHeader = null
await fetchRemoteRoute('/voices')
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toBeUndefined()
})
it('forwards params, timeout and signal to axios', async () => {
const controller = new AbortController()
await fetchRemoteRoute('/voices', {
params: { filter: 'pro', limit: '10' },
timeout: 5000,
signal: controller.signal
})
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.params).toEqual({ filter: 'pro', limit: '10' })
expect(config?.timeout).toBe(5000)
expect(config?.signal).toBe(controller.signal)
})
it('returns the axios response', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: { items: [1, 2] } })
const res = await fetchRemoteRoute('/voices')
expect(res.data).toEqual({ items: [1, 2] })
})
})

View File

@@ -0,0 +1,41 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
/**
* Resolve a RemoteComboOptions route to a full URL. Routes are always relative
* paths and are prepended with the comfy-api base URL.
*/
function resolveRoute(route: string): string {
return getComfyApiBaseUrl() + route
}
/**
* Get auth headers for a remote request. Always injected — comfy-api requires it.
*/
async function getRemoteAuthHeaders(): Promise<{ headers?: AuthHeader }> {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return { headers: authHeader }
}
return {}
}
/**
* Convenience: make an authenticated GET request to a remote route.
*/
export async function fetchRemoteRoute(
route: string,
options: {
params?: Record<string, string>
timeout?: number
signal?: AbortSignal
} = {}
) {
const url = resolveRoute(route)
const authHeaders = await getRemoteAuthHeaders()
return axios.get(url, { ...options, ...authHeaders })
}

View File

@@ -0,0 +1,254 @@
import { describe, expect, it } from 'vitest'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem,
resolveLabel
} from '@/renderer/extensions/vueNodes/widgets/utils/itemSchemaUtils'
describe('getByPath', () => {
it('returns a top-level value for a plain key', () => {
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
})
it('traverses nested objects via dot-path', () => {
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
'Alice'
)
})
it('treats numeric segments as array indices', () => {
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
})
it('combines nested objects and array indices', () => {
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
})
it('returns undefined for a missing top-level key', () => {
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
})
it('returns undefined when traversing past a null segment', () => {
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
})
it('returns undefined when the root is null', () => {
expect(getByPath(null, 'a')).toBeUndefined()
})
it('returns undefined when the root is undefined', () => {
expect(getByPath(undefined, 'a')).toBeUndefined()
})
it('returns undefined for an out-of-bounds array index', () => {
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
})
})
describe('resolveLabel', () => {
it('resolves a plain dot-path to its value', () => {
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
})
it('resolves a nested dot-path without placeholders', () => {
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
'Alice'
)
})
it('substitutes a single {field} placeholder', () => {
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
})
it('substitutes multiple placeholders', () => {
expect(
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
).toBe('Alice Liddell')
})
it('substitutes placeholders with dot-paths', () => {
expect(
resolveLabel('{profile.name} ({profile.age})', {
profile: { name: 'Alice', age: 30 }
})
).toBe('Alice (30)')
})
it('replaces missing placeholder fields with an empty string', () => {
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
'Alice - '
)
})
it('returns an empty string when a plain path resolves to undefined', () => {
expect(resolveLabel('missing', { a: 1 })).toBe('')
})
it('coerces numeric values to strings', () => {
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
})
})
describe('mapToDropdownItem', () => {
it('maps required fields to id and name', () => {
const item = mapToDropdownItem(
{ voice_id: 'v1', label: 'Roger' },
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
)
expect(item).toEqual({
id: 'v1',
name: 'Roger',
description: undefined,
preview_url: undefined
})
})
it('includes description when description_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
{
value_field: 'id',
label_field: 'label',
description_field: 'desc',
preview_type: 'image'
}
)
expect(item.description).toBe('Laid-back American male')
})
it('includes preview_url when preview_url_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
{
value_field: 'id',
label_field: 'label',
preview_url_field: 'sample',
preview_type: 'audio'
}
)
expect(item.preview_url).toBe('https://example.com/a.mp3')
})
it('resolves label_field templates with placeholders', () => {
const item = mapToDropdownItem(
{ id: 'v1', first: 'Alice', last: 'Liddell' },
{
value_field: 'id',
label_field: '{first} {last}',
preview_type: 'image'
}
)
expect(item.name).toBe('Alice Liddell')
})
it('resolves dot-path fields for nested data', () => {
const item = mapToDropdownItem(
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
{
value_field: 'task_result.elements.0.element_id',
label_field: 'task_result.elements.0.name',
preview_type: 'image'
}
)
expect(item.id).toBe('e1')
expect(item.name).toBe('Elem')
})
it('stringifies non-string value_field', () => {
const item = mapToDropdownItem(
{ id: 42, label: 'Answer' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('42')
})
it('returns an empty string id when value_field is missing', () => {
const item = mapToDropdownItem(
{ label: 'Orphan' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('')
})
})
describe('extractItems', () => {
it('returns the full response when responseKey is undefined', () => {
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
})
it('extracts items from a top-level key', () => {
expect(
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
).toEqual([{ id: 'a' }, { id: 'b' }])
})
it('extracts items via a dot-path', () => {
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
1, 2
])
})
it('returns an empty array for a valid empty list', () => {
expect(extractItems([])).toEqual([])
})
it('returns null when the path does not exist', () => {
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
})
it('returns null when the path resolves to a non-array', () => {
expect(
extractItems({ data: { items: 'not an array' } }, 'data.items')
).toBeNull()
})
it('returns null when the full response is not an array', () => {
expect(extractItems({ not: 'array' })).toBeNull()
})
it('returns null when response is null', () => {
expect(extractItems(null)).toBeNull()
})
})
describe('buildSearchText', () => {
it('joins multiple fields with a space', () => {
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
'hello world'
)
})
it('lowercases the result', () => {
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
})
it('drops missing fields', () => {
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
'alice'
)
})
it('supports dot-path fields', () => {
expect(
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
'profile.name',
'profile.age'
])
).toBe('alice 30')
})
it('returns an empty string when all fields are missing', () => {
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
})
})

View File

@@ -0,0 +1,62 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
/** Traverse an object by dot-path, treating numeric segments as array indices */
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
/** Resolve a label — either dot-path or template with {field.path} placeholders */
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
/** Map a raw API object to a FormDropdownItem using the item_schema */
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema
): FormDropdownItem {
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
}
}
/**
* Extract items array from a full API response using `responseKey`.
* Returns `null` when the resolved value isn't an array (path missing,
* wrong shape, etc.) so callers can distinguish a malformed response
* from a legitimate empty list.
*/
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] | null {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : null
}
/** Build search text for an item from the specified search fields */
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}

View File

@@ -0,0 +1,249 @@
import { AxiosError, AxiosHeaders } from 'axios'
import { describe, expect, it } from 'vitest'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '@/renderer/extensions/vueNodes/widgets/utils/richComboHelpers'
const baseConfig: RemoteComboConfig = {
route: '/voices',
item_schema: {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
}
function parseKey(key: string): URLSearchParams {
return new URL(key).searchParams
}
describe('buildCacheKey', () => {
it('encodes the route and response_key', () => {
const params = parseKey(
buildCacheKey(
{
...baseConfig,
route: '/voices',
response_key: 'data.items'
},
'fb:user-a'
)
)
expect(params.get('route')).toBe('/voices')
expect(params.get('responseKey')).toBe('data.items')
})
it('partitions by authScope', () => {
const a = buildCacheKey(baseConfig, 'ws:team-a')
const b = buildCacheKey(baseConfig, 'ws:team-b')
expect(a).not.toBe(b)
expect(parseKey(a).get('u')).toBe('ws:team-a')
expect(parseKey(b).get('u')).toBe('ws:team-b')
})
it('treats workspace, firebase, and api-key scopes as distinct buckets', () => {
const ws = buildCacheKey(baseConfig, 'ws:abc')
const fb = buildCacheKey(baseConfig, 'fb:abc')
const apikey = buildCacheKey(baseConfig, 'apikey')
expect(new Set([ws, fb, apikey]).size).toBe(3)
})
it('falls back to "anon" when authScope is missing', () => {
expect(parseKey(buildCacheKey(baseConfig, null)).get('u')).toBe('anon')
expect(parseKey(buildCacheKey(baseConfig, undefined)).get('u')).toBe('anon')
})
it('treats missing optional fields as empty so the key stays stable', () => {
const params = parseKey(buildCacheKey(baseConfig, 'fb:user-a'))
expect(params.get('responseKey')).toBe('')
})
})
describe('getBackoff', () => {
it('grows exponentially from 1s', () => {
expect(getBackoff(1)).toBe(2000)
expect(getBackoff(2)).toBe(4000)
expect(getBackoff(3)).toBe(8000)
expect(getBackoff(4)).toBe(16000)
})
it('caps at 16s for higher attempt counts', () => {
expect(getBackoff(5)).toBe(16000)
expect(getBackoff(10)).toBe(16000)
expect(getBackoff(100)).toBe(16000)
})
})
describe('isRetriableError', () => {
function axiosErrorWithStatus(status: number): AxiosError {
return new AxiosError(
`HTTP ${status}`,
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
}
it('retries non-axios errors (e.g. unexpected throws)', () => {
expect(isRetriableError(new Error('boom'))).toBe(true)
expect(isRetriableError('string error')).toBe(true)
expect(isRetriableError(undefined)).toBe(true)
})
it('retries axios errors with no response (network failures)', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(isRetriableError(err)).toBe(true)
})
it('retries 5xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
})
it('retries 408 (request timeout) and 429 (too many requests)', () => {
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
})
it('does not retry other 4xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
})
})
describe('summarizeError', () => {
it('extracts message, code and status from an axios error', () => {
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status: 500,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
expect(summarizeError(err)).toEqual({
message: 'Request failed',
code: 'ERR_BAD_RESPONSE',
status: 500
})
})
it('does not include axios config, headers, request or response data', () => {
const authedConfig = {
url: '/voices',
method: 'get',
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
}
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
authedConfig,
undefined,
{
status: 500,
statusText: '',
headers: { 'set-cookie': ['session=PRIVATE'] },
config: authedConfig,
data: { user_email: 'private@example.com' }
}
)
const summary = summarizeError(err)
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
expect(JSON.stringify(summary)).not.toContain('private@example.com')
expect(summary).not.toHaveProperty('config')
expect(summary).not.toHaveProperty('request')
expect(summary).not.toHaveProperty('response')
})
it('reports an axios network error with no response as undefined status', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(summarizeError(err)).toEqual({
message: 'Network Error',
code: 'ERR_NETWORK',
status: undefined
})
})
it('summarizes a plain Error using its name and message', () => {
expect(summarizeError(new TypeError('boom'))).toEqual({
message: 'boom',
name: 'TypeError'
})
})
it('coerces non-Error throwables to a message string', () => {
expect(summarizeError('oops')).toEqual({ message: 'oops' })
expect(summarizeError(42)).toEqual({ message: '42' })
expect(summarizeError(null)).toEqual({ message: 'null' })
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
})
})
describe('summarizePayload', () => {
it('reports array length without exposing values', () => {
expect(
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
).toEqual({
type: 'array',
length: 3
})
})
it('reports object keys without exposing values', () => {
expect(
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
).toEqual({
type: 'object',
keys: ['user_email', 'voices'],
keyCount: 2
})
})
it('caps the keys sample at 10 but reports the full key count', () => {
const big: Record<string, number> = {}
for (let i = 0; i < 25; i++) big[`k${i}`] = i
const summary = summarizePayload(big) as {
type: string
keys: string[]
keyCount: number
}
expect(summary.type).toBe('object')
expect(summary.keys).toHaveLength(10)
expect(summary.keyCount).toBe(25)
})
it('distinguishes null and undefined', () => {
expect(summarizePayload(null)).toEqual({ type: 'null' })
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
})
it('reports primitive types without their value', () => {
expect(summarizePayload('hello')).toEqual({ type: 'string' })
expect(summarizePayload(123)).toEqual({ type: 'number' })
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
})
})

View File

@@ -0,0 +1,91 @@
import axios from 'axios'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
const BACKOFF_BASE_MS = 1000
const BACKOFF_CAP_MS = 16000
/**
* Build a stable cache key for a remote combo configuration.
*
* All remote-combo routes go through comfy-api with auth, so the cache is
* always partitioned by `authScope` — an opaque, non-secret identifier of
* the active auth context (workspace id, firebase uid, etc.). Resolving the
* scope is the caller's responsibility, which keeps this helper pure and
* trivially testable.
*/
export function buildCacheKey(
config: RemoteComboConfig,
authScope: string | null | undefined
): string {
const params = new URLSearchParams({
route: config.route,
responseKey: config.response_key ?? '',
u: authScope ?? 'anon'
})
return `https://cache.comfy.invalid/?${params}`
}
/**
* Exponential backoff in milliseconds, capped at 16s. `count` is the
* number of failed attempts so far (1-indexed for the first retry).
*/
export function getBackoff(count: number): number {
return Math.min(BACKOFF_BASE_MS * Math.pow(2, count), BACKOFF_CAP_MS)
}
/**
* Distinguish transient errors (worth retrying) from permanent ones.
* 401/403/404 etc. won't fix themselves — retrying wastes time.
* Network-level failures (no response) are treated as retriable.
*/
export function isRetriableError(err: unknown): boolean {
if (!axios.isAxiosError(err)) return true
const status = err.response?.status
if (status == null) return true
if (status >= 500) return true
return status === 408 || status === 429
}
/**
* Build a console-safe summary of an unknown error. Authenticated remote
* routes inject auth headers via fetchRemoteRoute, and AxiosError serializes
* its `config` (including those headers) by default — so logging the raw
* error would leak bearer tokens to devtools and any attached telemetry.
* This summary keeps only the diagnostic essentials.
*/
export function summarizeError(err: unknown): Record<string, unknown> {
if (axios.isAxiosError(err)) {
return {
message: err.message,
code: err.code,
status: err.response?.status
}
}
if (err instanceof Error) {
return { message: err.message, name: err.name }
}
return { message: String(err) }
}
const PAYLOAD_KEY_SAMPLE = 10
/**
* Build a console-safe summary of a remote response payload. Logs the
* structural shape so devs can diagnose schema mismatches without the
* actual values, which for authenticated routes may contain private data.
*/
export function summarizePayload(data: unknown): Record<string, unknown> {
if (data === null) return { type: 'null' }
if (data === undefined) return { type: 'undefined' }
if (Array.isArray(data)) return { type: 'array', length: data.length }
if (typeof data === 'object') {
const keys = Object.keys(data as Record<string, unknown>)
return {
type: 'object',
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
keyCount: keys.length
}
}
return { type: typeof data }
}

View File

@@ -5,6 +5,11 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
/**
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
*/
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -15,6 +20,32 @@ const zRemoteWidgetConfig = z.object({
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional()
})
/**
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, and filtering.
* Requires `item_schema`. Vue-nodes only. Routes are always relative paths and resolve against
* the comfy-api base URL with auth headers injected. The endpoint returns the full items array
* in a single response.
*/
const zRemoteComboConfig = z.object({
route: z.string().startsWith('/'),
item_schema: zRemoteItemSchema,
refresh_button: z.boolean().optional(),
auto_select: z.enum(['first', 'last']).optional(),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
@@ -96,6 +127,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
remote_combo: zRemoteComboConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
@@ -352,7 +384,9 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>

View File

@@ -71,4 +71,66 @@ describe('validateNodeDef', () => {
})
}
)
describe('remote_combo route validation', () => {
const buildNodeDef = (remoteCombo: object): unknown => ({
...EXAMPLE_NODE_DEF,
input: {
required: {
voice: ['COMBO', { remote_combo: remoteCombo }]
}
}
})
const baseRemoteCombo = {
item_schema: { value_field: 'id', label_field: 'name' }
}
it('accepts a relative route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: '/voices'
})
)
).not.toBeNull()
})
it('rejects an absolute http URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'http://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects an absolute https URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects a route with no leading slash', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'voices'
}),
() => {}
)
).toBeNull()
})
})
})