mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-02 03:14:53 +00:00
Compare commits
8 Commits
fix/loadim
...
feat/Remot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09942a5b7f | ||
|
|
560e53c68f | ||
|
|
1999b7fba0 | ||
|
|
285421a87c | ||
|
|
5523df1aea | ||
|
|
65876c635d | ||
|
|
28c97d3687 | ||
|
|
97c2a0d364 |
@@ -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()
|
||||
|
||||
83
apps/website/scripts/README.md
Normal file
83
apps/website/scripts/README.md
Normal 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.
|
||||
110
apps/website/scripts/process-videos.sh
Executable file
110
apps/website/scripts/process-videos.sh
Executable 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."
|
||||
51
apps/website/src/assets/marketing/README.md
Normal file
51
apps/website/src/assets/marketing/README.md
Normal 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.
|
||||
68
apps/website/src/components/common/SiteVideo.vue
Normal file
68
apps/website/src/components/common/SiteVideo.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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': '播放' },
|
||||
|
||||
3
apps/website/src/utils/marketingImage.ts
Normal file
3
apps/website/src/utils/marketingImage.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const MARKETING_FORMATS = ['avif', 'webp'] as const
|
||||
|
||||
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const
|
||||
111
apps/website/src/utils/video.test.ts
Normal file
111
apps/website/src/utils/video.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
49
apps/website/src/utils/video.ts
Normal file
49
apps/website/src/utils/video.ts
Normal 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('|')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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] })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user